Skip to content

Commit

Permalink
feat(frontend): start user roles integration (hotosm#2207)
Browse files Browse the repository at this point in the history
* feat(permissions): permissions add

* feat(permissions): role based permission for component access & hide/unhide buttons

* feat(noAccessComponent): fallback component on user permission deny

* refactor(permissions): update naming convention

* feat(enums): project_roles add to enums

* refactor(usePermissions): use access based permission instead of action

* refactor(permissions): use access based permission

* feat(dialogTaskActions): allow project-manager, org-admin or super-admin to update the task state

* refactor(usePermission): remove console

* fix(organization): restrict organization creation if user already manages an organization

* refactor(projectDetailsForm): preselect organization dropdown if user is org admin of only one organization

* fix(projectDetailsForm): list only organizations which the user is associated with on organization list select
  • Loading branch information
NSUWAL123 authored Feb 24, 2025
1 parent 5467c2e commit 34f7982
Show file tree
Hide file tree
Showing 12 changed files with 104 additions and 12 deletions.
6 changes: 5 additions & 1 deletion src/frontend/src/components/DialogTaskActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { GetProjectTaskActivity } from '@/api/Project';
import { Modal } from '@/components/common/Modal';
import { useAppDispatch, useAppSelector } from '@/types/reduxTypes';
import { taskSubmissionInfoType } from '@/models/task/taskModel';
import { useIsOrganizationAdmin, useIsProjectManager } from '@/hooks/usePermissions';

type dialogPropType = {
taskId: number;
Expand Down Expand Up @@ -41,6 +42,9 @@ export default function Dialog({ taskId, feature }: dialogPropType) {
const authDetails = CoreModules.useAppSelector((state) => state.login.authDetails);
const projectTaskActivityList = useAppSelector((state) => state?.project?.projectTaskActivity);

const isOrganizationAdmin = useIsOrganizationAdmin(projectInfo?.organisation_id as number);
const isProjectManager = useIsProjectManager(projectInfo?.id as number);

const currentProjectId: string = params.id;
const projectIndex = projectData.findIndex((project) => project.id == parseInt(currentProjectId));
const selectedTask = {
Expand Down Expand Up @@ -180,7 +184,7 @@ export default function Dialog({ taskId, feature }: dialogPropType) {
}`}
>
{list_of_task_actions?.map((data, index) => {
return checkIfTaskAssignedOrNot(data.value) ? (
return checkIfTaskAssignedOrNot(data.value) || isOrganizationAdmin || isProjectManager ? (
<Button
key={index}
variant={data.btnType}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ import DescriptionSection from '@/components/createnewproject/Description';
import Select2 from '@/components/common/Select2';
import { GetUserListForSelect } from '@/api/User';
import { UserActions } from '@/store/slices/UserSlice';
import CoreModules from '@/shared/CoreModules';
import { useIsAdmin } from '@/hooks/usePermissions';
import { isEmpty } from '@/utilfunctions/commonUtils';

const VITE_API_URL = import.meta.env.VITE_API_URL;

const ProjectDetailsForm = ({ flag }) => {
useDocumentTitle('Create Project: Project Details');
const dispatch = useAppDispatch();
const navigate = useNavigate();
const isAdmin = useIsAdmin();

const projectDetails = useAppSelector((state) => state.createproject.projectDetails);
const organisationListData = useAppSelector((state) => state.createproject.organisationList);
Expand All @@ -33,6 +37,7 @@ const ProjectDetailsForm = ({ flag }) => {
value: user.id,
}));
const userListLoading = useAppSelector((state) => state.user.userListLoading);
const authDetails = CoreModules.useAppSelector((state) => state.login.authDetails);

const organisationList = organisationListData.map((item) => ({
id: item.id,
Expand All @@ -55,7 +60,9 @@ const ProjectDetailsForm = ({ flag }) => {
);

const onFocus = () => {
dispatch(OrganisationService(`${VITE_API_URL}/organisation`));
dispatch(
OrganisationService(isAdmin ? `${VITE_API_URL}/organisation` : `${VITE_API_URL}/organisation/my-organisations`),
);
};

useEffect(() => {
Expand All @@ -79,7 +86,6 @@ const ProjectDetailsForm = ({ flag }) => {
if (!orgIdInt) {
return;
}

const selectedOrg = organisationList.find((org) => org.value === orgIdInt);
handleCustomChange('organisation_id', orgIdInt);
handleCustomChange('useDefaultODKCredentials', selectedOrg?.hasODKCredentials || false);
Expand All @@ -101,13 +107,21 @@ const ProjectDetailsForm = ({ flag }) => {
}, [values.useDefaultODKCredentials]);

useEffect(() => {
if (isEmpty(organisationList)) return;
organisationList?.map((organization) => {
if (values?.organisation_id == organization?.value) {
setHasODKCredentials(organization.hasODKCredentials);
}
});
}, [organisationList]);

useEffect(() => {
if (!authDetails || isEmpty(organisationList)) return;
if (!isAdmin && authDetails && authDetails?.orgs_managed?.length === 1) {
handleOrganizationChange(authDetails?.orgs_managed[0]);
}
}, [authDetails, organisationListData]);

return (
<div className="fmtm-flex fmtm-gap-7 fmtm-flex-col lg:fmtm-flex-row fmtm-h-full">
<DescriptionSection section="Project Details" />
Expand Down Expand Up @@ -152,8 +166,10 @@ const ProjectDetailsForm = ({ flag }) => {
<p className={`fmtm-text-[1rem] fmtm-mb-2 fmtm-font-semibold !fmtm-bg-transparent`}>
Organization Name <span className="fmtm-text-red-500 fmtm-text-[1rem]">*</span>
</p>
{/* if user is organization-admin of only one org, then disable the dropdown & preselect the org */}
<Select2
options={organisationList || []}
disabled={!isAdmin && authDetails && authDetails?.orgs_managed?.length === 1}
value={values.organisation_id}
onChange={(value: any) => {
handleOrganizationChange(value);
Expand Down
16 changes: 10 additions & 6 deletions src/frontend/src/components/home/HomePageFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { HomeActions } from '@/store/slices/HomeSlice';
import Switch from '@/components/common/Switch';
import Searchbar from '@/components/common/SearchBar';
import Button from '@/components/common/Button';
import { useHasManagedAnyOrganization } from '@/hooks/usePermissions';

type homePageFiltersPropType = {
searchText: string;
onSearch: (data: string) => void;
};

const HomePageFilters = ({ searchText, onSearch }: homePageFiltersPropType) => {
const hasManagedAnyOrganization = useHasManagedAnyOrganization();
const dispatch = useAppDispatch();

const showMapStatus = useAppSelector((state) => state.home.showMapStatus);
Expand All @@ -36,12 +38,14 @@ const HomePageFilters = ({ searchText, onSearch }: homePageFiltersPropType) => {
onCheckedChange={() => dispatch(HomeActions.SetShowMapStatus(!showMapStatus))}
/>
</div>
<Link to={'/create-project'}>
<Button variant="primary-red">
<AssetModules.AddIcon className="!fmtm-text-[1.125rem]" />
<p>New Project</p>
</Button>
</Link>
{hasManagedAnyOrganization && (
<Link to={'/create-project'}>
<Button variant="primary-red">
<AssetModules.AddIcon className="!fmtm-text-[1.125rem]" />
<p>New Project</p>
</Button>
</Link>
)}
</div>
</div>
);
Expand Down
25 changes: 25 additions & 0 deletions src/frontend/src/hooks/usePermissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { project_roles, user_roles } from '@/types/enums';
import CoreModules from '@/shared/CoreModules';

export function useIsAdmin() {
const authDetails = CoreModules.useAppSelector((state) => state.login.authDetails);
return authDetails?.role === user_roles.ADMIN;
}

export function useHasManagedAnyOrganization() {
const authDetails = CoreModules.useAppSelector((state) => state.login.authDetails);
const orgs_managed = authDetails?.orgs_managed || [];
return authDetails?.role === user_roles.ADMIN || orgs_managed?.length > 0;
}

export function useIsOrganizationAdmin(id: number) {
const authDetails = CoreModules.useAppSelector((state) => state.login.authDetails);
return (
authDetails?.role === user_roles.ADMIN || (authDetails?.orgs_managed && authDetails?.orgs_managed?.includes(id))
);
}

export function useIsProjectManager(id: string | number) {
const authDetails = CoreModules.useAppSelector((state) => state.login.authDetails);
return authDetails?.role === user_roles.ADMIN || authDetails?.project_roles?.[id] === project_roles.PROJECT_MANAGER;
}
8 changes: 8 additions & 0 deletions src/frontend/src/types/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ export enum user_roles {
ADMIN = 'ADMIN',
}

export enum project_roles {
MAPPER = 'MAPPER',
VALIDATOR = 'VALIDATOR',
FIELD_MANAGER = 'FIELD_MANAGER',
PROJECT_MANAGER = 'PROJECT_MANAGER',
ASSOCIATE_PROJECT_MANAGER = 'ASSOCIATE_PROJECT_MANAGER',
}

export type NewGeomTypes = {
POINT: 'POINT';
POLYGON: 'POLYGON';
Expand Down
5 changes: 3 additions & 2 deletions src/frontend/src/utilities/PrimaryAppBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ import {
DropdownMenuPortal,
DropdownMenuItem,
} from '@/components/common/Dropdown';
import { useIsAdmin } from '@/hooks/usePermissions';
import Button from '@/components/common/Button';
import { user_roles } from '@/types/enums';

export default function PrimaryAppBar() {
const isAdmin = useIsAdmin();
const location = useLocation();
const navigate = useNavigate();
const dispatch = useAppDispatch();
Expand Down Expand Up @@ -95,7 +96,7 @@ export default function PrimaryAppBar() {
align="center"
sideOffset={10}
>
{authDetails && authDetails?.role === user_roles['ADMIN'] && (
{isAdmin && (
<Link to="/manage/user">
<DropdownMenuItem>Manage User</DropdownMenuItem>
</Link>
Expand Down
5 changes: 5 additions & 0 deletions src/frontend/src/views/ApproveOrganization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import React from 'react';
import ApproveOrganizationHeader from '@/components/ApproveOrganization/ApproveOrganizationHeader';
import OrganizationForm from '@/components/ApproveOrganization/OrganizationForm';
import useDocumentTitle from '@/utilfunctions/useDocumentTitle';
import { useIsAdmin } from '@/hooks/usePermissions';
import NoAccessComponent from '@/views/NoAccessComponent';

const ApproveOrganization = () => {
const isAdmin = useIsAdmin();
if (!isAdmin) return <NoAccessComponent />;

useDocumentTitle('Approve Organization');
return (
<div className="fmtm-bg-[#F5F5F5]">
Expand Down
9 changes: 9 additions & 0 deletions src/frontend/src/views/CreateEditOrganization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,20 @@ import ConsentDetailsForm from '@/components/CreateEditOrganization/ConsentDetai
import CreateEditOrganizationForm from '@/components/CreateEditOrganization/CreateEditOrganizationForm';
import { OrganisationAction } from '@/store/slices/organisationSlice';
import { useAppDispatch, useAppSelector } from '@/types/reduxTypes';
import { useHasManagedAnyOrganization, useIsAdmin, useIsOrganizationAdmin } from '@/hooks/usePermissions';
import NoAccessComponent from '@/views/NoAccessComponent';

const CreateEditOrganization = () => {
const params = CoreModules.useParams();
const dispatch = useAppDispatch();
const organizationId: string = params.id;
const isAdmin = useIsAdmin();
const isOrganizationAdmin = useIsOrganizationAdmin(+organizationId);
const hasManagedAnyOrganization = useHasManagedAnyOrganization();

if ((organizationId && !isOrganizationAdmin) || (!organizationId && hasManagedAnyOrganization && !isAdmin))
return <NoAccessComponent />;

const consentApproval = useAppSelector((state) => state.organisation.consentApproval);

useEffect(() => {
Expand Down
5 changes: 5 additions & 0 deletions src/frontend/src/views/CreateNewProject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { CommonActions } from '@/store/slices/CommonSlice';
import { useAppDispatch, useAppSelector } from '@/types/reduxTypes';
import Prompt from '@/hooks/Prompt';
import { useHasManagedAnyOrganization } from '@/hooks/usePermissions';
import NoAccessComponent from '@/views/NoAccessComponent';

const CreateNewProject = () => {
const hasManagedAnyOrganization = useHasManagedAnyOrganization();
if (!hasManagedAnyOrganization) return <NoAccessComponent />;

const location = useLocation();
const dispatch = useAppDispatch();
const navigate = useNavigate();
Expand Down
5 changes: 5 additions & 0 deletions src/frontend/src/views/ManageUsers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { user_roles } from '@/types/enums';
import { CommonActions } from '@/store/slices/CommonSlice';
import Searchbar from '@/components/common/SearchBar';
import useDebouncedInput from '@/hooks/useDebouncedInput';
import { useIsAdmin } from '@/hooks/usePermissions';
import NoAccessComponent from './NoAccessComponent';

const VITE_API_URL = import.meta.env.VITE_API_URL;

Expand All @@ -19,6 +21,9 @@ const roleLabel = {
};

const ManageUsers = () => {
const isAdmin = useIsAdmin();
if (!isAdmin) return <NoAccessComponent />;

const dispatch = useAppDispatch();
const userListLoading = useAppSelector((state) => state.user.userListLoading);
const userList = useAppSelector((state) => state.user.userList);
Expand Down
7 changes: 7 additions & 0 deletions src/frontend/src/views/NoAccessComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import React from 'react';

const NoAccessComponent = () => {
return <div>Access Denied</div>;
};

export default NoAccessComponent;
5 changes: 4 additions & 1 deletion src/frontend/src/views/Organisation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ import OrganisationCardSkeleton from '@/components/organisation/OrganizationCard
import windowDimention from '@/hooks/WindowDimension';
import { useAppDispatch, useAppSelector } from '@/types/reduxTypes';
import useDocumentTitle from '@/utilfunctions/useDocumentTitle';
import { useHasManagedAnyOrganization, useIsAdmin } from '@/hooks/usePermissions';

const Organisation = () => {
useDocumentTitle('Organizations');
const dispatch = useAppDispatch();
const { type } = windowDimention();
const isAdmin = useIsAdmin();
const hasManagedAnyOrganization = useHasManagedAnyOrganization();

const [searchKeyword, setSearchKeyword] = useState<string>('');
const [activeTab, setActiveTab] = useState<0 | 1>(0);
Expand Down Expand Up @@ -111,7 +114,7 @@ const Organisation = () => {
className="fmtm-duration-150"
onClick={() => loadMyOrganisations()}
/>
{authDetails && (
{(!hasManagedAnyOrganization || isAdmin) && (
<CoreModules.Link to={'/manage/organization/new'}>
<CoreModules.Button
variant="outlined"
Expand Down

0 comments on commit 34f7982

Please sign in to comment.