diff --git a/submit-api/src/submit_api/resources/package.py b/submit-api/src/submit_api/resources/package.py index f7988e3f..508c58eb 100644 --- a/submit-api/src/submit_api/resources/package.py +++ b/submit-api/src/submit_api/resources/package.py @@ -190,8 +190,9 @@ class PackageUpdateRequests(Resource): @cross_origin(origins=allowedorigins()) 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) + # Check basic package access (user can view the package) + # Item-level permissions (including GIS) are validated in PackageService.create_update_request + authorization.has_access_to_package(package_id) create_update_request_data = CreateUpdateRequestSchema().load(API.payload) package_with_created_update_request = PackageService.create_update_request( @@ -219,8 +220,9 @@ class PackageUpdateRequest(Resource): @cross_origin(origins=allowedorigins()) 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) + # Check basic package access (user can view the package) + # Item-level permissions (including GIS) are validated in PackageService.accept_update_request + authorization.has_access_to_package(package_id) accept_update_request = PackageService.accept_update_request(package_id, update_request_id) return StaffPackageSchema().dump(accept_update_request), HTTPStatus.OK @@ -236,8 +238,9 @@ def patch(package_id, update_request_id): @cross_origin(origins=allowedorigins()) 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) + # Check basic package access (user can view the package) + # Item-level permissions (including GIS) are validated in PackageService.withdraw_update_request + authorization.has_access_to_package(package_id) withdraw_update_request = PackageService.withdraw_update_request(package_id, update_request_id) return StaffPackageSchema().dump(withdraw_update_request), HTTPStatus.OK diff --git a/submit-web/src/components/App/Submission/DocumentRow/index.tsx b/submit-web/src/components/App/Submission/DocumentRow/index.tsx index a286625c..2c526958 100644 --- a/submit-web/src/components/App/Submission/DocumentRow/index.tsx +++ b/submit-web/src/components/App/Submission/DocumentRow/index.tsx @@ -23,10 +23,11 @@ import { BCDesignTokens } from "epic.theme"; import { useDocumentRow } from "@/hooks/useDocumentRow"; import { usePackageRoles } from "@/hooks/usePackageRoles"; import { useState } from "react"; -import { useAccount } from "@/store/accountStore"; import { lazy, Suspense } from "react"; import { GIS_ITEM_TYPE_NAME } from "@/utils/constants"; import { useGetGeoUploads } from "@/hooks/api/useGeo"; +import { useHasRole } from "@/hooks/common"; +import { EPIC_SUBMIT_ROLE } from "@/models/Role"; const MapPreviewModal = lazy(() => import("@/components/App/Map/MapPreviewModal").then((m) => ({ @@ -53,7 +54,6 @@ 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 (item type name "Geospatial Information") const isGISDocument = submissionItem.type.name === GIS_ITEM_TYPE_NAME; @@ -61,8 +61,8 @@ export default function DocumentRow({ // 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'); + // Check if user has GIS permissions (includes full_access check) + const hasGISPermissions = useHasRole(EPIC_SUBMIT_ROLE.gis_extended_edit); // State for GIS preview modal const [showPreviewModal, setShowPreviewModal] = useState(false); diff --git a/submit-web/src/components/App/Submission/SubmissionItemTableRow/StaffSubmissionItemTableRow/index.tsx b/submit-web/src/components/App/Submission/SubmissionItemTableRow/StaffSubmissionItemTableRow/index.tsx index b4d6cb07..355908d8 100644 --- a/submit-web/src/components/App/Submission/SubmissionItemTableRow/StaffSubmissionItemTableRow/index.tsx +++ b/submit-web/src/components/App/Submission/SubmissionItemTableRow/StaffSubmissionItemTableRow/index.tsx @@ -25,9 +25,10 @@ import SubmissionItemReviewConfirmation from "@/components/App/Submission/Submis import DocumentRow from "@/components/App/Submission/DocumentRow"; import { useMemo } from "react"; import { getSubmissionItemLabel } from "@/utils"; -import { useAccount } from "@/store/accountStore"; import { usePackageRoles } from "@/hooks/usePackageRoles"; import { GIS_ITEM_TYPE_NAME } from "@/utils/constants"; +import { useHasRole } from "@/hooks/common"; +import { EPIC_SUBMIT_ROLE } from "@/models/Role"; export default function StaffSubmissionItemTableRow({ item, @@ -50,7 +51,6 @@ export default function StaffSubmissionItemTableRow({ const { submissions, id } = item; const { submitted_on } = submissionPackage; - const { roles } = useAccount(); const name = useMemo(() => { return getSubmissionItemLabel(item.type.name); @@ -88,11 +88,11 @@ export default function StaffSubmissionItemTableRow({ // Get the appropriate roles for this package type const packageRoles = usePackageRoles(submissionPackage, packageType); - // Check if user has the appropriate create role for this package type - const hasPackageCreateRole = roles?.includes(packageRoles.create); + // Check if user has the appropriate create role for this package type (includes full_access check) + const hasPackageCreateRole = useHasRole(packageRoles.create); - // Check if user has GIS permissions - const hasGISPermissions = roles?.includes('gis_extended_edit') || roles?.includes('full_access'); + // Check if user has GIS permissions (includes full_access check) + const hasGISPermissions = useHasRole(EPIC_SUBMIT_ROLE.gis_extended_edit); // Determine if Request Update should be shown // For GIS items: show if user has GIS permissions diff --git a/submit-web/src/components/App/SubmissionItem/SectionUpdateRequestPanel/SentRequestCollapsible.tsx b/submit-web/src/components/App/SubmissionItem/SectionUpdateRequestPanel/SentRequestCollapsible.tsx index 4f1addf7..a20920b5 100644 --- a/submit-web/src/components/App/SubmissionItem/SectionUpdateRequestPanel/SentRequestCollapsible.tsx +++ b/submit-web/src/components/App/SubmissionItem/SectionUpdateRequestPanel/SentRequestCollapsible.tsx @@ -21,7 +21,8 @@ import { UPDATE_REQUEST_STATUS } from "@/models/UpdateRequest"; import ActionSplitButton from "@/components/Shared/ActionSplitButton/ActionSplitButton"; import { UpdateRequestAccordion } from "./UpdateRequestAccordion"; import { useUpdatePackageUpdateRequestNote, useCreatePackageUpdateRequesNote } from "@/hooks/api/usePackages"; -import { useAccount } from "@/store/accountStore"; +import { useHasRole } from "@/hooks/common"; +import { EPIC_SUBMIT_ROLE } from "@/models/Role"; interface SentRequestCollapsibleProps { request: SentRequest; @@ -51,10 +52,9 @@ export const SentRequestCollapsible: React.FC = ({ const [isEditingNote, setIsEditingNote] = useState(false); const [noteText, setNoteText] = useState(request.note || ""); const maxNoteLength = 500; - const { roles } = useAccount(); - // Check if user has GIS permissions - const hasGISPermissions = roles?.includes('gis_extended_edit') || roles?.includes('full_access'); + // Check if user has GIS permissions (includes full_access check) + const hasGISPermissions = useHasRole(EPIC_SUBMIT_ROLE.gis_extended_edit); // Determine if actions should be disabled for GIS requests const isActionDisabled = isGISRequest && !hasGISPermissions; diff --git a/submit-web/src/hooks/common.ts b/submit-web/src/hooks/common.ts index 7a7a6c53..f7e8b4d9 100644 --- a/submit-web/src/hooks/common.ts +++ b/submit-web/src/hooks/common.ts @@ -1,5 +1,7 @@ import { useMediaQuery, useTheme } from "@mui/material"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; +import { useAccount } from "@/store/accountStore"; +import { EPIC_SUBMIT_ROLE, EpicSubmitRole } from "@/models/Role"; export const useIsMobile = () => { const theme = useTheme(); @@ -13,3 +15,61 @@ export const useMounted = (callback: () => void) => { // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(callback, []); }; + +/** + * Hook to check if the current user has a specific role or full_access. + * This encapsulates the common pattern of checking for a role OR full_access. + * + * @param requiredRole - The specific role to check for (e.g., 'mp_create', 'w_edit') + * @returns boolean - true if user has the required role OR full_access + * + * @example + * const hasCreatePermission = useHasRole(EPIC_SUBMIT_ROLE.mp_create); + * const hasGISPermission = useHasRole(EPIC_SUBMIT_ROLE.gis_extended_edit); + */ +export const useHasRole = (requiredRole: EpicSubmitRole | string): boolean => { + const { roles } = useAccount(); + + return useMemo(() => { + if (!roles) return false; + return roles.includes(requiredRole) || roles.includes(EPIC_SUBMIT_ROLE.full_access); + }, [roles, requiredRole]); +}; + +/** + * Hook to check if the current user has ANY of the specified roles or full_access. + * + * @param requiredRoles - Array of roles to check for + * @returns boolean - true if user has any of the required roles OR full_access + * + * @example + * const hasAnyEditRole = useHasAnyRole([EPIC_SUBMIT_ROLE.mp_edit, EPIC_SUBMIT_ROLE.w_edit]); + */ +export const useHasAnyRole = (requiredRoles: (EpicSubmitRole | string)[]): boolean => { + const { roles } = useAccount(); + + return useMemo(() => { + if (!roles) return false; + if (roles.includes(EPIC_SUBMIT_ROLE.full_access)) return true; + return requiredRoles.some(role => roles.includes(role)); + }, [roles, requiredRoles]); +}; + +/** + * Hook to check if the current user has ALL of the specified roles or full_access. + * + * @param requiredRoles - Array of roles to check for + * @returns boolean - true if user has all of the required roles OR full_access + * + * @example + * const hasAllRoles = useHasAllRoles([EPIC_SUBMIT_ROLE.mp_view, EPIC_SUBMIT_ROLE.mp_edit]); + */ +export const useHasAllRoles = (requiredRoles: (EpicSubmitRole | string)[]): boolean => { + const { roles } = useAccount(); + + return useMemo(() => { + if (!roles) return false; + if (roles.includes(EPIC_SUBMIT_ROLE.full_access)) return true; + return requiredRoles.every(role => roles.includes(role)); + }, [roles, requiredRoles]); +}; diff --git a/submit-web/src/hooks/useStaffSubmissionPage.ts b/submit-web/src/hooks/useStaffSubmissionPage.ts index ce673904..23e285b2 100644 --- a/submit-web/src/hooks/useStaffSubmissionPage.ts +++ b/submit-web/src/hooks/useStaffSubmissionPage.ts @@ -20,6 +20,7 @@ import { useNavigate } from "@tanstack/react-router"; import { useAccount } from "@/store/accountStore"; import { hasPermission } from "@/components/Shared/PermissionGate/utils"; import { EPIC_SUBMIT_ROLE } from "@/models/Role"; +import { useHasRole } from "@/hooks/common"; interface UseStaffSubmissionPageOptions { submissionPackageId: number; @@ -174,11 +175,16 @@ 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({ + // GIS-only users (without full_access) should not be able to approve packages + const hasExtendedEdit = hasPermission({ permissions: account.roles || [], scopes: [EPIC_SUBMIT_ROLE.w_extended_edit], - }) && !account.roles?.includes(EPIC_SUBMIT_ROLE.gis_extended_edit); + }); + const hasGISRole = useHasRole(EPIC_SUBMIT_ROLE.gis_extended_edit); + const hasFullAccess = useHasRole(EPIC_SUBMIT_ROLE.full_access); + + // Exclude GIS-only users (those with GIS role but not full_access) + const canApprove = hasExtendedEdit && !(hasGISRole && !hasFullAccess); const showApproveButtons = isPackageAcknowledged && approval_type == SubmissionPackageApprovalType.C &&