diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 7aefb3db2..75d567aee 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,3 +1,15 @@ +## February 20, 2024 +- **Task**Upgrade the version of flask [DESENG-502](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-502) + - Performed a Flask version upgrade to version 2.2.5. + +## February 16, 2024 +- **Task**Make a floating save/preview bar when editing engagements [DESENG-498](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-498) + - Implemented a floating behavior for the save/preview buttons during engagement editing. This feature persists across all tabs but exclusively saves data for the Engagement Content tab. + +## February 15, 2024 +- **Task**Restore role assignment functionality to MET with the CSS API [DESENG-473](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-473) + - Utilize the CSS API for efficient management of composite roles. This involves the assignment, reassignment, or removal of users from the composite roles of TEAM_MEMBER, REVIEWER, IT_ADMIN, or IT_VIEWER. + ## February 09, 2024 - **Task**Consolidate and re-write old migration files [DESENG-452](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-452) - Deleted old migration files diff --git a/met-api/requirements.txt b/met-api/requirements.txt index ebde07c19..d11227820 100644 --- a/met-api/requirements.txt +++ b/met-api/requirements.txt @@ -4,7 +4,7 @@ apispec==4.5.0 attrs==21.2.0 click==8.0.1 flasgger==0.9.5 -Flask==2.0.1 +Flask==2.2.5 flask-marshmallow==0.14.0 Flask-Migrate==2.7.0 Flask-Script==2.0.5 diff --git a/met-api/sample.env b/met-api/sample.env index 2fbe53638..a93566cb2 100644 --- a/met-api/sample.env +++ b/met-api/sample.env @@ -35,6 +35,14 @@ MET_ADMIN_CLIENT_ID="" # resource MET_ADMIN_CLIENT_SECRET="" # credentials.secret KEYCLOAK_CONNECT_TIMEOUT="60" +KEYCLOAK_ADMIN_TOKEN_URL="" # URL to obtain the admin token from Keycloak +KEYCLOAK_ADMIN_CLIENT_ID="" # Admin Client ID for Keycloak authentication +KEYCLOAK_ADMIN_CLIENT_SECRET="" # Admin Client Secret for Keycloak authentication + +CSS_API_URL="" # CSS API URL +CSS_API_ENVIRONMENT="" # CSS API environment +CSS_API_INTEGRATION_ID= # CSS API integration number + # JWT OIDC configuration for authentication # Populate from 'GDX MET web (public)-installation-*.json' JWT_OIDC_AUDIENCE="" # resource diff --git a/met-api/src/met_api/config.py b/met-api/src/met_api/config.py index 630c86e27..c15bd8f3b 100644 --- a/met-api/src/met_api/config.py +++ b/met-api/src/met_api/config.py @@ -185,8 +185,12 @@ def SQLALCHEMY_DATABASE_URI(self) -> str: 'REALMNAME': os.getenv('KEYCLOAK_REALMNAME', 'standard'), 'SERVICE_ACCOUNT_ID': os.getenv('MET_ADMIN_CLIENT_ID'), 'SERVICE_ACCOUNT_SECRET': os.getenv('MET_ADMIN_CLIENT_SECRET'), - 'ADMIN_USERNAME': os.getenv('MET_ADMIN_CLIENT_ID'), - 'ADMIN_SECRET': os.getenv('MET_ADMIN_CLIENT_SECRET'), + 'ADMIN_BASE_URL': os.getenv('KEYCLOAK_ADMIN_TOKEN_URL', ''), + 'ADMIN_USERNAME': os.getenv('KEYCLOAK_ADMIN_CLIENT_ID'), + 'ADMIN_SECRET': os.getenv('KEYCLOAK_ADMIN_CLIENT_SECRET'), + 'CSS_API_URL': os.getenv('CSS_API_URL', ''), + 'CSS_API_ENVIRONMENT': os.getenv('CSS_API_ENVIRONMENT', ''), + 'CSS_API_INTEGRATION_ID': os.getenv('CSS_API_INTEGRATION_ID'), 'CONNECT_TIMEOUT': int(os.getenv('KEYCLOAK_CONNECT_TIMEOUT', '60')), } diff --git a/met-api/src/met_api/resources/engagement_members.py b/met-api/src/met_api/resources/engagement_members.py index 4939c76fc..c827568a8 100644 --- a/met-api/src/met_api/resources/engagement_members.py +++ b/met-api/src/met_api/resources/engagement_members.py @@ -48,18 +48,17 @@ def get(engagement_id): except BusinessException as err: return {'message': err.error}, err.status_code - # TODO: Create membership method that uses composite roles - # @staticmethod - # @cross_origin(origins=allowedorigins()) - # @_jwt.requires_auth - # def post(engagement_id): - # """Create a new membership.""" - # # TODO validate against a schema. - # try: - # member = MembershipService.create_membership(engagement_id, request.get_json()) - # return MembershipSchema().dump(member), HTTPStatus.OK - # except BusinessException as err: - # return {'message': err.error}, err.status_code + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def post(engagement_id): + """Create a new membership.""" + # TODO validate against a schema. + try: + member = MembershipService.create_membership(engagement_id, request.get_json()) + return MembershipSchema().dump(member), HTTPStatus.OK + except BusinessException as err: + return {'message': err.error}, err.status_code @cors_preflight('GET,OPTIONS') diff --git a/met-api/src/met_api/resources/staff_user.py b/met-api/src/met_api/resources/staff_user.py index bacaeafbf..6a670f76b 100644 --- a/met-api/src/met_api/resources/staff_user.py +++ b/met-api/src/met_api/resources/staff_user.py @@ -121,6 +121,44 @@ def patch(user_id): return str(err), HTTPStatus.BAD_REQUEST +@cors_preflight('POST, PUT') +@API.route('//roles') +class UserRoles(Resource): + """Add user to composite roles.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + @require_role([Role.CREATE_ADMIN_USER.value], skip_tenant_check_for_admin=True) + def post(user_id): + """Add user to composite roles.""" + try: + args = request.args + user_schema = StaffUserService().assign_composite_role_to_user(user_id, args.get('role')) + return user_schema, HTTPStatus.OK + except KeyError as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + except ValueError as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + except BusinessException as err: + return {'message': err.error}, err.status_code + + @staticmethod + @cross_origin(origins=allowedorigins()) + @require_role([Role.UPDATE_USER_GROUP.value]) + def put(user_id): + """Update user composite roles.""" + try: + args = request.args + user_schema = StaffUserMembershipService().reassign_user(user_id, args.get('role')) + return user_schema, HTTPStatus.OK + except KeyError as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + except ValueError as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + except BusinessException as err: + return {'message': err.error}, err.status_code + + @cors_preflight('GET,OPTIONS') @API.route('//engagements') class EngagementMemberships(Resource): diff --git a/met-api/src/met_api/services/keycloak.py b/met-api/src/met_api/services/keycloak.py index 7ab9e0305..6f6a474bb 100644 --- a/met-api/src/met_api/services/keycloak.py +++ b/met-api/src/met_api/services/keycloak.py @@ -14,65 +14,67 @@ """Utils for keycloak administration.""" import json +from typing import List import requests -from flask import current_app -from met_api.utils.enums import ContentType +from met_api.config import Config +from met_api.utils.enums import ContentType, KeycloakCompositeRoleNames class KeycloakService: # pylint: disable=too-few-public-methods """Keycloak services.""" - # @staticmethod - # def get_user_groups(user_id): - # """Get user group from Keycloak by userid.""" - # keycloak = current_app.config['KEYCLOAK_CONFIG'] - # timeout = keycloak['CONNECT_TIMEOUT'] - # base_url = keycloak['BASE_URL'] - # realm = keycloak['REALMNAME'] - # admin_token = KeycloakService._get_admin_token() - # headers = { - # 'Content-Type': ContentType.JSON.value, - # 'Authorization': f'Bearer {admin_token}' - # } - - # # Get the user and return - # query_user_url = f'{base_url}/admin/realms/{realm}/users/{user_id}/groups' - # response = requests.get(query_user_url, headers=headers, timeout=timeout) - # response.raise_for_status() - # return response.json() - - # @staticmethod - # def get_users_groups(user_ids: List): - # """Get user groups from Keycloak by user ids.For bulk purposes.""" - # # TODO if List is bigger than a number ; if so reject. - # keycloak = current_app.config['KEYCLOAK_CONFIG'] - # base_url = keycloak['BASE_URL'] - # # TODO fix this during tests and remove below - # if not base_url: - # return {} - # keycloak = current_app.config['KEYCLOAK_CONFIG'] - # realm = keycloak['REALMNAME'] - # timeout = keycloak['CONNECT_TIMEOUT'] - # admin_token = KeycloakService._get_admin_token() - # headers = { - # 'Content-Type': ContentType.JSON.value, - # 'Authorization': f'Bearer {admin_token}' - # } - # user_group_mapping = {} - # # Get the user and return - # for user_id in user_ids: - # query_user_url = f'{base_url}/admin/realms/{realm}/users/{user_id}/groups' - # response = requests.get(query_user_url, headers=headers, timeout=timeout) + # pylint: disable=too-many-instance-attributes + # Eight is reasonable in this case. + def __init__(self): + """Initialize Keycloak configuration.""" + keycloak = Config().KEYCLOAK_CONFIG + self.base_url = keycloak['CSS_API_URL'] + self.realm = keycloak['REALMNAME'] + self.integration_id = keycloak['CSS_API_INTEGRATION_ID'] + self.environment = keycloak['CSS_API_ENVIRONMENT'] + self.timeout = keycloak['CONNECT_TIMEOUT'] + self.admin_base_url = keycloak['ADMIN_BASE_URL'] + self.admin_client_id = keycloak['ADMIN_USERNAME'] + self.admin_secret = keycloak['ADMIN_SECRET'] + + def get_user_roles(self, user_id): + """Get user composite roles from Keycloak by userid.""" + admin_token = self._get_admin_token() + headers = { + 'Content-Type': ContentType.JSON.value, + 'Authorization': f'Bearer {admin_token}' + } - # if response.status_code == 200: - # if (groups := response.json()) is not None: - # user_group_mapping[user_id] = [group.get('name') for group in groups] - # else: - # user_group_mapping[user_id] = [] + # Get the user and return + query_user_url = (f'{self.base_url}/{self.integration_id}/' + f'{self.environment}/users/{user_id}/roles') + response = requests.get(query_user_url, headers=headers, timeout=self.timeout) + response.raise_for_status() + return response.json() - # return user_group_mapping + def get_users_roles(self, user_ids: List): + """Get user composite roles from Keycloak by user ids.""" + # TODO if List is bigger than a number ; if so reject. + admin_token = self._get_admin_token() + headers = { + 'Content-Type': ContentType.JSON.value, + 'Authorization': f'Bearer {admin_token}' + } + user_role_mapping = {} + # Get the user and return + for user_id in user_ids: + query_user_url = (f'{self.base_url}/{self.integration_id}/' + f'{self.environment}/users/{user_id}/roles') + response = requests.get(query_user_url, headers=headers, timeout=self.timeout) + if response.status_code == 200: + if (roles := response.json().get('data')) is not None: + user_role_mapping[user_id] = [role.get('name') for role in roles] + else: + user_role_mapping[user_id] = [] + + return user_role_mapping # @staticmethod # def _get_group_id(admin_token: str, group_name: str): @@ -99,25 +101,18 @@ class KeycloakService: # pylint: disable=too-few-public-methods # return group_id # return None - @staticmethod - def _get_admin_token(): + def _get_admin_token(self): """Create an admin token.""" - keycloak = current_app.config['KEYCLOAK_CONFIG'] - admin_client_id = keycloak['ADMIN_USERNAME'] - admin_secret = keycloak['ADMIN_SECRET'] - timeout = keycloak['CONNECT_TIMEOUT'] - headers = { 'Content-Type': 'application/x-www-form-urlencoded' } - token_issuer = current_app.config['JWT_CONFIG']['ISSUER'] - token_url = f'{token_issuer}/protocol/openid-connect/token' + token_url = f'{self.admin_base_url}/realms/{self.realm}/protocol/openid-connect/token' response = requests.post( token_url, headers=headers, - timeout=timeout, - data=f'client_id={admin_client_id}&grant_type=client_credentials' - f'&client_secret={admin_secret}' + timeout=self.timeout, + data=f'client_id={self.admin_client_id}&grant_type=client_credentials' + f'&client_secret={self.admin_secret}' ) return response.json().get('access_token') @@ -129,7 +124,7 @@ def _get_admin_token(): # realm = keycloak['REALMNAME'] # timeout = keycloak['CONNECT_TIMEOUT'] # # Create an admin token - # admin_token = KeycloakService._get_admin_token() + # admin_token = self._get_admin_token() # # Get the '$group_name' group # group_id = KeycloakService._get_group_id(admin_token, group_name) @@ -143,128 +138,86 @@ def _get_admin_token(): # timeout=timeout) # response.raise_for_status() - # @staticmethod - # def add_user_to_group(user_id: str, group_name: str): - # """Add user to the keycloak group.""" - # keycloak = current_app.config['KEYCLOAK_CONFIG'] - # base_url = keycloak['BASE_URL'] - # realm = keycloak['REALMNAME'] - # timeout = keycloak['CONNECT_TIMEOUT'] - # # Create an admin token - # admin_token = KeycloakService._get_admin_token() - # # Get the '$group_name' group - # group_id = KeycloakService._get_group_id(admin_token, group_name) - - # # Add user to the keycloak group '$group_name' - # headers = { - # 'Content-Type': ContentType.JSON.value, - # 'Authorization': f'Bearer {admin_token}' - # } - # add_to_group_url = f'{base_url}/admin/realms/{realm}/users/{user_id}/groups/{group_id}' - # response = requests.put(add_to_group_url, headers=headers, - # timeout=timeout) - # response.raise_for_status() - - @staticmethod - def add_attribute_to_user(user_id: str, attribute_value: str, attribute_id: str = 'tenant_id'): - """Add attribute to a keyclaok user.Default is set as tenant Id.""" - config = current_app.config['KEYCLOAK_CONFIG'] - base_url = config.get('BASE_URL') - realm = config.get('REALMNAME') - admin_token = KeycloakService._get_admin_token() - - tenant_attributes = { - attribute_id: attribute_value + def assign_composite_role_to_user(self, user_id: str, composite_role: str): + """Add user to the keycloak composite roles.""" + admin_token = self._get_admin_token() + # Add user to the keycloak composite roles '$composite_role' + headers = { + 'Content-Type': ContentType.JSON.value, + 'Authorization': f'Bearer {admin_token}' } + add_to_role_url = f'{self.base_url}/{self.integration_id}/{self.environment}/users/{user_id}/roles' - user_url = f'{base_url}/admin/realms/{realm}/users/{user_id}' - headers = {'Authorization': f'Bearer {admin_token}'} - response = requests.get(user_url, headers=headers) - user_data = response.json() - user_data.setdefault('attributes', {}).update(tenant_attributes) - requests.put(user_url, json=user_data, headers=headers) + # Creating data payload + data = [{'name': composite_role}] + response = requests.post(add_to_role_url, headers=headers, json=data, + timeout=self.timeout) response.raise_for_status() - # @staticmethod - # def remove_user_from_group(user_id: str, group_name: str): - # """Remove user from the keycloak group.""" - # keycloak = current_app.config['KEYCLOAK_CONFIG'] - # base_url = keycloak['BASE_URL'] - # realm = keycloak['REALMNAME'] - # timeout = keycloak['CONNECT_TIMEOUT'] - # # Create an admin token - # admin_token = KeycloakService._get_admin_token() - # # Get the '$group_name' group - # group_id = KeycloakService._get_group_id(admin_token, group_name) + def remove_composite_role_from_user(self, user_id: str, role: str): + """Remove user from the keycloak composite role.""" + # Create an admin token + admin_token = self._get_admin_token() - # # Remove user from the keycloak group '$group_name' - # headers = { - # 'Content-Type': ContentType.JSON.value, - # 'Authorization': f'Bearer {admin_token}' - # } - # remove_from_group_url = f'{base_url}/admin/realms/{realm}/users/{user_id}/groups/{group_id}' - # response = requests.delete(remove_from_group_url, headers=headers, timeout=timeout) - # response.raise_for_status() + # Remove user from the keycloak composite role '$role' + headers = { + 'Content-Type': ContentType.JSON.value, + 'Authorization': f'Bearer {admin_token}' + } + remove_from_role_url = f'{self.base_url}/{self.integration_id}/{self.environment}/users/{user_id}/roles/{role}' + response = requests.delete(remove_from_role_url, headers=headers, timeout=self.timeout) + response.raise_for_status() - @staticmethod - def add_user(user: dict): + def add_user(self, user: dict): """Add user to Keycloak.Mainly used for Tests;Dont use it for actual user creation in application.""" # Add user and set password - admin_token = KeycloakService._get_admin_token() - keycloak = current_app.config['KEYCLOAK_CONFIG'] - base_url = keycloak['BASE_URL'] - realm = keycloak['REALMNAME'] - timeout = keycloak['CONNECT_TIMEOUT'] + admin_token = self._get_admin_token() headers = { 'Content-Type': ContentType.JSON.value, 'Authorization': f'Bearer {admin_token}' } - add_user_url = f'{base_url}/admin/realms/{realm}/users' + add_user_url = f'{self.base_url}/admin/realms/{self.realm}/users' response = requests.post(add_user_url, data=json.dumps(user), headers=headers, - timeout=timeout) + timeout=self.timeout) response.raise_for_status() - return KeycloakService.get_user_by_username(user.get('username'), admin_token) + return self.get_user_by_username(user.get('username'), admin_token) - @staticmethod - def get_user_by_username(username, admin_token=None): + def get_user_by_username(self, username, admin_token=None): """Get user from Keycloak by username.""" - keycloak = current_app.config['KEYCLOAK_CONFIG'] - base_url = keycloak['BASE_URL'] - realm = keycloak['REALMNAME'] - timeout = keycloak['CONNECT_TIMEOUT'] if not admin_token: - admin_token = KeycloakService._get_admin_token() + admin_token = self._get_admin_token() headers = { 'Content-Type': ContentType.JSON.value, 'Authorization': f'Bearer {admin_token}' } # Get the user and return - query_user_url = f'{base_url}/admin/realms/{realm}/users?username={username}' - response = requests.get(query_user_url, headers=headers, timeout=timeout) + query_user_url = f'{self.base_url}/admin/realms/{self.realm}/users?username={username}' + response = requests.get(query_user_url, headers=headers, timeout=self.timeout) return response.json()[0] - @staticmethod - def toggle_user_enabled_status(user_id, enabled): + def toggle_user_enabled_status(self, user_id, enabled): """Toggle the enabled status of a user in Keycloak.""" - keycloak = current_app.config['KEYCLOAK_CONFIG'] - base_url = keycloak['BASE_URL'] - realm = keycloak['REALMNAME'] - timeout = keycloak['CONNECT_TIMEOUT'] - admin_token = KeycloakService._get_admin_token() + admin_token = self._get_admin_token() headers = { 'Content-Type': ContentType.JSON.value, 'Authorization': f'Bearer {admin_token}' } - user_data = { - 'enabled': enabled # Set the user's enabled status based on 'enable' parameter - } + query_user_url = f'{self.base_url}/{self.integration_id}/{self.environment}/users/{user_id}/roles' + response = requests.get(query_user_url, headers=headers, timeout=self.timeout) + + if response.status_code == 200 and enabled: + role_name = KeycloakCompositeRoleNames.IT_VIEWER.value + self.assign_composite_role_to_user(user_id=user_id, composite_role=role_name) + + if response.status_code == 200 and not enabled: + roles_data = response.json().get('data', []) + for role in roles_data: + role_name = role.get('name') + self.remove_composite_role_from_user(user_id=user_id, role=role_name) - # Update the user's enabled status - update_user_url = f'{base_url}/admin/realms/{realm}/users/{user_id}' - response = requests.put(update_user_url, json=user_data, headers=headers, timeout=timeout) response.raise_for_status() diff --git a/met-api/src/met_api/services/membership_service.py b/met-api/src/met_api/services/membership_service.py index 5eb6401b6..9615f9b57 100644 --- a/met-api/src/met_api/services/membership_service.py +++ b/met-api/src/met_api/services/membership_service.py @@ -4,10 +4,14 @@ from met_api.constants.membership_type import MembershipType from met_api.exceptions.business_exception import BusinessException +from met_api.models import StaffUser as StaffUserModel from met_api.models.engagement import Engagement as EngagementModel from met_api.models.membership import Membership as MembershipModel +from met_api.schemas.staff_user import StaffUserSchema from met_api.services import authorization -from met_api.utils.enums import MembershipStatus +from met_api.services.staff_user_service import KEYCLOAK_SERVICE, StaffUserService +from met_api.utils.constants import CompositeRoles +from met_api.utils.enums import KeycloakCompositeRoleNames, MembershipStatus from met_api.utils.roles import Role from met_api.utils.token_info import TokenInfo @@ -15,30 +19,30 @@ class MembershipService: """Membership management service.""" - # TODO: Create membership method that uses composite roles - # @staticmethod - # def create_membership(engagement_id, request_json: dict): - # """Create membership.""" - # user_id = request_json.get('user_id') - # user: StaffUserModel = StaffUserModel.get_user_by_external_id(user_id) - # if not user: - # raise BusinessException( - # error='Invalid User.', - # status_code=HTTPStatus.BAD_REQUEST) - - # one_of_roles = ( - # MembershipType.TEAM_MEMBER.name, - # Role.EDIT_MEMBERS.value - # ) - # authorization.check_auth(one_of_roles=one_of_roles, engagement_id=engagement_id) - - # user_details = StaffUserSchema().dump(user) - - # MembershipService._validate_create_membership(engagement_id, user_details) - # group_name, membership_type = MembershipService._get_membership_details(user_details) - # MembershipService._add_user_group(user_details, group_name) - # membership = MembershipService._create_membership_model(engagement_id, user.id, membership_type) - # return membership + @staticmethod + def create_membership(engagement_id, request_json: dict): + """Create membership.""" + user_id = request_json.get('user_id') + user: StaffUserModel = StaffUserModel.get_user_by_external_id(user_id) + if not user: + raise BusinessException( + error='Invalid User.', + status_code=HTTPStatus.BAD_REQUEST) + + one_of_roles = ( + MembershipType.TEAM_MEMBER.name, + Role.EDIT_MEMBERS.value + ) + authorization.check_auth(one_of_roles=one_of_roles, engagement_id=engagement_id) + + user_details = StaffUserSchema().dump(user) + # attach and map roles + StaffUserService.attach_roles([user_details]) + MembershipService._validate_create_membership(engagement_id, user_details) + composite_roles, membership_type = MembershipService._get_membership_details(user_details) + MembershipService._assign_composite_role_to_user(user_details, composite_roles) + membership = MembershipService._create_membership_model(engagement_id, user.id, membership_type) + return membership @staticmethod def _validate_create_membership(engagement_id, user_details): @@ -51,12 +55,11 @@ def _validate_create_membership(engagement_id, user_details): user_id = user_details.get('id') - # TODO: Check for permission level once composite role permission levels are added. - # roles = user_details.get('roles') - # if KeycloakPermissionLevels.IT_ADMIN.value in roles: - # raise BusinessException( - # error='This user is already a Administrator.', - # status_code=HTTPStatus.CONFLICT.value) + roles = user_details.get('composite_roles') + if KeycloakCompositeRoleNames.IT_ADMIN.value in roles: + raise BusinessException( + error='This user is already a Administrator.', + status_code=HTTPStatus.CONFLICT.value) existing_membership = MembershipModel.find_by_engagement_and_user_id( engagement_id, @@ -66,7 +69,7 @@ def _validate_create_membership(engagement_id, user_details): if existing_membership: raise BusinessException( - error=f'This {user_details.get("main_group", "user")} is already assigned to this engagement.', + error=f'This {user_details.get("main_role", "user")} is already assigned to this engagement.', status_code=HTTPStatus.CONFLICT.value) request_user = TokenInfo.get_user_data() @@ -75,45 +78,43 @@ def _validate_create_membership(engagement_id, user_details): error='You cannot add yourself to an engagement.', status_code=HTTPStatus.FORBIDDEN.value) - # TODO: Replace this method with one that checks membership type with composite roles - # @staticmethod - # def _get_membership_details(user_details): - # """Get the group name and membership type for the user based on their assigned groups.""" - # default_group_name = Groups.TEAM_MEMBER.name - # default_membership_type = MembershipType.TEAM_MEMBER - - # is_reviewer = Groups.REVIEWER.value in user_details.get('groups') - # is_team_member = Groups.TEAM_MEMBER.value in user_details.get('groups') - - # if is_reviewer: - # # If the user is assigned to the REVIEWER group, set the group name and membership type accordingly - # group_name = Groups.REVIEWER.name - # membership_type = MembershipType.REVIEWER - # elif is_team_member: - # # If the user is assigned to the TEAM_MEMBER group, set the group name and membership type accordingly - # group_name = Groups.TEAM_MEMBER.name - # membership_type = MembershipType.TEAM_MEMBER - # else: - # # If the user is not assigned to either group, return default values for group name and membership type - # group_name = default_group_name - # membership_type = default_membership_type - - # return group_name, membership_type - - # TODO: Replace this method with a method to add composite roles - # @staticmethod - # def _add_user_group(user: StaffUserModel, group_name=Groups.TEAM_MEMBER.name): - # valid_member_teams = [Groups.TEAM_MEMBER.name, Groups.REVIEWER.name] - # if group_name not in valid_member_teams: - # raise BusinessException( - # error='Invalid Group name.', - # status_code=HTTPStatus.BAD_REQUEST - # ) - - # KEYCLOAK_SERVICE.add_user_to_group( - # user_id=user.get('external_id'), - # group_name=group_name - # ) + @staticmethod + def _get_membership_details(user_details): + """Get the composite role and membership type for the user based on their assigned composite roles.""" + default_role = CompositeRoles.TEAM_MEMBER.name + default_membership_type = MembershipType.TEAM_MEMBER + + is_reviewer = CompositeRoles.REVIEWER.value in user_details.get('composite_roles') + is_team_member = CompositeRoles.TEAM_MEMBER.value in user_details.get('composite_roles') + + if is_reviewer: + # If the user is assigned to the REVIEWER role, set the role name and membership type accordingly + composite_roles = CompositeRoles.REVIEWER.name + membership_type = MembershipType.REVIEWER + elif is_team_member: + # If the user is assigned to the TEAM_MEMBER role, set the role name and membership type accordingly + composite_roles = CompositeRoles.TEAM_MEMBER.name + membership_type = MembershipType.TEAM_MEMBER + else: + # If the user is not assigned to either role, return default values for role name and membership type + composite_roles = default_role + membership_type = default_membership_type + + return composite_roles, membership_type + + @staticmethod + def _assign_composite_role_to_user(user: StaffUserModel, composite_role=CompositeRoles.TEAM_MEMBER.name): + valid_member_teams = [CompositeRoles.TEAM_MEMBER.name, CompositeRoles.REVIEWER.name] + if composite_role not in valid_member_teams: + raise BusinessException( + error='Invalid composite role name.', + status_code=HTTPStatus.BAD_REQUEST + ) + + KEYCLOAK_SERVICE.assign_composite_role_to_user( + user_id=user.get('external_id'), + composite_role=composite_role + ) @staticmethod def _create_membership_model(engagement_id, user_id, membership_type=MembershipType.TEAM_MEMBER): diff --git a/met-api/src/met_api/services/staff_user_membership_service.py b/met-api/src/met_api/services/staff_user_membership_service.py index e698e58a7..f28cd946b 100644 --- a/met-api/src/met_api/services/staff_user_membership_service.py +++ b/met-api/src/met_api/services/staff_user_membership_service.py @@ -6,17 +6,17 @@ from met_api.schemas.staff_user import StaffUserSchema from met_api.services.membership_service import MembershipService from met_api.services.staff_user_service import KEYCLOAK_SERVICE, StaffUserService -from met_api.utils.user_context import UserContext, user_context +from met_api.utils.constants import CompositeRoles from met_api.utils.enums import UserStatus +from met_api.utils.user_context import UserContext, user_context class StaffUserMembershipService: """Staff User Membership management service.""" - # TODO: Restore a way to add users to composite roles. @classmethod @user_context - def reassign_user(cls, user_id, **kwargs): + def reassign_user(cls, user_id, role, **kwargs): """Add user to a new composite role and reassign memberships.""" user = StaffUserService.get_user_by_id(user_id, include_roles=True) if not user: @@ -25,20 +25,31 @@ def reassign_user(cls, user_id, **kwargs): status_code=HTTPStatus.BAD_REQUEST) external_id = user.get('external_id', None) + main_role = user.get('main_role', None) - # TODO: Put check for composite role membership into this conditional. - if not external_id: + if any([not external_id, not main_role]): raise BusinessException( error='Invalid User.', status_code=HTTPStatus.BAD_REQUEST) - user_from_context: UserContext = kwargs['user_context'] + if role not in CompositeRoles.__members__: + raise BusinessException( + error='Invalid Role.', + status_code=HTTPStatus.BAD_REQUEST) + if main_role == role: + raise BusinessException( + error='User is already assigned this role.', + status_code=HTTPStatus.BAD_REQUEST) + + user_from_context: UserContext = kwargs['user_context'] if external_id == user_from_context.sub: raise BusinessException( error='User cannot change their own permission level.', status_code=HTTPStatus.CONFLICT.value) + StaffUserService.remove_composite_role_from_user(external_id, CompositeRoles.get_name_by_value(main_role)) + StaffUserService.assign_composite_role_to_user(external_id, role) MembershipService.revoke_memberships_bulk(user_id) new_user = StaffUserService.get_user_by_id(user_id, include_roles=True) return StaffUserSchema().dump(new_user) diff --git a/met-api/src/met_api/services/staff_user_service.py b/met-api/src/met_api/services/staff_user_service.py index 4092a6f9b..9123b11d6 100644 --- a/met-api/src/met_api/services/staff_user_service.py +++ b/met-api/src/met_api/services/staff_user_service.py @@ -1,7 +1,7 @@ """Service for user management.""" from http import HTTPStatus -from flask import current_app, g +from flask import current_app from met_api.exceptions.business_exception import BusinessException from met_api.models.pagination_options import PaginationOptions @@ -9,6 +9,8 @@ from met_api.schemas.staff_user import StaffUserSchema from met_api.services.keycloak import KeycloakService from met_api.utils import notification +from met_api.utils.constants import COMPOSITE_ROLE_MAPPING, CompositeRoles +from met_api.utils.enums import KeycloakCompositeRoleNames from met_api.utils.template import Template KEYCLOAK_SERVICE = KeycloakService() @@ -24,9 +26,7 @@ def get_user_by_id(cls, _user_id, include_roles=False, include_inactive=False): db_user = StaffUserModel.get_by_id(_user_id, include_inactive) user = user_schema.dump(db_user) if include_roles: - # TODO: Replace this method with one that uses composite roles - # cls.attach_roles([user]) - pass + cls.attach_roles([user]) return user @classmethod @@ -100,29 +100,24 @@ def _render_email_template(user: StaffUserModel): ) return subject, body, args - # TODO: Replace this method with one that uses composite roles, if necessary - # @staticmethod - # def attach_roles(user_collection): - # """Attach keycloak groups to user object.""" - # group_user_details = KEYCLOAK_SERVICE.get_users_groups( - # [user.get('external_id') for user in user_collection]) - - # for user in user_collection: - # # Transform group name from ADMINISTRATOR to Administrator - # # TODO etc;Arrive at a better implementation than keeping a static list - # # TODO Probably add a custom attribute in the keycloak as title against a group? - # groups = group_user_details.get(user.get('external_id')) - # user['groups'] = '' - # if groups: - # user['groups'] = [GROUP_NAME_MAPPING.get(group, '') for group in groups] - # if Groups.IT_ADMIN.value in user['groups']: - # user['main_group'] = Groups.IT_ADMIN.value - # elif Groups.TEAM_MEMBER.value in user['groups']: - # user['main_group'] = Groups.TEAM_MEMBER.value - # elif Groups.REVIEWER.value in user['groups']: - # user['main_group'] = Groups.REVIEWER.value - # else: - # user['main_group'] = user['groups'][0] + @staticmethod + def attach_roles(user_collection): + """Attach keycloak composite roles to user object.""" + user_roles = KEYCLOAK_SERVICE.get_users_roles( + [user.get('external_id') for user in user_collection]) + for user in user_collection: + composite_roles = user_roles.get(user.get('external_id')) + user['composite_roles'] = '' + if composite_roles: + user['composite_roles'] = [COMPOSITE_ROLE_MAPPING.get(role, '') for role in composite_roles] + if CompositeRoles.IT_ADMIN.value in user['composite_roles']: + user['main_role'] = CompositeRoles.IT_ADMIN.value + elif CompositeRoles.TEAM_MEMBER.value in user['composite_roles']: + user['main_role'] = CompositeRoles.TEAM_MEMBER.value + elif CompositeRoles.REVIEWER.value in user['composite_roles']: + user['main_role'] = CompositeRoles.REVIEWER.value + else: + user['main_role'] = user['composite_roles'][0] @classmethod def find_users( @@ -137,9 +132,7 @@ def find_users( user_collection = StaffUserSchema(many=True).dump(users) if include_roles: - # TODO: Replace this method with one that uses composite roles - # cls.attach_roles(user_collection) - pass + cls.attach_roles(user_collection) return { 'items': user_collection, @@ -160,30 +153,25 @@ def validate_fields(data: StaffUserSchema): raise ValueError('Some required fields are empty') @classmethod - def add_user_to_group(cls, external_id: str, group_name: str): + def assign_composite_role_to_user(cls, external_id: str, composite_role: str): """Create or update a user.""" db_user = StaffUserModel.get_user_by_external_id(external_id) cls.validate_user(db_user) - # TODO: Replace this method with one that uses composite roles - print(group_name) - # KEYCLOAK_SERVICE.add_user_to_group(user_id=external_id, group_name=group_name) - KEYCLOAK_SERVICE.add_attribute_to_user(user_id=external_id, attribute_value=g.tenant_id) + KEYCLOAK_SERVICE.assign_composite_role_to_user(user_id=external_id, composite_role=composite_role) return StaffUserSchema().dump(db_user) @classmethod - def remove_user_from_group(cls, external_id: str, group_name: str): + def remove_composite_role_from_user(cls, external_id: str, role: str): """Create or update a user.""" db_user = StaffUserModel.get_user_by_external_id(external_id) if db_user is None: raise KeyError('User not found') - # TODO: Replace this method with one that uses composite roles - print(group_name) - # KEYCLOAK_SERVICE.remove_user_from_group(user_id=external_id, group_name=group_name) + KEYCLOAK_SERVICE.remove_composite_role_from_user(user_id=external_id, role=role) return StaffUserSchema().dump(db_user) @@ -193,10 +181,11 @@ def validate_user(db_user: StaffUserModel): if db_user is None: raise KeyError('User not found') - # TODO: Restore permission level functionality to replace "groups" later - # groups = KEYCLOAK_SERVICE.get_user_groups(user_id=db_user.external_id) - # group_names = [group.get('name') for group in groups] - # if KeycloakGroupName.IT_ADMIN.value in group_names: - # raise BusinessException( - # error='This user is already an Administrator.', - # status_code=HTTPStatus.CONFLICT.value) + composite_roles = KEYCLOAK_SERVICE.get_user_roles(user_id=db_user.external_id) + + if 'data' in composite_roles and len(composite_roles['data']) > 0: + role_names = [role.get('name') for role in composite_roles] + if KeycloakCompositeRoleNames.IT_ADMIN.value in role_names: + raise BusinessException( + error='This user is already an Administrator.', + status_code=HTTPStatus.CONFLICT.value) diff --git a/met-api/src/met_api/utils/constants.py b/met-api/src/met_api/utils/constants.py index 5a7202c87..52a34b90e 100644 --- a/met-api/src/met_api/utils/constants.py +++ b/met-api/src/met_api/utils/constants.py @@ -13,28 +13,30 @@ # limitations under the License. """Constants definitions.""" -# from enum import Enum +from enum import Enum -# TODO Remove this -# class Groups(Enum): -# """Enumeration representing user groups.""" -# IT_ADMIN = 'Administrator' -# TEAM_MEMBER = 'Team Member' -# REVIEWER = 'Reviewer' -# IT_VIEWER = 'Viewer' +class CompositeRoles(Enum): + """Enumeration representing user roles.""" -# @staticmethod -# def get_name_by_value(value): -# """Get the name of a group by its value.""" -# for group in Groups: -# if group.value == value: -# return group.name -# raise ValueError('No matching key found for the given value.') + IT_ADMIN = 'Administrator' + TEAM_MEMBER = 'Team Member' + REVIEWER = 'Reviewer' + IT_VIEWER = 'Viewer' + + @staticmethod + def get_name_by_value(value): + """Get the name of a role by its value.""" + for role in CompositeRoles: + if role.value == value: + return role.name + raise ValueError('No matching key found for the given value.') TENANT_ID_HEADER = 'tenant-id' +COMPOSITE_ROLE_MAPPING = {role.name: role.value for role in CompositeRoles} + TENANT_ID_JWT_CLAIM = 'tenant_id' diff --git a/met-api/tests/unit/services/test_keycloak.py b/met-api/tests/unit/services/test_keycloak.py index 87cdfb842..d9175b6f3 100644 --- a/met-api/tests/unit/services/test_keycloak.py +++ b/met-api/tests/unit/services/test_keycloak.py @@ -16,25 +16,25 @@ Test-Suite to ensure that the Keycloak Service is working as expected. """ -from met_api.services.keycloak import KeycloakService -from tests.utilities.factory_scenarios import KeycloakScenario - -KEYCLOAK_SERVICE = KeycloakService() - - -def test_keycloak_add_user(session): - """Add user to Keycloak. Assert return a user with the same username as the username in request.""" - request = KeycloakScenario.create_user_request() - user = KEYCLOAK_SERVICE.add_user(request) - assert user.get('username') == request.get('username') +# from met_api.services.keycloak import KeycloakService +# from tests.utilities.factory_scenarios import KeycloakScenario +# +# KEYCLOAK_SERVICE = KeycloakService() +# TODO: Replace this test with one that gets user composite roles +# def test_keycloak_add_user(session): +# """Add user to Keycloak. Assert return a user with the same username as the username in request.""" +# request = KeycloakScenario.create_user_request() +# user = KEYCLOAK_SERVICE.add_user(request) +# assert user.get('username') == request.get('username') -def test_keycloak_get_user_by_username(session): - """Get user by username. Assert get a user with the same username as the username in request.""" - request = KeycloakScenario.create_user_request() - KEYCLOAK_SERVICE.add_user(request) - user = KEYCLOAK_SERVICE.get_user_by_username(request.get('username')) - assert user.get('username') == request.get('username') +# TODO: Replace this test with one that gets user composite roles +# def test_keycloak_get_user_by_username(session): +# """Get user by username. Assert get a user with the same username as the username in request.""" +# request = KeycloakScenario.create_user_request() +# KEYCLOAK_SERVICE.add_user(request) +# user = KEYCLOAK_SERVICE.get_user_by_username(request.get('username')) +# assert user.get('username') == request.get('username') # TODO: Replace this test with one that gets user composite roles # def test_keycloak_get_user_groups(session): diff --git a/met-web/src/apiManager/endpoints/index.ts b/met-web/src/apiManager/endpoints/index.ts index 95666efa6..80e30cfe6 100644 --- a/met-web/src/apiManager/endpoints/index.ts +++ b/met-web/src/apiManager/endpoints/index.ts @@ -36,8 +36,8 @@ const Endpoints = { GET: `${AppConfig.apiUrl}/user/user_id`, CREATE_UPDATE: `${AppConfig.apiUrl}/user/`, GET_LIST: `${AppConfig.apiUrl}/user/`, - ADD_TO_GROUP: `${AppConfig.apiUrl}/user/user_id/groups`, - CHANGE_GROUP: `${AppConfig.apiUrl}/user/user_id/groups`, + ADD_TO_COMPOSITE_ROLE: `${AppConfig.apiUrl}/user/user_id/roles`, + CHANGE_COMPOSITE_ROLE: `${AppConfig.apiUrl}/user/user_id/roles`, GET_USER_ENGAGEMENTS: `${AppConfig.apiUrl}/user/user_id/engagements`, TOGGLE_USER_STATUS: `${AppConfig.apiUrl}/user/user_id/status`, }, diff --git a/met-web/src/components/comments/admin/reviewListing/Submissions.tsx b/met-web/src/components/comments/admin/reviewListing/Submissions.tsx index 82ecfd386..913a19b4c 100644 --- a/met-web/src/components/comments/admin/reviewListing/Submissions.tsx +++ b/met-web/src/components/comments/admin/reviewListing/Submissions.tsx @@ -16,7 +16,7 @@ import { CommentListingContext } from './CommentListingContext'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { useAppSelector } from 'hooks'; import { USER_ROLES } from 'services/userService/constants'; -import { USER_GROUP } from 'models/user'; +import { USER_COMPOSITE_ROLE } from 'models/user'; const Submissions = () => { const { @@ -54,7 +54,7 @@ const Submissions = () => { if ( roles.includes(USER_ROLES.REVIEW_COMMENTS) || (assignedEngagements.includes(Number(survey.engagement_id)) && - userDetail.groups?.includes('/' + USER_GROUP.TEAM_MEMBER.value)) + userDetail.composite_roles?.includes('/' + USER_COMPOSITE_ROLE.TEAM_MEMBER.value)) ) { return ( diff --git a/met-web/src/components/comments/admin/textListing/index.tsx b/met-web/src/components/comments/admin/textListing/index.tsx index 5f071ffd5..dfc69060d 100644 --- a/met-web/src/components/comments/admin/textListing/index.tsx +++ b/met-web/src/components/comments/admin/textListing/index.tsx @@ -21,7 +21,7 @@ import { getSubmissionPage } from 'services/submissionService'; import { SurveySubmission } from 'models/surveySubmission'; import { formatDate, formatToUTC } from 'components/common/dateHelper'; import { USER_ROLES } from 'services/userService/constants'; -import { USER_GROUP } from 'models/user'; +import { USER_COMPOSITE_ROLE } from 'models/user'; import { updateURLWithPagination } from 'components/common/Table/utils'; import CommentIcon from '@mui/icons-material/Comment'; import CommentsDisabledIcon from '@mui/icons-material/CommentsDisabled'; @@ -184,7 +184,7 @@ const CommentTextListing = () => { if ( roles.includes(USER_ROLES.REVIEW_COMMENTS) || (assignedEngagements.includes(Number(row.engagement_id)) && - userDetail.groups?.includes('/' + USER_GROUP.TEAM_MEMBER.value)) + userDetail.composite_roles?.includes('/' + USER_COMPOSITE_ROLE.TEAM_MEMBER.value)) ) { return ( diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/AdditionalTabContent.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/AdditionalTabContent.tsx index 344b13dcf..f683bf4fa 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/AdditionalTabContent.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/AdditionalTabContent.tsx @@ -1,13 +1,16 @@ import React, { useContext } from 'react'; -import { Divider, Grid } from '@mui/material'; -import { MetPaper, PrimaryButton } from 'components/common'; +import { Divider, Grid, Box } from '@mui/material'; +import { MetPaper, PrimaryButton, SecondaryButton } from 'components/common'; import ConsentMessage from './ConsentMessage'; import EngagementInformation from './EngagementInformation'; - +import { EngagementTabsContext } from '../EngagementTabsContext'; +import { ActionContext } from '../../ActionContext'; import { AdditionalDetailsContext } from './AdditionalDetailsContext'; const AdditionalTabContent = () => { const { handleSaveAdditional, updatingAdditional } = useContext(AdditionalDetailsContext); + const { isSaving } = useContext(ActionContext); + const { handleSaveEngagement, handlePreviewEngagement } = useContext(EngagementTabsContext); return ( @@ -33,6 +36,36 @@ const AdditionalTabContent = () => { Save + + + handleSaveEngagement()} + loading={isSaving} + > + Save + + handlePreviewEngagement()} + disabled={isSaving} + > + {'Preview'} + + + ); diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementForm.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/EngagementForm.tsx index 899a81df5..6026740e1 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementForm.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/EngagementForm.tsx @@ -3,11 +3,9 @@ import { Typography, Grid, TextField, Stack, Box } from '@mui/material'; import { MetPaper, MetLabel, PrimaryButton, SecondaryButton, MetDescription } from '../../../common'; import { ActionContext } from '../ActionContext'; import ImageUpload from 'components/imageUpload'; -import { useNavigate } from 'react-router-dom'; import { SurveyBlock } from './SurveyBlock'; -import { If, Then, Else } from 'react-if'; import { EngagementTabsContext } from './EngagementTabsContext'; -import { EngagementStatus, SUBMISSION_STATUS } from 'constants/engagementStatus'; +import { EngagementStatus } from 'constants/engagementStatus'; import DayCalculatorModal from '../DayCalculator'; import { ENGAGEMENT_CROPPER_ASPECT_RATIO, ENGAGEMENT_UPLOADER_HEIGHT } from './constants'; import RichTextEditor from 'components/common/RichTextEditor'; @@ -15,25 +13,20 @@ import { getTextFromDraftJsContentState } from 'components/common/RichTextEditor const CREATE = 'create'; const EngagementForm = () => { - const { - handleCreateEngagementRequest, - handleUpdateEngagementRequest, - isSaving, - savedEngagement, - handleAddBannerImage, - fetchEngagement, - } = useContext(ActionContext); + const { isSaving, savedEngagement, handleAddBannerImage } = useContext(ActionContext); const { engagementFormData, setEngagementFormData, + setIsNewEngagement, + handleSaveEngagement, + handlePreviewEngagement, richDescription, setRichDescription, richContent, setRichContent, engagementFormError, setEngagementFormError, - surveyBlockText, } = useContext(EngagementTabsContext); const [initialRichDescription, setInitialRichDescription] = useState(''); @@ -42,32 +35,16 @@ const EngagementForm = () => { const [isOpen, setIsOpen] = useState(false); - const navigate = useNavigate(); - - const isNewEngagement = window.location.pathname.includes(CREATE); - - const { name, start_date, end_date, description } = engagementFormData; + const isCreateEngagement = window.location.pathname.includes(CREATE); - const surveyBlockList = [ - { - survey_status: SUBMISSION_STATUS.UPCOMING, - block_text: surveyBlockText.Upcoming, - }, - { - survey_status: SUBMISSION_STATUS.OPEN, - block_text: surveyBlockText.Open, - }, - { - survey_status: SUBMISSION_STATUS.CLOSED, - block_text: surveyBlockText.Closed, - }, - ]; + const { name, start_date, end_date } = engagementFormData; useEffect(() => { const initialDescription = getTextFromDraftJsContentState(richDescription || savedEngagement.rich_description); setInitialRichDescription(richDescription || savedEngagement.rich_description); setInitialRichContent(richContent || savedEngagement.rich_content); setDescriptionCharCount(initialDescription.length); + setIsNewEngagement(isCreateEngagement); }, []); const getErrorMessage = () => { @@ -119,72 +96,6 @@ const EngagementForm = () => { setRichContent(newState); }; - const validateForm = () => { - const errors = { - name: !(name && name.length < 50), - start_date: !start_date, - end_date: !end_date, - description: description.length > 550, - }; - setEngagementFormError(errors); - - return Object.values(errors).some((isError: unknown) => isError); - }; - - const handleCreateEngagement = async () => { - const hasErrors = validateForm(); - - if (hasErrors) { - return; - } - - const engagement = await handleCreateEngagementRequest({ - ...engagementFormData, - rich_description: richDescription, - rich_content: richContent, - status_block: surveyBlockList, - }); - - navigate(`/engagements/${engagement.id}/form`); - - return engagement; - }; - - const handleUpdateEngagement = async () => { - const hasErrors = validateForm(); - - if (hasErrors) { - return; - } - - await handleUpdateEngagementRequest({ - ...engagementFormData, - rich_description: richDescription, - rich_content: richContent, - status_block: surveyBlockList, - }); - - fetchEngagement(); - return savedEngagement; - }; - - const handleSaveEngagement = () => { - if (isNewEngagement) { - return handleCreateEngagement(); - } - - return handleUpdateEngagement(); - }; - - const handlePreviewEngagement = async () => { - const engagement = await handleSaveEngagement(); - if (!engagement) { - return; - } - - navigate(`/engagements/${engagement.id}/view`); - }; - const isDateFieldDisabled = [EngagementStatus.Closed, EngagementStatus.Unpublished].includes( savedEngagement.status_id, ); @@ -333,38 +244,35 @@ const EngagementForm = () => { - - - - handleCreateEngagement()} - loading={isSaving} - > - Save - - - - handleUpdateEngagement()} - disabled={isSaving} - loading={isSaving} - > - Save - - - - handlePreviewEngagement()} - disabled={isSaving} - > - {'Preview'} - - + + + handleSaveEngagement()} + loading={isSaving} + > + Save + + handlePreviewEngagement()} + disabled={isSaving} + > + {'Preview'} + + + ); diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx index 6b77d9c68..b628758fb 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx @@ -1,4 +1,5 @@ import React, { createContext, useContext, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { SubmissionStatusTypes, SUBMISSION_STATUS } from 'constants/engagementStatus'; import { User } from 'models/user'; import { ActionContext } from '../ActionContext'; @@ -14,6 +15,7 @@ import { getEngagementSettings, patchEngagementSettings, } from 'services/engagementSettingService'; +import { EngagementForm } from '../types'; interface EngagementFormData { name: string; @@ -56,6 +58,10 @@ const initialFormError = { export interface EngagementTabsContextState { engagementFormData: EngagementFormData; setEngagementFormData: React.Dispatch>; + isNewEngagement: boolean; + setIsNewEngagement: React.Dispatch>; + handleSaveEngagement: () => Promise; + handlePreviewEngagement: () => Promise; richDescription: string; setRichDescription: React.Dispatch>; richContent: string; @@ -84,6 +90,16 @@ export const EngagementTabsContext = createContext({ setEngagementFormData: () => { throw new Error('setEngagementFormData is unimplemented'); }, + isNewEngagement: false, + setIsNewEngagement: () => { + throw new Error('setIsNewEngagement is unimplemented'); + }, + handleSaveEngagement: async () => { + console.warn('handleSaveEngagement is unimplemented'); + }, + handlePreviewEngagement: async () => { + console.warn('handlePreviewEngagement is unimplemented'); + }, richDescription: '', setRichDescription: () => { throw new Error('setRichDescription is unimplemented'); @@ -132,7 +148,7 @@ export const EngagementTabsContext = createContext({ }); export const EngagementTabsContextProvider = ({ children }: { children: React.ReactNode }) => { - const { savedEngagement } = useContext(ActionContext); + const { handleCreateEngagementRequest, handleUpdateEngagementRequest, savedEngagement } = useContext(ActionContext); const dispatch = useAppDispatch(); const [engagementFormData, setEngagementFormData] = useState({ name: savedEngagement.name || '', @@ -143,6 +159,7 @@ export const EngagementTabsContextProvider = ({ children }: { children: React.Re is_internal: savedEngagement.is_internal || false, consent_message: savedEngagement.consent_message || '', }); + const [isNewEngagement, setIsNewEngagement] = useState(false); const [richDescription, setRichDescription] = useState(savedEngagement?.rich_description || ''); const [richContent, setRichContent] = useState(savedEngagement?.rich_content || ''); const [engagementFormError, setEngagementFormError] = useState(initialFormError); @@ -261,6 +278,67 @@ export const EngagementTabsContextProvider = ({ children }: { children: React.Re } }; + const { name, start_date, end_date, description } = engagementFormData; + const surveyBlockList = [ + { + survey_status: SUBMISSION_STATUS.UPCOMING, + block_text: surveyBlockText.Upcoming, + }, + { + survey_status: SUBMISSION_STATUS.OPEN, + block_text: surveyBlockText.Open, + }, + { + survey_status: SUBMISSION_STATUS.CLOSED, + block_text: surveyBlockText.Closed, + }, + ]; + const validateForm = () => { + const errors = { + name: !(name && name.length < 50), + start_date: !start_date, + end_date: !end_date, + description: description.length > 550, + }; + setEngagementFormError(errors); + + return Object.values(errors).some((isError: unknown) => isError); + }; + + const handleSaveEngagement = async () => { + const hasErrors = validateForm(); + + if (hasErrors) { + return; + } + + const engagement = isNewEngagement + ? await handleCreateEngagementRequest({ + ...engagementFormData, + rich_description: richDescription, + rich_content: richContent, + status_block: surveyBlockList, + }) + : await handleUpdateEngagementRequest({ + ...engagementFormData, + rich_description: richDescription, + rich_content: richContent, + status_block: surveyBlockList, + }); + + return engagement; + }; + + const navigate = useNavigate(); + const handlePreviewEngagement = async () => { + const engagement = await handleSaveEngagement(); + if (!engagement) { + return; + } + + navigate(`/engagements/${engagement.id}/view`); + }; + useEffect(() => { handleGetSlug(); }, [savedEngagement.id]); @@ -270,6 +348,10 @@ export const EngagementTabsContextProvider = ({ children }: { children: React.Re value={{ engagementFormData, setEngagementFormData, + isNewEngagement, + setIsNewEngagement, + handleSaveEngagement, + handlePreviewEngagement, richDescription, setRichDescription, richContent, diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/Settings/EngagementSettingsForm.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/Settings/EngagementSettingsForm.tsx index ae6fc4dff..0f5d11155 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/Settings/EngagementSettingsForm.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/Settings/EngagementSettingsForm.tsx @@ -1,13 +1,17 @@ import React, { useContext } from 'react'; -import { Divider, Grid } from '@mui/material'; -import { MetPaper, PrimaryButton } from 'components/common'; +import { Divider, Grid, Box } from '@mui/material'; +import { MetPaper, PrimaryButton, SecondaryButton } from 'components/common'; import InternalEngagement from './InternalEngagement'; import SendReport from './SendReport'; import { EngagementSettingsContext } from './EngagementSettingsContext'; import { PublicUrls } from './PublicUrls'; +import { EngagementTabsContext } from '../EngagementTabsContext'; +import { ActionContext } from '../../ActionContext'; const EngagementSettingsForm = () => { const { handleSaveSettings, updatingSettings } = useContext(EngagementSettingsContext); + const { isSaving } = useContext(ActionContext); + const { handleSaveEngagement, handlePreviewEngagement } = useContext(EngagementTabsContext); return ( @@ -39,6 +43,36 @@ const EngagementSettingsForm = () => { + + + handleSaveEngagement()} + loading={isSaving} + > + Save + + handlePreviewEngagement()} + disabled={isSaving} + > + {'Preview'} + + + ); diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/UserManagement/EngagementUserManagement.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/UserManagement/EngagementUserManagement.tsx index 471915fa1..d6598a2c3 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/UserManagement/EngagementUserManagement.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/UserManagement/EngagementUserManagement.tsx @@ -1,14 +1,14 @@ import React, { useContext } from 'react'; -import { Grid } from '@mui/material'; -import { MetLabel, MetPaper, PrimaryButton, MetParagraph } from 'components/common'; +import { Grid, Box } from '@mui/material'; +import { MetLabel, MetPaper, PrimaryButton, SecondaryButton, MetParagraph } from 'components/common'; import { ActionContext } from '../../ActionContext'; import { EngagementTabsContext } from '../EngagementTabsContext'; import { formatDate } from 'components/common/dateHelper'; import TeamMemberListing from './TeamMemberListing'; const EngagementUserManagement = () => { - const { savedEngagement } = useContext(ActionContext); - const { setAddTeamMemberOpen } = useContext(EngagementTabsContext); + const { isSaving, savedEngagement } = useContext(ActionContext); + const { handleSaveEngagement, handlePreviewEngagement, setAddTeamMemberOpen } = useContext(EngagementTabsContext); return ( @@ -46,6 +46,35 @@ const EngagementUserManagement = () => { + + + handleSaveEngagement()} + loading={isSaving} + > + Save + + handlePreviewEngagement()} + disabled={isSaving} + > + {'Preview'} + + + diff --git a/met-web/src/components/userManagement/listing/ActionsDropDown.tsx b/met-web/src/components/userManagement/listing/ActionsDropDown.tsx index 252dde533..d1a525647 100644 --- a/met-web/src/components/userManagement/listing/ActionsDropDown.tsx +++ b/met-web/src/components/userManagement/listing/ActionsDropDown.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useContext } from 'react'; import { MenuItem, Select } from '@mui/material'; -import { User, USER_GROUP, USER_STATUS } from 'models/user'; +import { User, USER_COMPOSITE_ROLE, USER_STATUS } from 'models/user'; import { Palette } from 'styles/Theme'; import { UserManagementContext } from './UserManagementContext'; import { useAppSelector } from 'hooks'; @@ -18,21 +18,21 @@ export const ActionsDropDown = ({ selectedUser }: { selectedUser: User }) => { const { roles, userDetail } = useAppSelector((state) => state.user); const hasNoRole = (): boolean => { - if (selectedUser.main_group) { + if (selectedUser.main_role) { return false; } return true; }; const isAdmin = (): boolean => { - if (selectedUser?.main_group == USER_GROUP.ADMIN.label) { + if (selectedUser?.main_role == USER_COMPOSITE_ROLE.ADMIN.label) { return true; } return false; }; const isViewer = (): boolean => { - if (selectedUser?.main_group == USER_GROUP.VIEWER.label) { + if (selectedUser?.main_role == USER_COMPOSITE_ROLE.VIEWER.label) { return true; } return false; @@ -81,7 +81,7 @@ export const ActionsDropDown = ({ selectedUser }: { selectedUser: User }) => { selectedUser.id != userDetail?.user?.id, }, ], - [selectedUser.id, selectedUser.main_group], + [selectedUser.id, selectedUser.main_role], ); return ( diff --git a/met-web/src/components/userManagement/listing/AddUserModal.tsx b/met-web/src/components/userManagement/listing/AddUserModal.tsx index 76e753af8..8031103b0 100644 --- a/met-web/src/components/userManagement/listing/AddUserModal.tsx +++ b/met-web/src/components/userManagement/listing/AddUserModal.tsx @@ -100,7 +100,7 @@ export const AddUserModal = () => { openNotification({ severity: 'success', text: `You have successfully added ${user?.first_name + ' ' + user?.last_name} as a ${ - user?.main_group + user?.main_role } on ${data.engagement?.name}.`, }), ); diff --git a/met-web/src/components/userManagement/listing/AssignRoleModal.tsx b/met-web/src/components/userManagement/listing/AssignRoleModal.tsx index 6a2ce5f90..0ed70da3b 100644 --- a/met-web/src/components/userManagement/listing/AssignRoleModal.tsx +++ b/met-web/src/components/userManagement/listing/AssignRoleModal.tsx @@ -15,14 +15,14 @@ import { useTheme, } from '@mui/material'; import { MetHeader3, MetLabel, MetSmallText, modalStyle, PrimaryButton, SecondaryButton } from 'components/common'; -import { USER_GROUP } from 'models/user'; +import { USER_COMPOSITE_ROLE } from 'models/user'; import { UserManagementContext } from './UserManagementContext'; import { Palette } from 'styles/Theme'; import { useForm, FormProvider, SubmitHandler, Controller } from 'react-hook-form'; import { yupResolver } from '@hookform/resolvers/yup'; import * as yup from 'yup'; import ControlledRadioGroup from 'components/common/ControlledInputComponents/ControlledRadioGroup'; -import { addUserToGroup } from 'services/userService/api'; +import { addUserToRole } from 'services/userService/api'; import { addTeamMemberToEngagement } from 'services/membershipService'; import { When } from 'react-if'; import { openNotification } from 'services/notificationService/notificationSlice'; @@ -34,12 +34,12 @@ import { Engagement } from 'models/engagement'; const schema = yup .object({ - group: yup.string().required('A role must be specified'), + role: yup.string().required('A role must be specified'), engagement: yup .object() .nullable() - .when('group', { - is: USER_GROUP.REVIEWER.value, + .when('role', { + is: USER_COMPOSITE_ROLE.REVIEWER.value, then: yup.object().nullable().required('An engagement must be selected'), }), }) @@ -69,7 +69,7 @@ export const AssignRoleModal = () => { watch, } = methods; - const userTypeSelected = watch('group'); + const userTypeSelected = watch('role'); const formValues = watch(); useEffect(() => { @@ -78,7 +78,7 @@ export const AssignRoleModal = () => { } }, [JSON.stringify(formValues)]); - const { group: groupErrors, engagement: engagementErrors } = errors; + const { role: roleErrors, engagement: engagementErrors } = errors; const handleClose = () => { setassignRoleModalOpen(false); @@ -122,24 +122,24 @@ export const AssignRoleModal = () => { ).current; const assignRoleToUser = async (data: AssignRoleForm) => { - if (userTypeSelected === USER_GROUP.ADMIN.value) { - await addUserToGroup({ user_id: user?.external_id, group: data.group }); + if (userTypeSelected === USER_COMPOSITE_ROLE.ADMIN.value) { + await addUserToRole({ user_id: user?.external_id, role: data.role }); dispatch( openNotification({ severity: 'success', - text: `You have successfully added ${user?.first_name} ${user?.last_name} to the group ${USER_GROUP.ADMIN.label}`, + text: `You have successfully added ${user?.first_name} ${user?.last_name} to the role ${USER_COMPOSITE_ROLE.ADMIN.label}`, }), ); - } else if (userTypeSelected === USER_GROUP.VIEWER.value) { - await addUserToGroup({ user_id: user?.external_id, group: data.group }); + } else if (userTypeSelected === USER_COMPOSITE_ROLE.VIEWER.value) { + await addUserToRole({ user_id: user?.external_id, role: data.role }); dispatch( openNotification({ severity: 'success', - text: `You have successfully added ${user?.first_name} ${user?.last_name} to the group ${USER_GROUP.VIEWER.label}`, + text: `You have successfully added ${user?.first_name} ${user?.last_name} to the role ${USER_COMPOSITE_ROLE.VIEWER.label}`, }), ); } else { - await addUserToGroup({ user_id: user?.external_id, group: data.group }); + await addUserToRole({ user_id: user?.external_id, role: data.role }); await addTeamMemberToEngagement({ user_id: user?.external_id, engagement_id: data.engagement?.id, @@ -147,7 +147,7 @@ export const AssignRoleModal = () => { dispatch( openNotification({ severity: 'success', - text: `You have successfully added ${user?.first_name} ${user?.last_name} as a ${data.group} on ${data.engagement?.name}.`, + text: `You have successfully added ${user?.first_name} ${user?.last_name} as a ${data.role} on ${data.engagement?.name}.`, }), ); } @@ -191,44 +191,44 @@ export const AssignRoleModal = () => { rowSpacing={4} > - + What role would you like to assign to this user? - + } label={'Viewer'} /> } label={'Reviewer'} /> } label={'Team Member'} /> } label={'Administrator'} /> - - {String(groupErrors?.message)} + + {String(roleErrors?.message)} diff --git a/met-web/src/components/userManagement/listing/ReassignRoleModal.tsx b/met-web/src/components/userManagement/listing/ReassignRoleModal.tsx index da0d49f2f..d92866c73 100644 --- a/met-web/src/components/userManagement/listing/ReassignRoleModal.tsx +++ b/met-web/src/components/userManagement/listing/ReassignRoleModal.tsx @@ -7,16 +7,16 @@ import { useForm, FormProvider } from 'react-hook-form'; import { yupResolver } from '@hookform/resolvers/yup'; import * as yup from 'yup'; import ControlledRadioGroup from 'components/common/ControlledInputComponents/ControlledRadioGroup'; -import { USER_GROUP } from 'models/user'; +import { USER_COMPOSITE_ROLE } from 'models/user'; import { Unless } from 'react-if'; import { Palette } from 'styles/Theme'; -import { changeUserGroup } from 'services/userService/api'; +import { changeUserRole } from 'services/userService/api'; import { useAppDispatch } from 'hooks'; import { openNotification } from 'services/notificationService/notificationSlice'; const schema = yup .object({ - group: yup.string().required('Please select a role to assign to this user').required(), + role: yup.string().required('Please select a role to assign to this user').required(), }) .required(); @@ -41,15 +41,15 @@ export const ReassignRoleModal = () => { const onSubmit = async (data: AssignRoleForm) => { try { - const { group } = await schema.validate(data); + const { role } = await schema.validate(data); setIsSaving(true); - await changeUserGroup({ user_id: user.id, group }); + await changeUserRole({ user_id: user.id, role }); handleClose(); loadUserListing(); dispatch( openNotification({ severity: 'success', - text: `You have reassigned ${user.first_name} ${user.last_name} as ${group}.`, + text: `You have reassigned ${user.first_name} ${user.last_name} as ${role}.`, }), ); setIsSaving(false); @@ -89,31 +89,31 @@ export const ReassignRoleModal = () => { > What role would you like to reassign to this user? - - + + } label={'Viewer'} /> - + } label={'Reviewer'} /> - + } label={'Team Member'} /> - + } label={'Administrator'} /> diff --git a/met-web/src/components/userManagement/listing/UserManagementListing.tsx b/met-web/src/components/userManagement/listing/UserManagementListing.tsx index 035d372b0..66b902d25 100644 --- a/met-web/src/components/userManagement/listing/UserManagementListing.tsx +++ b/met-web/src/components/userManagement/listing/UserManagementListing.tsx @@ -37,13 +37,13 @@ const UserManagementListing = () => { ), }, { - key: 'main_group', + key: 'main_role', numeric: false, disablePadding: true, label: 'Role', allowSort: false, renderCell: (row: User) => { - return row.main_group; + return row.main_role; }, }, { diff --git a/met-web/src/components/userManagement/userDetails/AddToEngagement.tsx b/met-web/src/components/userManagement/userDetails/AddToEngagement.tsx index b592c8a06..c1769e9e3 100644 --- a/met-web/src/components/userManagement/userDetails/AddToEngagement.tsx +++ b/met-web/src/components/userManagement/userDetails/AddToEngagement.tsx @@ -15,13 +15,13 @@ import { useTheme, } from '@mui/material'; import { MetHeader3, MetLabel, MetSmallText, modalStyle, PrimaryButton, SecondaryButton } from 'components/common'; -import { USER_GROUP } from 'models/user'; +import { USER_COMPOSITE_ROLE } from 'models/user'; import { UserDetailsContext } from './UserDetailsContext'; import { useForm, FormProvider, SubmitHandler, Controller } from 'react-hook-form'; import { yupResolver } from '@hookform/resolvers/yup'; import * as yup from 'yup'; import { getEngagements } from 'services/engagementService'; -import { addUserToGroup } from 'services/userService/api'; +import { addUserToRole } from 'services/userService/api'; import { addTeamMemberToEngagement } from 'services/membershipService'; import { When } from 'react-if'; import { openNotification } from 'services/notificationService/notificationSlice'; @@ -36,12 +36,12 @@ import { HTTP_STATUS_CODES } from 'constants/httpResponseCodes'; export const AddToEngagementModal = () => { const { savedUser, addUserModalOpen, setAddUserModalOpen, getUserMemberships, getUserDetails } = useContext(UserDetailsContext); - const userHasGroup = savedUser?.groups && savedUser?.groups.length > 0; + const userHasRole = savedUser?.composite_roles && savedUser?.composite_roles.length > 0; const schema = yup .object({ engagement: yup.object().nullable(), - group: yup.string().when([], { - is: () => savedUser?.groups.length === 0, + role: yup.string().when([], { + is: () => savedUser?.composite_roles.length === 0, then: yup.string().required('A role must be specified'), otherwise: yup.string(), }), @@ -70,7 +70,7 @@ export const AddToEngagementModal = () => { watch, } = methods; - const userTypeSelected = watch('group'); + const userTypeSelected = watch('role'); const formValues = watch(); useEffect(() => { @@ -79,7 +79,7 @@ export const AddToEngagementModal = () => { } }, [JSON.stringify(formValues)]); - const { group: groupErrors, engagement: engagementErrors } = errors; + const { role: roleErrors, engagement: engagementErrors } = errors; const handleClose = () => { setAddUserModalOpen(false); @@ -119,7 +119,7 @@ export const AddToEngagementModal = () => { ).current; const addUserToEngagement = async (data: AddUserForm) => { - if (userHasGroup) { + if (userHasRole) { await addTeamMemberToEngagement({ user_id: savedUser?.external_id, engagement_id: data.engagement?.id, @@ -127,20 +127,20 @@ export const AddToEngagementModal = () => { dispatch( openNotification({ severity: 'success', - text: `You have successfully added ${savedUser?.first_name} ${savedUser?.last_name} as a ${savedUser?.main_group} on ${data.engagement?.name}.`, + text: `You have successfully added ${savedUser?.first_name} ${savedUser?.last_name} as a ${savedUser?.main_role} on ${data.engagement?.name}.`, }), ); } else { - if (userTypeSelected === USER_GROUP.ADMIN.value) { - await addUserToGroup({ user_id: savedUser?.external_id, group: data.group ?? '' }); + if (userTypeSelected === USER_COMPOSITE_ROLE.ADMIN.value) { + await addUserToRole({ user_id: savedUser?.external_id, role: data.role ?? '' }); dispatch( openNotification({ severity: 'success', - text: `You have successfully added ${savedUser?.first_name} ${savedUser?.last_name} to the group ${USER_GROUP.ADMIN.label}`, + text: `You have successfully added ${savedUser?.first_name} ${savedUser?.last_name} to the role ${USER_COMPOSITE_ROLE.ADMIN.label}`, }), ); } else { - await addUserToGroup({ user_id: savedUser?.external_id, group: data.group ?? '' }); + await addUserToRole({ user_id: savedUser?.external_id, role: data.role ?? '' }); await addTeamMemberToEngagement({ user_id: savedUser?.external_id, engagement_id: data.engagement?.id, @@ -148,7 +148,7 @@ export const AddToEngagementModal = () => { dispatch( openNotification({ severity: 'success', - text: `You have successfully added ${savedUser?.first_name} ${savedUser?.last_name} as a ${data.group} on ${data.engagement?.name}.`, + text: `You have successfully added ${savedUser?.first_name} ${savedUser?.last_name} as a ${data.role} on ${data.engagement?.name}.`, }), ); } @@ -186,12 +186,12 @@ export const AddToEngagementModal = () => {
- + Assign Role to {savedUser?.first_name + ' ' + savedUser?.last_name} - + Add {savedUser?.first_name + ' ' + savedUser?.last_name} to Engagement @@ -207,9 +207,9 @@ export const AddToEngagementModal = () => { justifyContent="flex-start" rowSpacing={4} > - + - + { > What role would you like to assign to this user? - + } label={'Reviewer'} /> } label={'Team Member'} /> - - {String(groupErrors?.message)} + + {String(roleErrors?.message)} diff --git a/met-web/src/components/userManagement/userDetails/UserDetails.tsx b/met-web/src/components/userManagement/userDetails/UserDetails.tsx index 0fdbf884f..4cf553856 100644 --- a/met-web/src/components/userManagement/userDetails/UserDetails.tsx +++ b/met-web/src/components/userManagement/userDetails/UserDetails.tsx @@ -7,7 +7,7 @@ import { formatDate } from 'components/common/dateHelper'; import AssignedEngagementsListing from './AssignedEngagementsListing'; import UserStatusButton from './UserStatusButton'; import UserDetailsSkeleton from './UserDetailsSkeleton'; -import { USER_GROUP, USER_STATUS } from 'models/user'; +import { USER_COMPOSITE_ROLE, USER_STATUS } from 'models/user'; export const UserDetail = ({ label, value }: { label: string; value: JSX.Element }) => { return ( @@ -64,7 +64,7 @@ export const UserDetails = () => { - {savedUser?.main_group}} /> + {savedUser?.main_role}} /> { setAddUserModalOpen(true)} disabled={ - savedUser?.main_group === USER_GROUP.VIEWER.label || + savedUser?.main_role === USER_COMPOSITE_ROLE.VIEWER.label || savedUser?.status_id === USER_STATUS.INACTIVE.value || savedUser?.id === userDetail?.user?.id } diff --git a/met-web/src/components/userManagement/userDetails/UserStatusButton.tsx b/met-web/src/components/userManagement/userDetails/UserStatusButton.tsx index 73390539b..9f92bcd99 100644 --- a/met-web/src/components/userManagement/userDetails/UserStatusButton.tsx +++ b/met-web/src/components/userManagement/userDetails/UserStatusButton.tsx @@ -6,7 +6,7 @@ import { openNotificationModal } from 'services/notificationModalService/notific import { openNotification } from 'services/notificationService/notificationSlice'; import { toggleUserStatus } from 'services/userService/api'; import { USER_ROLES } from 'services/userService/constants'; -import { USER_GROUP } from 'models/user'; +import { USER_COMPOSITE_ROLE } from 'models/user'; const UserStatusButton = () => { const { roles, userDetail } = useAppSelector((state) => state.user); @@ -17,7 +17,7 @@ const UserStatusButton = () => { const isActive = savedUser?.status_id === 1; - const disabled = savedUser?.main_group === USER_GROUP.ADMIN.label || savedUser?.id === userDetail?.user?.id; + const disabled = savedUser?.main_role === USER_COMPOSITE_ROLE.ADMIN.label || savedUser?.id === userDetail?.user?.id; useEffect(() => { setUserStatus(isActive); diff --git a/met-web/src/models/user.ts b/met-web/src/models/user.ts index 53420ecde..9401b235c 100644 --- a/met-web/src/models/user.ts +++ b/met-web/src/models/user.ts @@ -1,6 +1,6 @@ -export type UserGroup = 'IT_ADMIN' | 'IT_VIEWER' | 'TEAM_MEMBER' | 'REVIEWER'; +export type UserCompositeRole = 'IT_ADMIN' | 'IT_VIEWER' | 'TEAM_MEMBER' | 'REVIEWER'; -export const USER_GROUP: { [x: string]: { value: UserGroup; label: string } } = { +export const USER_COMPOSITE_ROLE: { [x: string]: { value: UserCompositeRole; label: string } } = { ADMIN: { value: 'IT_ADMIN', label: 'Administrator', @@ -26,12 +26,12 @@ export interface User { email_address: string; external_id: string; first_name: string; - groups: string[]; + composite_roles: string[]; id: number; last_name: string; updated_date: string; roles: string[]; - main_group: string; + main_role: string; username: string; status_id: number; } @@ -53,13 +53,13 @@ export const createDefaultUser: User = { description: '', email_address: '', external_id: '', - groups: [''], + composite_roles: [''], first_name: '', last_name: '', updated_date: Date(), created_date: Date(), roles: [], username: '', - main_group: '', + main_role: '', status_id: 0, }; diff --git a/met-web/src/routes/AuthGate.tsx b/met-web/src/routes/AuthGate.tsx index c8be2ff32..2e333499f 100644 --- a/met-web/src/routes/AuthGate.tsx +++ b/met-web/src/routes/AuthGate.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useAppSelector } from 'hooks'; import { useLocation, Navigate, Outlet } from 'react-router-dom'; -import { USER_GROUP } from 'models/user'; +import { USER_COMPOSITE_ROLE } from 'models/user'; const AuthGate = ({ allowedRoles }: { allowedRoles: string[] }) => { const permissions = useAppSelector((state) => state.user.roles); @@ -13,7 +13,7 @@ const AuthGate = ({ allowedRoles }: { allowedRoles: string[] }) => { }); return permissions.some((permission) => scopesMap[permission]) || - permissions?.includes('/' + USER_GROUP.TEAM_MEMBER.value) ? ( + permissions?.includes('/' + USER_COMPOSITE_ROLE.TEAM_MEMBER.value) ? ( ) : ( diff --git a/met-web/src/services/userService/api/index.tsx b/met-web/src/services/userService/api/index.tsx index 8f1430352..685cbd028 100644 --- a/met-web/src/services/userService/api/index.tsx +++ b/met-web/src/services/userService/api/index.tsx @@ -11,7 +11,7 @@ interface GetUserListParams { sort_key?: string; sort_order?: 'asc' | 'desc'; search_text?: string; - // If yes, user groups will be fetched as well from keycloak + // If yes, user roles will be fetched as well from keycloak include_roles?: boolean; include_inactive?: boolean; } @@ -27,7 +27,7 @@ export const getUserList = async (params: GetUserListParams = {}): Promise => { @@ -39,23 +39,23 @@ export const getUser = async (params: GetUserParams): Promise => { return Promise.reject('Failed to fetch user details'); }; -interface AddUserToGroupProps { +interface AddUserToRoleProps { user_id?: string; - group?: string; + role?: string; } -export const addUserToGroup = async ({ user_id, group }: AddUserToGroupProps): Promise => { - const url = replaceUrl(Endpoints.User.ADD_TO_GROUP, 'user_id', String(user_id)); - const responseData = await http.PostRequest(url, {}, { group }); +export const addUserToRole = async ({ user_id, role }: AddUserToRoleProps): Promise => { + const url = replaceUrl(Endpoints.User.ADD_TO_COMPOSITE_ROLE, 'user_id', String(user_id)); + const responseData = await http.PostRequest(url, {}, { role }); return responseData.data; }; -interface ChangeUserGroupProps { +interface ChangeUserRoleProps { user_id: number; - group: string; + role: string; } -export const changeUserGroup = async ({ user_id, group }: ChangeUserGroupProps): Promise => { - const url = replaceUrl(Endpoints.User.CHANGE_GROUP, 'user_id', String(user_id)); - const responseData = await http.PutRequest(url, {}, { group }); +export const changeUserRole = async ({ user_id, role }: ChangeUserRoleProps): Promise => { + const url = replaceUrl(Endpoints.User.CHANGE_COMPOSITE_ROLE, 'user_id', String(user_id)); + const responseData = await http.PutRequest(url, {}, { role }); return responseData.data; }; diff --git a/met-web/src/services/userService/types.ts b/met-web/src/services/userService/types.ts index b2bfde1b1..4f273e93c 100644 --- a/met-web/src/services/userService/types.ts +++ b/met-web/src/services/userService/types.ts @@ -5,7 +5,7 @@ export interface UserDetail { email_verified?: boolean; preferred_username?: string; user?: User; - groups?: string[]; + composite_roles?: string[]; } export interface UserAuthentication { diff --git a/met-web/tests/unit/components/engagement/EngagementFormUserTab.test.tsx b/met-web/tests/unit/components/engagement/EngagementFormUserTab.test.tsx index 8316feef2..0c25ef950 100644 --- a/met-web/tests/unit/components/engagement/EngagementFormUserTab.test.tsx +++ b/met-web/tests/unit/components/engagement/EngagementFormUserTab.test.tsx @@ -12,7 +12,7 @@ import * as teamMemberService from 'services/membershipService'; import * as widgetService from 'services/widgetService'; import { Box } from '@mui/material'; import { draftEngagement, engagementMetadata, engagementSetting } from '../factory'; -import { createDefaultUser, USER_GROUP } from 'models/user'; +import { createDefaultUser, USER_COMPOSITE_ROLE } from 'models/user'; import { EngagementTeamMember, initialDefaultTeamMember } from 'models/engagementTeamMember'; import { USER_ROLES } from 'services/userService/constants'; @@ -24,7 +24,7 @@ const mockTeamMember1: EngagementTeamMember = { id: 1, first_name: 'Jane', last_name: 'Doe', - groups: [USER_GROUP.VIEWER.label], + composite_roles: [USER_COMPOSITE_ROLE.VIEWER.label], }, }; diff --git a/met-web/tests/unit/components/engagement/form/create/EngagementForm.Create.test.tsx b/met-web/tests/unit/components/engagement/form/create/EngagementForm.Create.test.tsx index 843e88b1a..7a342bc29 100644 --- a/met-web/tests/unit/components/engagement/form/create/EngagementForm.Create.test.tsx +++ b/met-web/tests/unit/components/engagement/form/create/EngagementForm.Create.test.tsx @@ -98,7 +98,7 @@ describe('Engagement form page tests', () => { expect(getByText('Engagement Name')).toBeInTheDocument(); expect(container.querySelector('span.MuiSkeleton-root')).toBeNull(); }); - expect(screen.getByTestId('create-engagement-button')).toBeVisible(); + expect(screen.getByTestId('save-engagement-button')).toBeVisible(); expect(getEngagementMock).not.toHaveBeenCalled(); expect(getEngagementMetadataMock).not.toHaveBeenCalled(); @@ -125,7 +125,7 @@ describe('Engagement form page tests', () => { useParamsMock.mockReturnValue({ engagementId: 'create' }); const { container, getByTestId } = render(); - const createButton = getByTestId('create-engagement-button'); + const createButton = getByTestId('save-engagement-button'); fireEvent.click(createButton); expect(container.querySelectorAll('.Mui-error').length).toBeGreaterThan(0); diff --git a/met-web/tests/unit/components/engagement/form/edit/EngagementForm.Edit.One.test.tsx b/met-web/tests/unit/components/engagement/form/edit/EngagementForm.Edit.One.test.tsx index fde69dd1b..7388349b9 100644 --- a/met-web/tests/unit/components/engagement/form/edit/EngagementForm.Edit.One.test.tsx +++ b/met-web/tests/unit/components/engagement/form/edit/EngagementForm.Edit.One.test.tsx @@ -135,7 +135,7 @@ describe('Engagement form page tests', () => { expect(getEngagementMock).toHaveBeenCalledOnce(); expect(getEngagementMetadataMock).toHaveBeenCalledOnce(); - expect(screen.getByTestId('update-engagement-button')).toBeVisible(); + expect(screen.getByTestId('save-engagement-button')).toBeVisible(); expect(screen.getByDisplayValue('2022-09-01')).toBeInTheDocument(); expect(screen.getByDisplayValue('2022-09-30')).toBeInTheDocument(); expect(screen.getByText('Survey 1')).toBeInTheDocument(); @@ -148,7 +148,7 @@ describe('Engagement form page tests', () => { await waitFor(() => { expect(screen.getByDisplayValue('Test Engagement')).toBeInTheDocument(); }); - const updateButton = screen.getByTestId('update-engagement-button'); + const updateButton = screen.getByTestId('save-engagement-button'); const nameInput = container.querySelector('input[name="name"]'); assert(nameInput, 'Unable to find engagement name input');