From f17d837399f16e20c77a2ad032ca9593a1f495a2 Mon Sep 17 00:00:00 2001
From: Dinesh <97143739+dinesh-aot@users.noreply.github.com>
Date: Wed, 10 Jun 2026 13:35:34 -0700
Subject: [PATCH 1/5] review feedbacks. white background color for accept
update (#921)
---
.../App/Submission/ItemsTable/ItemsTableHead.tsx | 13 +++++++++----
.../components/App/Submission/SuccessBox/index.tsx | 1 -
.../Shared/ActionSplitButton/ActionSplitButton.tsx | 2 --
3 files changed, 9 insertions(+), 7 deletions(-)
diff --git a/submit-web/src/components/App/Submission/ItemsTable/ItemsTableHead.tsx b/submit-web/src/components/App/Submission/ItemsTable/ItemsTableHead.tsx
index 75b9b441f..751a8281f 100644
--- a/submit-web/src/components/App/Submission/ItemsTable/ItemsTableHead.tsx
+++ b/submit-web/src/components/App/Submission/ItemsTable/ItemsTableHead.tsx
@@ -5,12 +5,17 @@ import { SubmissionPackageApprovalType } from "@/models/Package";
import InfoIcon from "@mui/icons-material/Info";
import TypeASvg from "@/assets/approval_type_svgs/submission-flow-type-a.svg";
import TypeCSvg from "@/assets/approval_type_svgs/submission-flow-type-c.svg";
+import { useAccount } from "@/store/accountStore";
+import { USER_TYPE } from "@/models/User";
type ItemsTableHeadProps = Readonly<{
approvalType?: SubmissionPackageApprovalType;
}>;
export default function ItemsTableHead({ approvalType }: ItemsTableHeadProps) {
+ const { userType } = useAccount();
+ const isStaffUser = userType === USER_TYPE.STAFF;
+
const getSvgForApprovalType = () => {
if (!approvalType) return null;
return approvalType === SubmissionPackageApprovalType.A ? TypeASvg : TypeCSvg;
@@ -56,8 +61,8 @@ export default function ItemsTableHead({ approvalType }: ItemsTableHeadProps) {
}}
>
- Actions
- {svgSrc && (
+ Actions
+ {svgSrc && isStaffUser && (
{AppConfig.supportEmail}
- .
diff --git a/submit-web/src/components/Shared/ActionSplitButton/ActionSplitButton.tsx b/submit-web/src/components/Shared/ActionSplitButton/ActionSplitButton.tsx
index 6e05181d4..dcac2b33b 100644
--- a/submit-web/src/components/Shared/ActionSplitButton/ActionSplitButton.tsx
+++ b/submit-web/src/components/Shared/ActionSplitButton/ActionSplitButton.tsx
@@ -49,7 +49,6 @@ export default function ActionSplitButton({
fontWeight: 400,
color: "#036",
borderColor: "#036",
- backgroundColor: "transparent",
textTransform: "none",
px: 1.5,
borderRadius: "3px",
@@ -86,7 +85,6 @@ export default function ActionSplitButton({
fontSize: "12px",
fontWeight: 400,
color: "#036",
- backgroundColor: "transparent",
textTransform: "none",
px: 1.5,
"&:hover": {
From 27208eac48fe8ea6381245525d7b6d2ab3b627b7 Mon Sep 17 00:00:00 2001
From: dinesh
Date: Mon, 29 Jun 2026 13:12:00 -0700
Subject: [PATCH 2/5] Approval/Not Approval restriction by TEAM_LEAD
---
.../models/queries/account_project.py | 47 +++++++++++++++++++
.../src/submit_api/resources/package.py | 13 ++++-
.../schemas/account_project_work.py | 1 +
.../src/submit_api/services/authorization.py | 4 ++
.../submit_api/services/package_service.py | 33 ++++++++++++-
.../src/hooks/useStaffSubmissionPage.ts | 8 +++-
submit-web/src/models/AccountProjectWork.ts | 3 ++
7 files changed, 106 insertions(+), 3 deletions(-)
diff --git a/submit-api/src/submit_api/models/queries/account_project.py b/submit-api/src/submit_api/models/queries/account_project.py
index 4e60afa6d..884772305 100644
--- a/submit-api/src/submit_api/models/queries/account_project.py
+++ b/submit-api/src/submit_api/models/queries/account_project.py
@@ -89,6 +89,12 @@ def get_account_project_by_id(cls, account_project_id: int, is_staff: bool):
package, user_type
)
+ # Attach work roles to AccountProjectWork objects for staff users
+ if account_project and is_staff and user and user.type == UserType.STAFF and user.staff_user:
+ cls._attach_work_roles_to_account_project_works(
+ account_project.account_project_works, user.staff_user.id
+ )
+
schema_class = StaffAccountProjectSchema if is_staff else AccountProjectSchema
account_project_dict = schema_class().dump(account_project)
@@ -215,6 +221,13 @@ def get_full_account_projects(cls, account_project_ids: list,
if package.id in package_statuses:
package._calculated_status = package_statuses[package.id]
+ # Attach work roles to AccountProjectWork objects for staff users
+ if not is_proponent and user and user.type == UserType.STAFF and user.staff_user:
+ for account_project in account_projects:
+ cls._attach_work_roles_to_account_project_works(
+ account_project.account_project_works, user.staff_user.id
+ )
+
schema_class = AccountProjectSchema if is_proponent else StaffAccountProjectSchema
account_projects_list = schema_class(many=True).dump(account_projects)
@@ -286,6 +299,40 @@ def _filter_by_search_criteria(cls, search_options: AccountProjectSearchOptions)
return query
+ @classmethod
+ def _attach_work_roles_to_account_project_works(cls, account_project_works, staff_user_id: int):
+ """Attach work roles to AccountProjectWork objects using a single query.
+
+ Args:
+ account_project_works: List of AccountProjectWork objects
+ staff_user_id: ID of the staff user
+ """
+ if not account_project_works:
+ return
+
+ # Get all work IDs from the account_project_works
+ work_ids = [apw.work_id for apw in account_project_works]
+
+ if not work_ids:
+ return
+
+ # Single query to get all work roles for this staff user
+ work_roles = db.session.query(
+ StaffUserWork.work_id,
+ StaffUserWork.role
+ ).filter(
+ StaffUserWork.staff_user_id == staff_user_id,
+ StaffUserWork.work_id.in_(work_ids),
+ StaffUserWork.is_active == True # noqa: E712
+ ).all()
+
+ # Create a mapping of work_id to role
+ work_role_map = {work_id: role for work_id, role in work_roles}
+
+ # Attach the role to each AccountProjectWork object
+ for apw in account_project_works:
+ apw.current_user_work_role = work_role_map.get(apw.work_id)
+
@classmethod
def _get_accessible_work_ids_for_staff_user(cls, user: User):
"""Get list of work IDs accessible to a staff user."""
diff --git a/submit-api/src/submit_api/resources/package.py b/submit-api/src/submit_api/resources/package.py
index 2d05e6cbb..3d551c244 100644
--- a/submit-api/src/submit_api/resources/package.py
+++ b/submit-api/src/submit_api/resources/package.py
@@ -70,7 +70,11 @@ class Package(Resource):
def get(package_id):
"""Get package by id."""
is_staff = jwt.contains_role([EpicSubmitRole.EAO_VIEW.value])
- if not is_staff:
+ if is_staff:
+ # Call has_access_to_package to set g.work_role for staff users
+ authorization.has_access_to_package(package_id)
+ else:
+ # For proponents, check access
authorization.has_access_to_package(package_id)
package = PackageService.get_package_by_id(package_id)
if not package:
@@ -116,11 +120,18 @@ class PackageState(Resource):
code=HTTPStatus.OK, model=package_model, description="Updated Package"
)
@API.response(HTTPStatus.BAD_REQUEST, "Bad Request")
+ @API.response(HTTPStatus.FORBIDDEN, "Forbidden - Team Lead access required for approval")
@cross_origin(origins=allowedorigins())
@auth.require
def post(package_id):
"""Update package state."""
request_body = PostPackageState().load(API.payload)
+
+ # Check authorization for package access
+ is_staff = jwt.contains_role([EpicSubmitRole.EAO_VIEW.value])
+ if is_staff:
+ authorization.has_access_to_package(package_id)
+
package = PackageService.update_package_state(package_id, request_body)
return PackageSchema().dump(package), HTTPStatus.OK
diff --git a/submit-api/src/submit_api/schemas/account_project_work.py b/submit-api/src/submit_api/schemas/account_project_work.py
index 18aa3bd77..0b2f0cf89 100644
--- a/submit-api/src/submit_api/schemas/account_project_work.py
+++ b/submit-api/src/submit_api/schemas/account_project_work.py
@@ -18,3 +18,4 @@ class Meta: # pylint: disable=too-few-public-methods
id = fields.Int(data_key="id")
work_id = fields.Int(data_key="work_id")
work = fields.Nested(TrackWorkSchema, data_key="work")
+ work_role = fields.Str(attribute='current_user_work_role', data_key="work_role", allow_none=True)
diff --git a/submit-api/src/submit_api/services/authorization.py b/submit-api/src/submit_api/services/authorization.py
index eafae25c6..1a2244f58 100644
--- a/submit-api/src/submit_api/services/authorization.py
+++ b/submit-api/src/submit_api/services/authorization.py
@@ -145,5 +145,9 @@ def require_team_lead_access():
Raises:
HTTPStatus.FORBIDDEN: If user doesn't have Team Lead role
"""
+ # Check for full_access role first - they have full permissions
+ if jwt.contains_role([EpicSubmitRole.FULL_ACCESS.value]):
+ return
+
if not has_work_role([WorkRole.TEAM_LEAD]):
abort(HTTPStatus.FORBIDDEN)
diff --git a/submit-api/src/submit_api/services/package_service.py b/submit-api/src/submit_api/services/package_service.py
index c9aab65c5..541923171 100644
--- a/submit-api/src/submit_api/services/package_service.py
+++ b/submit-api/src/submit_api/services/package_service.py
@@ -235,8 +235,30 @@ def calculate_package_statuses(package, user_type):
@classmethod
def get_package_by_id(cls, package_id):
"""Get package by id."""
+ from submit_api.models import User as UserModel # pylint: disable=import-outside-toplevel
+ from submit_api.models import StaffUserWork # pylint: disable=import-outside-toplevel
+ from submit_api.models.user import UserType # pylint: disable=import-outside-toplevel
+ from submit_api.utils.token_info import TokenInfo # pylint: disable=import-outside-toplevel
+
authorization.has_access_to_package(package_id)
package = PackageModel.get_package_by_id_with_items(package_id)
+
+ # Attach work_role to account_project_work for staff users
+ if package and package.account_project_work:
+ user = UserModel.get_by_guid(TokenInfo.get_username())
+ if user and user.type == UserType.STAFF and user.staff_user:
+ # Get the work role for this staff user and work
+ staff_user_work = StaffUserWork.query.filter_by(
+ staff_user_id=user.staff_user.id,
+ work_id=package.account_project_work.work_id,
+ is_active=True
+ ).first()
+
+ if staff_user_work:
+ package.account_project_work.current_user_work_role = staff_user_work.role
+ else:
+ package.account_project_work.current_user_work_role = None
+
return package
@classmethod
@@ -816,7 +838,16 @@ def _has_open_update_requests(package):
@classmethod
def approve_package(cls, package_id):
- """Approve the package."""
+ """Approve the package.
+
+ Only Team Leads and users with full_access role can approve packages.
+ Team Members can view, verify, acknowledge, and request updates.
+ """
+ from submit_api.services import authorization # pylint: disable=import-outside-toplevel
+
+ # Require Team Lead or full_access role for approval
+ authorization.require_team_lead_access()
+
with session_scope() as session:
package = cls.get_package_by_id(package_id)
if not package:
diff --git a/submit-web/src/hooks/useStaffSubmissionPage.ts b/submit-web/src/hooks/useStaffSubmissionPage.ts
index 07c9d32d4..cc60bf833 100644
--- a/submit-web/src/hooks/useStaffSubmissionPage.ts
+++ b/submit-web/src/hooks/useStaffSubmissionPage.ts
@@ -169,8 +169,14 @@ export function useStaffSubmissionPage({
SubmissionPackageApprovalType.C,
].includes(approval_type as SubmissionPackageApprovalType);
+ // Only Team Leads can approve packages (Team Members can view, verify, acknowledge, request updates)
+ const isTeamLead = submissionPackage?.account_project_work?.work_role === "TEAM_LEAD";
+ console.log("isTeamLead", isTeamLead);
+ console.log("work_role", submissionPackage);
const showApproveButtons =
- isPackageAcknowledged && approval_type == SubmissionPackageApprovalType.C;
+ isPackageAcknowledged &&
+ approval_type == SubmissionPackageApprovalType.C &&
+ isTeamLead;
useEffect(() => {
setIsLoading(updatingPackageState || refusingPackage);
diff --git a/submit-web/src/models/AccountProjectWork.ts b/submit-web/src/models/AccountProjectWork.ts
index 49c0c6ad0..196206c9b 100644
--- a/submit-web/src/models/AccountProjectWork.ts
+++ b/submit-web/src/models/AccountProjectWork.ts
@@ -1,7 +1,10 @@
import { TrackWork } from "./TrackWork";
+export type WorkRole = "TEAM_LEAD" | "TEAM_MEMBER";
+
export type AccountProjectWork = {
id: number;
work_id: number;
work: TrackWork;
+ work_role?: WorkRole;
};
From 5cb319f4e7b6cafb0bf4e5caa4a9a3e60dcf74d6 Mon Sep 17 00:00:00 2001
From: dinesh
Date: Thu, 2 Jul 2026 14:30:00 -0700
Subject: [PATCH 3/5] roles and permission changes
---
.../src/submit_api/enums/package_operation.py | 24 ++
.../models/queries/account_project.py | 82 ++++--
.../src/submit_api/resources/activity_log.py | 7 +-
submit-api/src/submit_api/resources/item.py | 7 +-
.../src/submit_api/resources/package.py | 17 +-
.../src/submit_api/resources/project.py | 11 +-
.../resources/submitted_document.py | 7 +-
.../src/submit_api/services/authorization.py | 53 ++--
.../services/package_access_control.py | 268 ++++++++++++++++++
.../submit_api/services/staff_user_service.py | 7 +
.../services/staff_user_work_service.py | 30 +-
submit-api/src/submit_api/utils/constants.py | 26 ++
submit-api/src/submit_api/utils/roles.py | 13 +-
.../App/Submission/DocumentRow/index.tsx | 13 +-
.../UpdateRequestWidget/RequestSection.tsx | 7 +-
.../Submission/UpdateRequestWidget/index.tsx | 9 +-
.../ReviewSection.tsx | 2 +-
.../IEMStaffView/ReviewSection.tsx | 2 +-
.../ManagementPlanStaffView/ReviewSection.tsx | 2 +-
.../components/Shared/PermissionGate/utils.ts | 2 +-
submit-web/src/hooks/usePackageRoles.ts | 50 ++++
submit-web/src/models/Role.tsx | 20 +-
22 files changed, 570 insertions(+), 89 deletions(-)
create mode 100644 submit-api/src/submit_api/enums/package_operation.py
create mode 100644 submit-api/src/submit_api/services/package_access_control.py
create mode 100644 submit-web/src/hooks/usePackageRoles.ts
diff --git a/submit-api/src/submit_api/enums/package_operation.py b/submit-api/src/submit_api/enums/package_operation.py
new file mode 100644
index 000000000..a9ad5a8e6
--- /dev/null
+++ b/submit-api/src/submit_api/enums/package_operation.py
@@ -0,0 +1,24 @@
+# Copyright © 2024 Province of British Columbia
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Package operation enums."""
+from enum import Enum
+
+
+class PackageOperation(Enum):
+ """Operations that can be performed on packages."""
+
+ READ = "read" # View package/items/submissions
+ CREATE = "create" # Create new package
+ EDIT = "edit" # Update existing package/items
+ APPROVE = "approve" # Approve/reject (manager/team lead)
diff --git a/submit-api/src/submit_api/models/queries/account_project.py b/submit-api/src/submit_api/models/queries/account_project.py
index 5c7268b6a..000e44cad 100644
--- a/submit-api/src/submit_api/models/queries/account_project.py
+++ b/submit-api/src/submit_api/models/queries/account_project.py
@@ -231,10 +231,27 @@ def get_full_account_projects(cls, account_project_ids: list,
schema_class = AccountProjectSchema if is_proponent else StaffAccountProjectSchema
account_projects_list = schema_class(many=True).dump(account_projects)
+ # ALWAYS apply user access filtering for packages
+ package_query = db.session.query(Package).filter(
+ Package.account_project_id.in_(account_project_ids)
+ )
+ package_query = cls._filter_packages_by_user_access(package_query, user)
+ accessible_package_ids = {pkg.id for pkg in package_query.all()}
+
+ # Combine with search filtering if provided
if filtered_package_ids:
- for account_project in account_projects_list:
- account_project['packages'] = [package for package in account_project['packages']
- if package['id'] in filtered_package_ids]
+ # Intersection: packages matching BOTH access control AND search criteria
+ final_package_ids = accessible_package_ids & set(filtered_package_ids)
+ else:
+ # No search criteria: use only access control filtering
+ final_package_ids = accessible_package_ids
+
+ # Filter packages in serialized output
+ for account_project in account_projects_list:
+ account_project['packages'] = [
+ package for package in account_project['packages']
+ if package['id'] in final_package_ids
+ ]
# Filter account_project_works for staff users without full_access role
if not is_proponent and user and user.type == UserType.STAFF:
@@ -302,6 +319,7 @@ def _filter_by_search_criteria(cls, search_options: AccountProjectSearchOptions)
@classmethod
def _attach_work_roles_to_account_project_works(cls, account_project_works, staff_user_id: int):
"""Attach work roles to AccountProjectWork objects using a single query.
+
Args:
account_project_works: List of AccountProjectWork objects
staff_user_id: ID of the staff user
@@ -364,28 +382,52 @@ def _filter_packages_by_user_access(cls, package_query=None, user: User = None):
if jwt.contains_role([EpicSubmitRole.FULL_ACCESS.value]):
return package_query
- # Filter packages based on staff user's work assignments
+ # Filter packages based on staff user's work assignments and permissions
staff_user = user.staff_user
if not staff_user:
return package_query.filter(False)
- # Get all active work IDs for this staff user
- active_work_ids = db.session.query(StaffUserWork.work_id).filter(
- StaffUserWork.staff_user_id == staff_user.id,
- StaffUserWork.is_active == True # noqa: E712
- ).subquery()
-
- # Filter packages to only those with account_project_work_id matching staff's work assignments
- # OR packages without account_project_work_id (non-work packages accessible via Keycloak roles)
- package_query = package_query.outerjoin(
- AccountProjectWork,
- Package.account_project_work_id == AccountProjectWork.id
- ).filter(
- or_(
- Package.account_project_work_id.is_(None), # Non-work packages
- AccountProjectWork.work_id.in_(active_work_ids) # Work packages staff has access to
+ # Check if user has w_view permission for works
+ has_w_view = jwt.contains_role(['w_view'])
+
+ # Check if user has mp_view permission for management plans
+ has_mp_view = jwt.contains_role(['mp_view'])
+
+ # Build filter conditions based on permissions
+ filter_conditions = []
+
+ # If user has w_view permission, check work assignments
+ if has_w_view:
+ # Get all active work IDs for this staff user
+ active_work_ids = db.session.query(StaffUserWork.work_id).filter(
+ StaffUserWork.staff_user_id == staff_user.id,
+ StaffUserWork.is_active == True # noqa: E712
+ ).subquery()
+
+ # Join with AccountProjectWork to filter work packages
+ package_query = package_query.outerjoin(
+ AccountProjectWork,
+ Package.account_project_work_id == AccountProjectWork.id
)
- )
+
+ # Add condition for work packages staff has access to
+ filter_conditions.append(AccountProjectWork.work_id.in_(active_work_ids))
+
+ # If user has mp_view permission, include management plan packages
+ if has_mp_view:
+ from submit_api.models.package_type import PackageType
+ from submit_api.utils.constants import MP_VIEW_PACKAGE_TYPES
+ # Add condition for all package types accessible via MP_VIEW role
+ filter_conditions.append(
+ Package.type.has(PackageType.name.in_(MP_VIEW_PACKAGE_TYPES))
+ )
+
+ # If no permissions, deny all access
+ if not filter_conditions:
+ return package_query.filter(False)
+
+ # Apply the filter conditions
+ package_query = package_query.filter(or_(*filter_conditions))
return package_query
diff --git a/submit-api/src/submit_api/resources/activity_log.py b/submit-api/src/submit_api/resources/activity_log.py
index 1537c11d3..44f9f0c20 100644
--- a/submit-api/src/submit_api/resources/activity_log.py
+++ b/submit-api/src/submit_api/resources/activity_log.py
@@ -18,11 +18,11 @@
from flask_cors import cross_origin
from flask_restx import Namespace, Resource
-from submit_api.auth import jwt
from submit_api.resources.apihelper import Api as ApiHelper
from submit_api.schemas.activity_log import ActivityLogSchema
from submit_api.services.activity_log_service import ActivityLogService
-from submit_api.utils.roles import EpicSubmitRole
+from submit_api.services.staff_user_service import StaffUserService
+from submit_api.utils.token_info import TokenInfo
from submit_api.utils.util import allowedorigins, cors_preflight
@@ -52,7 +52,8 @@ class ActivityLog(Resource):
def get(entity_type, entity_id):
"""Retrieve activity logs for a specific entity type and ID."""
# Retrieve logs
- is_staff = jwt.contains_role([EpicSubmitRole.EAO_VIEW.value])
+ staff_user = StaffUserService.get_staff_by_id(TokenInfo.get_username())
+ is_staff = staff_user is not None
logs = ActivityLogService.get_activity_logs(entity_type, entity_id)
schema = ActivityLogSchema(many=True, context={"is_proponent": not is_staff})
return schema.dump(logs), HTTPStatus.OK
diff --git a/submit-api/src/submit_api/resources/item.py b/submit-api/src/submit_api/resources/item.py
index 11f08c7fc..4b3c5cf6c 100644
--- a/submit-api/src/submit_api/resources/item.py
+++ b/submit-api/src/submit_api/resources/item.py
@@ -18,14 +18,16 @@
from flask_cors import cross_origin
from flask_restx import Namespace, Resource
-from submit_api.auth import auth, jwt
+from submit_api.auth import auth
from submit_api.resources.apihelper import Api as ApiHelper
from submit_api.schemas.item import ItemSchema
from submit_api.schemas.item import StaffItemSchema
from submit_api.schemas.submission_review import SaveSubmissionReviewRequestSchema, SubmissionReviewSchema
from submit_api.services.item_service import ItemService
+from submit_api.services.staff_user_service import StaffUserService
from submit_api.services.submission_review_service import SubmissionReviewService
from submit_api.utils.roles import EpicSubmitRole
+from submit_api.utils.token_info import TokenInfo
from submit_api.utils.util import allowedorigins, cors_preflight
@@ -58,7 +60,8 @@ class Item(Resource):
@auth.require
def get(item_id):
"""Get item by id."""
- is_staff = jwt.contains_role([EpicSubmitRole.EAO_VIEW.value])
+ staff_user = StaffUserService.get_staff_by_id(TokenInfo.get_username())
+ is_staff = staff_user is not None
item = ItemService.get_item_by_id(item_id)
if is_staff:
return StaffItemSchema().dump(item), HTTPStatus.OK
diff --git a/submit-api/src/submit_api/resources/package.py b/submit-api/src/submit_api/resources/package.py
index d22e5d461..5d2d58db2 100644
--- a/submit-api/src/submit_api/resources/package.py
+++ b/submit-api/src/submit_api/resources/package.py
@@ -18,7 +18,7 @@
from flask_cors import cross_origin
from flask_restx import Namespace, Resource
-from submit_api.auth import auth, jwt
+from submit_api.auth import auth
from submit_api.enums.role import ProponentPermissionsEnum
from submit_api.resources.apihelper import Api as ApiHelper
from submit_api.schemas.package import (
@@ -27,7 +27,9 @@
RefusePackageSchema)
from submit_api.services import authorization
from submit_api.services.package_service import PackageService
+from submit_api.services.staff_user_service import StaffUserService
from submit_api.utils.roles import EpicSubmitRole
+from submit_api.utils.token_info import TokenInfo
from submit_api.utils.util import allowedorigins, cors_preflight
@@ -69,7 +71,8 @@ class Package(Resource):
@auth.require
def get(package_id):
"""Get package by id."""
- is_staff = jwt.contains_role([EpicSubmitRole.EAO_VIEW.value])
+ staff_user = StaffUserService.get_staff_by_id(TokenInfo.get_username())
+ is_staff = staff_user is not None
if is_staff:
# Call has_access_to_package to set g.work_role for staff users
authorization.has_access_to_package(package_id)
@@ -125,11 +128,15 @@ class PackageState(Resource):
@auth.require
def post(package_id):
"""Update package state."""
+ from submit_api.enums.package_operation import PackageOperation
+ from submit_api.services.package_access_control import PackageAccessControl
+
request_body = PostPackageState().load(API.payload)
- # Check authorization for package access
- is_staff = jwt.contains_role([EpicSubmitRole.EAO_VIEW.value])
+ # Check authorization for package EDIT access
+ staff_user = StaffUserService.get_staff_by_id(TokenInfo.get_username())
+ is_staff = staff_user is not None
if is_staff:
- authorization.has_access_to_package(package_id)
+ PackageAccessControl.check_package_access(package_id, PackageOperation.EDIT)
package = PackageService.update_package_state(package_id, request_body)
return PackageSchema().dump(package), HTTPStatus.OK
diff --git a/submit-api/src/submit_api/resources/project.py b/submit-api/src/submit_api/resources/project.py
index 6ea82385e..0fbbd651c 100644
--- a/submit-api/src/submit_api/resources/project.py
+++ b/submit-api/src/submit_api/resources/project.py
@@ -19,13 +19,14 @@
from flask_cors import cross_origin
from flask_restx import Namespace, Resource
-from submit_api.auth import auth, jwt
+from submit_api.auth import auth
from submit_api.models.account_project_search_options import AccountProjectSearchOptions
from submit_api.models.package import NonCanonicalPackageStatus, PackageStatus
from submit_api.resources.apihelper import Api as ApiHelper
from submit_api.schemas.project import AddProjectSchema, ProjectSchema, StaffAccountProjectSchema
from submit_api.services.project_service import ProjectService
-from submit_api.utils.roles import EpicSubmitRole
+from submit_api.services.staff_user_service import StaffUserService
+from submit_api.utils.token_info import TokenInfo
from submit_api.utils.util import allowedorigins, cors_preflight
@@ -62,7 +63,8 @@ class AccountProjects(Resource):
@cross_origin(origins=allowedorigins())
def get():
"""Get paginated account projects."""
- is_staff = jwt.contains_role([EpicSubmitRole.EAO_VIEW.value])
+ staff_user = StaffUserService.get_staff_by_id(TokenInfo.get_username())
+ is_staff = staff_user is not None
args = request.args
search_text = args.get('search_text')
submitted_on_start = args.get('submitted_on_start')
@@ -194,7 +196,8 @@ class Projects(Resource):
@auth.require
def get(account_project_id):
"""Get account project by account_project_id."""
- is_staff = jwt.contains_role([EpicSubmitRole.EAO_VIEW.value])
+ staff_user = StaffUserService.get_staff_by_id(TokenInfo.get_username())
+ is_staff = staff_user is not None
account_project = ProjectService.get_account_project_by_id(account_project_id, is_staff=is_staff)
if not account_project:
return {"message": "Account project not found"}, HTTPStatus.NOT_FOUND
diff --git a/submit-api/src/submit_api/resources/submitted_document.py b/submit-api/src/submit_api/resources/submitted_document.py
index 904854dbf..f1690e2fc 100644
--- a/submit-api/src/submit_api/resources/submitted_document.py
+++ b/submit-api/src/submit_api/resources/submitted_document.py
@@ -19,13 +19,15 @@
from flask_cors import cross_origin
from flask_restx import Namespace, Resource
-from submit_api.auth import auth, jwt
+from submit_api.auth import auth
from submit_api.models.account_project_search_options import ProjectDocumentSearchOptions
from submit_api.resources.apihelper import Api as ApiHelper
from submit_api.schemas.submission import (
SubmissionSchema, SubmittedDocumentByProjectSchema, PaginatedProjectDocumentItemSchema)
+from submit_api.services.staff_user_service import StaffUserService
from submit_api.services.submitted_document_service import DocumentService
from submit_api.utils.roles import EpicSubmitRole
+from submit_api.utils.token_info import TokenInfo
from submit_api.utils.util import allowedorigins, cors_preflight
@@ -67,7 +69,8 @@ def get():
submitted_on_start = args.get('submitted_on_start')
submitted_on_end = args.get('submitted_on_end')
- is_staff = jwt.contains_role([EpicSubmitRole.EAO_VIEW.value])
+ staff_user = StaffUserService.get_staff_by_id(TokenInfo.get_username())
+ is_staff = staff_user is not None
if not is_staff:
if not project_id:
return {"error": "missing project_id"}, HTTPStatus.BAD_REQUEST
diff --git a/submit-api/src/submit_api/services/authorization.py b/submit-api/src/submit_api/services/authorization.py
index f3c3027a8..bcfb3b625 100644
--- a/submit-api/src/submit_api/services/authorization.py
+++ b/submit-api/src/submit_api/services/authorization.py
@@ -58,8 +58,17 @@ def check_has_permissions_on_project(permissions=None, account_project_ids=None)
def has_access_to_package(package_id): # pylint: disable=too-many-branches
- """Check if user is assigned to the package."""
+ """Check if user is assigned to the package.
+
+ This function now delegates to PackageAccessControl for staff users
+ to provide centralized, operation-aware access control.
+ """
from submit_api.models import StaffUserWork # pylint: disable=import-outside-toplevel
+ # pylint: disable=import-outside-toplevel
+ from submit_api.enums.package_operation import PackageOperation
+ # pylint: disable=import-outside-toplevel
+ from submit_api.services.package_access_control import PackageAccessControl
+
if not package_id:
abort(HTTPStatus.BAD_REQUEST)
@@ -68,35 +77,27 @@ def has_access_to_package(package_id): # pylint: disable=too-many-branches
abort(HTTPStatus.NOT_FOUND)
user: UserModel = UserModel.get_by_guid(TokenInfo.get_username())
- if user.type == UserType.STAFF:
- # Check for full_access role - they have full access
- if jwt.contains_role([EpicSubmitRole.FULL_ACCESS.value]):
- return # Full access, bypass all checks including work-based restrictions
- # Check if package is work-related
- if package.account_project_work_id:
- # Work-based package - check staff user has access to this work
+ # For staff users, use the new centralized access control
+ if user.type == UserType.STAFF:
+ # Store work_role in g for backward compatibility with existing code
+ if package.account_project_work_id and package.account_project_work:
staff_user = user.staff_user
- if not staff_user:
- abort(HTTPStatus.FORBIDDEN)
- # Get the work_id from the already-loaded relationship
- if not package.account_project_work:
- abort(HTTPStatus.FORBIDDEN)
- work_id = package.account_project_work.work_id
- # Check if staff user has active assignment to this work
- staff_user_work = StaffUserWork.query.filter_by(
- staff_user_id=staff_user.id,
- work_id=work_id,
- is_active=True
- ).first()
- if not staff_user_work:
- abort(HTTPStatus.FORBIDDEN)
- # Store role in g for later permission checks
- g.work_role = staff_user_work.role
- return
- # Non-work package - allow access via Keycloak roles
+ if staff_user:
+ work_id = package.account_project_work.work_id
+ staff_user_work = StaffUserWork.query.filter_by(
+ staff_user_id=staff_user.id,
+ work_id=work_id,
+ is_active=True
+ ).first()
+ if staff_user_work:
+ g.work_role = staff_user_work.role
+
+ # Delegate to centralized access control for READ operation
+ PackageAccessControl.check_package_access(package_id, PackageOperation.READ)
return
+ # For proponent users, keep existing logic
if not user or not user.account_user or not user.account_user.role:
abort(HTTPStatus.UNAUTHORIZED)
diff --git a/submit-api/src/submit_api/services/package_access_control.py b/submit-api/src/submit_api/services/package_access_control.py
new file mode 100644
index 000000000..1aff2dd73
--- /dev/null
+++ b/submit-api/src/submit_api/services/package_access_control.py
@@ -0,0 +1,268 @@
+# Copyright © 2024 Province of British Columbia
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""
+Centralized Package Access Control Service
+
+Provides operation-aware access control for packages based on package type
+and user roles. Supports MP-type packages (Management Plan, IEM) and work packages.
+"""
+from http import HTTPStatus
+
+from flask_restx import abort
+
+from submit_api.auth import jwt
+from submit_api.enums.package_operation import PackageOperation
+from submit_api.enums.work_role import WorkRole
+from submit_api.models import Package as PackageModel
+from submit_api.models import User as UserModel
+from submit_api.models.user import UserType
+from submit_api.utils.constants import MP_VIEW_PACKAGE_TYPES, MP_ROLE_OPERATIONS, W_ROLE_OPERATIONS
+from submit_api.utils.roles import EpicSubmitRole
+from submit_api.utils.token_info import TokenInfo
+
+
+class PackageAccessControl:
+ """Centralized package access control service."""
+
+ @staticmethod
+ def check_package_access(
+ package_id: int,
+ operation: PackageOperation,
+ abort_on_failure: bool = True
+ ) -> bool:
+ """
+ Check if current user has permission for operation on package.
+
+ Args:
+ package_id: Package ID to check access for
+ operation: Operation being performed (READ, EDIT, CREATE, APPROVE)
+ abort_on_failure: If True, abort with 403; if False, return bool
+
+ Returns:
+ bool: True if access granted, False otherwise (when abort_on_failure=False)
+
+ Raises:
+ HTTPStatus.FORBIDDEN: If access denied and abort_on_failure=True
+ HTTPStatus.NOT_FOUND: If package not found
+ HTTPStatus.BAD_REQUEST: If package_id is invalid
+ """
+ if not package_id:
+ if abort_on_failure:
+ abort(HTTPStatus.BAD_REQUEST, "Package ID is required")
+ return False
+
+ package = PackageModel.find_by_id(package_id)
+ if not package:
+ if abort_on_failure:
+ abort(HTTPStatus.NOT_FOUND, "Package not found")
+ return False
+
+ user = UserModel.get_by_guid(TokenInfo.get_username())
+ if not user:
+ if abort_on_failure:
+ abort(HTTPStatus.UNAUTHORIZED, "User not found")
+ return False
+
+ # Check FULL_ACCESS role - bypass all checks
+ if user.type == UserType.STAFF and jwt.contains_role([EpicSubmitRole.FULL_ACCESS.value]):
+ return True
+
+ # Determine package category and check access
+ if package.account_project_work_id:
+ # Work package (includes Additional Information)
+ has_access = PackageAccessControl._has_work_package_permission(
+ package.account_project_work_id, operation, user
+ )
+ elif package.type.name in MP_VIEW_PACKAGE_TYPES:
+ # MP-type package (Management Plan, IEM)
+ has_access = PackageAccessControl._has_mp_package_permission(
+ package.type.name, operation
+ )
+ else:
+ # Other packages - allow via EAO roles for backward compatibility
+ has_access = PackageAccessControl._has_eao_permission(operation)
+
+ if not has_access and abort_on_failure:
+ abort(HTTPStatus.FORBIDDEN, f"Access denied for {operation.value} operation on this package")
+
+ return has_access
+
+ @staticmethod
+ def check_package_type_access(
+ package_type_name: str,
+ operation: PackageOperation,
+ account_project_work_id: int = None,
+ abort_on_failure: bool = True
+ ) -> bool:
+ """Check if current user can perform operation on this package type.
+
+ Used for CREATE operations before package exists.
+
+ Args:
+ package_type_name: Name of package type (e.g., 'Management Plan')
+ operation: Operation being performed
+ account_project_work_id: Work ID if work package, None otherwise
+ abort_on_failure: If True, abort with 403; if False, return bool
+
+ Returns:
+ bool: True if access granted, False otherwise
+ """
+ user = UserModel.get_by_guid(TokenInfo.get_username())
+ if not user:
+ if abort_on_failure:
+ abort(HTTPStatus.UNAUTHORIZED, "User not found")
+ return False
+
+ # Check FULL_ACCESS role
+ if user.type == UserType.STAFF and jwt.contains_role([EpicSubmitRole.FULL_ACCESS.value]):
+ return True
+
+ # Determine package category
+ if account_project_work_id:
+ # Work package
+ has_access = PackageAccessControl._has_work_package_permission(
+ account_project_work_id, operation, user
+ )
+ elif package_type_name in MP_VIEW_PACKAGE_TYPES:
+ # MP-type package
+ has_access = PackageAccessControl._has_mp_package_permission(
+ package_type_name, operation
+ )
+ else:
+ # Other packages
+ has_access = PackageAccessControl._has_eao_permission(operation)
+
+ if not has_access and abort_on_failure:
+ abort(
+ HTTPStatus.FORBIDDEN,
+ f"Access denied for {operation.value} operation on {package_type_name} packages"
+ )
+
+ return has_access
+
+ @staticmethod
+ def get_user_package_permissions(package_id: int) -> list:
+ """Get list of operations current user can perform on package.
+
+ Useful for UI to show/hide buttons.
+
+ Args:
+ package_id: Package ID to check
+
+ Returns:
+ list: List of PackageOperation enums user can perform
+ """
+ permissions = []
+ for operation in PackageOperation:
+ if PackageAccessControl.check_package_access(package_id, operation, abort_on_failure=False):
+ permissions.append(operation)
+ return permissions
+
+ @staticmethod
+ def _has_mp_package_permission(package_type_name: str, operation: PackageOperation) -> bool:
+ """
+ Check if user has MP-type package permission for operation.
+
+ Args:
+ package_type_name: Package type name
+ operation: Operation to check
+
+ Returns:
+ bool: True if user has permission
+ """
+ operation_value = operation.value
+
+ # Check each MP role to see if it grants this operation
+ for role_name, allowed_operations in MP_ROLE_OPERATIONS.items():
+ if jwt.contains_role([role_name]) and operation_value in allowed_operations:
+ return True
+
+ return False
+
+ @staticmethod
+ def _has_work_package_permission(
+ account_project_work_id: int,
+ operation: PackageOperation,
+ user: UserModel
+ ) -> bool:
+ """
+ Check if user has work package permission for operation.
+
+ Args:
+ account_project_work_id: Work ID (can be AccountProjectWork ID)
+ operation: Operation to check
+ user: User model
+
+ Returns:
+ bool: True if user has permission
+ """
+ from submit_api.models import StaffUserWork, AccountProjectWork # pylint: disable=import-outside-toplevel
+
+ if user.type != UserType.STAFF:
+ return False
+
+ staff_user = user.staff_user
+ if not staff_user:
+ return False
+
+ # Get work_id from account_project_work_id
+ account_project_work = AccountProjectWork.query.filter_by(id=account_project_work_id).first()
+ if not account_project_work:
+ return False
+
+ work_id = account_project_work.work_id
+
+ # Check if staff user has active assignment to this work
+ staff_user_work = StaffUserWork.query.filter_by(
+ staff_user_id=staff_user.id,
+ work_id=work_id,
+ is_active=True
+ ).first()
+
+ if not staff_user_work:
+ return False
+
+ # Check W_* role permissions
+ operation_value = operation.value
+ for role_name, allowed_operations in W_ROLE_OPERATIONS.items():
+ if jwt.contains_role([role_name]) and operation_value in allowed_operations:
+ # For APPROVE operation, also check Team Lead work role
+ if operation == PackageOperation.APPROVE:
+ return staff_user_work.role == WorkRole.TEAM_LEAD.value
+ return True
+
+ return False
+
+ @staticmethod
+ def _has_eao_permission(operation: PackageOperation) -> bool:
+ """
+ Check if user has EAO permission for operation (backward compatibility).
+
+ Args:
+ operation: Operation to check
+
+ Returns:
+ bool: True if user has permission
+ """
+ if operation == PackageOperation.READ:
+ return jwt.contains_role([EpicSubmitRole.EAO_VIEW.value])
+ elif operation == PackageOperation.EDIT:
+ return jwt.contains_role([EpicSubmitRole.EAO_EDIT.value])
+ elif operation == PackageOperation.CREATE:
+ return jwt.contains_role([EpicSubmitRole.EAO_CREATE.value])
+ elif operation == PackageOperation.APPROVE:
+ # No EAO role for approve - would need specific implementation
+ return False
+
+ return False
diff --git a/submit-api/src/submit_api/services/staff_user_service.py b/submit-api/src/submit_api/services/staff_user_service.py
index a03dd221f..305bd5d5d 100644
--- a/submit-api/src/submit_api/services/staff_user_service.py
+++ b/submit-api/src/submit_api/services/staff_user_service.py
@@ -54,6 +54,13 @@ def get_or_create_staff_user_from_email(cls, email: str) -> tuple:
ResourceNotFoundError: If user not found in Keycloak
ValueError: If Keycloak user doesn't have a valid username
"""
+ # Check if staff user already exists with this email to avoid Keycloak call
+ existing_staff_user = StaffUser.query.filter_by(work_email_address=email).first()
+ if existing_staff_user:
+ username = existing_staff_user.user.auth_guid
+ current_app.logger.info(f"Found existing StaffUser for email {email}")
+ return existing_staff_user, username
+
keycloak_user = KeycloakService.get_user_by_email(email)
username = keycloak_user.get("username")
first_name = keycloak_user.get("firstName") or ""
diff --git a/submit-api/src/submit_api/services/staff_user_work_service.py b/submit-api/src/submit_api/services/staff_user_work_service.py
index fabfdc67c..535cfacf2 100644
--- a/submit-api/src/submit_api/services/staff_user_work_service.py
+++ b/submit-api/src/submit_api/services/staff_user_work_service.py
@@ -34,20 +34,32 @@ def create_or_update_staff_user_work(cls, email: str, work_id: int, role: str):
current_app.logger.error(f"Failed to fetch user from Keycloak: {str(e)}")
raise ResourceNotFoundError(f"User with email '{email}' not found in Keycloak.") from e
- # Check if user has EAO_TEAM_MEMBER Keycloak group (subgroup under SUBMIT)
- # Assign EAO_TEAM_MEMBER group only if not already assigned (idempotent)
+ # Assign Keycloak group based on role (subgroup under SUBMIT)
+ # TEAM_LEAD -> SUBMIT/OPS_TEAM_LEAD, TEAM_MEMBER -> SUBMIT/OPS_TEAM_MEMBER
+ # Assign group only if not already assigned (idempotent)
try:
+ role_group_mapping = {
+ 'TEAM_LEAD': 'SUBMIT/OPS_TEAM_LEAD',
+ 'TEAM_MEMBER': 'SUBMIT/OPS_TEAM_MEMBER'
+ }
+
+ keycloak_group = role_group_mapping.get(role)
+ if not keycloak_group:
+ raise BadRequestError(f"Invalid role '{role}'. Must be TEAM_LEAD or TEAM_MEMBER.")
+
user_groups = KeycloakService.get_user_groups(username)
- has_eao_team_member = any(group.get('path') == '/SUBMIT/EAO_TEAM_MEMBER'
- for group in user_groups)
- if not has_eao_team_member:
- group_id = KeycloakService.get_group_id_by_path('SUBMIT/EAO_TEAM_MEMBER')
+ has_role_group = any(group.get('path') == f'/{keycloak_group}'
+ for group in user_groups)
+ if not has_role_group:
+ group_id = KeycloakService.get_group_id_by_path(keycloak_group)
KeycloakService.update_user_group(user_id=username, group_id=group_id)
- current_app.logger.info(f"Assigned SUBMIT/EAO_TEAM_MEMBER group to user {username}")
+ current_app.logger.info(f"Assigned {keycloak_group} group to user {username}")
else:
- current_app.logger.info(f"User {username} already has SUBMIT/EAO_TEAM_MEMBER group")
+ current_app.logger.info(f"User {username} already has {keycloak_group} group")
+ except BadRequestError:
+ raise
except Exception as e: # noqa: B902 # pylint: disable=broad-exception-caught
- current_app.logger.warning(f"Failed to assign SUBMIT/EAO_TEAM_MEMBER group: {str(e)}")
+ current_app.logger.warning(f"Failed to assign {keycloak_group} group: {str(e)}")
# Don't fail the entire operation if group assignment fails
# 6. Validate work_id exists in track_works
diff --git a/submit-api/src/submit_api/utils/constants.py b/submit-api/src/submit_api/utils/constants.py
index 6c106d3ee..651703e5f 100644
--- a/submit-api/src/submit_api/utils/constants.py
+++ b/submit-api/src/submit_api/utils/constants.py
@@ -17,6 +17,32 @@
'Management Plan': 'EAO.ManagementPlanSupport@gov.bc.ca'
}
+# Package types accessible via MP_VIEW role
+# Add new package types here when they should be accessible to users with mp_view permission
+MP_VIEW_PACKAGE_TYPES = [
+ 'Management Plan',
+ 'IEM',
+]
+
+# Role-to-operation mappings for MP-type packages
+# Note: Roles do NOT stack - each must be granted explicitly
+# Users need multiple roles for multiple operations (e.g., mp_view + mp_edit)
+MP_ROLE_OPERATIONS = {
+ 'mp_view': ['read'],
+ 'mp_edit': ['edit'], # Does NOT include read
+ 'mp_create': ['create'], # Does NOT include read or edit
+ 'mp_extended_edit': ['approve'], # Manager-level approval
+}
+
+# Role-to-operation mappings for work packages (includes Additional Information)
+# Note: Roles do NOT stack - each must be granted explicitly
+W_ROLE_OPERATIONS = {
+ 'w_view': ['read'],
+ 'w_edit': ['edit'], # Does NOT include read
+ 'w_create': ['create'], # Does NOT include read or edit
+ 'w_extended_edit': ['approve'], # Team Lead approval
+}
+
MANAGEMENT_PLAN_SUBMISSION_CONFIRMATION_EMAIL_TEMPLATE = 'management_plan_submission_verification.html'
MANAGEMENT_PLAN_SUBMISSION_CONFIRMATION_EMAIL_SUBJECT = 'Management Plan Submission Confirmation'
MANAGEMENT_PLAN_UPDATE_REQUEST_CREATED_EMAIL_TEMPLATE = 'management_plan_update_request_created.html'
diff --git a/submit-api/src/submit_api/utils/roles.py b/submit-api/src/submit_api/utils/roles.py
index 3f9e03305..317461ea8 100644
--- a/submit-api/src/submit_api/utils/roles.py
+++ b/submit-api/src/submit_api/utils/roles.py
@@ -21,6 +21,17 @@ class EpicSubmitRole(Enum):
EAO_EDIT = "eao_edit"
EAO_VIEW = "eao_view"
EAO_CREATE = "eao_create"
- EXTENDED_EAO_EDIT = "extended_eao_edit"
MANAGE_USERS = "manage-users"
FULL_ACCESS = "full_access"
+
+ # Management Plan package roles
+ MP_VIEW = "mp_view"
+ MP_EDIT = "mp_edit"
+ MP_CREATE = "mp_create"
+ MP_EXTENDED_EDIT = "mp_extended_edit"
+
+ # Work package roles
+ W_VIEW = "w_view"
+ W_EDIT = "w_edit"
+ W_CREATE = "w_create"
+ W_EXTENDED_EDIT = "w_extended_edit"
diff --git a/submit-web/src/components/App/Submission/DocumentRow/index.tsx b/submit-web/src/components/App/Submission/DocumentRow/index.tsx
index 6a8c14b2e..eb81c3e29 100644
--- a/submit-web/src/components/App/Submission/DocumentRow/index.tsx
+++ b/submit-web/src/components/App/Submission/DocumentRow/index.tsx
@@ -14,7 +14,6 @@ import DoneAllIcon from "@mui/icons-material/DoneAll";
import UndoIcon from "@mui/icons-material/Undo";
import { ActionButton } from "./ActionButton";
import PermissionsGate from "@/components/Shared/PermissionGate";
-import { EPIC_SUBMIT_ROLE } from "@/models/Role";
import { SubmissionPackage, PackageType } from "@/models/Package";
import { DocumentLink } from "@/components/Shared/DocumentLink";
import ActionSplitButton, {
@@ -22,6 +21,7 @@ import ActionSplitButton, {
} from "@/components/Shared/ActionSplitButton/ActionSplitButton";
import { BCDesignTokens } from "epic.theme";
import { useDocumentRow } from "@/hooks/useDocumentRow";
+import { usePackageRoles } from "@/hooks/usePackageRoles";
type DocumentRowProps = Readonly<{
documentSubmission: Submission;
@@ -42,6 +42,9 @@ export default function DocumentRow({
documentSubmission;
const packageType = propsPackageType || submissionPackage?.type;
const name = submitted_document?.name || "";
+
+ // Get the correct package-specific roles
+ const packageRoles = usePackageRoles(submissionPackage, packageType);
const {
pendingGetObject,
@@ -207,7 +210,7 @@ export default function DocumentRow({
}}
>
{showUndoVerificationButton && (
-
+
)}
{showUndoAcknowledgementButton && (
-
+
)}
{splitButtonConfig ? (
-
+
) : showDefaultActionButton ? (
-
+
) : null}
diff --git a/submit-web/src/components/App/Submission/UpdateRequestWidget/RequestSection.tsx b/submit-web/src/components/App/Submission/UpdateRequestWidget/RequestSection.tsx
index e3d2a9f56..03a58c9b0 100644
--- a/submit-web/src/components/App/Submission/UpdateRequestWidget/RequestSection.tsx
+++ b/submit-web/src/components/App/Submission/UpdateRequestWidget/RequestSection.tsx
@@ -15,7 +15,7 @@ import { USER_TYPE } from "@/models/User";
import { LoadingButton } from "@/components/Shared/LoadingButton";
import { UpdateRequestStatusChip } from "@/components/App/UpdateRequestStatusChip";
import PermissionsGate from "@/components/Shared/PermissionGate";
-import { EPIC_SUBMIT_ROLE } from "@/models/Role";
+import { usePackageRoles } from "@/hooks/usePackageRoles";
import { getSubmissionItemLabel } from "@/utils";
type UpdateRequestProps = Readonly<{
@@ -40,6 +40,9 @@ export default function RequestSection({
const { userType } = useAccount();
const isProponent = userType === USER_TYPE.PROPONENT;
+
+ // Get the correct package-specific roles
+ const packageRoles = usePackageRoles(submissionPackage);
const submissionItems = submissionPackage.items.filter((item) =>
submission_item_types.includes(item.type_id),
@@ -97,7 +100,7 @@ export default function RequestSection({
{reason}
-
+
{status === UPDATE_REQUEST_STATUS.ACCEPTED.value ? (
{
if (!submissionPackage?.update_requests) return [];
@@ -187,7 +190,7 @@ export default function UpdateRequestWidget({
/>
{!isApprovedOrRejected && (
-
+
-
+
-
+
<>
-
+
<>
-
+
<>
{
}
return hasPermission({
permissions: roles || [],
- scopes: [EPIC_SUBMIT_ROLE.extended_eao_edit],
+ scopes: [EPIC_SUBMIT_ROLE.mp_extended_edit],
});
};
diff --git a/submit-web/src/hooks/usePackageRoles.ts b/submit-web/src/hooks/usePackageRoles.ts
new file mode 100644
index 000000000..b9a1d9720
--- /dev/null
+++ b/submit-web/src/hooks/usePackageRoles.ts
@@ -0,0 +1,50 @@
+import { useMemo } from "react";
+import { EPIC_SUBMIT_ROLE } from "@/models/Role";
+import { SubmissionPackage, PackageType } from "@/models/Package";
+
+/**
+ * Hook to determine the correct package-specific roles based on package type.
+ * Returns the appropriate MP_* or W_* roles for the given package.
+ */
+export const usePackageRoles = (
+ submissionPackage?: SubmissionPackage,
+ packageType?: PackageType
+) => {
+ const actualPackageType = packageType || submissionPackage?.type;
+
+ return useMemo(() => {
+ // Determine if this is a work package or MP-type package
+ const isWorkPackage = submissionPackage?.account_project_work != null;
+
+ // MP-type packages: Management Plan, IEM
+ const isMPPackage =
+ actualPackageType?.name === "Management Plan" ||
+ actualPackageType?.name === "IEM";
+
+ if (isWorkPackage) {
+ // Work package roles
+ return {
+ view: EPIC_SUBMIT_ROLE.w_view,
+ edit: EPIC_SUBMIT_ROLE.w_edit,
+ create: EPIC_SUBMIT_ROLE.w_create,
+ approve: EPIC_SUBMIT_ROLE.w_extended_edit,
+ };
+ } else if (isMPPackage) {
+ // Management Plan package roles
+ return {
+ view: EPIC_SUBMIT_ROLE.mp_view,
+ edit: EPIC_SUBMIT_ROLE.mp_edit,
+ create: EPIC_SUBMIT_ROLE.mp_create,
+ approve: EPIC_SUBMIT_ROLE.mp_extended_edit,
+ };
+ } else {
+ // Fallback to EAO roles for other package types (backward compatibility)
+ return {
+ view: EPIC_SUBMIT_ROLE.eao_view,
+ edit: EPIC_SUBMIT_ROLE.eao_edit,
+ create: EPIC_SUBMIT_ROLE.eao_create,
+ approve: EPIC_SUBMIT_ROLE.eao_edit, // No EAO approve role
+ };
+ }
+ }, [submissionPackage?.account_project_work, actualPackageType?.name]);
+};
diff --git a/submit-web/src/models/Role.tsx b/submit-web/src/models/Role.tsx
index ec3b91d9e..8f3d5c76e 100644
--- a/submit-web/src/models/Role.tsx
+++ b/submit-web/src/models/Role.tsx
@@ -2,8 +2,15 @@ export type EpicSubmitRole =
| "eao_edit"
| "eao_view"
| "eao_create"
- | "extended_eao_edit"
- | "full_access";
+ | "full_access"
+ | "mp_view"
+ | "mp_edit"
+ | "mp_create"
+ | "mp_extended_edit"
+ | "w_view"
+ | "w_edit"
+ | "w_create"
+ | "w_extended_edit";
export const EPIC_SUBMIT_ROLE = Object.freeze<
Record
@@ -11,8 +18,15 @@ export const EPIC_SUBMIT_ROLE = Object.freeze<
eao_edit: "eao_edit",
eao_view: "eao_view",
eao_create: "eao_create",
- extended_eao_edit: "extended_eao_edit",
full_access: "full_access",
+ mp_view: "mp_view",
+ mp_edit: "mp_edit",
+ mp_create: "mp_create",
+ mp_extended_edit: "mp_extended_edit",
+ w_view: "w_view",
+ w_edit: "w_edit",
+ w_create: "w_create",
+ w_extended_edit: "w_extended_edit",
});
export enum USER_MANAGEMENT_ROLE {
From f8923df5347e6b5825787272a839fb0a387f8c75 Mon Sep 17 00:00:00 2001
From: dinesh
Date: Fri, 3 Jul 2026 14:18:21 -0700
Subject: [PATCH 4/5] roles and permission related changes
---
.../src/submit_api/resources/activity_log.py | 2 +-
submit-api/src/submit_api/resources/item.py | 8 ++++----
.../src/submit_api/resources/package.py | 20 +++++--------------
.../src/submit_api/resources/submission.py | 5 ++---
.../resources/submission_item_note.py | 4 ++--
submit-api/src/submit_api/schemas/package.py | 3 +++
.../src/submit_api/services/authorization.py | 2 --
.../services/package_access_control.py | 13 ++++++------
.../services/staff_user_work_service.py | 8 ++++----
.../InternalDocumentSection.tsx | 1 -
10 files changed, 27 insertions(+), 39 deletions(-)
diff --git a/submit-api/src/submit_api/resources/activity_log.py b/submit-api/src/submit_api/resources/activity_log.py
index 8d68247dd..e2327fa26 100644
--- a/submit-api/src/submit_api/resources/activity_log.py
+++ b/submit-api/src/submit_api/resources/activity_log.py
@@ -16,9 +16,9 @@
from http import HTTPStatus
from flask_cors import cross_origin
-from submit_api.auth import jwt
from flask_restx import Namespace, Resource
+from submit_api.auth import jwt
from submit_api.resources.apihelper import Api as ApiHelper
from submit_api.schemas.activity_log import ActivityLogSchema
from submit_api.services.activity_log_service import ActivityLogService
diff --git a/submit-api/src/submit_api/resources/item.py b/submit-api/src/submit_api/resources/item.py
index 25e45d5be..f851e21dd 100644
--- a/submit-api/src/submit_api/resources/item.py
+++ b/submit-api/src/submit_api/resources/item.py
@@ -89,10 +89,10 @@ def post(item_id):
item = ItemModel.find_by_id(item_id)
if not item:
abort(HTTPStatus.NOT_FOUND, "Item not found")
-
+
# Check package-aware access control (mp_create, w_create, or eao_create)
PackageAccessControl.check_package_access(item.package_id, PackageOperation.CREATE)
-
+
request_body = SaveSubmissionReviewRequestSchema().load(API.payload)
review = SubmissionReviewService.save_submission_review(item_id, request_body)
return SubmissionReviewSchema().dump(review), HTTPStatus.OK
@@ -113,9 +113,9 @@ def delete(item_id):
item = ItemModel.find_by_id(item_id)
if not item:
abort(HTTPStatus.NOT_FOUND, "Item not found")
-
+
# Check package-aware access control (mp_create, w_create, or eao_create)
PackageAccessControl.check_package_access(item.package_id, PackageOperation.CREATE)
-
+
review = SubmissionReviewService.undo_staff_recommendation(item_id)
return SubmissionReviewSchema().dump(review), HTTPStatus.OK
diff --git a/submit-api/src/submit_api/resources/package.py b/submit-api/src/submit_api/resources/package.py
index 13265c3f6..f7988e3f5 100644
--- a/submit-api/src/submit_api/resources/package.py
+++ b/submit-api/src/submit_api/resources/package.py
@@ -71,13 +71,6 @@ class Package(Resource):
@auth.require
def get(package_id):
"""Get package by id."""
- is_staff = jwt.contains_role([EpicSubmitRole.EAO_VIEW.value])
- if is_staff:
- # Call has_access_to_package to set g.work_role for staff users
- authorization.has_access_to_package(package_id)
- else:
- # For proponents, check access
- authorization.has_access_to_package(package_id)
package = PackageService.get_package_by_id(package_id)
if not package:
return {"message": "Package not found"}, HTTPStatus.NOT_FOUND
@@ -127,9 +120,6 @@ class PackageState(Resource):
@auth.require
def post(package_id):
"""Update package state."""
- from submit_api.enums.package_operation import PackageOperation
- from submit_api.services.package_access_control import PackageAccessControl
-
request_body = PostPackageState().load(API.payload)
# Check authorization for package EDIT access
is_staff = jwt.contains_role([EpicSubmitRole.EAO_VIEW.value])
@@ -172,10 +162,10 @@ def post(original_package_id): # pylint: disable=unused-argument
"""Create a new package version."""
package_version_data = CreatePackageVersionSchema().load(API.payload)
package_id = package_version_data.get("package_id")
-
+
# Check package-aware access control (mp_create, w_create, or eao_create)
PackageAccessControl.check_package_access(package_id, PackageOperation.CREATE)
-
+
package_with_created_package_version = PackageService.create_new_package_version_with_contacts(package_id)
return PackageSchema().dump(package_with_created_package_version), HTTPStatus.CREATED
@@ -202,7 +192,7 @@ def post(package_id):
"""Create an update request."""
# Check package-aware access control (mp_create, w_create, or eao_create)
PackageAccessControl.check_package_access(package_id, PackageOperation.CREATE)
-
+
create_update_request_data = CreateUpdateRequestSchema().load(API.payload)
package_with_created_update_request = PackageService.create_update_request(
package_id, create_update_request_data)
@@ -231,7 +221,7 @@ def patch(package_id, update_request_id):
"""Accept an update request."""
# Check package-aware access control (mp_create, w_create, or eao_create)
PackageAccessControl.check_package_access(package_id, PackageOperation.CREATE)
-
+
accept_update_request = PackageService.accept_update_request(package_id, update_request_id)
return StaffPackageSchema().dump(accept_update_request), HTTPStatus.OK
@@ -248,7 +238,7 @@ def delete(package_id, update_request_id):
"""Withdraw an update request."""
# Check package-aware access control (mp_create, w_create, or eao_create)
PackageAccessControl.check_package_access(package_id, PackageOperation.CREATE)
-
+
withdraw_update_request = PackageService.withdraw_update_request(package_id, update_request_id)
return StaffPackageSchema().dump(withdraw_update_request), HTTPStatus.OK
diff --git a/submit-api/src/submit_api/resources/submission.py b/submit-api/src/submit_api/resources/submission.py
index e9acb1089..c81f0b7a5 100644
--- a/submit-api/src/submit_api/resources/submission.py
+++ b/submit-api/src/submit_api/resources/submission.py
@@ -26,7 +26,6 @@
from submit_api.schemas.submission import CreateSubmissionRequestSchema, SubmissionSchema, UpdateSubmissionStatusSchema
from submit_api.services.package_access_control import PackageAccessControl
from submit_api.services.submission import SubmissionService
-from submit_api.utils.roles import EpicSubmitRole
from submit_api.utils.util import allowedorigins, cors_preflight
@@ -163,9 +162,9 @@ def delete(submission_id):
submission = SubmissionModel.find_by_id(submission_id)
if not submission:
abort(HTTPStatus.NOT_FOUND, "Submission not found")
-
+
PackageAccessControl.check_package_access(submission.item.package_id, PackageOperation.EDIT)
-
+
deleted_submission = SubmissionService.soft_delete_submission(submission_id)
return SubmissionSchema().dump(deleted_submission), HTTPStatus.OK
diff --git a/submit-api/src/submit_api/resources/submission_item_note.py b/submit-api/src/submit_api/resources/submission_item_note.py
index cdf179661..771e47658 100644
--- a/submit-api/src/submit_api/resources/submission_item_note.py
+++ b/submit-api/src/submit_api/resources/submission_item_note.py
@@ -60,13 +60,13 @@ def post(submission_item_id):
submission_item = ItemModel.find_by_id(submission_item_id)
if not submission_item:
abort(HTTPStatus.NOT_FOUND, "Submission item not found")
-
+
# Check package-aware access control (mp_create, w_create, or eao_create)
PackageAccessControl.check_package_access(
submission_item.package_id,
PackageOperation.CREATE
)
-
+
create_note_data = PostSubmissionItemNote().load(API.payload)
created_note = SubmissionItemNoteService.create_note(submission_item_id, create_note_data)
return SubmissionItemNote().dump(created_note), HTTPStatus.CREATED
diff --git a/submit-api/src/submit_api/schemas/package.py b/submit-api/src/submit_api/schemas/package.py
index 4fa8ad184..ee83cb5ae 100644
--- a/submit-api/src/submit_api/schemas/package.py
+++ b/submit-api/src/submit_api/schemas/package.py
@@ -174,6 +174,9 @@ class Meta: # pylint: disable=too-few-public-methods
data_key="version", exclude=["package_id"])
account_project_work = fields.Nested(
AccountProjectWorkSchema, data_key="account_project_work", allow_none=True)
+ internal_staff_documents = fields.Nested(InternalStaffDocumentSchema,
+ data_key="internal_staff_documents",
+ many=True)
def get_submitted_by(self, obj):
"""Get submitted by."""
diff --git a/submit-api/src/submit_api/services/authorization.py b/submit-api/src/submit_api/services/authorization.py
index 3e4ea78f6..9e2e3177f 100644
--- a/submit-api/src/submit_api/services/authorization.py
+++ b/submit-api/src/submit_api/services/authorization.py
@@ -6,7 +6,6 @@
"""
from http import HTTPStatus
-from flask import g
from flask_restx import abort
from submit_api.auth import jwt
@@ -62,7 +61,6 @@ def has_access_to_package(package_id): # pylint: disable=too-many-branches
This function now delegates to PackageAccessControl for staff users
to provide centralized, operation-aware access control.
"""
- from submit_api.models import StaffUserWork # pylint: disable=import-outside-toplevel
# pylint: disable=import-outside-toplevel
from submit_api.enums.package_operation import PackageOperation
# pylint: disable=import-outside-toplevel
diff --git a/submit-api/src/submit_api/services/package_access_control.py b/submit-api/src/submit_api/services/package_access_control.py
index 1aff2dd73..f40e0173c 100644
--- a/submit-api/src/submit_api/services/package_access_control.py
+++ b/submit-api/src/submit_api/services/package_access_control.py
@@ -170,7 +170,7 @@ def get_user_package_permissions(package_id: int) -> list:
return permissions
@staticmethod
- def _has_mp_package_permission(package_type_name: str, operation: PackageOperation) -> bool:
+ def _has_mp_package_permission(_package_type_name: str, operation: PackageOperation) -> bool:
"""
Check if user has MP-type package permission for operation.
@@ -209,12 +209,11 @@ def _has_work_package_permission(
"""
from submit_api.models import StaffUserWork, AccountProjectWork # pylint: disable=import-outside-toplevel
- if user.type != UserType.STAFF:
+ # Early exit for non-staff users or users without staff profile
+ if user.type != UserType.STAFF or not user.staff_user:
return False
staff_user = user.staff_user
- if not staff_user:
- return False
# Get work_id from account_project_work_id
account_project_work = AccountProjectWork.query.filter_by(id=account_project_work_id).first()
@@ -257,11 +256,11 @@ def _has_eao_permission(operation: PackageOperation) -> bool:
"""
if operation == PackageOperation.READ:
return jwt.contains_role([EpicSubmitRole.EAO_VIEW.value])
- elif operation == PackageOperation.EDIT:
+ if operation == PackageOperation.EDIT:
return jwt.contains_role([EpicSubmitRole.EAO_EDIT.value])
- elif operation == PackageOperation.CREATE:
+ if operation == PackageOperation.CREATE:
return jwt.contains_role([EpicSubmitRole.EAO_CREATE.value])
- elif operation == PackageOperation.APPROVE:
+ if operation == PackageOperation.APPROVE:
# No EAO role for approve - would need specific implementation
return False
diff --git a/submit-api/src/submit_api/services/staff_user_work_service.py b/submit-api/src/submit_api/services/staff_user_work_service.py
index fd8a0c6ed..101abc115 100644
--- a/submit-api/src/submit_api/services/staff_user_work_service.py
+++ b/submit-api/src/submit_api/services/staff_user_work_service.py
@@ -2,7 +2,7 @@
from flask import current_app
from submit_api.exceptions import BadRequestError, ResourceNotFoundError
-from submit_api.models import StaffUserWork, TrackWork, User, StaffUser
+from submit_api.models import StaffUserWork, TrackWork, StaffUser
from submit_api.services.keycloak import KeycloakService
from submit_api.services.staff_user_service import StaffUserService
from submit_api.utils.constants import STAFF_WORK_ROLE_KEYCLOAK_GROUPS
@@ -41,11 +41,11 @@ def create_or_update_staff_user_work(cls, email: str, work_id: int, role: str):
keycloak_group = STAFF_WORK_ROLE_KEYCLOAK_GROUPS.get(role)
if not keycloak_group:
raise BadRequestError(f"Invalid role '{role}'. Must be TEAM_LEAD or TEAM_MEMBER.")
-
+
try:
user_groups = KeycloakService.get_user_groups(username)
has_role_group = any(group.get('path') == f'/{keycloak_group}'
- for group in user_groups)
+ for group in user_groups)
if not has_role_group:
group_id = KeycloakService.get_group_id_by_path(keycloak_group)
KeycloakService.update_user_group(user_id=username, group_id=group_id)
@@ -108,7 +108,7 @@ def remove_staff_user_work(cls, email: str, work_id: int):
username = staff_user.user.auth_guid
try:
user_groups = KeycloakService.get_user_groups(username)
- for role, group_path in STAFF_WORK_ROLE_KEYCLOAK_GROUPS.items():
+ for _role, group_path in STAFF_WORK_ROLE_KEYCLOAK_GROUPS.items():
has_group = any(group.get('path') == f'/{group_path}' for group in user_groups)
if has_group:
group_id = KeycloakService.get_group_id_by_path(group_path)
diff --git a/submit-web/src/components/App/SubmissionItem/InternalDocumentSection.tsx b/submit-web/src/components/App/SubmissionItem/InternalDocumentSection.tsx
index eb0bb2493..eeeef0a5f 100644
--- a/submit-web/src/components/App/SubmissionItem/InternalDocumentSection.tsx
+++ b/submit-web/src/components/App/SubmissionItem/InternalDocumentSection.tsx
@@ -32,7 +32,6 @@ export default function InternalDocumentSection() {
() => submissionPackage?.internal_staff_documents || [],
[submissionPackage],
);
-
useEffect(() => {
initializeFiles(internalStaffDocuments);
}, [internalStaffDocuments, initializeFiles]);
From 4154810ea87cf3ab451c9b45561d0812cb4991e1 Mon Sep 17 00:00:00 2001
From: dinesh
Date: Mon, 6 Jul 2026 14:44:11 -0700
Subject: [PATCH 5/5] Geospatial role implementation
---
.../src/submit_api/models/staff_user_work.py | 2 +-
.../src/submit_api/models/track_work.py | 3 +-
.../src/submit_api/resources/submission.py | 14 ++
.../src/submit_api/services/authorization.py | 29 +++
.../src/submit_api/services/user_service.py | 26 ++
submit-api/src/submit_api/utils/roles.py | 3 +
.../App/Submission/DocumentRow/index.tsx | 227 +++++++++++++-----
.../components/Shared/PermissionGate/utils.ts | 10 +
submit-web/src/hooks/usePackageRoles.ts | 18 +-
.../src/hooks/useStaffSubmissionPage.ts | 3 +-
submit-web/src/models/Role.tsx | 4 +-
submit-web/src/router.tsx | 6 +-
12 files changed, 278 insertions(+), 67 deletions(-)
diff --git a/submit-api/src/submit_api/models/staff_user_work.py b/submit-api/src/submit_api/models/staff_user_work.py
index 73603420b..798b35f49 100644
--- a/submit-api/src/submit_api/models/staff_user_work.py
+++ b/submit-api/src/submit_api/models/staff_user_work.py
@@ -46,7 +46,7 @@ class StaffUserWork(BaseModel):
lazy='joined',
back_populates='work_assignments')
- work = db.relationship('TrackWork', foreign_keys=[work_id], lazy='joined')
+ work = db.relationship('TrackWork', foreign_keys=[work_id], lazy='joined', back_populates='staff_assignments')
@classmethod
def find_by_staff_user_id(cls, staff_user_id: int):
diff --git a/submit-api/src/submit_api/models/track_work.py b/submit-api/src/submit_api/models/track_work.py
index 6fca87d10..3f84bc517 100644
--- a/submit-api/src/submit_api/models/track_work.py
+++ b/submit-api/src/submit_api/models/track_work.py
@@ -36,7 +36,8 @@ class TrackWork(BaseModel):
foreign_keys='StaffUserWork.work_id',
lazy='select',
cascade='all, delete',
- passive_deletes=True
+ passive_deletes=True,
+ back_populates='work'
)
__table_args__ = (
diff --git a/submit-api/src/submit_api/resources/submission.py b/submit-api/src/submit_api/resources/submission.py
index c81f0b7a5..284aab4c0 100644
--- a/submit-api/src/submit_api/resources/submission.py
+++ b/submit-api/src/submit_api/resources/submission.py
@@ -24,6 +24,7 @@
from submit_api.models import Submission as SubmissionModel
from submit_api.resources.apihelper import Api as ApiHelper
from submit_api.schemas.submission import CreateSubmissionRequestSchema, SubmissionSchema, UpdateSubmissionStatusSchema
+from submit_api.services.authorization import has_gis_extended_edit_role
from submit_api.services.package_access_control import PackageAccessControl
from submit_api.services.submission import SubmissionService
from submit_api.utils.util import allowedorigins, cors_preflight
@@ -226,10 +227,23 @@ class SubmissionUpdateStatus(Resource):
code=HTTPStatus.OK, model=submission_model, description="Submission"
)
@API.response(HTTPStatus.BAD_REQUEST, "Bad Request")
+ @API.response(HTTPStatus.FORBIDDEN, "Insufficient permissions")
@cross_origin(origins=allowedorigins())
@auth.require
def patch(submission_id):
"""Edit a submission status."""
+ # Get the submission to check if it's a GIS document
+ submission = SubmissionModel.find_by_id(submission_id)
+ if not submission:
+ abort(HTTPStatus.NOT_FOUND, "Submission not found")
+
+ # Check if this is a GIS document and validate GIS role
+ if (submission.submitted_document and
+ submission.submitted_document.folder == 'geospatial'):
+ # For GIS documents, user must have GIS extended edit role or full access
+ if not has_gis_extended_edit_role():
+ abort(HTTPStatus.FORBIDDEN, "GIS extended edit role required for GIS documents")
+
edit_submission_data = UpdateSubmissionStatusSchema().load(API.payload)
status = edit_submission_data.get("status")
edited_submission = SubmissionService.update_submission_status(submission_id, status)
diff --git a/submit-api/src/submit_api/services/authorization.py b/submit-api/src/submit_api/services/authorization.py
index 9e2e3177f..d7540ca21 100644
--- a/submit-api/src/submit_api/services/authorization.py
+++ b/submit-api/src/submit_api/services/authorization.py
@@ -120,3 +120,32 @@ def require_team_lead_access():
if jwt.contains_role([EpicSubmitRole.FULL_ACCESS.value, EpicSubmitRole.W_EXTENDED_EDIT.value]):
return
abort(HTTPStatus.FORBIDDEN)
+
+
+def has_gis_extended_edit_role(user_roles=None):
+ """Check if user has GIS extended edit role.
+
+ Args:
+ user_roles: List of user roles to check. If None, checks current user's roles.
+
+ Returns:
+ bool: True if user has GIS extended edit role or full access
+ """
+ if user_roles is None:
+ # Check current user's roles via JWT
+ return jwt.contains_role([EpicSubmitRole.GIS_EXTENDED_EDIT.value, EpicSubmitRole.FULL_ACCESS.value])
+
+ # Check provided roles list
+ return EpicSubmitRole.GIS_EXTENDED_EDIT.value in user_roles or EpicSubmitRole.FULL_ACCESS.value in user_roles
+
+
+def require_gis_extended_edit_access():
+ """Check if current user has GIS extended edit permission.
+
+ Raises:
+ HTTPStatus.FORBIDDEN: If user doesn't have GIS extended edit permission
+ """
+ # Check for full_access or gis_extended_edit role
+ if jwt.contains_role([EpicSubmitRole.FULL_ACCESS.value, EpicSubmitRole.GIS_EXTENDED_EDIT.value]):
+ return
+ abort(HTTPStatus.FORBIDDEN)
diff --git a/submit-api/src/submit_api/services/user_service.py b/submit-api/src/submit_api/services/user_service.py
index d74724e62..6311df2aa 100644
--- a/submit-api/src/submit_api/services/user_service.py
+++ b/submit-api/src/submit_api/services/user_service.py
@@ -27,6 +27,27 @@ def get_or_provision_by_auth_guid(cls, _guid, token_info=None):
# Check if user has staff roles in their token
if cls._has_staff_roles(token_info):
user = cls._auto_provision_staff_user(_guid, token_info)
+ elif user and user.type == UserType.STAFF and not user.staff_user and token_info:
+ # User exists as STAFF but staff_user record is missing - create it
+ if cls._has_staff_roles(token_info):
+ from submit_api.models import db
+
+ email = token_info.get('email')
+ given_name = token_info.get('given_name')
+ family_name = token_info.get('family_name')
+
+ staff_user_data = {
+ 'first_name': given_name,
+ 'last_name': family_name,
+ 'work_email_address': email,
+ 'user_id': user.id
+ }
+ StaffUser.create_staff_user(staff_user_data)
+
+ # Refresh the user object to load the staff_user relationship
+ db.session.refresh(user)
+
+ current_app.logger.info(f"Created missing staff_user record for: {email} (guid: {_guid})")
if not user:
raise ResourceNotFoundError(f"User with auth guid {_guid} not found")
@@ -47,6 +68,8 @@ def _has_staff_roles(cls, token_info):
@classmethod
def _auto_provision_staff_user(cls, _guid, token_info):
"""Auto-provision a staff user from token info."""
+ from submit_api.models import db
+
email = token_info.get('email')
given_name = token_info.get('given_name')
family_name = token_info.get('family_name')
@@ -67,6 +90,9 @@ def _auto_provision_staff_user(cls, _guid, token_info):
}
StaffUser.create_staff_user(staff_user_data)
+ # Refresh the user object to load the staff_user relationship
+ db.session.refresh(user)
+
current_app.logger.info(f"Auto-provisioned staff user: {email} (guid: {_guid})")
return user
diff --git a/submit-api/src/submit_api/utils/roles.py b/submit-api/src/submit_api/utils/roles.py
index 317461ea8..575872527 100644
--- a/submit-api/src/submit_api/utils/roles.py
+++ b/submit-api/src/submit_api/utils/roles.py
@@ -35,3 +35,6 @@ class EpicSubmitRole(Enum):
W_EDIT = "w_edit"
W_CREATE = "w_create"
W_EXTENDED_EDIT = "w_extended_edit"
+
+ # GIS role
+ GIS_EXTENDED_EDIT = "gis_extended_edit"
diff --git a/submit-web/src/components/App/Submission/DocumentRow/index.tsx b/submit-web/src/components/App/Submission/DocumentRow/index.tsx
index eb81c3e29..4aeac1bde 100644
--- a/submit-web/src/components/App/Submission/DocumentRow/index.tsx
+++ b/submit-web/src/components/App/Submission/DocumentRow/index.tsx
@@ -1,4 +1,4 @@
-import { Box, IconButton, TableRow, Typography } from "@mui/material";
+import { Box, IconButton, TableRow, Typography, Button, Tooltip } from "@mui/material";
import { Submission, SUBMISSION_STATUS } from "@/models/Submission";
import { SubmissionItem } from "@/models/SubmissionItem";
import {
@@ -12,6 +12,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import CheckIcon from "@mui/icons-material/Check";
import DoneAllIcon from "@mui/icons-material/DoneAll";
import UndoIcon from "@mui/icons-material/Undo";
+import VisibilityIcon from "@mui/icons-material/Visibility";
import { ActionButton } from "./ActionButton";
import PermissionsGate from "@/components/Shared/PermissionGate";
import { SubmissionPackage, PackageType } from "@/models/Package";
@@ -22,6 +23,17 @@ import ActionSplitButton, {
import { BCDesignTokens } from "epic.theme";
import { useDocumentRow } from "@/hooks/useDocumentRow";
import { usePackageRoles } from "@/hooks/usePackageRoles";
+import { S3_FOLDER } from "@/hooks/api/useObjectStorage";
+import { useState } from "react";
+import { useAccount } from "@/store/accountStore";
+import { lazy, Suspense } from "react";
+import { useGetGeoUploads } from "@/hooks/api/useGeo";
+
+const MapPreviewModal = lazy(() =>
+ import("@/components/App/Map/MapPreviewModal").then((m) => ({
+ default: m.MapPreviewModal,
+ })),
+);
type DocumentRowProps = Readonly<{
documentSubmission: Submission;
@@ -42,9 +54,26 @@ export default function DocumentRow({
documentSubmission;
const packageType = propsPackageType || submissionPackage?.type;
const name = submitted_document?.name || "";
+ const { roles } = useAccount();
+
+ // Check if this is a GIS document
+ const isGISDocument = submitted_document?.folder === S3_FOLDER.GEOSPATIAL.value;
+
+ // Get the correct package-specific roles (include document folder for GIS handling)
+ const packageRoles = usePackageRoles(submissionPackage, packageType, submitted_document?.folder);
+
+ // Check if user has GIS permissions
+ const hasGISPermissions = roles?.includes('gis_extended_edit') || roles?.includes('full_access');
- // Get the correct package-specific roles
- const packageRoles = usePackageRoles(submissionPackage, packageType);
+ // State for GIS preview modal
+ const [showPreviewModal, setShowPreviewModal] = useState(false);
+
+ // Get geo uploads for GIS preview
+ const { data: geoUploads } = useGetGeoUploads({
+ itemId: submissionItem.id,
+ autoRefetch: false,
+ });
+ const uploads = geoUploads as any[];
const {
pendingGetObject,
@@ -148,32 +177,48 @@ export default function DocumentRow({
]}
>
-
- {staff ? (
-
+
+ {staff ? (
+
+
+
+ ) : (
+
+ )}
+
+ {isGISDocument && (
+ }
+ onClick={() => setShowPreviewModal(true)}
+ sx={{
+ minWidth: "auto",
+ fontSize: "0.75rem",
+ textTransform: "none"
+ }}
>
-
-
- ) : (
-
+ Preview GIS File
+
)}
-
+
{submitted_by || ""}
@@ -210,42 +255,90 @@ export default function DocumentRow({
}}
>
{showUndoVerificationButton && (
-
-
- Undo Verification
-
-
+ isGISDocument && !hasGISPermissions ? (
+
+
+ Undo Verification
+
+
+ ) : (
+
+
+ Undo Verification
+
+
+ )
)}
{showUndoAcknowledgementButton && (
-
-
- Undo Acknowledgement
-
-
+ isGISDocument && !hasGISPermissions ? (
+
+
+ Undo Acknowledgement
+
+
+ ) : (
+
+
+ Undo Acknowledgement
+
+
+ )
)}
{splitButtonConfig ? (
-
-
-
+ isGISDocument && !hasGISPermissions ? (
+
+
+ {} // Disabled click
+ }}
+ secondaryActions={splitButtonConfig.secondary.map(action => ({
+ ...action,
+ onClick: () => {} // Disabled click
+ }))}
+ disabled
+ />
+
+
+ ) : (
+
+
+
+ )
) : showDefaultActionButton ? (
@@ -264,6 +357,22 @@ export default function DocumentRow({
)}
+
+ {/* GIS Preview Modal */}
+ {isGISDocument && (
+
+ u.raw_s3_key === documentSubmission.submitted_document?.url)?.id ?? null}
+ documentItem={documentSubmission}
+ fileSizeKb={uploads?.find((u) => u.raw_s3_key === documentSubmission.submitted_document?.url)?.file_size_kb}
+ status={uploads?.find((u) => u.raw_s3_key === documentSubmission.submitted_document?.url)?.status}
+ errorMessage={uploads?.find((u) => u.raw_s3_key === documentSubmission.submitted_document?.url)?.error_message}
+ onApprove={() => {}} // No approve action needed for preview
+ onReject={async () => {}} // No reject action needed for preview
+ />
+
+ )}
>
);
}
diff --git a/submit-web/src/components/Shared/PermissionGate/utils.ts b/submit-web/src/components/Shared/PermissionGate/utils.ts
index 9cea33564..fdce2e6e6 100644
--- a/submit-web/src/components/Shared/PermissionGate/utils.ts
+++ b/submit-web/src/components/Shared/PermissionGate/utils.ts
@@ -23,6 +23,16 @@ export const checkIfStaff = (roles?: string[]) => {
);
};
+export const checkIfGISUser = (roles?: string[]) => {
+ if (!roles) {
+ return false;
+ }
+ return hasPermission({
+ permissions: roles || [],
+ scopes: [EPIC_SUBMIT_ROLE.gis_extended_edit],
+ });
+};
+
export const hasPermission = ({
permissions,
scopes,
diff --git a/submit-web/src/hooks/usePackageRoles.ts b/submit-web/src/hooks/usePackageRoles.ts
index b9a1d9720..5244541d6 100644
--- a/submit-web/src/hooks/usePackageRoles.ts
+++ b/submit-web/src/hooks/usePackageRoles.ts
@@ -1,18 +1,32 @@
import { useMemo } from "react";
import { EPIC_SUBMIT_ROLE } from "@/models/Role";
import { SubmissionPackage, PackageType } from "@/models/Package";
+import { S3_FOLDER } from "@/hooks/api/useObjectStorage";
/**
* Hook to determine the correct package-specific roles based on package type.
* Returns the appropriate MP_* or W_* roles for the given package.
+ * For GIS documents, includes GIS role handling.
*/
export const usePackageRoles = (
submissionPackage?: SubmissionPackage,
- packageType?: PackageType
+ packageType?: PackageType,
+ documentFolder?: string
) => {
const actualPackageType = packageType || submissionPackage?.type;
+ const isGISDocument = documentFolder === S3_FOLDER.GEOSPATIAL.value;
return useMemo(() => {
+ // For GIS documents, use GIS role + full_access
+ if (isGISDocument) {
+ return {
+ view: EPIC_SUBMIT_ROLE.gis_extended_edit,
+ edit: EPIC_SUBMIT_ROLE.gis_extended_edit,
+ create: EPIC_SUBMIT_ROLE.gis_extended_edit,
+ approve: EPIC_SUBMIT_ROLE.gis_extended_edit,
+ };
+ }
+
// Determine if this is a work package or MP-type package
const isWorkPackage = submissionPackage?.account_project_work != null;
@@ -46,5 +60,5 @@ export const usePackageRoles = (
approve: EPIC_SUBMIT_ROLE.eao_edit, // No EAO approve role
};
}
- }, [submissionPackage?.account_project_work, actualPackageType?.name]);
+ }, [submissionPackage?.account_project_work, actualPackageType?.name, isGISDocument]);
};
diff --git a/submit-web/src/hooks/useStaffSubmissionPage.ts b/submit-web/src/hooks/useStaffSubmissionPage.ts
index f4fe2b17d..ce6739041 100644
--- a/submit-web/src/hooks/useStaffSubmissionPage.ts
+++ b/submit-web/src/hooks/useStaffSubmissionPage.ts
@@ -174,10 +174,11 @@ export function useStaffSubmissionPage({
].includes(approval_type as SubmissionPackageApprovalType);
// Only users with w_extended_edit permission can approve packages
+ // GIS-only users should not be able to approve packages
const canApprove = hasPermission({
permissions: account.roles || [],
scopes: [EPIC_SUBMIT_ROLE.w_extended_edit],
- });
+ }) && !account.roles?.includes(EPIC_SUBMIT_ROLE.gis_extended_edit);
const showApproveButtons =
isPackageAcknowledged &&
approval_type == SubmissionPackageApprovalType.C &&
diff --git a/submit-web/src/models/Role.tsx b/submit-web/src/models/Role.tsx
index 8f3d5c76e..e6375bf56 100644
--- a/submit-web/src/models/Role.tsx
+++ b/submit-web/src/models/Role.tsx
@@ -10,7 +10,8 @@ export type EpicSubmitRole =
| "w_view"
| "w_edit"
| "w_create"
- | "w_extended_edit";
+ | "w_extended_edit"
+ | "gis_extended_edit";
export const EPIC_SUBMIT_ROLE = Object.freeze<
Record
@@ -27,6 +28,7 @@ export const EPIC_SUBMIT_ROLE = Object.freeze<
w_edit: "w_edit",
w_create: "w_create",
w_extended_edit: "w_extended_edit",
+ gis_extended_edit: "gis_extended_edit",
});
export enum USER_MANAGEMENT_ROLE {
diff --git a/submit-web/src/router.tsx b/submit-web/src/router.tsx
index 93fa2c840..d9aa995ed 100644
--- a/submit-web/src/router.tsx
+++ b/submit-web/src/router.tsx
@@ -49,8 +49,10 @@ export default function RouterProviderWithAuthContext({
}, [authentication, router, setAccount]);
useEffect(() => {
- getAccountData();
- }, [authentication, getAccountData]);
+ if (authentication.isAuthenticated && authentication.user?.profile.preferred_username) {
+ getAccountData();
+ }
+ }, [authentication.isAuthenticated, authentication.user?.profile.preferred_username, getAccountData]);
useEffect(() => {
// the `return` is important - addAccessTokenExpiring() returns a cleanup function