From ed2b21ffa6fb8332c32e9396dfb402a742fe566e Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Fri, 23 Apr 2021 10:42:26 -0700 Subject: [PATCH 01/12] BHBC-993: Review access requests + misc user/role management endpoints + misc cleanup --- api/src/models/user.ts | 2 + api/src/paths/access-request.ts | 160 +++++++++++ api/src/paths/administrative-activities.ts | 6 +- api/src/paths/administrative-activity.ts | 241 ++++++++++++---- api/src/paths/codes.ts | 14 + api/src/paths/user.ts | 106 +++++-- api/src/paths/user/self.ts | 93 +++++++ api/src/paths/user/{userId}/system-roles.ts | 262 ++++++++++++++++++ .../administrative-activity-queries.ts | 48 +++- api/src/queries/codes/code-queries.ts | 8 + api/src/queries/users/system-role-queries.ts | 83 ++++++ api/src/queries/users/user-queries.ts | 45 ++- api/src/utils/code-utils.ts | 12 +- app/src/App.tsx | 6 +- app/src/AppRouter.tsx | 6 +- app/src/components/dialog/RequestDialog.tsx | 111 ++++++++ app/src/constants/i18n.ts | 6 + .../admin/users/AccessRequestList.tsx | 262 +++++++++++++----- .../admin/users/ReviewAccessRequestForm.tsx | 119 ++++++++ .../features/projects/CreateProjectPage.tsx | 6 +- .../features/projects/view/ProjectPage.tsx | 12 +- app/src/hooks/api/useAdminApi.ts | 109 +++++++- .../hooks/api/useAdministrativeActivityApi.ts | 40 --- app/src/hooks/api/useUserApi.ts | 2 +- app/src/hooks/useBioHubApi.ts | 4 - app/src/hooks/useKeycloakWrapper.test.tsx | 26 ++ app/src/hooks/useKeycloakWrapper.tsx | 43 ++- app/src/interfaces/useAdminApi.interface.ts | 32 ++- app/src/interfaces/useCodesApi.interface.ts | 1 + app/src/layouts/AuthLayout.tsx | 2 +- app/src/pages/access/AccessRequestPage.tsx | 5 +- app/src/test-helpers/code-helpers.ts | 3 +- 32 files changed, 1615 insertions(+), 260 deletions(-) create mode 100644 api/src/paths/access-request.ts create mode 100644 api/src/paths/user/self.ts create mode 100644 api/src/paths/user/{userId}/system-roles.ts create mode 100644 api/src/queries/users/system-role-queries.ts create mode 100644 app/src/components/dialog/RequestDialog.tsx create mode 100644 app/src/features/admin/users/ReviewAccessRequestForm.tsx delete mode 100644 app/src/hooks/api/useAdministrativeActivityApi.ts create mode 100644 app/src/hooks/useKeycloakWrapper.test.tsx diff --git a/api/src/models/user.ts b/api/src/models/user.ts index 19ca328ba6..e54181ce5f 100644 --- a/api/src/models/user.ts +++ b/api/src/models/user.ts @@ -5,6 +5,7 @@ const defaultLog = getLogger('models/user'); export class UserObject { id: number; user_identifier: string; + role_ids: number[]; role_names: string[]; constructor(obj?: any) { @@ -12,6 +13,7 @@ export class UserObject { this.id = obj?.id || null; this.user_identifier = obj?.user_identifier || null; + this.role_ids = (obj?.role_ids?.length && obj.role_ids) || []; this.role_names = (obj?.role_names?.length && obj.role_names) || []; } } diff --git a/api/src/paths/access-request.ts b/api/src/paths/access-request.ts new file mode 100644 index 0000000000..7f9c033875 --- /dev/null +++ b/api/src/paths/access-request.ts @@ -0,0 +1,160 @@ +'use strict'; + +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { WRITE_ROLES } from '../constants/roles'; +import { getDBConnection } from '../database/db'; +import { HTTP400, HTTP500 } from '../errors/CustomError'; +import { UserObject } from '../models/user'; +import { getUserByUserIdentifierSQL } from '../queries/users/user-queries'; +import { getLogger } from '../utils/logger'; +import { logRequest } from '../utils/path-utils'; +import { updateAdministrativeActivity } from './administrative-activity'; +import { addSystemUser } from './user'; +import { addSystemRoles } from './user/{userId}/system-roles'; + +const defaultLog = getLogger('paths/access-request'); + +export const PUT: Operation = [logRequest('paths/access-request', 'POST'), updateAccessRequest()]; + +PUT.apiDoc = { + description: "Update a user's system access request and add any specified system roles to the user.", + tags: ['user'], + security: [ + { + Bearer: WRITE_ROLES + } + ], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + required: ['userIdentifier', 'identitySource', 'requestId', 'requestStatusTypeId'], + properties: { + userIdentifier: { + type: 'string', + description: 'The user identifier for the user.' + }, + identitySource: { + type: 'string', + description: 'The identity source for the user.' + }, + requestId: { + type: 'number', + description: 'The id of the access request to update.' + }, + requestStatusTypeId: { + type: 'number', + description: 'The status type id to set for the access request.' + }, + roleIds: { + type: 'array', + items: { + type: 'number' + }, + description: + 'An array of role ids to add, if the access-request was approved. Ignored if the access-request was denied.' + } + } + } + } + } + }, + responses: { + 200: { + description: 'Add system user roles to user OK.' + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/401' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +function updateAccessRequest(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'updateAccessRequest', message: 'params', req_body: req.body }); + + const userIdentifier = req.body?.userIdentifier || null; + const identitySource = req.body?.identitySource || null; + const administrativeActivityId = Number(req.body?.requestId) || null; + const administrativeActivityStatusTypeId = Number(req.body?.requestStatusTypeId) || null; + const roleIds: number[] = req.body?.roleIds || []; + + if (!userIdentifier) { + throw new HTTP400('Missing required body param: userIdentifier'); + } + + if (!identitySource) { + throw new HTTP400('Missing required body param: identitySource'); + } + + if (!administrativeActivityId) { + throw new HTTP400('Missing required body param: requestId'); + } + + if (!administrativeActivityStatusTypeId) { + throw new HTTP400('Missing required body param: requestStatusTypeId'); + } + + const getUserSQLStatement = getUserByUserIdentifierSQL(userIdentifier); + + if (!getUserSQLStatement) { + throw new HTTP400('Failed to build SQL get statement'); + } + + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + // Get the user by their user identifier (user may not exist) + const getUserResponse = await connection.query(getUserSQLStatement.text, getUserSQLStatement.values); + + let userData = (getUserResponse && getUserResponse.rowCount && getUserResponse.rows[0]) || null; + + if (!userData) { + // Found no existing user, add them + userData = await addSystemUser(userIdentifier, identitySource, connection); + } + + const userObject = new UserObject(userData); + + if (!userObject) { + throw new HTTP500('Failed to get or add system user'); + } + + // Filter out any system roles that have already been added to the user + const rolesToAdd = roleIds.filter((roleId) => !userObject.role_ids.includes(roleId)); + + if (rolesToAdd?.length) { + // Add any missing roles (if any) + await addSystemRoles(userObject.id, roleIds, connection); + } + + // Update the access request record status + await updateAdministrativeActivity(administrativeActivityId, administrativeActivityStatusTypeId, connection); + + await connection.commit(); + + return res.status(200).send(); + } catch (error) { + defaultLog.debug({ label: 'updateAccessRequest', message: 'error', error }); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/administrative-activities.ts b/api/src/paths/administrative-activities.ts index f9dd21275b..b5cd6a3a80 100644 --- a/api/src/paths/administrative-activities.ts +++ b/api/src/paths/administrative-activities.ts @@ -13,7 +13,7 @@ export const GET: Operation = [logRequest('paths/administrative-activity', 'GET' GET.apiDoc = { description: 'Get a list of administrative activities based on the provided criteria.', - tags: ['project'], + tags: ['admin'], security: [ { Bearer: READ_ROLES @@ -82,7 +82,7 @@ GET.apiDoc = { }; /** - * Get all projects. + * Get all administrative activities for the specified type, or all if no type is provided. * * @returns {RequestHandler} */ @@ -105,7 +105,7 @@ function getAdministrativeActivities(): RequestHandler { await connection.commit(); - const result = (response && response.rows && response.rows[0]) || []; + const result = (response && response.rowCount && response.rows) || []; return res.status(200).json(result); } catch (error) { diff --git a/api/src/paths/administrative-activity.ts b/api/src/paths/administrative-activity.ts index 6d848e77da..70abb6378e 100644 --- a/api/src/paths/administrative-activity.ts +++ b/api/src/paths/administrative-activity.ts @@ -1,14 +1,16 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; -import { getAPIUserDBConnection } from '../database/db'; +import { WRITE_ROLES } from '../constants/roles'; +import { getAPIUserDBConnection, getDBConnection, IDBConnection } from '../database/db'; import { HTTP400, HTTP500 } from '../errors/CustomError'; import { administrativeActivityResponseObject, hasPendingAdministrativeActivitiesResponseObject } from '../openapi/schemas/administrative-activity'; import { + countPendingAdministrativeActivitiesSQL, postAdministrativeActivitySQL, - countPendingAdministrativeActivitiesSQL + putAdministrativeActivitySQL } from '../queries/administrative-activity/administrative-activity-queries'; import { getUserIdentifier } from '../utils/keycloak-utils'; import { getLogger } from '../utils/logger'; @@ -19,62 +21,6 @@ const defaultLog = getLogger('paths/administrative-activity-request'); export const POST: Operation = [logRequest('paths/administrative-activity', 'POST'), createAdministrativeActivity()]; export const GET: Operation = [logRequest('paths/administrative-activity', 'GET'), getPendingAccessRequestsCount()]; -const postPutResponses = { - 200: { - description: 'Administrative activity post response object.', - content: { - 'application/json': { - schema: { - ...(administrativeActivityResponseObject as object) - } - } - } - }, - 400: { - $ref: '#/components/responses/400' - }, - 401: { - $ref: '#/components/responses/401' - }, - 403: { - $ref: '#/components/responses/401' - }, - 500: { - $ref: '#/components/responses/500' - }, - default: { - $ref: '#/components/responses/default' - } -}; - -const getResponses = { - 200: { - description: '`Has Pending Administrative Activities` get response object.', - content: { - 'application/json': { - schema: { - ...(hasPendingAdministrativeActivitiesResponseObject as object) - } - } - } - }, - 400: { - $ref: '#/components/responses/400' - }, - 401: { - $ref: '#/components/responses/401' - }, - 403: { - $ref: '#/components/responses/401' - }, - 500: { - $ref: '#/components/responses/500' - }, - default: { - $ref: '#/components/responses/default' - } -}; - POST.apiDoc = { description: 'Create a new Administrative Activity.', tags: ['admin'], @@ -96,7 +42,31 @@ POST.apiDoc = { } }, responses: { - ...postPutResponses + 200: { + description: 'Administrative activity post response object.', + content: { + 'application/json': { + schema: { + ...(administrativeActivityResponseObject as object) + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/401' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } } }; @@ -121,7 +91,31 @@ GET.apiDoc = { } }, responses: { - ...getResponses + 200: { + description: '`Has Pending Administrative Activities` get response object.', + content: { + 'application/json': { + schema: { + ...(hasPendingAdministrativeActivitiesResponseObject as object) + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/401' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } } }; @@ -219,3 +213,130 @@ function getPendingAccessRequestsCount(): RequestHandler { } }; } + +export const PUT: Operation = [ + logRequest('paths/administrative-activity', 'PUT'), + getUpdateAdministrativeActivityHandler() +]; + +PUT.apiDoc = { + description: 'Update an existing administrative activity.', + tags: ['admin'], + security: [ + { + Bearer: WRITE_ROLES + } + ], + requestBody: { + description: 'Administrative activity request object.', + content: { + 'application/json': { + schema: { + title: 'Administrative activity put object', + type: 'object', + required: ['id', 'status'], + properties: { + id: { + title: 'administrative activity record ID', + type: 'number' + }, + status: { + title: 'administrative activity status type code ID', + type: 'number' + } + } + } + } + } + }, + responses: { + 200: { + description: 'Put administrative activity OK' + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/401' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Get a request handler to update an existing administrative activity. + * + * @returns {RequestHandler} + */ +function getUpdateAdministrativeActivityHandler(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ + label: 'updateAdministrativeActivity', + message: 'params', + req_body: req.body + }); + + const administrativeActivityId = Number(req.body?.id); + const administrativeActivityStatusTypeId = Number(req.body?.status); + + if (!administrativeActivityId) { + throw new HTTP400('Missing required body parameter: id'); + } + + if (!administrativeActivityStatusTypeId) { + throw new HTTP400('Missing required body parameter: status'); + } + + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + await updateAdministrativeActivity(administrativeActivityId, administrativeActivityStatusTypeId, connection); + + await connection.commit(); + + return res.status(200).send(); + } catch (error) { + defaultLog.debug({ label: 'updateAdministrativeActivity', message: 'error', error }); + throw error; + } finally { + connection.release(); + } + }; +} + +/** + * Update an existing administrative activity. + * + * @param {number} administrativeActivityId + * @param {number} administrativeActivityStatusTypeId + * @param {IDBConnection} connection + */ +export const updateAdministrativeActivity = async ( + administrativeActivityId: number, + administrativeActivityStatusTypeId: number, + connection: IDBConnection +) => { + const sqlStatement = putAdministrativeActivitySQL(administrativeActivityId, administrativeActivityStatusTypeId); + + if (!sqlStatement) { + throw new HTTP400('Failed to build SQL put statement'); + } + + const response = await connection.query(sqlStatement.text, sqlStatement.values); + + const result = (response && response.rowCount) || null; + + if (!result) { + throw new HTTP500('Failed to update administrative activity'); + } +}; diff --git a/api/src/paths/codes.ts b/api/src/paths/codes.ts index 294e222f2b..7bfe7d7eb0 100644 --- a/api/src/paths/codes.ts +++ b/api/src/paths/codes.ts @@ -202,6 +202,20 @@ GET.apiDoc = { } } } + }, + system_role: { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'number' + }, + name: { + type: 'string' + } + } + } } } } diff --git a/api/src/paths/user.ts b/api/src/paths/user.ts index 5c36706d71..5c914564f7 100644 --- a/api/src/paths/user.ts +++ b/api/src/paths/user.ts @@ -1,38 +1,48 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; -import { READ_ROLES } from '../constants/roles'; -import { getDBConnection } from '../database/db'; -import { HTTP400 } from '../errors/CustomError'; -import { getUserByIdSQL } from '../queries/users/user-queries'; +import { WRITE_ROLES } from '../constants/roles'; +import { getDBConnection, IDBConnection } from '../database/db'; +import { HTTP400, HTTP500 } from '../errors/CustomError'; +import { addSystemUserSQL } from '../queries/users/user-queries'; import { getLogger } from '../utils/logger'; import { logRequest } from '../utils/path-utils'; const defaultLog = getLogger('paths/user'); -export const GET: Operation = [logRequest('paths/user', 'GET'), getUser()]; +export const POST: Operation = [logRequest('paths/user', 'POST'), addUser()]; -GET.apiDoc = { - description: 'Get user details for the currently authenticated user.', +POST.apiDoc = { + description: 'Add a new system user.', tags: ['user'], security: [ { - Bearer: READ_ROLES + Bearer: WRITE_ROLES } ], - responses: { - 200: { - description: 'User details for the currently authenticated user.', - content: { - 'application/json': { - schema: { - title: 'User Response Object', - type: 'object', - properties: { - // TODO needs finalizing (here and in the user-queries.ts SQL) + requestBody: { + description: 'Add system user request object.', + content: { + 'application/json': { + schema: { + title: 'User Response Object', + type: 'object', + required: ['userIdentifier', 'identitySource'], + properties: { + userIdentifier: { + type: 'string' + }, + identitySource: { + type: 'string', + enum: ['idir', 'bceid'] } } } } + } + }, + responses: { + 200: { + description: 'Add system user OK.' }, 400: { $ref: '#/components/responses/400' @@ -53,36 +63,45 @@ GET.apiDoc = { }; /** - * Get a user by its user identifier. + * Add a system user by its user identifier. * * @returns {RequestHandler} */ -function getUser(): RequestHandler { +function addUser(): RequestHandler { return async (req, res) => { const connection = getDBConnection(req['keycloak_token']); - try { - await connection.open(); + const userIdentifier = req.body?.userIdentifier || null; + const identitySource = req.body?.identitySource || null; - const systemUserId = connection.systemUserId(); + if (!userIdentifier) { + throw new HTTP400('Missing required body param: userIdentifier'); + } - if (!systemUserId) { - throw new HTTP400('Failed to identify system user ID'); - } + if (!identitySource) { + throw new HTTP400('Missing required body param: identitySource'); + } - const getUserSQLStatement = getUserByIdSQL(systemUserId); + try { + const addSystemUserSQLStatement = addSystemUserSQL(userIdentifier, identitySource); - if (!getUserSQLStatement) { + if (!addSystemUserSQLStatement) { throw new HTTP400('Failed to build SQL get statement'); } - const response = await connection.query(getUserSQLStatement.text, getUserSQLStatement.values); + await connection.open(); + + const response = await connection.query(addSystemUserSQLStatement.text, addSystemUserSQLStatement.values); await connection.commit(); const result = (response && response.rows && response.rows[0]) || null; - return res.status(200).json(result); + if (!result) { + throw new HTTP500('Failed to add system user'); + } + + return res.status(200).json(); } catch (error) { defaultLog.debug({ label: 'getUser', message: 'error', error }); throw error; @@ -91,3 +110,30 @@ function getUser(): RequestHandler { } }; } + +/** + * Adds a new system user. + * + * Note: Does not account for the user already existing. + * + * @param {string} userIdentifier + * @param {string} identitySource + * @param {IDBConnection} connection + */ +export const addSystemUser = async (userIdentifier: string, identitySource: string, connection: IDBConnection) => { + const addSystemUserSQLStatement = addSystemUserSQL(userIdentifier, identitySource); + + if (!addSystemUserSQLStatement) { + throw new HTTP400('Failed to build SQL get statement'); + } + + const response = await connection.query(addSystemUserSQLStatement.text, addSystemUserSQLStatement.values); + + const result = (response && response.rows && response.rows[0]) || null; + + if (!result) { + throw new HTTP500('Failed to add system user'); + } + + return result; +}; diff --git a/api/src/paths/user/self.ts b/api/src/paths/user/self.ts new file mode 100644 index 0000000000..ef9af08088 --- /dev/null +++ b/api/src/paths/user/self.ts @@ -0,0 +1,93 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { READ_ROLES } from '../../constants/roles'; +import { getDBConnection } from '../../database/db'; +import { HTTP400 } from '../../errors/CustomError'; +import { getUserByIdSQL } from '../../queries/users/user-queries'; +import { getLogger } from '../../utils/logger'; +import { logRequest } from '../../utils/path-utils'; + +const defaultLog = getLogger('paths/user/{userId}'); + +export const GET: Operation = [logRequest('paths/user/{userId}', 'GET'), getUser()]; + +GET.apiDoc = { + description: 'Get user details for the currently authenticated user.', + tags: ['user'], + security: [ + { + Bearer: READ_ROLES + } + ], + responses: { + 200: { + description: 'User details for the currently authenticated user.', + content: { + 'application/json': { + schema: { + title: 'User Response Object', + type: 'object', + properties: { + // TODO needs finalizing (here and in the user-queries.ts SQL) + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/401' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Get a user by its user identifier. + * + * @returns {RequestHandler} + */ +function getUser(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + const systemUserId = connection.systemUserId(); + + if (!systemUserId) { + throw new HTTP400('Failed to identify system user ID'); + } + + const getUserSQLStatement = getUserByIdSQL(systemUserId); + + if (!getUserSQLStatement) { + throw new HTTP400('Failed to build SQL get statement'); + } + + const response = await connection.query(getUserSQLStatement.text, getUserSQLStatement.values); + + await connection.commit(); + + const result = (response && response.rows && response.rows[0]) || null; + + return res.status(200).json(result); + } catch (error) { + defaultLog.debug({ label: 'getUser', message: 'error', error }); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/user/{userId}/system-roles.ts b/api/src/paths/user/{userId}/system-roles.ts new file mode 100644 index 0000000000..25d2e5d555 --- /dev/null +++ b/api/src/paths/user/{userId}/system-roles.ts @@ -0,0 +1,262 @@ +'use strict'; + +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { WRITE_ROLES } from '../../../constants/roles'; +import { getDBConnection, IDBConnection } from '../../../database/db'; +import { HTTP400, HTTP500 } from '../../../errors/CustomError'; +import { UserObject } from '../../../models/user'; +import { deleteSystemRolesSQL, postSystemRolesSQL } from '../../../queries/users/system-role-queries'; +import { getUserByIdSQL } from '../../../queries/users/user-queries'; +import { getLogger } from '../../../utils/logger'; +import { logRequest } from '../../../utils/path-utils'; + +const defaultLog = getLogger('paths/user/{userId}/system-roles'); + +export const POST: Operation = [logRequest('paths/user/{userId}/system-roles', 'POST'), getAddSystemRolesHandler()]; + +POST.apiDoc = { + description: 'Add system roles to a user.', + tags: ['user'], + security: [ + { + Bearer: WRITE_ROLES + } + ], + parameters: [ + { + in: 'path', + name: 'userId', + schema: { + type: 'number' + }, + required: true + } + ], + requestBody: { + description: 'Add system roles to a user request object.', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['roles'], + properties: { + roles: { + type: 'array', + items: { + type: 'number' + }, + description: 'An array of role ids' + } + } + } + } + } + }, + responses: { + 200: { + description: 'Add system user roles to user OK.' + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/401' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +function getAddSystemRolesHandler(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'addSystemRoles', message: 'params', req_params: req.params, req_body: req.body }); + + const userId = Number(req.params?.userId) || null; + const roles: number[] = req.body?.roles || []; + + if (!userId) { + throw new HTTP400('Missing required path param: userId'); + } + + if (!roles?.length) { + throw new HTTP400('Missing required body param: roles'); + } + + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + // Get the system user and their current roles + const getUserSQLStatement = getUserByIdSQL(userId); + + if (!getUserSQLStatement) { + throw new HTTP400('Failed to build SQL get statement'); + } + + const getUserResponse = await connection.query(getUserSQLStatement.text, getUserSQLStatement.values); + + const userResult = (getUserResponse && getUserResponse.rowCount && getUserResponse.rows[0]) || null; + + if (!userResult) { + throw new HTTP400('Failed to get system user'); + } + + const userObject = new UserObject(userResult); + + // Filter out any system roles that have already been added to the user + const rolesToAdd = roles.filter((role) => !userObject.role_ids.includes(role)); + + if (!rolesToAdd?.length) { + // No new system roles to add, do nothing + return res.status(200).send(); + } + + await addSystemRoles(userId, roles, connection); + + await connection.commit(); + + return res.status(200).send(); + } catch (error) { + defaultLog.debug({ label: 'addSystemRoles', message: 'error', error }); + throw error; + } finally { + connection.release(); + } + }; +} + +/** + * Adds the specified roleIds to the user. + * + * Note: Does not account for any existing roles the user may already have. + * + * @param {number} userId + * @param {number[]} roleIds + * @param {IDBConnection} connection + */ +export const addSystemRoles = async (userId: number, roleIds: number[], connection: IDBConnection) => { + const postSystemRolesSqlStatement = postSystemRolesSQL(userId, roleIds); + + if (!postSystemRolesSqlStatement) { + throw new HTTP400('Failed to build SQL get statement'); + } + + const postSystemRolesResponse = await connection.query( + postSystemRolesSqlStatement.text, + postSystemRolesSqlStatement.values + ); + + const systemRolesResult = (postSystemRolesResponse && postSystemRolesResponse.rowCount) || null; + + if (!systemRolesResult) { + throw new HTTP400('Failed to add system roles'); + } +}; + +export const DELETE: Operation = [logRequest('paths/user/{userId}/system-roles', 'DELETE'), removeSystemRoles()]; + +DELETE.apiDoc = { + description: 'Remove system roles from a user.', + tags: ['user'], + security: [ + { + Bearer: WRITE_ROLES + } + ], + parameters: [ + { + in: 'path', + name: 'userId', + schema: { + type: 'number' + }, + required: true + }, + { + in: 'query', + name: 'role', + schema: { + type: 'array', + items: { + type: 'number' + } + }, + required: true + } + ], + responses: { + 200: { + description: 'Remove system user roles from user OK.' + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/401' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +function removeSystemRoles(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'removeSystemRoles', message: 'params', req_params: req.params, req_body: req.body }); + + const userId = Number(req.params?.userId) || null; + const roles = (req.query?.role as string[]) || []; + + if (!userId) { + throw new HTTP400('Missing required path param: userId'); + } + + if (!roles?.length) { + throw new HTTP400('Missing required body param: roles'); + } + + const connection = getDBConnection(req['keycloak_token']); + + try { + const sqlStatement = deleteSystemRolesSQL(userId, roles); + + if (!sqlStatement) { + throw new HTTP400('Failed to build SQL get statement'); + } + + await connection.open(); + + const response = await connection.query(sqlStatement.text, sqlStatement.values); + + await connection.commit(); + + const result = (response && response.rowCount) || null; + + if (!result) { + throw new HTTP500('Failed to remove system roles'); + } + + return res.status(200).send(); + } catch (error) { + defaultLog.debug({ label: 'removeSystemRoles', message: 'error', error }); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/queries/administrative-activity/administrative-activity-queries.ts b/api/src/queries/administrative-activity/administrative-activity-queries.ts index 1e9c339b29..6dba1330ea 100644 --- a/api/src/queries/administrative-activity/administrative-activity-queries.ts +++ b/api/src/queries/administrative-activity/administrative-activity-queries.ts @@ -22,8 +22,9 @@ export const getAdministrativeActivitiesSQL = ( const sqlStatement = SQL` SELECT - aat.id as aat, - aast.id as aast, + aat.id as id, + aast.id as status, + aast.name as status_name, aa.description, aa.data, aa.notes, @@ -147,3 +148,46 @@ export const countPendingAdministrativeActivitiesSQL = (userIdentifier: string): return sqlStatement; }; + +/** + * SQL query update and existing administrative activity record. + * + * @param {number} administrativeActivityId + * @param {number} administrativeActivityStatusTypeId + * @return {*} {(SQLStatement | null)} + */ +export const putAdministrativeActivitySQL = ( + administrativeActivityId: number, + administrativeActivityStatusTypeId: number +): SQLStatement | null => { + defaultLog.debug({ + label: 'putAdministrativeActivitySQL', + message: 'params', + administrativeActivityId, + administrativeActivityStatusTypeId + }); + + if (!administrativeActivityId || !administrativeActivityStatusTypeId) { + return null; + } + + const sqlStatement = SQL` + UPDATE + administrative_activity + SET + aast_id = ${administrativeActivityStatusTypeId} + WHERE + id = ${administrativeActivityId} + RETURNING + id; + `; + + defaultLog.debug({ + label: 'putAdministrativeActivitySQL', + message: 'sql', + 'sqlStatement.text': sqlStatement.text, + 'sqlStatement.values': sqlStatement.values + }); + + return sqlStatement; +}; diff --git a/api/src/queries/codes/code-queries.ts b/api/src/queries/codes/code-queries.ts index fd1e6a1fdd..74475d1d7d 100644 --- a/api/src/queries/codes/code-queries.ts +++ b/api/src/queries/codes/code-queries.ts @@ -80,3 +80,11 @@ export const getIUCNConservationActionLevel3SubclassificationSQL = (): SQLStatem * @returns {SQLStatement} sql query object */ export const getSystemRolesSQL = (): SQLStatement => SQL`SELECT id, name from system_role;`; + +/** + * SQL query to fetch administrative activity status type codes. + * + * @returns {SQLStatement} sql query object + */ +export const getAdministrativeActivityStatusTypeSQL = (): SQLStatement => + SQL`SELECT id, name FROM administrative_activity_status_type;`; diff --git a/api/src/queries/users/system-role-queries.ts b/api/src/queries/users/system-role-queries.ts new file mode 100644 index 0000000000..a3c500aaaa --- /dev/null +++ b/api/src/queries/users/system-role-queries.ts @@ -0,0 +1,83 @@ +import { SQL, SQLStatement } from 'sql-template-strings'; +import { getLogger } from '../../utils/logger'; + +const defaultLog = getLogger('queries/user/system-role-queries'); + +/** + * SQL query to add one or more system roles to a user. + * + * @param {number} userId + * @param {number[]} roleIds + * @return {*} {(SQLStatement | null)} + */ +export const postSystemRolesSQL = (userId: number, roleIds: number[]): SQLStatement | null => { + defaultLog.debug({ label: 'postSystemRolesSQL', message: 'params', userId, roleIds }); + + if (!userId || !roleIds?.length) { + return null; + } + + const sqlStatement = SQL` + INSERT INTO system_user_role ( + su_id, + sr_id + ) VALUES `; + + roleIds.forEach((roleId, index) => { + sqlStatement.append(SQL` + (${userId},${roleId}) + `); + + if (index !== roleIds.length - 1) { + sqlStatement.append(','); + } + }); + + sqlStatement.append(';'); + + defaultLog.debug({ + label: 'postSystemRolesSQL', + message: 'sql', + 'sqlStatement.text': sqlStatement.text, + 'sqlStatement.values': sqlStatement.values + }); + + return sqlStatement; +}; + +/** + * SQL query to remove one or more system roles from a user. + * + * @param {number} userId + * @param {number[]} roleIds + * @return {*} {(SQLStatement | null)} + */ +export const deleteSystemRolesSQL = (userId: number, roleIds: string[]): SQLStatement | null => { + defaultLog.debug({ label: 'deleteSystemRolesSQL', message: 'params', userId, roleIds }); + + if (!userId || !roleIds?.length) { + return null; + } + + const sqlStatement = SQL` + DELETE FROM + system_user_role + WHERE + su_id = ${userId} + AND + sr_id IN ( + ${roleIds.join(', ')} + ); + `; + + sqlStatement.append(';'); + + defaultLog.debug({ + label: 'deleteSystemRolesSQL', + message: 'sql', + 'sqlStatement.text': sqlStatement.text, + 'sqlStatement.values': sqlStatement.values + }); + + return sqlStatement; +}; diff --git a/api/src/queries/users/user-queries.ts b/api/src/queries/users/user-queries.ts index d5db79bba3..6f97fbd3ce 100644 --- a/api/src/queries/users/user-queries.ts +++ b/api/src/queries/users/user-queries.ts @@ -6,8 +6,6 @@ const defaultLog = getLogger('queries/user/user-queries'); /** * SQL query to get a single user and their system roles, based on their user_identifier. * - * // TODO SQL needs finalizing/optimizing - * * @param {string} userIdentifier * @returns {SQLStatement} sql query object */ @@ -22,6 +20,7 @@ export const getUserByUserIdentifierSQL = (userIdentifier: string): SQLStatement SELECT su.id, su.user_identifier, + array_remove(array_agg(sr.id), NULL) AS role_ids, array_remove(array_agg(sr.name), NULL) AS role_names FROM system_user su @@ -53,8 +52,6 @@ export const getUserByUserIdentifierSQL = (userIdentifier: string): SQLStatement /** * SQL query to get a single user and their system roles, based on their id. * - * // TODO SQL needs finalizing/optimizing - * * @param {number} userId * @returns {SQLStatement} sql query object */ @@ -69,6 +66,7 @@ export const getUserByIdSQL = (userId: number): SQLStatement | null => { SELECT su.id, su.user_identifier, + array_remove(array_agg(sr.id), NULL) AS role_ids, array_remove(array_agg(sr.name), NULL) AS role_names FROM system_user su @@ -109,7 +107,8 @@ export const getUserListSQL = (): SQLStatement | null => { SELECT su.id, su.user_identifier, - array_agg(sr.name) as role_names + array_remove(array_agg(sr.id), NULL) AS role_ids, + array_remove(array_agg(sr.name), NULL) AS role_names FROM system_user su LEFT JOIN @@ -134,3 +133,39 @@ export const getUserListSQL = (): SQLStatement | null => { return sqlStatement; }; + +/** + * SQL query to add a new system user. + * + * @param {string} userIdentifier + * @param {string} identitySource + * @return {*} {(SQLStatement | null)} + */ +export const addSystemUserSQL = (userIdentifier: string, identitySource: string): SQLStatement | null => { + defaultLog.debug({ label: 'addSystemUserSQL', message: 'addSystemUserSQL' }); + + if (!userIdentifier || !identitySource) { + return null; + } + + const sqlStatement = SQL` + INSERT INTO system_user ( + uis_id, + user_identifier, + record_effective_date + ) VALUES ( + Select id FROM user_identity_source WHERE name = ${identitySource}, + ${userIdentifier}, + now() + ); + `; + + defaultLog.debug({ + label: 'addSystemUserSQL', + message: 'sql', + 'sqlStatement.text': sqlStatement.text, + 'sqlStatement.values': sqlStatement.values + }); + + return sqlStatement; +}; diff --git a/api/src/utils/code-utils.ts b/api/src/utils/code-utils.ts index adccb1dddc..33f1530f45 100644 --- a/api/src/utils/code-utils.ts +++ b/api/src/utils/code-utils.ts @@ -10,7 +10,8 @@ import { getIUCNConservationActionLevel3SubclassificationSQL, getActivitySQL, getProjectTypeSQL, - getSystemRolesSQL + getSystemRolesSQL, + getAdministrativeActivityStatusTypeSQL } from '../queries/codes/code-queries'; import { getLogger } from '../utils/logger'; import { coordinator_agency, region, species, regional_offices } from '../constants/codes'; @@ -33,6 +34,7 @@ export interface IAllCodeSets { iucn_conservation_action_level_3_subclassification: object; system_roles: object; regional_offices: object; + administrative_activity_status_type: object; } /** @@ -61,7 +63,8 @@ export async function getAllCodeSets(connection: IDBConnection): Promise { {(config) => { if (!config) { - return ; + return ; } //@ts-ignore @@ -28,13 +28,13 @@ const App: React.FC = () => { }> + LoadingComponent={}> {(context: IAuthState) => { if (!context.ready) { - return ; + return ; } return ; }} diff --git a/app/src/AppRouter.tsx b/app/src/AppRouter.tsx index 9d7adb72ca..797204ae74 100644 --- a/app/src/AppRouter.tsx +++ b/app/src/AppRouter.tsx @@ -23,12 +23,14 @@ const AppRouter: React.FC = (props: any) => { path="/access-request" title={getTitle('Access Request')} component={AccessRequestPage} - layout={PublicLayout}> + layout={PublicLayout} + /> + layout={PublicLayout} + /> void; + /** + * Callback fired if the 'Deny' button is clicked. + * + * @memberof IRequestDialog + */ + onDeny: () => void; + /** + * Callback fired if the 'Approve' button is clicked. + * + * @memberof IRequestDialog + */ + onApprove: (values: any) => void; +} + +const RequestDialog: React.FC = (props) => { + if (!props.open) { + return <>; + } + + return ( + + { + props.onApprove(values); + }}> + {(formikProps) => ( + + {props.dialogTitle} + + {props.component.element} + + + + + + + + + + )} + + + ); +}; + +export default RequestDialog; diff --git a/app/src/constants/i18n.ts b/app/src/constants/i18n.ts index 14cd03b624..1032088f7e 100644 --- a/app/src/constants/i18n.ts +++ b/app/src/constants/i18n.ts @@ -104,3 +104,9 @@ export const AccessRequestI18N = { requestErrorText: 'An error has occurred while attempting to make an access request, please try again. If the error persists, please contact your system administrator.' }; + +export const ReviewAccessRequestI18N = { + reviewErrorTitle: 'Error reviewing access request', + reviewErrorText: + 'An error has occurred while attempting to review this access request, please try again. If the error persists, please contact your system administrator.' +}; diff --git a/app/src/features/admin/users/AccessRequestList.tsx b/app/src/features/admin/users/AccessRequestList.tsx index b685d8d151..daa8a28d2d 100644 --- a/app/src/features/admin/users/AccessRequestList.tsx +++ b/app/src/features/admin/users/AccessRequestList.tsx @@ -1,6 +1,7 @@ import Box from '@material-ui/core/Box'; import Button from '@material-ui/core/Button'; import Chip from '@material-ui/core/Chip'; +import CircularProgress from '@material-ui/core/CircularProgress'; import Paper from '@material-ui/core/Paper'; import { Theme } from '@material-ui/core/styles/createMuiTheme'; import makeStyles from '@material-ui/core/styles/makeStyles'; @@ -12,11 +13,20 @@ import TableHead from '@material-ui/core/TableHead'; import TableRow from '@material-ui/core/TableRow'; import Typography from '@material-ui/core/Typography'; import clsx from 'clsx'; +import { ErrorDialog, IErrorDialogProps } from 'components/dialog/ErrorDialog'; +import RequestDialog from 'components/dialog/RequestDialog'; import { DATE_FORMAT } from 'constants/dateFormats'; +import { ReviewAccessRequestI18N } from 'constants/i18n'; import { useBiohubApi } from 'hooks/useBioHubApi'; -import { GetAccessRequestListItem, IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; +import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; +import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; import React, { useEffect, useState } from 'react'; import { getFormattedDate } from 'utils/Utils'; +import ReviewAccessRequestForm, { + IReviewAccessRequestForm, + ReviewAccessRequestFormInitialValues, + ReviewAccessRequestFormYupSchema +} from './ReviewAccessRequestForm'; export enum administrativeActivityStatus { PENDING = 'pending', @@ -58,92 +68,208 @@ const AccessRequestList: React.FC = () => { const biohubApi = useBiohubApi(); const [accessRequests, setAccessRequests] = useState([]); + const [isLoadingAccessRequests, setIsLoadingAccessRequests] = useState(false); + const [hasLoadedAccessRequests, setHasLoadedAccessRequests] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [hasLoaded, setHasLoaded] = useState(false); + const [codes, setCodes] = useState(); + const approvedCodeId = codes?.administrative_activity_status_type.find((item) => item.name === 'Actioned')?.id as any; + const rejectedCodeId = codes?.administrative_activity_status_type.find((item) => item.name === 'Rejected')?.id as any; + + const [isLoadingCodes, setIsLoadingCodes] = useState(false); + + const [activeReviewDialog, setActiveReviewDialog] = useState<{ + open: boolean; + request: IGetAccessRequestsListResponse | any; + }>({ + open: false, + request: null + }); + + const [openErrorDialogProps, setOpenErrorDialogProps] = useState({ + dialogTitle: ReviewAccessRequestI18N.reviewErrorTitle, + dialogText: ReviewAccessRequestI18N.reviewErrorText, + open: false, + onClose: () => { + setOpenErrorDialogProps({ ...openErrorDialogProps, open: false }); + }, + onOk: () => { + setOpenErrorDialogProps({ ...openErrorDialogProps, open: false }); + } + }); useEffect(() => { const getAccessRequests = async () => { - const accessRequestResponse = await biohubApi.admin.getAccessRequests(); + const accessResponse = await biohubApi.admin.getAccessRequests(); setAccessRequests(() => { - setHasLoaded(true); - setIsLoading(false); - return accessRequestResponse; + setHasLoadedAccessRequests(true); + setIsLoadingAccessRequests(false); + return accessResponse; }); }; - if (hasLoaded || isLoading) { + if (isLoadingAccessRequests || hasLoadedAccessRequests) { return; } - setIsLoading(true); + setIsLoadingAccessRequests(true); getAccessRequests(); - }, [biohubApi, isLoading, hasLoaded]); + }, [biohubApi.admin, isLoadingAccessRequests, hasLoadedAccessRequests]); + + useEffect(() => { + const getCodes = async () => { + const codesResponse = await biohubApi.codes.getAllCodeSets(); + + if (!codesResponse) { + // TODO error handling/messaging + return; + } + + setCodes(() => { + setIsLoadingCodes(false); + return codesResponse; + }); + }; + + if (isLoadingCodes || codes) { + return; + } + + setIsLoadingCodes(true); + + getCodes(); + }, [biohubApi.codes, isLoadingCodes, codes]); + + const handleReviewDialogApprove = async (values: IReviewAccessRequestForm) => { + const updatedRequest = activeReviewDialog.request as IGetAccessRequestsListResponse; + + setActiveReviewDialog({ open: false, request: null }); + + try { + await biohubApi.admin.updateAccessRequest( + updatedRequest.data.username, + updatedRequest.data.identitySource, + updatedRequest.id, + approvedCodeId, + values.system_roles + ); + } catch (error) { + setOpenErrorDialogProps({ ...openErrorDialogProps, open: true, dialogErrorDetails: error }); + } + }; + + const handleReviewDialogDeny = async () => { + const updatedRequest = activeReviewDialog.request; + + setActiveReviewDialog({ open: false, request: null }); + + try { + await biohubApi.admin.updateAccessRequest( + updatedRequest.data.username, + updatedRequest.data.identitySource, + updatedRequest.id, + rejectedCodeId + ); + } catch (error) { + setOpenErrorDialogProps({ ...openErrorDialogProps, open: true, dialogErrorDetails: error }); + } + }; + + if (!hasLoadedAccessRequests || !codes) { + return ; + } return ( - - - Access Requests ({accessRequests?.length || 0}) - - - - - - Name - Username - Company - Regional Offices - Request Date - Status - - - - - {!accessRequests?.length && ( - - - No Access Requests - + <> + + setActiveReviewDialog({ open: false, request: null })} + onDeny={handleReviewDialogDeny} + onApprove={handleReviewDialogApprove} + component={{ + initialValues: ReviewAccessRequestFormInitialValues, + validationSchema: ReviewAccessRequestFormYupSchema, + element: ( + { + return { value: item.id, label: item.name }; + }) || [] + } + /> + ) + }} + /> + + + Access Requests ({accessRequests?.length || 0}) + + +
+ + + Name + Username + Company + Regional Offices + Request Date + Status + - )} - {accessRequests?.map((row, index) => { - const accessItem = new GetAccessRequestListItem(row); - - return ( - - {accessItem.name || 'Not Applicable'} - {accessItem.username || 'Not Applicable'} - {accessItem.company || 'Not Applicable'} - {accessItem.regional_offices.join(', ') || 'Not Applicable'} - {getFormattedDate(DATE_FORMAT.MediumDateFormat2, accessItem.create_date)} - - - - - - + + + {!accessRequests?.length && ( + + + No Access Requests - ); - })} - -
-
-
+ )} + {accessRequests?.map((row, index) => { + return ( + + {row.data.name || 'Not Applicable'} + {row.data.username || 'Not Applicable'} + {row.data.company || 'Not Applicable'} + {row.data.regional_offices.join(', ') || 'Not Applicable'} + {getFormattedDate(DATE_FORMAT.MediumDateFormat2, row.create_date)} + + + + + + {row.status_name === 'Pending' && ( + + )} + + + ); + })} + + + + + ); }; diff --git a/app/src/features/admin/users/ReviewAccessRequestForm.tsx b/app/src/features/admin/users/ReviewAccessRequestForm.tsx new file mode 100644 index 0000000000..a1f0121b6d --- /dev/null +++ b/app/src/features/admin/users/ReviewAccessRequestForm.tsx @@ -0,0 +1,119 @@ +import Box from '@material-ui/core/Box'; +import Grid from '@material-ui/core/Grid'; +import Typography from '@material-ui/core/Typography'; +import MultiAutocompleteFieldVariableSize, { + IMultiAutocompleteFieldOption +} from 'components/fields/MultiAutocompleteFieldVariableSize'; +import { DATE_FORMAT } from 'constants/dateFormats'; +import { useFormikContext } from 'formik'; +import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; +import React from 'react'; +import { getFormattedDate } from 'utils/Utils'; +import yup from 'utils/YupSchema'; + +export interface IReviewAccessRequestForm { + system_roles: number[]; +} + +export const ReviewAccessRequestFormInitialValues: IReviewAccessRequestForm = { + system_roles: [] +}; + +export const ReviewAccessRequestFormYupSchema = yup.object().shape({ + system_roles: yup.array().min(1).required('Required') +}); + +export interface IReviewAccessRequestFormProps { + request: IGetAccessRequestsListResponse; + system_roles: IMultiAutocompleteFieldOption[]; +} + +/** + * Component to review system access requests. + * + * @return {*} + */ +const ReviewAccessRequestForm: React.FC = (props) => { + const { handleSubmit } = useFormikContext(); + + return ( + + + + User Details + +
+ + + + Name + + + {props.request.data.name} + + + + + Username + + + {props.request.data.username} + + + + + Email Address + + + TODO Placeholder + + + + + Regional Offices + + + {props.request.data.regional_offices.join(', ')} + + + + + Request Date + + + {getFormattedDate(DATE_FORMAT.ShortDateFormatMonthFirst, props.request.create_date)} + + + + + Additional Comments + + + {props.request.notes} + + + +
+
+ + + Review / Update Role + +
+ + + + + +
+
+
+ ); +}; + +export default ReviewAccessRequestForm; diff --git a/app/src/features/projects/CreateProjectPage.tsx b/app/src/features/projects/CreateProjectPage.tsx index 898aa356f0..6d7679c0ed 100644 --- a/app/src/features/projects/CreateProjectPage.tsx +++ b/app/src/features/projects/CreateProjectPage.tsx @@ -622,7 +622,7 @@ const CreateProjectPage: React.FC = () => { }; if (!stepForms.length) { - return ; + return ; } const handleLocationChange = (location: History.Location, action: History.Action) => { @@ -653,7 +653,9 @@ const CreateProjectPage: React.FC = () => { open={openDraftDialog} component={{ element: , - initialValues: ProjectDraftFormInitialValues, + initialValues: { + draft_name: stepForms[2].stepValues.project_name || ProjectDraftFormInitialValues.draft_name + }, validationSchema: ProjectDraftFormYupSchema }} onCancel={() => setOpenDraftDialog(false)} diff --git a/app/src/features/projects/view/ProjectPage.tsx b/app/src/features/projects/view/ProjectPage.tsx index e10f3634d2..9e1b0a899f 100644 --- a/app/src/features/projects/view/ProjectPage.tsx +++ b/app/src/features/projects/view/ProjectPage.tsx @@ -77,30 +77,28 @@ const ProjectPage: React.FC = () => { getCodes(); setIsLoadingCodes(true); } - }, [urlParams, biohubApi, isLoadingCodes, codes]); + }, [urlParams, biohubApi.codes, isLoadingCodes, codes]); const getProject = useCallback(async () => { - const codesResponse = await biohubApi.codes.getAllCodeSets(); const projectWithDetailsResponse = await biohubApi.project.getProjectForView(urlParams['id']); - if (!projectWithDetailsResponse || !codesResponse) { + if (!projectWithDetailsResponse) { // TODO error handling/messaging return; } setProjectWithDetails(projectWithDetailsResponse); - setCodes(codesResponse); - }, [biohubApi.codes, biohubApi.project, urlParams]); + }, [biohubApi.project, urlParams]); useEffect(() => { if (!isLoadingProject && !projectWithDetails) { getProject(); setIsLoadingProject(true); } - }, [urlParams, biohubApi, isLoadingProject, projectWithDetails, getProject]); + }, [isLoadingProject, projectWithDetails, getProject]); if (!codes || !projectWithDetails) { - return ; + return ; } return ( diff --git a/app/src/hooks/api/useAdminApi.ts b/app/src/hooks/api/useAdminApi.ts index 0f4c6b93be..5f42ed3829 100644 --- a/app/src/hooks/api/useAdminApi.ts +++ b/app/src/hooks/api/useAdminApi.ts @@ -1,5 +1,6 @@ import { AxiosInstance } from 'axios'; import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; +import { IAccessRequestResponse } from 'interfaces/useAdministrativeActivityApi.interface'; import qs from 'qs'; /** @@ -26,8 +27,114 @@ const useAdminApi = (axios: AxiosInstance) => { return data; }; + /** + * Update a user access request + * + * @param {string} userIdentifier + * @param {string} identitySource + * @param {number} requestId + * @param {string} requestStatusTypeId + * @param {number[]} [roleIds=[]] + * @returns {*} {Promise} + */ + const updateAccessRequest = async ( + userIdentifier: string, + identitySource: string, + requestId: number, + requestStatusTypeId: number, + roleIds: number[] = [] + ): Promise => { + const { data } = await axios.put(`/api/access-request`, { + userIdentifier, + identitySource, + requestId, + requestStatusTypeId, + roleIds: roleIds + }); + + return data; + }; + + /** + * Update an administrative activity + * + * @param {AxiosInstance} axios + * @returns {*} {Promise} + */ + const updateAdministrativeActivity = async ( + administrativeActivityId: number, + administrativeActivityStatusTypeId: number + ): Promise => { + const { data } = await axios.put(`/api/administrative-activity`, { + id: administrativeActivityId, + status: administrativeActivityStatusTypeId + }); + + return data; + }; + + /** + * Create a new access request record. + * + * @param {unknown} administrativeActivityData + * @return {*} {Promise} + */ + const createAdministrativeActivity = async (administrativeActivityData: unknown): Promise => { + const { data } = await axios.post('/api/administrative-activity', administrativeActivityData); + + return data; + }; + + /** + * Has pending access requests. + * + * @return {*} {Promise} + */ + const hasPendingAdministrativeActivities = async (): Promise => { + const { data } = await axios.get('/api/administrative-activity'); + + return data; + }; + + /** + * Grant one or more system roles to a user. + * + * @param {number} userId + * @param {number[]} roleIds + * @return {*} {Promise} + */ + const addSystemUserRoles = async (userId: number, roleIds: number[]): Promise => { + const { data } = await axios.post(`/api/user/${userId}/system-roles`, { roles: roleIds }); + + return data; + }; + + /** + * Remove one or more system roles from a user. + * + * @param {number} userId + * @param {number[]} roleIds + * @return {*} {Promise} + */ + const removeSystemUserRoles = async (userId: number, roleIds: number[]): Promise => { + const { data } = await axios.delete(`/api/user/${userId}/system-roles`, { + params: { role: roleIds }, + paramsSerializer: (params) => { + return qs.stringify(params); + } + }); + + return data; + }; + return { - getAccessRequests + getAccessRequests, + updateAccessRequest, + updateAdministrativeActivity, + createAdministrativeActivity, + hasPendingAdministrativeActivities, + addSystemUserRoles, + removeSystemUserRoles }; }; diff --git a/app/src/hooks/api/useAdministrativeActivityApi.ts b/app/src/hooks/api/useAdministrativeActivityApi.ts deleted file mode 100644 index 883c8881fe..0000000000 --- a/app/src/hooks/api/useAdministrativeActivityApi.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { AxiosInstance } from 'axios'; -import { IAccessRequestResponse } from 'interfaces/useAdministrativeActivityApi.interface'; - -/** - * Returns a set of supported api methods for working with access requests. - * - * @param {AxiosInstance} axios - * @return {*} object whose properties are supported api methods. - */ -const useAdministrativeActivityApi = (axios: AxiosInstance) => { - /** - * Create a new access request record. - * - * @param {unknown} administrativeActivityData - * @return {*} {Promise} - */ - const createAdministrativeActivity = async (administrativeActivityData: unknown): Promise => { - const { data } = await axios.post('/api/administrative-activity', administrativeActivityData); - - return data; - }; - - /** - * Has pending access requests. - * - * @return {*} {Promise} - */ - const hasPendingAdministrativeActivities = async (): Promise => { - const { data } = await axios.get('/api/administrative-activity'); - - return data; - }; - - return { - createAdministrativeActivity, - hasPendingAdministrativeActivities - }; -}; - -export default useAdministrativeActivityApi; diff --git a/app/src/hooks/api/useUserApi.ts b/app/src/hooks/api/useUserApi.ts index b9ac93cc6e..a9c8e9fc81 100644 --- a/app/src/hooks/api/useUserApi.ts +++ b/app/src/hooks/api/useUserApi.ts @@ -14,7 +14,7 @@ const useUserApi = (axios: AxiosInstance) => { * @return {*} {Promise} */ const getUser = async (): Promise => { - const { data } = await axios.get('/api/user'); + const { data } = await axios.get('/api/user/self'); return data; }; diff --git a/app/src/hooks/useBioHubApi.ts b/app/src/hooks/useBioHubApi.ts index ebda7d6a2c..a97b3c90af 100644 --- a/app/src/hooks/useBioHubApi.ts +++ b/app/src/hooks/useBioHubApi.ts @@ -2,7 +2,6 @@ import useAxios from './api/useAxios'; import useProjectApi from './api/useProjectApi'; import useCodesApi from './api/useCodesApi'; import useDraftApi from './api/useDraftApi'; -import useAdministrativeActivityApi from './api/useAdministrativeActivityApi'; import useUserApi from './api/useUserApi'; import useAdminApi from './api/useAdminApi'; @@ -20,8 +19,6 @@ export const useBiohubApi = () => { const draft = useDraftApi(axios); - const accessRequest = useAdministrativeActivityApi(axios); - const user = useUserApi(axios); const admin = useAdminApi(axios); @@ -30,7 +27,6 @@ export const useBiohubApi = () => { project, codes, draft, - accessRequest, user, admin }; diff --git a/app/src/hooks/useKeycloakWrapper.test.tsx b/app/src/hooks/useKeycloakWrapper.test.tsx new file mode 100644 index 0000000000..d723f06683 --- /dev/null +++ b/app/src/hooks/useKeycloakWrapper.test.tsx @@ -0,0 +1,26 @@ +import { cleanup } from '@testing-library/react'; +import { useBiohubApi } from 'hooks/useBioHubApi'; + +jest.mock('./useBioHubApi'); +const mockUseBiohubApi = { + user: { + getUser: jest.fn() + } +}; + +const mockBiohubApi = ((useBiohubApi as unknown) as jest.Mock).mockReturnValue( + mockUseBiohubApi +); + +describe('AttachmentsList', () => { + beforeEach(() => { + // clear mocks before each test + mockBiohubApi().user.getUser.mockClear(); + }); + + afterEach(() => { + cleanup(); + }); + + it('', () => {}); +}); diff --git a/app/src/hooks/useKeycloakWrapper.tsx b/app/src/hooks/useKeycloakWrapper.tsx index b9ffdb1543..ad744192d9 100644 --- a/app/src/hooks/useKeycloakWrapper.tsx +++ b/app/src/hooks/useKeycloakWrapper.tsx @@ -39,7 +39,18 @@ export interface IKeycloakWrapper { hasSystemRole: (validSystemRoles?: string[]) => boolean; hasAccessRequest: boolean; + /** + * Get out the username portion of the preferred_username from the token. + * + * @memberof IKeycloakWrapper + */ getUserIdentifier: () => string | null; + /** + * Get the identity source portion of the preferred_username from the token. + * + * @memberof IKeycloakWrapper + */ + getIdentitySource: () => string | null; } /** @@ -63,7 +74,7 @@ function useKeycloakWrapper(): IKeycloakWrapper { const [shouldLoadAccessRequest, setShouldLoadAccessRequest] = useState(false); /** - * Parses out the preferred_username name from the token. + * Parses out the username portion of the preferred_username from the token. * * @param {object} keycloakToken * @return {*} {(string | null)} @@ -78,6 +89,22 @@ function useKeycloakWrapper(): IKeycloakWrapper { return userIdentifier; }, [keycloakUserInfo]); + /** + * Parses out the identity source portion of the preferred_username from the token. + * + * @param {object} keycloakToken + * @return {*} {(string | null)} + */ + const getIdentitySource = useCallback((): string | null => { + const identitySource = keycloakUserInfo?.['preferred_username']?.split('@')?.[1]; + + if (!identitySource) { + return null; + } + + return identitySource; + }, [keycloakUserInfo]); + useEffect(() => { const getUser = async () => { let userDetails: IGetUserResponse; @@ -109,7 +136,7 @@ function useKeycloakWrapper(): IKeycloakWrapper { useEffect(() => { const getSystemAccessRequest = async () => { - const accessRequests = await biohubApi.accessRequest.hasPendingAdministrativeActivities(); + const accessRequests = await biohubApi.admin.hasPendingAdministrativeActivities(); setHasAccessRequest(() => { setHasLoadedUserRelevantInfo(true); @@ -122,14 +149,7 @@ function useKeycloakWrapper(): IKeycloakWrapper { } getSystemAccessRequest(); - }, [ - biohubApi.accessRequest, - biohubApi.admin, - getUserIdentifier, - hasAccessRequest, - keycloakUserInfo, - shouldLoadAccessRequest - ]); + }, [biohubApi.admin, getUserIdentifier, hasAccessRequest, keycloakUserInfo, shouldLoadAccessRequest]); useEffect(() => { const loadUserInfo = async () => { @@ -169,7 +189,8 @@ function useKeycloakWrapper(): IKeycloakWrapper { systemRoles: getSystemRoles(), hasSystemRole, hasAccessRequest, - getUserIdentifier + getUserIdentifier, + getIdentitySource }; } diff --git a/app/src/interfaces/useAdminApi.interface.ts b/app/src/interfaces/useAdminApi.interface.ts index ebf3415a31..9538cca960 100644 --- a/app/src/interfaces/useAdminApi.interface.ts +++ b/app/src/interfaces/useAdminApi.interface.ts @@ -1,11 +1,20 @@ +export interface IAccessRequestDataObject { + name: string; + username: string; + identitySource: string; + company: string; + regional_offices: string[]; +} + export interface IGetAccessRequestsListResponse { id: number; status: number; status_name: string; description: string; notes: string; - data: string; create_date: string; + + data: IAccessRequestDataObject; } /** @@ -21,14 +30,9 @@ export class GetAccessRequestListItem implements IGetAccessRequestsListResponse status_name: string; description: string; notes: string; - data: string; create_date: string; - // Fields parsed from `data` - name: string; - username: string; - company: string; - regional_offices: string[]; + data: IAccessRequestDataObject; constructor(obj?: any) { this.id = obj?.id || null; @@ -36,14 +40,14 @@ export class GetAccessRequestListItem implements IGetAccessRequestsListResponse this.status_name = obj?.status_name || null; this.description = obj?.description || null; this.notes = obj?.notes || null; - this.data = obj?.data || null; this.create_date = obj?.create_date || null; - const dataObject = (this.data && JSON.parse(this.data)) || null; - - this.name = dataObject?.name || null; - this.username = dataObject?.username || null; - this.company = dataObject?.company || null; - this.regional_offices = (dataObject?.regional_offices?.length && dataObject.regional_offices) || []; + this.data = obj?.data && { + name: obj.data?.name || null, + username: obj.data?.username || null, + identitySource: obj.data?.identitySource || null, + company: obj.data?.company || null, + regional_offices: (obj.data?.regional_offices?.length && obj.data.regional_offices) || [] + }; } } diff --git a/app/src/interfaces/useCodesApi.interface.ts b/app/src/interfaces/useCodesApi.interface.ts index db37da28d2..933b0d519c 100644 --- a/app/src/interfaces/useCodesApi.interface.ts +++ b/app/src/interfaces/useCodesApi.interface.ts @@ -37,4 +37,5 @@ export interface IGetAllCodeSetsResponse { iucn_conservation_action_level_3_subclassification: CodeSet<{ id: number; iucn2_id: number; name: string }>; system_roles: CodeSet; regional_offices: CodeSet; + administrative_activity_status_type: CodeSet; } diff --git a/app/src/layouts/AuthLayout.tsx b/app/src/layouts/AuthLayout.tsx index 9b54c5ba7a..f97b92dae9 100644 --- a/app/src/layouts/AuthLayout.tsx +++ b/app/src/layouts/AuthLayout.tsx @@ -8,7 +8,7 @@ const AuthLayout: React.FC = (props) => { {(context) => { if (!context.ready) { - return ; + return ; } return {props.children}; diff --git a/app/src/pages/access/AccessRequestPage.tsx b/app/src/pages/access/AccessRequestPage.tsx index 148bfba1d5..727252cf85 100644 --- a/app/src/pages/access/AccessRequestPage.tsx +++ b/app/src/pages/access/AccessRequestPage.tsx @@ -149,9 +149,10 @@ export const AccessRequestPage: React.FC = () => { const handleSubmitAccessRequest = async (values: IAccessRequestForm) => { try { - const response = await biohubApi.accessRequest.createAdministrativeActivity({ + const response = await biohubApi.admin.createAdministrativeActivity({ ...values, - username: keycloakWrapper?.getUserIdentifier() + username: keycloakWrapper?.getUserIdentifier(), + identitySource: keycloakWrapper?.getIdentitySource() }); if (!response?.id) { diff --git a/app/src/test-helpers/code-helpers.ts b/app/src/test-helpers/code-helpers.ts index b823bbc1a7..92b5692d8d 100644 --- a/app/src/test-helpers/code-helpers.ts +++ b/app/src/test-helpers/code-helpers.ts @@ -15,5 +15,6 @@ export const codes: IGetAllCodeSetsResponse = { iucn_conservation_action_level_2_subclassification: [{ id: 1, iucn1_id: 1, name: 'IUCN subclass 1' }], iucn_conservation_action_level_3_subclassification: [{ id: 1, iucn2_id: 1, name: 'IUCN subclass 2' }], system_roles: [{ id: 1, name: 'Role 1' }], - regional_offices: [{ id: 1, name: 'Office 1' }] + regional_offices: [{ id: 1, name: 'Office 1' }], + administrative_activity_status_type: [{ id: 1, name: 'Pending' }] }; From a45180cb4cbacd1f8d8397ea676499c16038be51 Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Fri, 23 Apr 2021 18:32:01 -0700 Subject: [PATCH 02/12] - Update tests - Move useAdministrativeActivityApi interfaces into useAdminApi interfaces --- api/src/queries/users/user-queries.ts | 6 +- .../admin/users/AccessRequestList.test.tsx | 43 ++++++--- .../admin/users/AccessRequestList.tsx | 8 +- .../admin/users/ManageUsersPage.test.tsx | 13 +++ app/src/hooks/api/useAdminApi.ts | 2 +- app/src/hooks/api/useUserApi.test.ts | 2 +- .../interfaces/useAdminApi.interface.test.ts | 96 ++++++++++--------- app/src/interfaces/useAdminApi.interface.ts | 23 +++-- .../useAdministrativeActivityApi.interface.ts | 10 -- .../pages/access/AccessRequestPage.test.tsx | 3 +- 10 files changed, 122 insertions(+), 84 deletions(-) delete mode 100644 app/src/interfaces/useAdministrativeActivityApi.interface.ts diff --git a/api/src/queries/users/user-queries.ts b/api/src/queries/users/user-queries.ts index 6f97fbd3ce..b31b551962 100644 --- a/api/src/queries/users/user-queries.ts +++ b/api/src/queries/users/user-queries.ts @@ -157,7 +157,11 @@ export const addSystemUserSQL = (userIdentifier: string, identitySource: string) Select id FROM user_identity_source WHERE name = ${identitySource}, ${userIdentifier}, now() - ); + ) + RETURNING + uis_id, + user_identifier, + record_effective_date; `; defaultLog.debug({ diff --git a/app/src/features/admin/users/AccessRequestList.test.tsx b/app/src/features/admin/users/AccessRequestList.test.tsx index 793ae4fceb..db1ef43ddf 100644 --- a/app/src/features/admin/users/AccessRequestList.test.tsx +++ b/app/src/features/admin/users/AccessRequestList.test.tsx @@ -11,6 +11,9 @@ jest.mock('../../../hooks/useBioHubApi'); const mockUseBiohubApi = { admin: { getAccessRequests: jest.fn() + }, + codes: { + getAllCodeSets: jest.fn() } }; @@ -22,6 +25,16 @@ describe('AccessRequestList', () => { beforeEach(() => { // clear mocks before each test mockBiohubApi().admin.getAccessRequests.mockClear(); + mockBiohubApi().codes.getAllCodeSets.mockClear(); + + // mock code set response + mockBiohubApi().codes.getAllCodeSets.mockReturnValue({ + system_roles: [{ id: 1, name: 'Role 1' }], + administrative_activity_status_type: [ + { id: 1, name: 'Actioned' }, + { id: 1, name: 'Rejected' } + ] + }); }); afterEach(() => { @@ -46,12 +59,13 @@ describe('AccessRequestList', () => { status_name: 'Pending', description: 'test description', notes: 'test notes', - data: JSON.stringify({ + data: { name: 'test user', username: 'testusername', + identitySource: 'idir', company: 'test company', regional_offices: ['office 1', 'office 2'] - }), + }, create_date: '2020-04-20' } ]); @@ -76,17 +90,18 @@ describe('AccessRequestList', () => { status_name: 'Rejected', description: 'test description', notes: 'test notes', - data: JSON.stringify({ + data: { name: 'test user', username: 'testusername', + identitySource: 'idir', company: 'test company', regional_offices: ['office 1', 'office 2'] - }), + }, create_date: '2020-04-20' } ]); - const { getByText, getByRole } = renderContainer(); + const { getByText, queryByRole } = renderContainer(); await waitFor(() => { expect(getByText('test user')).toBeVisible(); @@ -94,7 +109,7 @@ describe('AccessRequestList', () => { expect(getByText('test company')).toBeVisible(); expect(getByText('April-20-2020')).toBeVisible(); expect(getByText('Rejected')).toBeVisible(); - expect(getByRole('button')).toHaveTextContent('Review'); + expect(queryByRole('button')).not.toBeInTheDocument(); }); }); @@ -106,17 +121,18 @@ describe('AccessRequestList', () => { status_name: 'Actioned', description: 'test description', notes: 'test notes', - data: JSON.stringify({ + data: { name: 'test user', username: 'testusername', + identitySource: 'idir', company: 'test company', regional_offices: ['office 1', 'office 2'] - }), + }, create_date: '2020-04-20' } ]); - const { getByText, getByRole } = renderContainer(); + const { getByText, queryByRole } = renderContainer(); await waitFor(() => { expect(getByText('test user')).toBeVisible(); @@ -124,11 +140,11 @@ describe('AccessRequestList', () => { expect(getByText('test company')).toBeVisible(); expect(getByText('April-20-2020')).toBeVisible(); expect(getByText('Actioned')).toBeVisible(); - expect(getByRole('button')).toHaveTextContent('Review'); + expect(queryByRole('button')).not.toBeInTheDocument(); }); }); - it('shows a table row when the json data is empty', async () => { + it('shows a table row when the data object is null', async () => { mockBiohubApi().admin.getAccessRequests.mockReturnValue([ { id: 1, @@ -136,18 +152,17 @@ describe('AccessRequestList', () => { status_name: 'Actioned', description: 'test description', notes: 'test notes', - data: '', + data: null, create_date: '2020-04-20' } ]); - const { getByText, getAllByText, getByRole } = renderContainer(); + const { getByText, getAllByText } = renderContainer(); await waitFor(() => { expect(getAllByText('Not Applicable').length).toEqual(4); expect(getByText('April-20-2020')).toBeVisible(); expect(getByText('Actioned')).toBeVisible(); - expect(getByRole('button')).toHaveTextContent('Review'); }); }); }); diff --git a/app/src/features/admin/users/AccessRequestList.tsx b/app/src/features/admin/users/AccessRequestList.tsx index daa8a28d2d..a05af9ce82 100644 --- a/app/src/features/admin/users/AccessRequestList.tsx +++ b/app/src/features/admin/users/AccessRequestList.tsx @@ -232,10 +232,10 @@ const AccessRequestList: React.FC = () => { {accessRequests?.map((row, index) => { return ( - {row.data.name || 'Not Applicable'} - {row.data.username || 'Not Applicable'} - {row.data.company || 'Not Applicable'} - {row.data.regional_offices.join(', ') || 'Not Applicable'} + {row.data?.name || 'Not Applicable'} + {row.data?.username || 'Not Applicable'} + {row.data?.company || 'Not Applicable'} + {row.data?.regional_offices?.join(', ') || 'Not Applicable'} {getFormattedDate(DATE_FORMAT.MediumDateFormat2, row.create_date)} { // clear mocks before each test mockBiohubApi().admin.getAccessRequests.mockClear(); mockBiohubApi().user.getUsersList.mockClear(); + mockBiohubApi().codes.getAllCodeSets.mockClear(); + + // mock code set response + mockBiohubApi().codes.getAllCodeSets.mockReturnValue({ + system_roles: [{ id: 1, name: 'Role 1' }], + administrative_activity_status_type: [ + { id: 1, name: 'Actioned' }, + { id: 1, name: 'Rejected' } + ] + }); }); afterEach(() => { diff --git a/app/src/hooks/api/useAdminApi.ts b/app/src/hooks/api/useAdminApi.ts index 5f42ed3829..4d5e3ea583 100644 --- a/app/src/hooks/api/useAdminApi.ts +++ b/app/src/hooks/api/useAdminApi.ts @@ -1,6 +1,6 @@ import { AxiosInstance } from 'axios'; import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; -import { IAccessRequestResponse } from 'interfaces/useAdministrativeActivityApi.interface'; +import { IAccessRequestResponse } from 'interfaces/useAdminApi.interface'; import qs from 'qs'; /** diff --git a/app/src/hooks/api/useUserApi.test.ts b/app/src/hooks/api/useUserApi.test.ts index 496c683762..2dc073388c 100644 --- a/app/src/hooks/api/useUserApi.test.ts +++ b/app/src/hooks/api/useUserApi.test.ts @@ -14,7 +14,7 @@ describe('useUserApi', () => { }); it('getUser works as expected', async () => { - mock.onGet('/api/user').reply(200, { + mock.onGet('/api/user/self').reply(200, { id: 1, user_identifier: 'myidirboss', role_names: ['role 1', 'role 2'] diff --git a/app/src/interfaces/useAdminApi.interface.test.ts b/app/src/interfaces/useAdminApi.interface.test.ts index 3c77c84e8c..96a7fac56e 100644 --- a/app/src/interfaces/useAdminApi.interface.test.ts +++ b/app/src/interfaces/useAdminApi.interface.test.ts @@ -28,28 +28,28 @@ describe('GetAccessRequestListItem', () => { expect(data.notes).toEqual(null); }); - it('set data', () => { - expect(data.data).toEqual(null); - }); - it('set create_date', () => { expect(data.create_date).toEqual(null); }); it('set name', () => { - expect(data.name).toEqual(null); + expect(data.data.name).toEqual(null); }); it('set username', () => { - expect(data.username).toEqual(null); + expect(data.data.username).toEqual(null); + }); + + it('set identitySource', () => { + expect(data.data.identitySource).toEqual(null); }); it('set company', () => { - expect(data.company).toEqual(null); + expect(data.data.company).toEqual(null); }); it('set regional_offices', () => { - expect(data.regional_offices).toEqual([]); + expect(data.data.regional_offices).toEqual([]); }); }); @@ -80,28 +80,28 @@ describe('GetAccessRequestListItem', () => { expect(data.notes).toEqual(null); }); - it('set data', () => { - expect(data.data).toEqual(null); - }); - it('set create_date', () => { expect(data.create_date).toEqual(null); }); it('set name', () => { - expect(data.name).toEqual(null); + expect(data.data.name).toEqual(null); }); it('set username', () => { - expect(data.username).toEqual(null); + expect(data.data.username).toEqual(null); + }); + + it('set identitySource', () => { + expect(data.data.identitySource).toEqual(null); }); it('set company', () => { - expect(data.company).toEqual(null); + expect(data.data.company).toEqual(null); }); it('set regional_offices', () => { - expect(data.regional_offices).toEqual([]); + expect(data.data.regional_offices).toEqual([]); }); }); @@ -140,28 +140,28 @@ describe('GetAccessRequestListItem', () => { expect(data.notes).toEqual('test notes'); }); - it('set data', () => { - expect(data.data).toEqual(null); - }); - it('set create_date', () => { expect(data.create_date).toEqual('2020-04-20'); }); it('set name', () => { - expect(data.name).toEqual(null); + expect(data.data.name).toEqual(null); }); it('set username', () => { - expect(data.username).toEqual(null); + expect(data.data.username).toEqual(null); + }); + + it('set identitySource', () => { + expect(data.data.identitySource).toEqual(null); }); it('set company', () => { - expect(data.company).toEqual(null); + expect(data.data.company).toEqual(null); }); it('set regional_offices', () => { - expect(data.regional_offices).toEqual([]); + expect(data.data.regional_offices).toEqual([]); }); }); @@ -200,8 +200,8 @@ describe('GetAccessRequestListItem', () => { expect(data.notes).toEqual('test notes'); }); - it('set data', () => { - expect(data.data).toEqual(null); + it('set create_date', () => { + expect(data.create_date).toEqual('2020-04-20'); }); it('set create_date', () => { @@ -209,42 +209,42 @@ describe('GetAccessRequestListItem', () => { }); it('set name', () => { - expect(data.name).toEqual(null); + expect(data.data.name).toEqual(null); }); it('set username', () => { - expect(data.username).toEqual(null); + expect(data.data.username).toEqual(null); + }); + + it('set identitySource', () => { + expect(data.data.identitySource).toEqual(null); }); it('set company', () => { - expect(data.company).toEqual(null); + expect(data.data.company).toEqual(null); }); it('set regional_offices', () => { - expect(data.regional_offices).toEqual([]); + expect(data.data.regional_offices).toEqual([]); }); }); describe('Valid obj provided with valid data', () => { let data: GetAccessRequestListItem; - let jsonDataString: string; - beforeAll(() => { - jsonDataString = JSON.stringify({ - name: 'test name', - username: 'test username', - company: 'test company', - regional_offices: ['office 1', 'office 2'] - }); - data = new GetAccessRequestListItem({ id: 1, status: 2, status_name: 'Rejected', description: 'test description', notes: 'test notes', - data: jsonDataString, + data: { + name: 'test name', + username: 'test username', + company: 'test company', + regional_offices: ['office 1', 'office 2'] + }, create_date: '2020-04-20' }); }); @@ -269,8 +269,8 @@ describe('GetAccessRequestListItem', () => { expect(data.notes).toEqual('test notes'); }); - it('set data', () => { - expect(data.data).toEqual(jsonDataString); + it('set create_date', () => { + expect(data.create_date).toEqual('2020-04-20'); }); it('set create_date', () => { @@ -278,19 +278,23 @@ describe('GetAccessRequestListItem', () => { }); it('set name', () => { - expect(data.name).toEqual('test name'); + expect(data.data.name).toEqual('test name'); }); it('set username', () => { - expect(data.username).toEqual('test username'); + expect(data.data.username).toEqual('test username'); + }); + + it('set identitySource', () => { + expect(data.data.identitySource).toEqual(null); }); it('set company', () => { - expect(data.company).toEqual('test company'); + expect(data.data.company).toEqual('test company'); }); it('set regional_offices', () => { - expect(data.regional_offices).toEqual(['office 1', 'office 2']); + expect(data.data.regional_offices).toEqual(['office 1', 'office 2']); }); }); }); diff --git a/app/src/interfaces/useAdminApi.interface.ts b/app/src/interfaces/useAdminApi.interface.ts index 9538cca960..dcff3ed97e 100644 --- a/app/src/interfaces/useAdminApi.interface.ts +++ b/app/src/interfaces/useAdminApi.interface.ts @@ -42,12 +42,23 @@ export class GetAccessRequestListItem implements IGetAccessRequestsListResponse this.notes = obj?.notes || null; this.create_date = obj?.create_date || null; - this.data = obj?.data && { - name: obj.data?.name || null, - username: obj.data?.username || null, - identitySource: obj.data?.identitySource || null, - company: obj.data?.company || null, - regional_offices: (obj.data?.regional_offices?.length && obj.data.regional_offices) || [] + this.data = { + name: obj?.data?.name || null, + username: obj?.data?.username || null, + identitySource: obj?.data?.identitySource || null, + company: obj?.data?.company || null, + regional_offices: (obj?.data?.regional_offices?.length && obj.data.regional_offices) || [] }; } } + +/** + * Create/Update draft response object. + * + * @export + * @interface IAccessRequestResponse + */ +export interface IAccessRequestResponse { + id: number; + date: string; +} diff --git a/app/src/interfaces/useAdministrativeActivityApi.interface.ts b/app/src/interfaces/useAdministrativeActivityApi.interface.ts deleted file mode 100644 index 8fd8817eb5..0000000000 --- a/app/src/interfaces/useAdministrativeActivityApi.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Create/Update draft response object. - * - * @export - * @interface IAccessRequestResponse - */ -export interface IAccessRequestResponse { - id: number; - date: string; -} diff --git a/app/src/pages/access/AccessRequestPage.test.tsx b/app/src/pages/access/AccessRequestPage.test.tsx index fe80f2affc..41c0912ef5 100644 --- a/app/src/pages/access/AccessRequestPage.test.tsx +++ b/app/src/pages/access/AccessRequestPage.test.tsx @@ -210,7 +210,8 @@ describe('AccessRequestPage', () => { systemRoles: [], getUserIdentifier: jest.fn(), hasAccessRequest: true, - hasSystemRole: jest.fn() + hasSystemRole: jest.fn(), + getIdentitySource: jest.fn() } }; From 718d1b722aaf3cb541d4cd23d1a549c6b4b008c5 Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Mon, 26 Apr 2021 09:56:48 -0700 Subject: [PATCH 03/12] Misc minor updates --- api/src/paths/administrative-activities.ts | 20 +++++++- .../administrative-activity-queries.ts | 4 +- .../admin/users/AccessRequestList.tsx | 34 +++++++++----- .../interfaces/useAdminApi.interface.test.ts | 46 +++++++++++++++++++ app/src/interfaces/useAdminApi.interface.ts | 6 +++ 5 files changed, 97 insertions(+), 13 deletions(-) diff --git a/api/src/paths/administrative-activities.ts b/api/src/paths/administrative-activities.ts index b5cd6a3a80..791555440a 100644 --- a/api/src/paths/administrative-activities.ts +++ b/api/src/paths/administrative-activities.ts @@ -40,7 +40,24 @@ GET.apiDoc = { type: 'object', properties: { id: { - type: 'number' + type: 'number', + description: 'Administrative activity row ID' + }, + type: { + type: 'number', + description: 'Administrative activity type ID' + }, + type_name: { + type: 'string', + description: 'Administrative activity type name' + }, + status: { + type: 'number', + description: 'Administrative activity status type ID' + }, + status_name: { + type: 'string', + description: 'Administrative activity status type name' }, description: { type: 'string' @@ -50,6 +67,7 @@ GET.apiDoc = { }, data: { type: 'object', + description: 'JSON data blob containing additional information about the activity record', properties: { // Don't specify as this is a JSON blob column } diff --git a/api/src/queries/administrative-activity/administrative-activity-queries.ts b/api/src/queries/administrative-activity/administrative-activity-queries.ts index 6dba1330ea..8be8812de4 100644 --- a/api/src/queries/administrative-activity/administrative-activity-queries.ts +++ b/api/src/queries/administrative-activity/administrative-activity-queries.ts @@ -22,7 +22,9 @@ export const getAdministrativeActivitiesSQL = ( const sqlStatement = SQL` SELECT - aat.id as id, + aa.id as id, + aat.id as type, + aat.name as type_name, aast.id as status, aast.name as status_name, aa.description, diff --git a/app/src/features/admin/users/AccessRequestList.tsx b/app/src/features/admin/users/AccessRequestList.tsx index a05af9ce82..ce93b585cf 100644 --- a/app/src/features/admin/users/AccessRequestList.tsx +++ b/app/src/features/admin/users/AccessRequestList.tsx @@ -29,9 +29,9 @@ import ReviewAccessRequestForm, { } from './ReviewAccessRequestForm'; export enum administrativeActivityStatus { - PENDING = 'pending', - ACTIONED = 'actioned', - REJECTED = 'rejected' + PENDING = 'Pending', + ACTIONED = 'Actioned', + REJECTED = 'Rejected' } const useStyles = makeStyles((theme: Theme) => ({ @@ -72,8 +72,12 @@ const AccessRequestList: React.FC = () => { const [hasLoadedAccessRequests, setHasLoadedAccessRequests] = useState(false); const [codes, setCodes] = useState(); - const approvedCodeId = codes?.administrative_activity_status_type.find((item) => item.name === 'Actioned')?.id as any; - const rejectedCodeId = codes?.administrative_activity_status_type.find((item) => item.name === 'Rejected')?.id as any; + const approvedCodeId = codes?.administrative_activity_status_type.find( + (item) => item.name === administrativeActivityStatus.ACTIONED + )?.id as any; + const rejectedCodeId = codes?.administrative_activity_status_type.find( + (item) => item.name === administrativeActivityStatus.REJECTED + )?.id as any; const [isLoadingCodes, setIsLoadingCodes] = useState(false); @@ -97,6 +101,12 @@ const AccessRequestList: React.FC = () => { } }); + const getAccessRequests = async () => { + const accessResponse = await biohubApi.admin.getAccessRequests(); + + setAccessRequests(accessResponse); + }; + useEffect(() => { const getAccessRequests = async () => { const accessResponse = await biohubApi.admin.getAccessRequests(); @@ -154,13 +164,15 @@ const AccessRequestList: React.FC = () => { approvedCodeId, values.system_roles ); + + await getAccessRequests(); } catch (error) { setOpenErrorDialogProps({ ...openErrorDialogProps, open: true, dialogErrorDetails: error }); } }; const handleReviewDialogDeny = async () => { - const updatedRequest = activeReviewDialog.request; + const updatedRequest = activeReviewDialog.request as IGetAccessRequestsListResponse; setActiveReviewDialog({ open: false, request: null }); @@ -171,6 +183,8 @@ const AccessRequestList: React.FC = () => { updatedRequest.id, rejectedCodeId ); + + await getAccessRequests(); } catch (error) { setOpenErrorDialogProps({ ...openErrorDialogProps, open: true, dialogErrorDetails: error }); } @@ -241,10 +255,8 @@ const AccessRequestList: React.FC = () => { { - {row.status_name === 'Pending' && ( + {row.status_name === administrativeActivityStatus.PENDING && (