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 && ( + )} - + {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