Add multi-role permission system with a Role Management admin page
Users can now hold multiple roles, each with its own editable set of create/edit/delete-style permissions across assets, shots, tasks, task assignment, review approve/retake, submissions, uploads, and notes (including internal vs. client note visibility). The 4 existing roles (coordinator/director/artist/developer) are migrated into the new system as system roles, seeded to reproduce today's actual behavior exactly; admins can create custom roles (e.g. "Reviewer", "Outsourcing") via the new Role Management page and assign multiple roles to a user via a new "Manage Roles" action on the Team page. Backend: - New Role/Permission models and role_permissions/user_roles tables, plus a one-off, idempotent seed/backfill migration script. - New require_permission()/user_has_permission() dependency, wired into the actual mutation endpoints across shots/assets/tasks/reviews, always preserving existing ownership- and self-service-based access (e.g. artists editing their own task status, own notes, own uploads, own submissions) as an unconditional fallback alongside the new permission checks - nothing that worked before now requires a role. - New endpoints: PUT/DELETE on task submissions (wires up soft-deletion columns that existed on the model but were never exposed), plus full role CRUD and per-user role assignment. - Along the way: fixed newly-created users not being linked to their matching system role (silently leaving them with zero permissions), and unified an inconsistency between the single vs. bulk task status endpoints that allowed different roles to bulk-update status. Frontend: - Role Management page with a grouped, human-readable permission editor (icons, plain-language action labels, per-resource select-all, live selected count) replacing an earlier dense matrix prototype. - hasPermission() added to the existing usePermission() composable without touching its current isAdmin/isCoordinatorOrAdmin consumers. - Note composer gets an Internal/Client toggle; submissions gain inline edit/delete actions gated the same ownership-or-permission way as notes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -8,7 +8,7 @@ import json
|
||||
import os
|
||||
|
||||
from database import engine, Base
|
||||
from routers import auth, users, projects, episodes, assets, shots, tasks, reviews, files, developer, settings, notifications, activities, admin, data_consistency
|
||||
from routers import auth, users, projects, episodes, assets, shots, tasks, reviews, files, developer, settings, notifications, activities, admin, data_consistency, roles
|
||||
# Import models to ensure they are registered with SQLAlchemy
|
||||
import models
|
||||
|
||||
@@ -109,6 +109,7 @@ app.include_router(notifications.router, tags=["notifications"])
|
||||
app.include_router(activities.router, tags=["activities"])
|
||||
app.include_router(admin.router, prefix="/admin", tags=["admin"])
|
||||
app.include_router(data_consistency.router, prefix="/data-consistency", tags=["data-consistency"])
|
||||
app.include_router(roles.router, prefix="/roles", tags=["roles"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
One-off migration script for the multi-role permission system.
|
||||
|
||||
Creates the new roles/permissions/role_permissions/user_roles tables, seeds
|
||||
the permission catalog (asset/shot/task create/edit/delete, plus assignment,
|
||||
review, submission, upload, and note actions), seeds the 4 system roles
|
||||
(coordinator/director/artist/developer) with role_permissions matching
|
||||
today's actual behavior, then backfills user_roles from each existing
|
||||
user's legacy `role` column.
|
||||
|
||||
Does not read or write users.role or users.is_admin beyond reading `role` to
|
||||
resolve which system Role to link. Safe to re-run (every step is idempotent
|
||||
and purely additive - re-running after adding new PERMISSIONS/grants below
|
||||
only inserts what's missing, never removes or resets existing data).
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from database import DATABASE_URL, Base
|
||||
from models.user import User
|
||||
from models.role import Role, Permission
|
||||
import logging
|
||||
|
||||
import models # noqa: F401 - ensures every model is registered on Base.metadata
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The full permission catalog: (resource, action, description).
|
||||
PERMISSIONS = [
|
||||
("asset", "create", "Can create assets"),
|
||||
("asset", "edit", "Can edit assets"),
|
||||
("asset", "delete", "Can delete assets"),
|
||||
("shot", "create", "Can create shots"),
|
||||
("shot", "edit", "Can edit shots"),
|
||||
("shot", "delete", "Can delete shots"),
|
||||
("task", "create", "Can create tasks"),
|
||||
("task", "edit", "Can edit tasks"),
|
||||
("task", "delete", "Can delete tasks"),
|
||||
("task", "change_status", "Can change task status"),
|
||||
("assignment", "create", "Can assign a task to a user"),
|
||||
("assignment", "edit", "Can reassign a task to a different user"),
|
||||
("assignment", "delete", "Can unassign a task"),
|
||||
("review", "publish", "Can approve a submission"),
|
||||
("review", "retake", "Can request a retake on a submission"),
|
||||
("submission", "create", "Can submit work for a task"),
|
||||
("submission", "edit", "Can edit a submission's notes"),
|
||||
("submission", "delete", "Can delete a submission"),
|
||||
("upload", "create", "Can upload task attachments"),
|
||||
("upload", "delete", "Can delete task attachments"),
|
||||
("note", "create", "Can add task notes"),
|
||||
("note", "edit", "Can edit task notes"),
|
||||
("note", "delete", "Can delete task notes"),
|
||||
("note", "view_internal", "Can view internal notes"),
|
||||
("note", "view_client", "Can view client notes"),
|
||||
]
|
||||
|
||||
SYSTEM_ROLE_DESCRIPTIONS = {
|
||||
"coordinator": "Can create, edit, and delete assets, shots, and tasks.",
|
||||
"director": "Director role, migrated from the legacy single-role system.",
|
||||
"artist": "Artist role, migrated from the legacy single-role system.",
|
||||
"developer": "Developer role, migrated from the legacy single-role system.",
|
||||
}
|
||||
|
||||
# Explicit per-role grants, faithful to today's actual behavior for each
|
||||
# action (not a blanket "coordinator gets everything" - e.g. director
|
||||
# already has real elevated rights on review publish/retake today, and
|
||||
# non-artist roles already have unrestricted note/upload create today).
|
||||
# submission:create is granted to nobody by default since today it's a pure
|
||||
# ownership check (must literally be the assigned user), not role-based -
|
||||
# is_admin still bypasses it, and self-service is preserved in the endpoint.
|
||||
# note:view_internal goes to everyone (matches today - all notes are visible
|
||||
# to anyone with task access); note:view_client is new/more restrictive by
|
||||
# default, granted only to coordinator+director.
|
||||
# task:change_status is granted to coordinator only. This deliberately
|
||||
# resolves a pre-existing inconsistency: the single-task status endpoint only
|
||||
# allowed COORDINATOR (+artist self-service), while the bulk endpoint also
|
||||
# allowed DIRECTOR - now both use this one permission, so director loses the
|
||||
# bulk-only status-change access they had before (can be re-granted via a
|
||||
# custom role if that capability is actually wanted).
|
||||
# submission:edit/delete are brand new endpoints (didn't exist before this
|
||||
# permission was added), so there's no "today's behavior" to preserve -
|
||||
# granted to coordinator as the natural production-lead capability; artists
|
||||
# keep editing/deleting their own submissions via the ownership check.
|
||||
SYSTEM_ROLE_GRANTS = {
|
||||
"coordinator": {
|
||||
("asset", "create"), ("asset", "edit"), ("asset", "delete"),
|
||||
("shot", "create"), ("shot", "edit"), ("shot", "delete"),
|
||||
("task", "create"), ("task", "edit"), ("task", "delete"), ("task", "change_status"),
|
||||
("assignment", "create"), ("assignment", "edit"), ("assignment", "delete"),
|
||||
("review", "publish"), ("review", "retake"),
|
||||
("submission", "edit"), ("submission", "delete"),
|
||||
("upload", "create"), ("upload", "delete"),
|
||||
("note", "create"), ("note", "view_internal"), ("note", "view_client"),
|
||||
},
|
||||
"director": {
|
||||
("review", "publish"), ("review", "retake"),
|
||||
("upload", "create"),
|
||||
("note", "create"), ("note", "view_internal"), ("note", "view_client"),
|
||||
},
|
||||
"artist": {
|
||||
("note", "view_internal"),
|
||||
},
|
||||
"developer": {
|
||||
("upload", "create"),
|
||||
("note", "create"), ("note", "view_internal"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def migrate_role_permissions():
|
||||
engine = create_engine(DATABASE_URL)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# 0. Add note_type to production_notes if missing (create_all only
|
||||
# creates missing tables, it doesn't alter existing ones).
|
||||
try:
|
||||
db.execute(text("SELECT note_type FROM production_notes LIMIT 1"))
|
||||
logger.info("note_type column already exists")
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.info("Adding note_type column to production_notes table")
|
||||
db.execute(text(
|
||||
"ALTER TABLE production_notes ADD COLUMN note_type VARCHAR(8) NOT NULL DEFAULT 'internal'"
|
||||
))
|
||||
db.commit()
|
||||
|
||||
# 1. Seed permissions (idempotent by resource+action)
|
||||
permissions_by_key = {}
|
||||
created_permissions = 0
|
||||
for resource, action, description in PERMISSIONS:
|
||||
perm = db.query(Permission).filter(
|
||||
Permission.resource == resource, Permission.action == action
|
||||
).first()
|
||||
if not perm:
|
||||
perm = Permission(resource=resource, action=action, description=description)
|
||||
db.add(perm)
|
||||
db.flush()
|
||||
created_permissions += 1
|
||||
permissions_by_key[(resource, action)] = perm
|
||||
logger.info(f"Permissions: {created_permissions} created, {len(permissions_by_key)} total")
|
||||
|
||||
# 2. Seed system roles (idempotent by name)
|
||||
roles_by_name = {}
|
||||
created_roles = 0
|
||||
for name, description in SYSTEM_ROLE_DESCRIPTIONS.items():
|
||||
role = db.query(Role).filter(Role.name == name).first()
|
||||
if not role:
|
||||
role = Role(name=name, description=description, is_system=True)
|
||||
db.add(role)
|
||||
db.flush()
|
||||
created_roles += 1
|
||||
roles_by_name[name] = role
|
||||
logger.info(f"System roles: {created_roles} created, {len(roles_by_name)} total")
|
||||
|
||||
# 3. Link role_permissions (idempotent - only add missing links)
|
||||
linked = 0
|
||||
for name, grants in SYSTEM_ROLE_GRANTS.items():
|
||||
role = roles_by_name[name]
|
||||
for key in grants:
|
||||
perm = permissions_by_key[key]
|
||||
if perm not in role.permissions:
|
||||
role.permissions.append(perm)
|
||||
linked += 1
|
||||
db.commit()
|
||||
logger.info(f"role_permissions: {linked} new links created")
|
||||
|
||||
# 4. Backfill user_roles from users.role (idempotent - only add missing links)
|
||||
users = db.query(User).all()
|
||||
backfilled = 0
|
||||
for user in users:
|
||||
role = roles_by_name.get(user.role.value)
|
||||
if role and role not in user.roles:
|
||||
user.roles.append(role)
|
||||
backfilled += 1
|
||||
db.commit()
|
||||
logger.info(f"user_roles: {backfilled} new links created for {len(users)} users")
|
||||
|
||||
# Verification summary
|
||||
logger.info("--- Migration summary ---")
|
||||
for name, role in roles_by_name.items():
|
||||
logger.info(f" Role '{name}': is_system={role.is_system}, permissions={len(role.permissions)}, users={len(role.users)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Migration failed: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("Starting role/permission migration...")
|
||||
migrate_role_permissions()
|
||||
logger.info("Migration completed!")
|
||||
@@ -6,13 +6,14 @@ from .asset import Asset, AssetCategory, AssetStatus
|
||||
from .shot import Shot, ShotStatus
|
||||
from .task import (
|
||||
Task, Submission, Review, ProductionNote, TaskAttachment,
|
||||
TaskType, TaskStatus, ReviewDecision, AttachmentType
|
||||
TaskType, TaskStatus, ReviewDecision, AttachmentType, NoteType
|
||||
)
|
||||
from .api_key import APIKey, APIKeyScope
|
||||
from .api_key_usage import APIKeyUsage
|
||||
from .global_settings import GlobalSettings
|
||||
from .notification import Notification, UserNotificationPreference, NotificationType
|
||||
from .activity import Activity, ActivityType
|
||||
from .role import Role, Permission, role_permissions, user_roles
|
||||
|
||||
__all__ = [
|
||||
# User models
|
||||
@@ -27,7 +28,7 @@ __all__ = [
|
||||
"Shot", "ShotStatus",
|
||||
# Task models
|
||||
"Task", "Submission", "Review", "ProductionNote", "TaskAttachment",
|
||||
"TaskType", "TaskStatus", "ReviewDecision", "AttachmentType",
|
||||
"TaskType", "TaskStatus", "ReviewDecision", "AttachmentType", "NoteType",
|
||||
# API Key models
|
||||
"APIKey", "APIKeyScope", "APIKeyUsage",
|
||||
# Global Settings models
|
||||
@@ -35,5 +36,7 @@ __all__ = [
|
||||
# Notification models
|
||||
"Notification", "UserNotificationPreference", "NotificationType",
|
||||
# Activity models
|
||||
"Activity", "ActivityType"
|
||||
"Activity", "ActivityType",
|
||||
# Role models
|
||||
"Role", "Permission", "role_permissions", "user_roles"
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Table, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
|
||||
|
||||
role_permissions = Table(
|
||||
"role_permissions",
|
||||
Base.metadata,
|
||||
Column("role_id", Integer, ForeignKey("roles.id"), primary_key=True),
|
||||
Column("permission_id", Integer, ForeignKey("permissions.id"), primary_key=True),
|
||||
)
|
||||
|
||||
user_roles = Table(
|
||||
"user_roles",
|
||||
Base.metadata,
|
||||
Column("user_id", Integer, ForeignKey("users.id"), primary_key=True),
|
||||
Column("role_id", Integer, ForeignKey("roles.id"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
resource = Column(String, nullable=False, index=True) # e.g. "asset", "shot", "task"
|
||||
action = Column(String, nullable=False) # e.g. "create", "edit", "delete"
|
||||
description = Column(String, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("resource", "action", name="uq_permission_resource_action"),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
roles = relationship("Role", secondary=role_permissions, back_populates="permissions")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Permission(id={self.id}, resource='{self.resource}', action='{self.action}')>"
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, unique=True, nullable=False, index=True)
|
||||
description = Column(String, nullable=True)
|
||||
is_system = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
permissions = relationship("Permission", secondary=role_permissions, back_populates="roles")
|
||||
users = relationship("User", secondary=user_roles, back_populates="roles")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Role(id={self.id}, name='{self.name}', is_system={self.is_system})>"
|
||||
@@ -38,6 +38,11 @@ class AttachmentType(str, enum.Enum):
|
||||
DOCUMENTATION = "documentation"
|
||||
|
||||
|
||||
class NoteType(str, enum.Enum):
|
||||
INTERNAL = "internal"
|
||||
CLIENT = "client"
|
||||
|
||||
|
||||
class Task(Base):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
@@ -190,10 +195,11 @@ class ProductionNote(Base):
|
||||
task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
note_type = Column(Enum(NoteType), nullable=False, default=NoteType.INTERNAL)
|
||||
parent_note_id = Column(Integer, ForeignKey("production_notes.id"), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
# Soft deletion columns
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
deleted_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
@@ -47,6 +47,7 @@ class User(Base):
|
||||
api_keys = relationship("APIKey", back_populates="user", cascade="all, delete-orphan")
|
||||
notifications = relationship("Notification", back_populates="user", cascade="all, delete-orphan")
|
||||
notification_preferences = relationship("UserNotificationPreference", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
||||
roles = relationship("Role", secondary="user_roles", back_populates="users")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, email='{self.email}', role='{self.role}')>"
|
||||
@@ -9,7 +9,7 @@ from models.task import Task, TaskType, TaskStatus
|
||||
from models.user import User, UserRole
|
||||
from schemas.asset import AssetCreate, AssetUpdate, AssetResponse, AssetListResponse, TaskStatusInfo
|
||||
from schemas.task import TaskCreate
|
||||
from utils.auth import get_current_user_from_token
|
||||
from utils.auth import get_current_user_from_token, require_permission
|
||||
from services.asset_soft_deletion import AssetSoftDeletionService
|
||||
|
||||
router = APIRouter()
|
||||
@@ -24,22 +24,6 @@ def get_current_user_with_db(
|
||||
return _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
|
||||
def require_coordinator_or_admin(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Require coordinator or admin role."""
|
||||
from utils.auth import _get_user_from_db
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
if current_user.role != UserRole.COORDINATOR and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def get_status_sort_order(status: str, project_custom_statuses: list = None) -> int:
|
||||
"""Get sort order for task status, including custom statuses."""
|
||||
# Default system status order
|
||||
@@ -374,7 +358,7 @@ async def create_asset(
|
||||
asset: AssetCreate,
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('asset', 'create'))
|
||||
):
|
||||
"""Create a new asset in a project with optional default tasks"""
|
||||
# Check project access
|
||||
@@ -536,7 +520,7 @@ async def create_asset_task(
|
||||
asset_id: int,
|
||||
task_type: str, # Changed from TaskType enum to str
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('task', 'create'))
|
||||
):
|
||||
"""Create a new task for an asset"""
|
||||
# Exclude soft deleted assets
|
||||
@@ -595,7 +579,7 @@ async def update_asset(
|
||||
asset_id: int,
|
||||
asset_update: AssetUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('asset', 'edit'))
|
||||
):
|
||||
"""Update an asset"""
|
||||
# Exclude soft deleted assets
|
||||
@@ -651,7 +635,7 @@ async def update_asset(
|
||||
async def get_asset_deletion_info(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('asset', 'delete'))
|
||||
):
|
||||
"""Get information about what will be deleted when deleting an asset"""
|
||||
# Exclude soft deleted assets
|
||||
@@ -701,7 +685,7 @@ async def get_asset_deletion_info(
|
||||
async def delete_asset(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('asset', 'delete'))
|
||||
):
|
||||
"""Soft delete an asset and all its associated data"""
|
||||
# Exclude soft deleted assets
|
||||
|
||||
@@ -54,7 +54,11 @@ async def register(user_data: UserRegister, db: Session = Depends(get_db)):
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
|
||||
from utils.auth import link_system_role
|
||||
link_system_role(new_user, db)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "User registered successfully. Awaiting admin approval.",
|
||||
"user_id": new_user.id
|
||||
|
||||
@@ -7,7 +7,7 @@ from database import get_db
|
||||
from models.task import Task, Submission, Review, TaskStatus
|
||||
from models.user import User, UserRole
|
||||
from schemas.task import ReviewCreate, ReviewResponse, SubmissionResponse
|
||||
from utils.auth import get_current_user_from_token, _get_user_from_db
|
||||
from utils.auth import get_current_user_from_token, _get_user_from_db, require_permission
|
||||
from utils.notifications import notification_service
|
||||
|
||||
router = APIRouter()
|
||||
@@ -126,9 +126,9 @@ async def approve_submission(
|
||||
submission_id: int,
|
||||
review: ReviewCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_director_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('review', 'publish'))
|
||||
):
|
||||
"""Approve a submission. Only directors, coordinators, and users with admin permission can approve."""
|
||||
"""Approve a submission. Requires review:publish permission (or admin)."""
|
||||
|
||||
submission = db.query(Submission).options(
|
||||
joinedload(Submission.task)
|
||||
@@ -189,9 +189,9 @@ async def request_retake(
|
||||
submission_id: int,
|
||||
review: ReviewCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_director_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('review', 'retake'))
|
||||
):
|
||||
"""Request a retake for a submission. Only directors, coordinators, and users with admin permission can request retakes."""
|
||||
"""Request a retake for a submission. Requires review:retake permission (or admin)."""
|
||||
|
||||
submission = db.query(Submission).options(
|
||||
joinedload(Submission.task)
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.role import Role, Permission
|
||||
from models.user import User
|
||||
from schemas.role import (
|
||||
RoleResponse, RoleCreate, RoleUpdate, PermissionResponse
|
||||
)
|
||||
from utils.auth import require_admin_permission
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _role_to_response(role: Role) -> RoleResponse:
|
||||
return RoleResponse(
|
||||
id=role.id,
|
||||
name=role.name,
|
||||
description=role.description,
|
||||
is_system=role.is_system,
|
||||
permissions=[PermissionResponse.model_validate(p) for p in role.permissions],
|
||||
user_count=len(role.users),
|
||||
created_at=role.created_at,
|
||||
updated_at=role.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[RoleResponse])
|
||||
async def list_roles(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission())
|
||||
):
|
||||
"""List all roles with their permissions and assigned-user count (admin only)."""
|
||||
roles = db.query(Role).order_by(Role.is_system.desc(), Role.name).all()
|
||||
return [_role_to_response(role) for role in roles]
|
||||
|
||||
|
||||
@router.get("/permissions", response_model=List[PermissionResponse])
|
||||
async def list_permissions(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission())
|
||||
):
|
||||
"""List the full permission catalog (admin only)."""
|
||||
return db.query(Permission).order_by(Permission.resource, Permission.action).all()
|
||||
|
||||
|
||||
@router.post("/", response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_role(
|
||||
role_data: RoleCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission())
|
||||
):
|
||||
"""Create a custom role (admin only)."""
|
||||
existing = db.query(Role).filter(Role.name == role_data.name).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Role '{role_data.name}' already exists"
|
||||
)
|
||||
|
||||
permissions = []
|
||||
if role_data.permission_ids:
|
||||
permissions = db.query(Permission).filter(Permission.id.in_(role_data.permission_ids)).all()
|
||||
if len(permissions) != len(set(role_data.permission_ids)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="One or more permission_ids are invalid"
|
||||
)
|
||||
|
||||
role = Role(
|
||||
name=role_data.name,
|
||||
description=role_data.description,
|
||||
is_system=False,
|
||||
permissions=permissions,
|
||||
)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return _role_to_response(role)
|
||||
|
||||
|
||||
@router.put("/{role_id}", response_model=RoleResponse)
|
||||
async def update_role(
|
||||
role_id: int,
|
||||
role_update: RoleUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission())
|
||||
):
|
||||
"""Update a role's name/description/permissions (admin only).
|
||||
|
||||
System roles cannot be renamed or have their description changed, but
|
||||
their permissions ARE editable, same as custom roles.
|
||||
"""
|
||||
role = db.query(Role).filter(Role.id == role_id).first()
|
||||
if not role:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
|
||||
|
||||
if role.is_system:
|
||||
if role_update.name is not None and role_update.name != role.name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="System role name cannot be changed"
|
||||
)
|
||||
if role_update.description is not None and role_update.description != role.description:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="System role description cannot be changed"
|
||||
)
|
||||
else:
|
||||
if role_update.name is not None:
|
||||
existing = db.query(Role).filter(Role.name == role_update.name, Role.id != role_id).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Role '{role_update.name}' already exists"
|
||||
)
|
||||
role.name = role_update.name
|
||||
if role_update.description is not None:
|
||||
role.description = role_update.description
|
||||
|
||||
if role_update.permission_ids is not None:
|
||||
permissions = db.query(Permission).filter(Permission.id.in_(role_update.permission_ids)).all()
|
||||
if len(permissions) != len(set(role_update.permission_ids)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="One or more permission_ids are invalid"
|
||||
)
|
||||
role.permissions = permissions
|
||||
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return _role_to_response(role)
|
||||
|
||||
|
||||
@router.delete("/{role_id}")
|
||||
async def delete_role(
|
||||
role_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission())
|
||||
):
|
||||
"""Delete a custom role (admin only). System roles and roles with assigned users cannot be deleted."""
|
||||
role = db.query(Role).filter(Role.id == role_id).first()
|
||||
if not role:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
|
||||
|
||||
if role.is_system:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="System roles cannot be deleted"
|
||||
)
|
||||
|
||||
user_count = len(role.users)
|
||||
if user_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"{user_count} user(s) still have this role — reassign them first"
|
||||
)
|
||||
|
||||
role.permissions = []
|
||||
db.delete(role)
|
||||
db.commit()
|
||||
|
||||
return {"message": f"Role '{role.name}' deleted successfully"}
|
||||
@@ -12,7 +12,7 @@ from schemas.shot import (
|
||||
ShotCreate, ShotUpdate, ShotResponse, ShotListResponse,
|
||||
BulkShotCreate, BulkShotResponse, TaskStatusInfo
|
||||
)
|
||||
from utils.auth import get_current_user_from_token
|
||||
from utils.auth import get_current_user_from_token, require_permission
|
||||
from services.shot_soft_deletion import ShotSoftDeletionService
|
||||
|
||||
router = APIRouter()
|
||||
@@ -27,22 +27,6 @@ def get_current_user_with_db(
|
||||
return _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
|
||||
def require_coordinator_or_admin(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Require coordinator or admin role."""
|
||||
from utils.auth import _get_user_from_db
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
if current_user.role != UserRole.COORDINATOR and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def check_episode_access(episode_id: int, current_user: User, db: Session):
|
||||
"""Check if user has access to the episode and its project."""
|
||||
# Debug logging
|
||||
@@ -381,7 +365,7 @@ async def create_shot(
|
||||
episode_id: int,
|
||||
create_default_tasks: bool = True,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('shot', 'create'))
|
||||
):
|
||||
"""Create a new shot in an episode"""
|
||||
# Check episode access
|
||||
@@ -452,7 +436,7 @@ async def create_shots_bulk(
|
||||
bulk_shot: BulkShotCreate,
|
||||
episode_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('shot', 'create'))
|
||||
):
|
||||
"""Create multiple shots with naming pattern and default tasks"""
|
||||
# Check episode access
|
||||
@@ -674,7 +658,7 @@ async def create_shot_task(
|
||||
shot_id: int,
|
||||
task_type: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('task', 'create'))
|
||||
):
|
||||
"""Create a new task for a shot"""
|
||||
# Exclude soft deleted shots
|
||||
@@ -737,7 +721,7 @@ async def update_shot(
|
||||
shot_id: int,
|
||||
shot_update: ShotUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('shot', 'edit'))
|
||||
):
|
||||
"""Update a shot"""
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -818,7 +802,7 @@ async def update_shot(
|
||||
async def get_shot_deletion_info(
|
||||
shot_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('shot', 'delete'))
|
||||
):
|
||||
"""Get information about what will be deleted when deleting a shot"""
|
||||
# Exclude soft deleted shots
|
||||
@@ -868,7 +852,7 @@ async def get_shot_deletion_info(
|
||||
async def delete_shot(
|
||||
shot_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
current_user: User = Depends(require_permission('shot', 'delete'))
|
||||
):
|
||||
"""Soft delete a shot and all its associated data"""
|
||||
# Exclude soft deleted shots
|
||||
|
||||
+151
-49
@@ -8,7 +8,7 @@ import json
|
||||
from datetime import datetime
|
||||
|
||||
from database import get_db
|
||||
from models.task import Task, ProductionNote, TaskAttachment, Submission, Review
|
||||
from models.task import Task, ProductionNote, TaskAttachment, Submission, Review, NoteType
|
||||
from models.user import User, UserRole, DepartmentRole
|
||||
from models.project import Project, ProjectMember
|
||||
from models.asset import Asset
|
||||
@@ -19,10 +19,10 @@ from schemas.task import (
|
||||
TaskCreate, TaskUpdate, TaskResponse, TaskListResponse, TaskStatusUpdate, TaskAssignment,
|
||||
ProductionNoteCreate, ProductionNoteUpdate, ProductionNoteResponse,
|
||||
TaskAttachmentCreate, TaskAttachmentResponse,
|
||||
SubmissionCreate, SubmissionResponse,
|
||||
SubmissionCreate, SubmissionUpdate, SubmissionResponse,
|
||||
BulkStatusUpdate, BulkAssignment, BulkActionResult
|
||||
)
|
||||
from utils.auth import get_current_user_from_token, _get_user_from_db, require_role
|
||||
from utils.auth import get_current_user_from_token, _get_user_from_db, require_role, require_permission, user_has_permission
|
||||
from utils.notifications import notification_service
|
||||
from utils.file_handler import file_handler
|
||||
|
||||
@@ -93,20 +93,6 @@ def validate_task_status(db: Session, project_id: int, status_value: str) -> boo
|
||||
return False
|
||||
|
||||
|
||||
def require_admin_or_coordinator(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Dependency to require admin permission or coordinator role."""
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
if not current_user.is_admin and current_user.role != UserRole.COORDINATOR:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin permission or Coordinator role required"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
@@ -372,7 +358,7 @@ async def get_my_tasks(
|
||||
async def create_task(
|
||||
task: TaskCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_coordinator)
|
||||
current_user: User = Depends(require_permission('task', 'create'))
|
||||
):
|
||||
"""Create a new task. Only coordinators and users with admin permission can create tasks."""
|
||||
|
||||
@@ -520,7 +506,7 @@ async def bulk_update_task_status(
|
||||
})
|
||||
failed_count += 1
|
||||
continue
|
||||
elif current_user.role not in [UserRole.COORDINATOR, UserRole.DIRECTOR] and not current_user.is_admin:
|
||||
elif not user_has_permission(current_user, 'task', 'change_status', db):
|
||||
errors.append({
|
||||
"task_id": task_id,
|
||||
"error": "Insufficient permissions"
|
||||
@@ -597,7 +583,7 @@ async def bulk_update_task_status(
|
||||
async def bulk_assign_tasks(
|
||||
bulk_assignment: BulkAssignment,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_coordinator)
|
||||
current_user: User = Depends(require_permission('assignment', 'edit'))
|
||||
):
|
||||
"""
|
||||
Assign multiple tasks to a user atomically.
|
||||
@@ -780,7 +766,7 @@ async def update_task(
|
||||
# Artists can only update status
|
||||
if task_update.model_dump(exclude_unset=True).keys() - {"status"}:
|
||||
raise HTTPException(status_code=403, detail="Artists can only update task status")
|
||||
elif current_user.role != UserRole.COORDINATOR and not current_user.is_admin:
|
||||
elif not user_has_permission(current_user, 'task', 'edit', db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to update tasks")
|
||||
|
||||
# Verify assigned user if being updated
|
||||
@@ -789,7 +775,11 @@ async def update_task(
|
||||
assigned_user = db.query(User).filter(User.id == task_update.assigned_user_id).first()
|
||||
if not assigned_user:
|
||||
raise HTTPException(status_code=404, detail="Assigned user not found")
|
||||
|
||||
|
||||
assignment_action = 'edit' if task.assigned_user_id else 'create'
|
||||
if not user_has_permission(current_user, 'assignment', assignment_action, db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to assign this task")
|
||||
|
||||
# Check if user is a project member
|
||||
project_member = db.query(ProjectMember).filter(
|
||||
and_(
|
||||
@@ -800,6 +790,8 @@ async def update_task(
|
||||
if not project_member:
|
||||
raise HTTPException(status_code=400, detail="Assigned user is not a member of this project")
|
||||
else:
|
||||
if not user_has_permission(current_user, 'assignment', 'delete', db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to unassign this task")
|
||||
task_update.assigned_user_id = None
|
||||
|
||||
# Validate status if being updated
|
||||
@@ -893,9 +885,10 @@ async def update_task_status(
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Permission check
|
||||
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to update this task")
|
||||
elif current_user.role not in [UserRole.ARTIST, UserRole.COORDINATOR] and not current_user.is_admin:
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
if task.assigned_user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to update this task")
|
||||
elif not user_has_permission(current_user, 'task', 'change_status', db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to update task status")
|
||||
|
||||
# Validate the status for the task's project
|
||||
@@ -966,10 +959,10 @@ async def assign_task(
|
||||
task_id: int,
|
||||
assignment: TaskAssignment,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_coordinator)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Assign a task to a user with department role filtering."""
|
||||
|
||||
|
||||
task = db.query(Task).outerjoin(Shot, Task.shot_id == Shot.id).outerjoin(Asset, Task.asset_id == Asset.id).filter(
|
||||
Task.id == task_id,
|
||||
Task.deleted_at.is_(None),
|
||||
@@ -981,7 +974,13 @@ async def assign_task(
|
||||
).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
|
||||
# Assigning a previously-unassigned task requires assignment:create;
|
||||
# reassigning an already-assigned task requires assignment:edit.
|
||||
assignment_action = 'edit' if task.assigned_user_id else 'create'
|
||||
if not user_has_permission(current_user, 'assignment', assignment_action, db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to assign this task")
|
||||
|
||||
# Verify assigned user exists and is a project member
|
||||
assigned_user = db.query(User).filter(User.id == assignment.assigned_user_id).first()
|
||||
if not assigned_user:
|
||||
@@ -1064,7 +1063,7 @@ async def assign_task(
|
||||
async def delete_task(
|
||||
task_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_coordinator)
|
||||
current_user: User = Depends(require_permission('task', 'delete'))
|
||||
):
|
||||
"""Delete a task. Only coordinators and users with admin permission can delete tasks."""
|
||||
|
||||
@@ -1118,7 +1117,18 @@ async def get_task_notes(
|
||||
ProductionNote.task_id == task_id,
|
||||
ProductionNote.deleted_at.is_(None)
|
||||
).order_by(ProductionNote.created_at).all()
|
||||
|
||||
|
||||
# Filter by note-type view permission (a note whose type the viewer can't
|
||||
# see is dropped entirely - any reply to it is dropped too, since it
|
||||
# would otherwise reference content the viewer isn't allowed to see)
|
||||
can_view_internal = user_has_permission(current_user, 'note', 'view_internal', db)
|
||||
can_view_client = user_has_permission(current_user, 'note', 'view_client', db)
|
||||
notes = [
|
||||
note for note in notes
|
||||
if (note.note_type == NoteType.INTERNAL and can_view_internal)
|
||||
or (note.note_type == NoteType.CLIENT and can_view_client)
|
||||
]
|
||||
|
||||
# Build threaded structure
|
||||
notes_dict = {}
|
||||
root_notes = []
|
||||
@@ -1127,6 +1137,7 @@ async def get_task_notes(
|
||||
note_data = {
|
||||
"id": note.id,
|
||||
"content": note.content,
|
||||
"note_type": note.note_type,
|
||||
"parent_note_id": note.parent_note_id,
|
||||
"task_id": note.task_id,
|
||||
"user_id": note.user_id,
|
||||
@@ -1171,10 +1182,13 @@ async def create_task_note(
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Artists can only add notes to their own tasks
|
||||
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to add notes to this task")
|
||||
|
||||
# Artists can only add notes to their own tasks; everyone else needs note:create
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
if task.assigned_user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to add notes to this task")
|
||||
elif not user_has_permission(current_user, 'note', 'create', db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to add notes")
|
||||
|
||||
# Verify parent note exists if specified
|
||||
if note.parent_note_id:
|
||||
parent_note = db.query(ProductionNote).filter(
|
||||
@@ -1192,6 +1206,7 @@ async def create_task_note(
|
||||
task_id=task_id,
|
||||
user_id=current_user.id,
|
||||
content=note.content,
|
||||
note_type=note.note_type,
|
||||
parent_note_id=note.parent_note_id
|
||||
)
|
||||
db.add(db_note)
|
||||
@@ -1206,6 +1221,7 @@ async def create_task_note(
|
||||
note_data = {
|
||||
"id": db_note.id,
|
||||
"content": db_note.content,
|
||||
"note_type": db_note.note_type,
|
||||
"parent_note_id": db_note.parent_note_id,
|
||||
"task_id": db_note.task_id,
|
||||
"user_id": db_note.user_id,
|
||||
@@ -1217,7 +1233,7 @@ async def create_task_note(
|
||||
"user_avatar_url": db_note.user.avatar_url,
|
||||
"child_notes": []
|
||||
}
|
||||
|
||||
|
||||
return ProductionNoteResponse(**note_data)
|
||||
|
||||
|
||||
@@ -1244,8 +1260,9 @@ async def update_task_note(
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# Users can only update their own notes, unless they have admin permission
|
||||
if note.user_id != current_user.id and not current_user.is_admin:
|
||||
# Users can only update their own notes, unless they have admin permission or note:edit
|
||||
if (note.user_id != current_user.id and not current_user.is_admin
|
||||
and not user_has_permission(current_user, 'note', 'edit', db)):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to update this note")
|
||||
|
||||
note.content = note_update.content
|
||||
@@ -1255,6 +1272,7 @@ async def update_task_note(
|
||||
note_data = {
|
||||
"id": note.id,
|
||||
"content": note.content,
|
||||
"note_type": note.note_type,
|
||||
"parent_note_id": note.parent_note_id,
|
||||
"task_id": note.task_id,
|
||||
"user_id": note.user_id,
|
||||
@@ -1290,8 +1308,9 @@ async def delete_task_note(
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# Users can only delete their own notes, unless they have admin permission
|
||||
if note.user_id != current_user.id and not current_user.is_admin:
|
||||
# Users can only delete their own notes, unless they have admin permission or note:delete
|
||||
if (note.user_id != current_user.id and not current_user.is_admin
|
||||
and not user_has_permission(current_user, 'note', 'delete', db)):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to delete this note")
|
||||
|
||||
db.delete(note)
|
||||
@@ -1368,9 +1387,12 @@ async def upload_task_attachment(
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Artists can only upload attachments to their own tasks
|
||||
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to upload attachments to this task")
|
||||
# Artists can only upload attachments to their own tasks; everyone else needs upload:create
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
if task.assigned_user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to upload attachments to this task")
|
||||
elif not user_has_permission(current_user, 'upload', 'create', db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to upload attachments")
|
||||
|
||||
# Validate file using file handler
|
||||
file_handler.validate_file(file, file_handler.MAX_ATTACHMENT_SIZE, db)
|
||||
@@ -1443,9 +1465,9 @@ async def delete_task_attachment(
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
|
||||
# Users can only delete their own attachments, unless they're admin/coordinator
|
||||
if (attachment.user_id != current_user.id and
|
||||
not current_user.is_admin and current_user.role != UserRole.COORDINATOR):
|
||||
# Users can only delete their own attachments, unless they have admin permission or upload:delete
|
||||
if (attachment.user_id != current_user.id and not current_user.is_admin
|
||||
and not user_has_permission(current_user, 'upload', 'delete', db)):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to delete this attachment")
|
||||
|
||||
# Delete file from filesystem using file handler
|
||||
@@ -1534,8 +1556,9 @@ async def submit_work(
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Only assigned artist can submit work
|
||||
if task.assigned_user_id != current_user.id:
|
||||
# Only the assigned artist can submit work, unless the caller holds submission:create
|
||||
if (task.assigned_user_id != current_user.id
|
||||
and not user_has_permission(current_user, 'submission', 'create', db)):
|
||||
raise HTTPException(status_code=403, detail="Only the assigned artist can submit work for this task")
|
||||
|
||||
# Validate file using file handler
|
||||
@@ -1598,5 +1621,84 @@ async def submit_work(
|
||||
"thumbnail_url": f"/files/submissions/{db_submission.id}?thumbnail=true" if file_handler.is_image_file(db_submission.file_path) else None,
|
||||
"stream_url": f"/files/submissions/{db_submission.id}/stream" if file_handler.is_video_file(db_submission.file_path) else None
|
||||
}
|
||||
|
||||
return SubmissionResponse(**submission_data)
|
||||
|
||||
return SubmissionResponse(**submission_data)
|
||||
|
||||
|
||||
@router.put("/{task_id}/submissions/{submission_id}", response_model=SubmissionResponse)
|
||||
async def update_task_submission(
|
||||
task_id: int,
|
||||
submission_id: int,
|
||||
submission_update: SubmissionUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a submission's notes. Users can only update their own submissions."""
|
||||
|
||||
submission = db.query(Submission).options(
|
||||
joinedload(Submission.user)
|
||||
).filter(
|
||||
Submission.id == submission_id,
|
||||
Submission.task_id == task_id,
|
||||
Submission.deleted_at.is_(None)
|
||||
).first()
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
|
||||
# Users can only update their own submissions, unless they have admin permission or submission:edit
|
||||
if (submission.user_id != current_user.id and not current_user.is_admin
|
||||
and not user_has_permission(current_user, 'submission', 'edit', db)):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to update this submission")
|
||||
|
||||
if submission_update.notes is not None:
|
||||
submission.notes = submission_update.notes
|
||||
db.commit()
|
||||
db.refresh(submission)
|
||||
|
||||
submission_data = {
|
||||
"id": submission.id,
|
||||
"task_id": submission.task_id,
|
||||
"user_id": submission.user_id,
|
||||
"file_path": submission.file_path,
|
||||
"file_name": submission.file_name,
|
||||
"version_number": submission.version_number,
|
||||
"notes": submission.notes,
|
||||
"submitted_at": submission.submitted_at,
|
||||
"user_first_name": submission.user.first_name,
|
||||
"user_last_name": submission.user.last_name,
|
||||
"latest_review": None,
|
||||
"download_url": f"/files/submissions/{submission.id}",
|
||||
"thumbnail_url": f"/files/submissions/{submission.id}?thumbnail=true" if file_handler.is_image_file(submission.file_path) else None,
|
||||
"stream_url": f"/files/submissions/{submission.id}/stream" if file_handler.is_video_file(submission.file_path) else None
|
||||
}
|
||||
|
||||
return SubmissionResponse(**submission_data)
|
||||
|
||||
|
||||
@router.delete("/{task_id}/submissions/{submission_id}")
|
||||
async def delete_task_submission(
|
||||
task_id: int,
|
||||
submission_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Soft delete a submission. Users can only delete their own submissions."""
|
||||
|
||||
submission = db.query(Submission).filter(
|
||||
Submission.id == submission_id,
|
||||
Submission.task_id == task_id,
|
||||
Submission.deleted_at.is_(None)
|
||||
).first()
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
|
||||
# Users can only delete their own submissions, unless they have admin permission or submission:delete
|
||||
if (submission.user_id != current_user.id and not current_user.is_admin
|
||||
and not user_has_permission(current_user, 'submission', 'delete', db)):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to delete this submission")
|
||||
|
||||
submission.deleted_at = datetime.utcnow()
|
||||
submission.deleted_by = current_user.id
|
||||
db.commit()
|
||||
|
||||
return {"message": "Submission deleted successfully"}
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
from typing import List, Optional
|
||||
from passlib.context import CryptContext
|
||||
from pathlib import Path
|
||||
@@ -12,7 +12,9 @@ from database import get_db
|
||||
from models.user import User, UserRole
|
||||
from models.project import ProjectMember
|
||||
from models.task import Task
|
||||
from models.role import Role
|
||||
from schemas.user import UserResponse, UserApproval, UserRoleUpdate, UserUpdate, UserAdminUpdate, UserAdminCreate, UserAdminEdit, UserPasswordReset, UserPasswordChange
|
||||
from schemas.role import UserRolesUpdate
|
||||
from utils.auth import get_current_user_from_token, _get_user_from_db, require_admin_permission
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
@@ -98,7 +100,7 @@ async def update_user_role(
|
||||
|
||||
user.role = role_data.role
|
||||
db.commit()
|
||||
|
||||
|
||||
return {
|
||||
"message": f"User {user.email} role updated to {role_data.role}",
|
||||
"user_id": user.id,
|
||||
@@ -106,6 +108,42 @@ async def update_user_role(
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{user_id}/roles", response_model=dict)
|
||||
async def update_user_roles(
|
||||
user_id: int,
|
||||
roles_data: UserRolesUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission_with_db)
|
||||
):
|
||||
"""Replace a user's full set of assigned custom roles (Admin permission required).
|
||||
|
||||
This is the new multi-role system and is independent of the legacy
|
||||
single `role` field updated by PUT /{user_id}/role above.
|
||||
"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
roles = db.query(Role).filter(Role.id.in_(roles_data.role_ids)).all()
|
||||
if len(roles) != len(set(roles_data.role_ids)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="One or more role_ids are invalid"
|
||||
)
|
||||
|
||||
user.roles = roles
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"User {user.email} roles updated",
|
||||
"user_id": user.id,
|
||||
"role_ids": [role.id for role in user.roles]
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{user_id}/admin", response_model=dict)
|
||||
async def update_user_admin_permission(
|
||||
user_id: int,
|
||||
@@ -149,7 +187,7 @@ async def list_users(
|
||||
current_user: User = Depends(require_admin_or_coordinator)
|
||||
):
|
||||
"""List all users (Admin and Coordinator only)."""
|
||||
users = db.query(User).offset(skip).limit(limit).all()
|
||||
users = db.query(User).options(selectinload(User.roles)).offset(skip).limit(limit).all()
|
||||
return users
|
||||
|
||||
|
||||
@@ -169,7 +207,10 @@ async def get_current_user_profile(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's profile."""
|
||||
from utils.auth import compute_effective_permissions
|
||||
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
current_user.permissions = compute_effective_permissions(current_user, db)
|
||||
return current_user
|
||||
|
||||
|
||||
@@ -214,7 +255,7 @@ async def get_user(
|
||||
current_user: User = Depends(require_admin_or_coordinator)
|
||||
):
|
||||
"""Get user by ID (Admin and Coordinator only)."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
user = db.query(User).options(selectinload(User.roles)).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -255,7 +296,12 @@ async def admin_create_user(
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
|
||||
from utils.auth import link_system_role
|
||||
link_system_role(new_user, db)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
return new_user
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class PermissionResponse(BaseModel):
|
||||
id: int
|
||||
resource: str
|
||||
action: str
|
||||
description: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RoleSummary(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
is_system: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RoleResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
is_system: bool
|
||||
permissions: List[PermissionResponse]
|
||||
user_count: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RoleCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
permission_ids: List[int] = []
|
||||
|
||||
|
||||
class RoleUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
permission_ids: Optional[List[int]] = None
|
||||
|
||||
|
||||
class UserRolesUpdate(BaseModel):
|
||||
role_ids: List[int]
|
||||
@@ -3,7 +3,7 @@ from typing import Optional, List
|
||||
from datetime import date, datetime
|
||||
from enum import Enum
|
||||
|
||||
from models.task import TaskType, TaskStatus, ReviewDecision, AttachmentType
|
||||
from models.task import TaskType, TaskStatus, ReviewDecision, AttachmentType, NoteType
|
||||
from models.user import DepartmentRole
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ class ProductionNoteBase(BaseModel):
|
||||
|
||||
|
||||
class ProductionNoteCreate(ProductionNoteBase):
|
||||
pass
|
||||
note_type: NoteType = NoteType.INTERNAL
|
||||
|
||||
|
||||
class ProductionNoteUpdate(BaseModel):
|
||||
@@ -105,6 +105,7 @@ class ProductionNoteResponse(ProductionNoteBase):
|
||||
id: int
|
||||
task_id: int
|
||||
user_id: int
|
||||
note_type: NoteType
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -162,6 +163,10 @@ class SubmissionCreate(SubmissionBase):
|
||||
pass
|
||||
|
||||
|
||||
class SubmissionUpdate(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class SubmissionResponse(SubmissionBase):
|
||||
id: int
|
||||
task_id: int
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from models.user import UserRole
|
||||
from schemas.role import RoleSummary
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
@@ -30,6 +31,8 @@ class UserResponse(UserBase):
|
||||
avatar_url: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
roles: List[RoleSummary] = []
|
||||
permissions: Optional[List[str]] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -244,6 +244,101 @@ def require_admin_permission():
|
||||
return admin_checker
|
||||
|
||||
|
||||
def compute_effective_permissions(current_user, db: Session) -> list:
|
||||
"""Return the current user's effective permissions as "resource:action" strings.
|
||||
|
||||
is_admin gets every permission in the catalog; otherwise the union of all
|
||||
permissions granted by any of the user's assigned roles (most-permissive-wins).
|
||||
"""
|
||||
from models.role import Permission, role_permissions, user_roles
|
||||
|
||||
if current_user.is_admin:
|
||||
rows = db.query(Permission.resource, Permission.action).all()
|
||||
else:
|
||||
rows = (
|
||||
db.query(Permission.resource, Permission.action)
|
||||
.join(role_permissions, role_permissions.c.permission_id == Permission.id)
|
||||
.join(user_roles, user_roles.c.role_id == role_permissions.c.role_id)
|
||||
.filter(user_roles.c.user_id == current_user.id)
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
return [f"{resource}:{action}" for resource, action in rows]
|
||||
|
||||
|
||||
def link_system_role(user, db: Session) -> None:
|
||||
"""Link a newly-created user to the system Role matching their legacy
|
||||
`role` value, mirroring the one-time migration backfill so new users
|
||||
start with the same baseline permissions as already-migrated ones.
|
||||
No-op if the matching system role doesn't exist or is already linked.
|
||||
Caller is responsible for commit/refresh.
|
||||
"""
|
||||
from models.role import Role
|
||||
|
||||
role = db.query(Role).filter(Role.name == user.role.value, Role.is_system == True).first()
|
||||
if role and role not in user.roles:
|
||||
user.roles.append(role)
|
||||
|
||||
|
||||
def user_has_permission(current_user, resource: str, action: str, db: Session) -> bool:
|
||||
"""Most-permissive-wins check across all of current_user's assigned roles.
|
||||
|
||||
is_admin is a universal bypass and never needs a role/permission row.
|
||||
"""
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
|
||||
from models.role import Permission, role_permissions, user_roles
|
||||
|
||||
exists = (
|
||||
db.query(Permission.id)
|
||||
.join(role_permissions, role_permissions.c.permission_id == Permission.id)
|
||||
.join(user_roles, user_roles.c.role_id == role_permissions.c.role_id)
|
||||
.filter(
|
||||
user_roles.c.user_id == current_user.id,
|
||||
Permission.resource == resource,
|
||||
Permission.action == action,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return exists is not None
|
||||
|
||||
|
||||
def require_permission(resource: str, action: str):
|
||||
"""Dependency factory requiring the current user to hold resource:action
|
||||
via any assigned role (or be an admin). Usage: Depends(require_permission('shot', 'create'))
|
||||
"""
|
||||
def permission_checker(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(lambda: None)
|
||||
):
|
||||
from database import get_db
|
||||
|
||||
# Get database session if not provided
|
||||
if db is None:
|
||||
db_gen = get_db()
|
||||
db = next(db_gen)
|
||||
try:
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
if not user_has_permission(current_user, resource, action, db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing permission: {resource}:{action}"
|
||||
)
|
||||
return current_user
|
||||
finally:
|
||||
db.close()
|
||||
else:
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
if not user_has_permission(current_user, resource, action, db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing permission: {resource}:{action}"
|
||||
)
|
||||
return current_user
|
||||
return permission_checker
|
||||
|
||||
|
||||
def create_role_dependency(required_roles: list):
|
||||
"""Create a dependency that requires specific user roles with proper DB injection."""
|
||||
def role_checker(
|
||||
|
||||
@@ -181,6 +181,7 @@ import {
|
||||
Camera,
|
||||
Package,
|
||||
ListTodo,
|
||||
ShieldCheck,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -271,6 +272,7 @@ const navigationItems = computed(() => {
|
||||
// Admin-specific navigation items
|
||||
const adminItems = computed(() => [
|
||||
{ title: 'Recovery Management', url: '/admin/deleted-items', icon: RotateCcw },
|
||||
{ title: 'Role Management', url: '/admin/roles', icon: ShieldCheck },
|
||||
])
|
||||
|
||||
// Developer-specific navigation items
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="$emit('update:open', $event)">
|
||||
<DialogContent class="sm:max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ role ? 'Edit Role' : 'Create Role' }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ role?.is_system
|
||||
? 'System role permissions are editable, but the name and description cannot be changed.'
|
||||
: 'Define a role and the permissions it grants.' }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="flex-1 min-h-0 flex flex-col gap-4">
|
||||
<div class="grid grid-cols-2 gap-4 flex-shrink-0">
|
||||
<div class="space-y-2">
|
||||
<Label for="role_name">Name</Label>
|
||||
<Input
|
||||
id="role_name"
|
||||
v-model="formData.name"
|
||||
placeholder="e.g. Reviewer"
|
||||
required
|
||||
:disabled="role?.is_system"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="role_description">Description</Label>
|
||||
<Input
|
||||
id="role_description"
|
||||
v-model="formData.description"
|
||||
placeholder="What this role is for"
|
||||
:disabled="role?.is_system"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between flex-shrink-0">
|
||||
<Label>Permissions</Label>
|
||||
<span class="text-xs text-muted-foreground">{{ selectedPermissionIds.size }} of {{ permissions.length }} selected</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto rounded-md border p-3 space-y-4">
|
||||
<div v-for="group in permissionGroups" :key="group.title" class="space-y-2">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ group.title }}</h4>
|
||||
<div class="space-y-2">
|
||||
<div v-for="resource in group.resources" :key="resource" class="rounded-md border bg-muted/30 p-2.5">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2 text-sm font-medium capitalize">
|
||||
<component :is="resourceIcon(resource)" class="h-4 w-4 text-muted-foreground" />
|
||||
{{ resource }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
@click="toggleAllForResource(resource)"
|
||||
>
|
||||
{{ allSelectedForResource(resource) ? 'Clear' : 'Select all' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1.5">
|
||||
<label
|
||||
v-for="perm in permissionsForResource(resource)"
|
||||
:key="perm.id"
|
||||
class="flex items-center gap-1.5 text-sm cursor-pointer"
|
||||
:title="perm.description || undefined"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="selectedPermissionIds.has(perm.id)"
|
||||
@update:model-value="(val) => togglePermission(perm.id, !!val)"
|
||||
/>
|
||||
{{ actionLabel(perm.action) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="formError" class="text-sm text-destructive flex-shrink-0">{{ formError }}</p>
|
||||
|
||||
<DialogFooter class="flex-shrink-0">
|
||||
<Button type="button" variant="outline" @click="$emit('update:open', false)">Cancel</Button>
|
||||
<Button type="submit" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
Package, Camera, ListTodo, UserCheck, CheckCircle2, UploadCloud, Paperclip, MessageSquare, Shield
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import type { Role, Permission } from '@/services/role'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
role: Role | null
|
||||
permissions: Permission[]
|
||||
saving?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
saved: [data: { name?: string; description?: string; permission_ids: number[] }]
|
||||
}>()
|
||||
|
||||
const formData = ref({ name: '', description: '' })
|
||||
const selectedPermissionIds = ref<Set<number>>(new Set())
|
||||
const formError = ref('')
|
||||
|
||||
// Resources grouped into scannable sections, in a fixed, deliberate order
|
||||
// (not alphabetical) so related concepts sit together.
|
||||
const RESOURCE_GROUPS: { title: string; resources: string[] }[] = [
|
||||
{ title: 'Production', resources: ['asset', 'shot', 'task'] },
|
||||
{ title: 'Task workflow', resources: ['assignment', 'submission', 'upload'] },
|
||||
{ title: 'Review', resources: ['review'] },
|
||||
{ title: 'Notes', resources: ['note'] },
|
||||
]
|
||||
|
||||
const RESOURCE_ICONS: Record<string, any> = {
|
||||
asset: Package,
|
||||
shot: Camera,
|
||||
task: ListTodo,
|
||||
assignment: UserCheck,
|
||||
submission: UploadCloud,
|
||||
upload: Paperclip,
|
||||
review: CheckCircle2,
|
||||
note: MessageSquare,
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
create: 'Create',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
publish: 'Approve',
|
||||
retake: 'Request Retake',
|
||||
view_internal: 'View Internal',
|
||||
view_client: 'View Client',
|
||||
change_status: 'Change Status',
|
||||
}
|
||||
|
||||
function resourceIcon(resource: string) {
|
||||
return RESOURCE_ICONS[resource] ?? Shield
|
||||
}
|
||||
|
||||
function actionLabel(action: string): string {
|
||||
return ACTION_LABELS[action] ?? action
|
||||
}
|
||||
|
||||
// Only show groups/resources that actually have permissions in the catalog,
|
||||
// and fall back to a catch-all group for any future resource not yet
|
||||
// assigned to a section above (so nothing silently disappears from the UI).
|
||||
const permissionGroups = computed(() => {
|
||||
const knownResources = new Set(RESOURCE_GROUPS.flatMap(g => g.resources))
|
||||
const groups = RESOURCE_GROUPS
|
||||
.map(g => ({ title: g.title, resources: g.resources.filter(r => permissionsForResource(r).length > 0) }))
|
||||
.filter(g => g.resources.length > 0)
|
||||
|
||||
const otherResources = [...new Set(props.permissions.map(p => p.resource))].filter(r => !knownResources.has(r))
|
||||
if (otherResources.length > 0) {
|
||||
groups.push({ title: 'Other', resources: otherResources })
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function permissionsForResource(resource: string): Permission[] {
|
||||
return props.permissions.filter(p => p.resource === resource)
|
||||
}
|
||||
|
||||
function allSelectedForResource(resource: string): boolean {
|
||||
const perms = permissionsForResource(resource)
|
||||
return perms.length > 0 && perms.every(p => selectedPermissionIds.value.has(p.id))
|
||||
}
|
||||
|
||||
function toggleAllForResource(resource: string) {
|
||||
const perms = permissionsForResource(resource)
|
||||
const shouldSelect = !allSelectedForResource(resource)
|
||||
for (const perm of perms) {
|
||||
if (shouldSelect) selectedPermissionIds.value.add(perm.id)
|
||||
else selectedPermissionIds.value.delete(perm.id)
|
||||
}
|
||||
}
|
||||
|
||||
function togglePermission(id: number, checked: boolean) {
|
||||
if (checked) selectedPermissionIds.value.add(id)
|
||||
else selectedPermissionIds.value.delete(id)
|
||||
}
|
||||
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (!isOpen) return
|
||||
formError.value = ''
|
||||
formData.value = {
|
||||
name: props.role?.name ?? '',
|
||||
description: props.role?.description ?? ''
|
||||
}
|
||||
selectedPermissionIds.value = new Set(props.role?.permissions.map(p => p.id) ?? [])
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
formError.value = ''
|
||||
if (!props.role && !formData.value.name.trim()) {
|
||||
formError.value = 'Name is required'
|
||||
return
|
||||
}
|
||||
|
||||
const payload = props.role?.is_system
|
||||
? { permission_ids: [...selectedPermissionIds.value] }
|
||||
: {
|
||||
name: formData.value.name,
|
||||
description: formData.value.description || undefined,
|
||||
permission_ids: [...selectedPermissionIds.value]
|
||||
}
|
||||
emit('saved', payload)
|
||||
}
|
||||
</script>
|
||||
@@ -30,6 +30,7 @@
|
||||
<span v-if="note.updated_at !== note.created_at" class="text-xs text-muted-foreground">
|
||||
(edited)
|
||||
</span>
|
||||
<Badge v-if="note.note_type === 'client'" variant="outline" class="text-xs">Client</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Note Content -->
|
||||
@@ -117,6 +118,7 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { Reply, Pencil, Trash2 } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
@@ -131,6 +133,7 @@ import {
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { taskService, type ProductionNote } from '@/services/task'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -145,17 +148,20 @@ const emit = defineEmits<{
|
||||
|
||||
const { toast } = useToast()
|
||||
const authStore = useAuthStore()
|
||||
const { hasPermission } = usePermission()
|
||||
|
||||
const editing = ref(false)
|
||||
const editContent = ref('')
|
||||
const showDeleteDialog = ref(false)
|
||||
|
||||
const isOwnNote = computed(() => authStore.user?.id === props.note.user_id)
|
||||
|
||||
const canEdit = computed(() => {
|
||||
return authStore.user?.id === props.note.user_id || authStore.user?.is_admin
|
||||
return isOwnNote.value || authStore.user?.is_admin || hasPermission('note', 'edit')
|
||||
})
|
||||
|
||||
const canDelete = computed(() => {
|
||||
return authStore.user?.id === props.note.user_id || authStore.user?.is_admin
|
||||
return isOwnNote.value || authStore.user?.is_admin || hasPermission('note', 'delete')
|
||||
})
|
||||
|
||||
function getInitials(firstName: string, lastName: string): string {
|
||||
|
||||
@@ -56,11 +56,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="submission.notes" class="text-sm bg-muted p-2 rounded">
|
||||
<div v-if="!editing && submission.notes" class="text-sm bg-muted p-2 rounded">
|
||||
<p class="font-semibold text-xs mb-1">Notes:</p>
|
||||
<p class="line-clamp-2">{{ submission.notes }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="editing" class="space-y-2">
|
||||
<Textarea v-model="editNotes" rows="2" class="resize-none text-sm" placeholder="Notes about this submission..." />
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" @click="handleSaveNotes">Save</Button>
|
||||
<Button size="sm" variant="outline" @click="editing = false">Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="submission.latest_review?.feedback" class="text-sm border-l-2 pl-2" :class="getReviewBorderClass(submission.latest_review.decision)">
|
||||
<p class="font-semibold text-xs mb-1">Review Feedback:</p>
|
||||
<p class="line-clamp-2">{{ submission.latest_review.feedback }}</p>
|
||||
@@ -69,37 +77,122 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="emit('view', submission)"
|
||||
>
|
||||
<Eye class="h-4 w-4 mr-2" />
|
||||
View Details
|
||||
</Button>
|
||||
<div v-if="!editing" class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="emit('view', submission)"
|
||||
>
|
||||
<Eye class="h-4 w-4 mr-2" />
|
||||
View Details
|
||||
</Button>
|
||||
<Button v-if="canEdit" variant="ghost" size="sm" @click="startEdit">
|
||||
<Pencil class="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button v-if="canDelete" variant="ghost" size="sm" @click="showDeleteDialog = true">
|
||||
<Trash2 class="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog :open="showDeleteDialog" @update:open="(val: boolean) => { showDeleteDialog = val }">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Submission</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this submission? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction @click="handleDelete" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { FileIcon, Download, Eye, Play } from 'lucide-vue-next'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { FileIcon, Download, Eye, Play, Pencil, Trash2 } from 'lucide-vue-next'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import type { Submission } from '@/services/task'
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { taskService, type Submission } from '@/services/task'
|
||||
import { apiClient } from '@/services/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
submission: Submission
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
view: [submission: Submission]
|
||||
submissionUpdated: []
|
||||
}>()
|
||||
|
||||
const { toast } = useToast()
|
||||
const authStore = useAuthStore()
|
||||
const { hasPermission } = usePermission()
|
||||
|
||||
const isOwnSubmission = computed(() => authStore.user?.id === props.submission.user_id)
|
||||
const canEdit = computed(() => isOwnSubmission.value || authStore.user?.is_admin || hasPermission('submission', 'edit'))
|
||||
const canDelete = computed(() => isOwnSubmission.value || authStore.user?.is_admin || hasPermission('submission', 'delete'))
|
||||
|
||||
const editing = ref(false)
|
||||
const editNotes = ref('')
|
||||
const showDeleteDialog = ref(false)
|
||||
|
||||
function startEdit() {
|
||||
editing.value = true
|
||||
editNotes.value = props.submission.notes || ''
|
||||
}
|
||||
|
||||
async function handleSaveNotes() {
|
||||
try {
|
||||
await taskService.updateSubmission(props.taskId, props.submission.id, editNotes.value)
|
||||
editing.value = false
|
||||
emit('submissionUpdated')
|
||||
toast({ title: 'Success', description: 'Submission updated successfully' })
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to update submission',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await taskService.deleteSubmission(props.taskId, props.submission.id)
|
||||
emit('submissionUpdated')
|
||||
toast({ title: 'Success', description: 'Submission deleted successfully' })
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to delete submission',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
showDeleteDialog.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const thumbnailBlobUrl = ref<string | null>(null)
|
||||
|
||||
function getFileExtension(filename: string): string {
|
||||
|
||||
@@ -26,7 +26,25 @@
|
||||
rows="2"
|
||||
class="resize-none text-sm"
|
||||
/>
|
||||
<div class="flex justify-end">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
:variant="newNoteType === 'internal' ? 'secondary' : 'ghost'"
|
||||
@click="newNoteType = 'internal'"
|
||||
>
|
||||
Internal
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
:variant="newNoteType === 'client' ? 'secondary' : 'ghost'"
|
||||
@click="newNoteType = 'client'"
|
||||
>
|
||||
Client
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
@click="handleAddNote"
|
||||
:disabled="!newNoteContent.trim() || submitting"
|
||||
@@ -47,7 +65,7 @@ import { MessageSquarePlus } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import NoteItem from './NoteItem.vue'
|
||||
import { taskService, type ProductionNote } from '@/services/task'
|
||||
import { taskService, type ProductionNote, type NoteType } from '@/services/task'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -62,6 +80,7 @@ const emit = defineEmits<{
|
||||
const { toast } = useToast()
|
||||
|
||||
const newNoteContent = ref('')
|
||||
const newNoteType = ref<NoteType>('internal')
|
||||
const submitting = ref(false)
|
||||
const replyToNoteId = ref<number | null>(null)
|
||||
|
||||
@@ -73,9 +92,11 @@ async function handleAddNote() {
|
||||
await taskService.createTaskNote(
|
||||
props.taskId,
|
||||
newNoteContent.value,
|
||||
replyToNoteId.value || undefined
|
||||
replyToNoteId.value || undefined,
|
||||
newNoteType.value
|
||||
)
|
||||
newNoteContent.value = ''
|
||||
newNoteType.value = 'internal'
|
||||
replyToNoteId.value = null
|
||||
emit('notesUpdated')
|
||||
toast({
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
<SubmissionCard
|
||||
v-for="submission in submissions"
|
||||
:key="submission.id"
|
||||
:task-id="taskId"
|
||||
:submission="submission"
|
||||
@view="handleView"
|
||||
@submission-updated="emit('submissionsUpdated')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -114,7 +114,10 @@
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{{ formatRole(user.role) }}</Badge>
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<Badge variant="outline">{{ formatRole(user.role) }}</Badge>
|
||||
<Badge v-for="role in user.roles" :key="role.id" variant="secondary">{{ role.name }}</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge v-if="user.is_admin" variant="destructive">Admin</Badge>
|
||||
@@ -144,6 +147,10 @@
|
||||
<Key class="h-4 w-4 mr-2" />
|
||||
Reset Password
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="handleManageRoles(user)">
|
||||
<ShieldCheck class="h-4 w-4 mr-2" />
|
||||
Manage Roles
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
v-if="!user.is_approved"
|
||||
@@ -216,7 +223,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { MoreHorizontal, Eye, Check, UserX, UserCheck, Edit, Key, Trash2, ArrowUpDown } from "lucide-vue-next";
|
||||
import { MoreHorizontal, Eye, Check, UserX, UserCheck, Edit, Key, Trash2, ArrowUpDown, ShieldCheck } from "lucide-vue-next";
|
||||
import type { User } from "@/types/auth";
|
||||
|
||||
interface Props {
|
||||
@@ -232,6 +239,7 @@ interface Emits {
|
||||
(e: "approveUser", userId: number): void;
|
||||
(e: "editUser", user: User): void;
|
||||
(e: "resetPassword", user: User): void;
|
||||
(e: "manageRoles", user: User): void;
|
||||
(e: "deleteUser", user: User): void;
|
||||
}
|
||||
|
||||
@@ -378,6 +386,10 @@ const handleResetPassword = (user: User) => {
|
||||
emit("resetPassword", user);
|
||||
};
|
||||
|
||||
const handleManageRoles = (user: User) => {
|
||||
emit("manageRoles", user);
|
||||
};
|
||||
|
||||
const handleDeleteUser = (user: User) => {
|
||||
emit("deleteUser", user);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="$emit('update:open', $event)">
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Roles</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ user ? `${user.first_name} ${user.last_name}` : '' }} — assign custom roles in addition to their base role
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-2 max-h-72 overflow-auto">
|
||||
<label
|
||||
v-for="role in roles"
|
||||
:key="role.id"
|
||||
class="flex items-center gap-2 rounded-md p-2 hover:bg-accent cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="selectedRoleIds.has(role.id)"
|
||||
@update:model-value="(val) => toggleRole(role.id, !!val)"
|
||||
/>
|
||||
<span class="flex-1">{{ role.name }}</span>
|
||||
<Badge :variant="role.is_system ? 'secondary' : 'outline'">{{ role.is_system ? 'System' : 'Custom' }}</Badge>
|
||||
</label>
|
||||
<p v-if="roles.length === 0" class="text-sm text-muted-foreground p-2">No roles available.</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" @click="$emit('update:open', false)">Cancel</Button>
|
||||
<Button :disabled="saving" @click="handleSave">{{ saving ? 'Saving...' : 'Save' }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { Role } from '@/services/role'
|
||||
import type { User } from '@/types/auth'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
user: User | null
|
||||
roles: Role[]
|
||||
saving?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
saved: [roleIds: number[]]
|
||||
}>()
|
||||
|
||||
const selectedRoleIds = ref<Set<number>>(new Set())
|
||||
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (!isOpen) return
|
||||
selectedRoleIds.value = new Set(props.user?.roles?.map(r => r.id) ?? [])
|
||||
})
|
||||
|
||||
function toggleRole(id: number, checked: boolean) {
|
||||
if (checked) selectedRoleIds.value.add(id)
|
||||
else selectedRoleIds.value.delete(id)
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
emit('saved', [...selectedRoleIds.value])
|
||||
}
|
||||
</script>
|
||||
@@ -15,5 +15,15 @@ export function usePermission() {
|
||||
authStore.user?.role === 'coordinator' || !!authStore.user?.is_admin
|
||||
)
|
||||
|
||||
return { isAdmin, isCoordinatorOrAdmin }
|
||||
/**
|
||||
* Resource/action check backed by the new multi-role permission system
|
||||
* (e.g. hasPermission('shot', 'create')). Reads authStore.user.permissions,
|
||||
* which is only ever populated on the current session's own user.
|
||||
*/
|
||||
function hasPermission(resource: string, action: string): boolean {
|
||||
if (authStore.isAdmin) return true
|
||||
return authStore.user?.permissions?.includes(`${resource}:${action}`) ?? false
|
||||
}
|
||||
|
||||
return { isAdmin, isCoordinatorOrAdmin, hasPermission }
|
||||
}
|
||||
|
||||
@@ -129,12 +129,22 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/admin/deleted-items',
|
||||
name: 'RecoveryManagement',
|
||||
component: () => import('@/views/admin/DeletedItemsManagementView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
adminPermission: 'required',
|
||||
title: 'Recovery Management'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/roles',
|
||||
name: 'RoleManagement',
|
||||
component: () => import('@/views/admin/RoleManagementView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
adminPermission: 'required',
|
||||
title: 'Role Management'
|
||||
}
|
||||
},
|
||||
|
||||
// Developer routes
|
||||
{
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { apiClient } from './api'
|
||||
|
||||
export interface Permission {
|
||||
id: number
|
||||
resource: string
|
||||
action: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: number
|
||||
name: string
|
||||
description?: string | null
|
||||
is_system: boolean
|
||||
permissions: Permission[]
|
||||
user_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface RoleCreate {
|
||||
name: string
|
||||
description?: string
|
||||
permission_ids?: number[]
|
||||
}
|
||||
|
||||
export interface RoleUpdate {
|
||||
name?: string
|
||||
description?: string
|
||||
permission_ids?: number[]
|
||||
}
|
||||
|
||||
export const roleService = {
|
||||
async getRoles(): Promise<Role[]> {
|
||||
const response = await apiClient.get('/roles/')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getPermissions(): Promise<Permission[]> {
|
||||
const response = await apiClient.get('/roles/permissions')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async createRole(data: RoleCreate): Promise<Role> {
|
||||
const response = await apiClient.post('/roles/', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateRole(roleId: number, data: RoleUpdate): Promise<Role> {
|
||||
const response = await apiClient.put(`/roles/${roleId}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async deleteRole(roleId: number): Promise<{ message: string }> {
|
||||
const response = await apiClient.delete(`/roles/${roleId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateUserRoles(userId: number, roleIds: number[]): Promise<{ message: string; user_id: number; role_ids: number[] }> {
|
||||
const response = await apiClient.put(`/users/${userId}/roles`, { role_ids: roleIds })
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
@@ -55,9 +55,12 @@ export interface TaskStatusInfo {
|
||||
assigned_user_id?: number
|
||||
}
|
||||
|
||||
export type NoteType = 'internal' | 'client'
|
||||
|
||||
export interface ProductionNote {
|
||||
id: number
|
||||
content: string
|
||||
note_type: NoteType
|
||||
parent_note_id?: number
|
||||
task_id: number
|
||||
user_id: number
|
||||
@@ -184,10 +187,11 @@ class TaskService {
|
||||
return response.data
|
||||
}
|
||||
|
||||
async createTaskNote(taskId: number, content: string, parentNoteId?: number): Promise<ProductionNote> {
|
||||
async createTaskNote(taskId: number, content: string, parentNoteId?: number, noteType: NoteType = 'internal'): Promise<ProductionNote> {
|
||||
const response = await apiClient.post(`/tasks/${taskId}/notes`, {
|
||||
content,
|
||||
parent_note_id: parentNoteId
|
||||
parent_note_id: parentNoteId,
|
||||
note_type: noteType
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
@@ -256,6 +260,15 @@ class TaskService {
|
||||
return response.data
|
||||
}
|
||||
|
||||
async updateSubmission(taskId: number, submissionId: number, notes: string): Promise<Submission> {
|
||||
const response = await apiClient.put(`/tasks/${taskId}/submissions/${submissionId}`, { notes })
|
||||
return response.data
|
||||
}
|
||||
|
||||
async deleteSubmission(taskId: number, submissionId: number): Promise<void> {
|
||||
await apiClient.delete(`/tasks/${taskId}/submissions/${submissionId}`)
|
||||
}
|
||||
|
||||
async createAssetTask(assetId: number, taskType: string): Promise<TaskStatusInfo> {
|
||||
const response = await apiClient.post(`/assets/${assetId}/tasks?task_type=${taskType}`)
|
||||
return response.data
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { roleService, type Role, type Permission, type RoleCreate, type RoleUpdate } from '@/services/role'
|
||||
|
||||
// Cache duration: 5 minutes
|
||||
const CACHE_DURATION = 5 * 60 * 1000
|
||||
|
||||
export const useRolesStore = defineStore('roles', () => {
|
||||
const roles = ref<Role[] | null>(null)
|
||||
const permissions = ref<Permission[] | null>(null)
|
||||
const rolesLastFetched = ref<number>(0)
|
||||
const permissionsLastFetched = ref<number>(0)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
let rolesInFlight: Promise<Role[]> | null = null
|
||||
let permissionsInFlight: Promise<Permission[]> | null = null
|
||||
|
||||
async function fetchRoles(force = false): Promise<Role[]> {
|
||||
const now = Date.now()
|
||||
if (!force && roles.value && now - rolesLastFetched.value < CACHE_DURATION) {
|
||||
return roles.value
|
||||
}
|
||||
if (rolesInFlight) return rolesInFlight
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
rolesInFlight = (async () => {
|
||||
try {
|
||||
const data = await roleService.getRoles()
|
||||
roles.value = data
|
||||
rolesLastFetched.value = Date.now()
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to fetch roles'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
rolesInFlight = null
|
||||
}
|
||||
})()
|
||||
|
||||
return rolesInFlight
|
||||
}
|
||||
|
||||
async function fetchPermissions(force = false): Promise<Permission[]> {
|
||||
const now = Date.now()
|
||||
if (!force && permissions.value && now - permissionsLastFetched.value < CACHE_DURATION) {
|
||||
return permissions.value
|
||||
}
|
||||
if (permissionsInFlight) return permissionsInFlight
|
||||
|
||||
permissionsInFlight = (async () => {
|
||||
try {
|
||||
const data = await roleService.getPermissions()
|
||||
permissions.value = data
|
||||
permissionsLastFetched.value = Date.now()
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to fetch permissions'
|
||||
throw err
|
||||
} finally {
|
||||
permissionsInFlight = null
|
||||
}
|
||||
})()
|
||||
|
||||
return permissionsInFlight
|
||||
}
|
||||
|
||||
async function createRole(data: RoleCreate): Promise<Role> {
|
||||
const role = await roleService.createRole(data)
|
||||
if (roles.value) roles.value = [...roles.value, role]
|
||||
return role
|
||||
}
|
||||
|
||||
async function updateRole(roleId: number, data: RoleUpdate): Promise<Role> {
|
||||
const role = await roleService.updateRole(roleId, data)
|
||||
if (roles.value) roles.value = roles.value.map(r => r.id === roleId ? role : r)
|
||||
return role
|
||||
}
|
||||
|
||||
async function deleteRole(roleId: number): Promise<void> {
|
||||
await roleService.deleteRole(roleId)
|
||||
if (roles.value) roles.value = roles.value.filter(r => r.id !== roleId)
|
||||
}
|
||||
|
||||
return {
|
||||
roles,
|
||||
permissions,
|
||||
isLoading,
|
||||
error,
|
||||
fetchRoles,
|
||||
fetchPermissions,
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole
|
||||
}
|
||||
})
|
||||
@@ -1,3 +1,9 @@
|
||||
export interface RoleSummary {
|
||||
id: number
|
||||
name: string
|
||||
is_system: boolean
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
email: string
|
||||
@@ -9,6 +15,9 @@ export interface User {
|
||||
avatar_url?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
roles?: RoleSummary[]
|
||||
/** Only populated on the current session's own user (from GET /users/me). */
|
||||
permissions?: string[]
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
@approve-user="handleApproveUser"
|
||||
@edit-user="handleEditUser"
|
||||
@reset-password="handleResetPassword"
|
||||
@manage-roles="handleManageRoles"
|
||||
@delete-user="handleDeleteUser"
|
||||
/>
|
||||
</div>
|
||||
@@ -112,6 +113,16 @@
|
||||
:is-deleting="isDeletingUser"
|
||||
/>
|
||||
|
||||
<!-- User Roles (custom multi-role) Dialog -->
|
||||
<UserRolesDialog
|
||||
:open="showRolesDialog"
|
||||
@update:open="showRolesDialog = $event"
|
||||
:user="selectedUser"
|
||||
:roles="rolesStore.roles ?? []"
|
||||
:saving="isSavingRoles"
|
||||
@saved="handleUserRolesSubmit"
|
||||
/>
|
||||
|
||||
<!-- Success Toast -->
|
||||
<div v-if="successMessage" class="fixed bottom-4 right-4 z-50">
|
||||
<Alert class="w-80 bg-green-50 border-green-200 dark:bg-green-950 dark:border-green-800">
|
||||
@@ -131,12 +142,15 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Users, UserPlus, RefreshCw, AlertCircle, Loader2, CheckCircle } from 'lucide-vue-next'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useRolesStore } from '@/stores/roles'
|
||||
import { roleService } from '@/services/role'
|
||||
import PendingUsersDashboard from '@/components/user/PendingUsersDashboard.vue'
|
||||
import UserManagementTable from '@/components/user/UserManagementTable.vue'
|
||||
import UserCreateDialog from '@/components/user/UserCreateDialog.vue'
|
||||
import UserEditDialog from '@/components/user/UserEditDialog.vue'
|
||||
import PasswordResetDialog from '@/components/user/PasswordResetDialog.vue'
|
||||
import UserDeleteConfirmDialog from '@/components/user/UserDeleteConfirmDialog.vue'
|
||||
import UserRolesDialog from '@/components/user/UserRolesDialog.vue'
|
||||
import type { User } from '@/types/auth'
|
||||
import type { UserCreateData } from '@/components/user/UserCreateDialog.vue'
|
||||
import type { UserEditData } from '@/components/user/UserEditDialog.vue'
|
||||
@@ -144,6 +158,7 @@ import type { UserEditData } from '@/components/user/UserEditDialog.vue'
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
const rolesStore = useRolesStore()
|
||||
|
||||
// Local state
|
||||
const processingUserId = ref<number | null>(null)
|
||||
@@ -157,12 +172,14 @@ const showCreateDialog = ref(false)
|
||||
const showEditDialog = ref(false)
|
||||
const showPasswordResetDialog = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showRolesDialog = ref(false)
|
||||
|
||||
// Loading states
|
||||
const isCreatingUser = ref(false)
|
||||
const isEditingUser = ref(false)
|
||||
const isResettingPassword = ref(false)
|
||||
const isDeletingUser = ref(false)
|
||||
const isSavingRoles = ref(false)
|
||||
|
||||
// Computed
|
||||
const users = computed(() => userStore.users)
|
||||
@@ -287,6 +304,31 @@ const handleResetPassword = (user: User) => {
|
||||
showPasswordResetDialog.value = true
|
||||
}
|
||||
|
||||
const handleManageRoles = async (user: User) => {
|
||||
selectedUser.value = user
|
||||
showRolesDialog.value = true
|
||||
try {
|
||||
await rolesStore.fetchRoles()
|
||||
} catch (err) {
|
||||
console.error('Failed to load roles:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUserRolesSubmit = async (roleIds: number[]) => {
|
||||
if (!selectedUser.value) return
|
||||
try {
|
||||
isSavingRoles.value = true
|
||||
await roleService.updateUserRoles(selectedUser.value.id, roleIds)
|
||||
showRolesDialog.value = false
|
||||
showSuccessMessage('User roles updated successfully')
|
||||
await refreshData()
|
||||
} catch (err: any) {
|
||||
showErrorMessage(err.response?.data?.detail || 'Failed to update user roles')
|
||||
} finally {
|
||||
isSavingRoles.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasswordResetSubmit = async (userId: number, password: string) => {
|
||||
try {
|
||||
isResettingPassword.value = true
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div class="container mx-auto py-6 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Role Management</h1>
|
||||
<p class="text-muted-foreground">
|
||||
Create custom roles and edit create/edit/delete permissions for assets, shots, and tasks
|
||||
</p>
|
||||
</div>
|
||||
<Button @click="openCreateDialog">
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Create Role
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent class="p-0">
|
||||
<div v-if="isLoading" class="p-6 text-center text-muted-foreground">Loading roles...</div>
|
||||
<table v-else class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b bg-muted/50">
|
||||
<th class="text-left font-medium p-3">Name</th>
|
||||
<th class="text-left font-medium p-3">Description</th>
|
||||
<th class="text-left font-medium p-3">Permissions</th>
|
||||
<th class="text-left font-medium p-3">Users</th>
|
||||
<th class="text-right font-medium p-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="role in roles" :key="role.id" class="border-b last:border-b-0">
|
||||
<td class="p-3 font-medium">
|
||||
{{ role.name }}
|
||||
<Badge :variant="role.is_system ? 'secondary' : 'outline'" class="ml-2">
|
||||
{{ role.is_system ? 'System' : 'Custom' }}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="p-3 text-muted-foreground">{{ role.description || '—' }}</td>
|
||||
<td class="p-3">{{ role.permissions.length }}</td>
|
||||
<td class="p-3">{{ role.user_count }}</td>
|
||||
<td class="p-3 text-right space-x-2">
|
||||
<Button variant="ghost" size="sm" @click="openEditDialog(role)">
|
||||
<Pencil class="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="role.is_system || role.user_count > 0"
|
||||
:title="role.is_system ? 'System roles cannot be deleted' : role.user_count > 0 ? 'Reassign users before deleting' : 'Delete role'"
|
||||
@click="confirmDelete(role)"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<RoleFormDialog
|
||||
v-model:open="showFormDialog"
|
||||
:role="editingRole"
|
||||
:permissions="permissions"
|
||||
:saving="isSaving"
|
||||
@saved="handleSave"
|
||||
/>
|
||||
|
||||
<AlertDialog :open="showDeleteDialog" @update:open="(val: boolean) => { showDeleteDialog = val }">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete role "{{ roleToDelete?.name }}"?</AlertDialogTitle>
|
||||
<AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction @click="handleDelete">Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { useRolesStore } from '@/stores/roles'
|
||||
import RoleFormDialog from '@/components/role/RoleFormDialog.vue'
|
||||
import type { Role } from '@/services/role'
|
||||
|
||||
const { toast } = useToast()
|
||||
const rolesStore = useRolesStore()
|
||||
|
||||
const isLoading = ref(true)
|
||||
const isSaving = ref(false)
|
||||
const showFormDialog = ref(false)
|
||||
const editingRole = ref<Role | null>(null)
|
||||
const showDeleteDialog = ref(false)
|
||||
const roleToDelete = ref<Role | null>(null)
|
||||
|
||||
const roles = computed(() => rolesStore.roles ?? [])
|
||||
const permissions = computed(() => rolesStore.permissions ?? [])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([rolesStore.fetchRoles(), rolesStore.fetchPermissions()])
|
||||
} catch (err: any) {
|
||||
toast({ title: 'Error', description: err.response?.data?.detail || 'Failed to load roles', variant: 'destructive' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function openCreateDialog() {
|
||||
editingRole.value = null
|
||||
showFormDialog.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(role: Role) {
|
||||
editingRole.value = role
|
||||
showFormDialog.value = true
|
||||
}
|
||||
|
||||
async function handleSave(data: { name?: string; description?: string; permission_ids: number[] }) {
|
||||
isSaving.value = true
|
||||
try {
|
||||
if (editingRole.value) {
|
||||
await rolesStore.updateRole(editingRole.value.id, data)
|
||||
toast({ title: 'Role updated', description: `"${editingRole.value.name}" was updated.` })
|
||||
} else {
|
||||
const role = await rolesStore.createRole({ name: data.name!, description: data.description, permission_ids: data.permission_ids })
|
||||
toast({ title: 'Role created', description: `"${role.name}" was created.` })
|
||||
}
|
||||
showFormDialog.value = false
|
||||
} catch (err: any) {
|
||||
toast({ title: 'Error', description: err.response?.data?.detail || 'Failed to save role', variant: 'destructive' })
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(role: Role) {
|
||||
roleToDelete.value = role
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
// Captured locally: AlertDialogAction closes the dialog (and fires @update:open)
|
||||
// as part of the same click, so showDeleteDialog can't be trusted to still
|
||||
// reflect "open" by the time this runs - roleToDelete is never reset by that
|
||||
// close, only here, so it's safe to read.
|
||||
const role = roleToDelete.value
|
||||
if (!role) return
|
||||
try {
|
||||
await rolesStore.deleteRole(role.id)
|
||||
toast({ title: 'Role deleted', description: `"${role.name}" was deleted.` })
|
||||
} catch (err: any) {
|
||||
toast({ title: 'Error', description: err.response?.data?.detail || 'Failed to delete role', variant: 'destructive' })
|
||||
} finally {
|
||||
showDeleteDialog.value = false
|
||||
roleToDelete.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user