diff --git a/api/src/constants/administrative-activity.ts b/api/src/constants/administrative-activity.ts index 4ba33f6b46..b2cd8b0ab5 100644 --- a/api/src/constants/administrative-activity.ts +++ b/api/src/constants/administrative-activity.ts @@ -3,6 +3,7 @@ export enum ADMINISTRATIVE_ACTIVITY_TYPE { } export enum ADMINISTRATIVE_ACTIVITY_STATUS_TYPE { + INVITED = 'Invited', PENDING = 'Pending', ACTIONED = 'Actioned', REJECTED = 'Rejected' diff --git a/api/src/paths/administrative-activity/invited.ts b/api/src/paths/administrative-activity/invited.ts new file mode 100644 index 0000000000..bcf115b0bb --- /dev/null +++ b/api/src/paths/administrative-activity/invited.ts @@ -0,0 +1,140 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../constants/roles'; +import { getDBConnection } from '../../database/db'; +import { HTTP400 } from '../../errors/http-error'; +import { authorizeRequestHandler } from '../../request-handlers/security/authorization'; +import { AdministrativeActivityService } from '../../services/administrative-activity-service'; +import { getLogger } from '../../utils/logger'; + +const defaultLog = getLogger('paths/administrative-activity/invited'); + +export const POST: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + createInvitedAccessRequest() +]; + +POST.apiDoc = { + description: 'Create a new invited Administrative Activity for users invited via email.', + tags: ['admin'], + security: [ + { + Bearer: [] + } + ], + requestBody: { + description: 'Invited Administrative Activity post request object.', + required: true, + content: { + 'application/json': { + schema: { + title: 'Invited Administrative Activity request object', + type: 'object', + additionalProperties: false, + required: ['systemUserId', 'userGuid', 'email'], + properties: { + systemUserId: { type: 'number', description: 'The system user ID for the invited user.' }, + reason: { type: 'string', description: 'Reason for the invitation.' }, + userGuid: { type: 'string', description: 'Unique keycloak GUID for the user.' }, + name: { type: 'string' }, + username: { type: 'string' }, + email: { type: 'string' }, + identitySource: { type: 'string', description: 'Whether the account is an IDIR or BCeID.' }, + company: { type: 'string', description: 'The company that the user belongs to.', nullable: true }, + displayName: { type: 'string' } + } + } + } + } + }, + responses: { + 200: { + description: 'Invited administrative activity post response object.', + content: { + 'application/json': { + schema: { + title: 'Invited Administrative Activity Response Object', + type: 'object', + additionalProperties: false, + required: ['id', 'date'], + properties: { + id: { + type: 'number' + }, + date: { + description: 'The date this administrative activity was made', + type: 'string' + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Creates a new administrative activity for an invited access request. + * + * @returns {RequestHandler} + */ +export function createInvitedAccessRequest(): RequestHandler { + return async (req, res) => { + const systemUserId = req.body.systemUserId; + + if (!systemUserId) { + throw new HTTP400('Missing required systemUserId'); + } + + // Extract user data from request body (excluding systemUserId) + const { systemUserId: _, ...userData } = req.body; + + if (!userData.email) { + throw new HTTP400('Missing required email'); + } + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const administrativeActivityService = new AdministrativeActivityService(connection); + + const response = await administrativeActivityService.createInvitedAccessRequest(systemUserId, userData); + + await connection.commit(); + + return res.status(200).json(response); + } catch (error) { + defaultLog.error({ label: 'createInvitedAccessRequest', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/user/add.ts b/api/src/paths/user/add.ts index 2c60f4457f..0a26182d7b 100644 --- a/api/src/paths/user/add.ts +++ b/api/src/paths/user/add.ts @@ -96,7 +96,23 @@ POST.apiDoc = { }, responses: { 200: { - description: 'Add system user OK.' + description: 'System user created successfully.', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + required: ['system_user_id'], + properties: { + system_user_id: { + type: 'integer', + minimum: 1, + description: 'The ID of the created system user' + } + } + } + } + } }, 400: { $ref: '#/components/responses/400' @@ -180,7 +196,7 @@ export function addSystemRoleUser(): RequestHandler { await connection.commit(); - return res.status(200).send(); + return res.status(200).json({ system_user_id: userObject.system_user_id }); } catch (error) { defaultLog.error({ label: 'addSystemRoleUser', message: 'error', error }); await connection.rollback(); diff --git a/api/src/repositories/administrative-activity-repository.ts b/api/src/repositories/administrative-activity-repository.ts index 684de26399..ae16fa6dca 100644 --- a/api/src/repositories/administrative-activity-repository.ts +++ b/api/src/repositories/administrative-activity-repository.ts @@ -185,6 +185,61 @@ export class AdministrativeActivityRepository extends BaseRepository { return response.rows[0]; } + /** + * Inserts a row in the administrative_activity table reflecting an invited access request. + * Used when users are invited via email to surveys. + * + * @param {number} systemUserId + * @param {(string | object)} data + * @return {*} {Promise} + * @memberof AdministrativeActivityRepository + */ + async createInvitedAccessRequest( + systemUserId: number, + data: string | object + ): Promise { + const sqlStatement = SQL` + INSERT INTO administrative_activity ( + reported_system_user_id, + administrative_activity_type_id, + administrative_activity_status_type_id, + data + ) VALUES ( + ${systemUserId}, + ( + SELECT + aat.administrative_activity_type_id + FROM + administrative_activity_type aat + WHERE + aat.name = ${ADMINISTRATIVE_ACTIVITY_TYPE.SYSTEM_ACCESS} + ), + ( + SELECT + aast.administrative_activity_status_type_id + FROM + administrative_activity_status_type aast + WHERE + aast.name = ${ADMINISTRATIVE_ACTIVITY_STATUS_TYPE.INVITED} + ), + ${data} + ) + RETURNING + administrative_activity_id AS id, + create_date::timestamptz AS date + `; + + const response = await this.connection.sql(sqlStatement, ICreateAdministrativeActivity); + + if (!response.rows.length) { + throw new ApiExecuteSQLError('Failed to create administrative activity record', [ + 'AdministrativeActivityRepository->createInvitedAccessRequest' + ]); + } + + return response.rows[0]; + } + /** * SQL query to count pending records in the administrative_activity table for a given user GUID * diff --git a/api/src/services/administrative-activity-service.ts b/api/src/services/administrative-activity-service.ts index 873a28bf01..02505bf7fc 100644 --- a/api/src/services/administrative-activity-service.ts +++ b/api/src/services/administrative-activity-service.ts @@ -69,6 +69,22 @@ export class AdministrativeActivityService extends DBService { return this.administrativeActivityRepository.createPendingAccessRequest(systemUserId, data); } + /** + * Create a new administrative activity record with type "System Access" and status "Invited". + * Used when users are invited via email to surveys. + * + * @param {number} systemUserId + * @param {(string | object)} data + * @return {*} {Promise} + * @memberof AdministrativeActivityService + */ + async createInvitedAccessRequest( + systemUserId: number, + data: string | object + ): Promise { + return this.administrativeActivityRepository.createInvitedAccessRequest(systemUserId, data); + } + /** * Update the status of an existing administrative activity record. * diff --git a/app/src/constants/colours.ts b/app/src/constants/colours.ts index 57d310e8c6..a1e4aa149b 100644 --- a/app/src/constants/colours.ts +++ b/app/src/constants/colours.ts @@ -71,6 +71,7 @@ const NRM_REGION_COLOUR_MAP = { * */ const ACCESS_REQUEST_STATUS_COLOUR_MAP = { + Invited: { colour: blueGrey }, Pending: { colour: purple }, Actioned: { colour: green }, Rejected: { colour: red } diff --git a/app/src/constants/misc.ts b/app/src/constants/misc.ts index f52321e41b..8b9e21d07a 100644 --- a/app/src/constants/misc.ts +++ b/app/src/constants/misc.ts @@ -3,6 +3,7 @@ export enum AdministrativeActivityType { } export enum AdministrativeActivityStatusType { + INVITED = 'Invited', PENDING = 'Pending', ACTIONED = 'Actioned', REJECTED = 'Rejected' diff --git a/app/src/features/admin/AdminManagePage.tsx b/app/src/features/admin/AdminManagePage.tsx index 7057bbb845..b2e7c00649 100644 --- a/app/src/features/admin/AdminManagePage.tsx +++ b/app/src/features/admin/AdminManagePage.tsx @@ -18,6 +18,7 @@ const AdminManagePage = () => { [AdministrativeActivityType.SYSTEM_ACCESS], [ AdministrativeActivityStatusType.PENDING, + AdministrativeActivityStatusType.INVITED, AdministrativeActivityStatusType.REJECTED, AdministrativeActivityStatusType.ACTIONED ] diff --git a/app/src/features/admin/users/access-requests/AccessRequestContainer.tsx b/app/src/features/admin/users/access-requests/AccessRequestContainer.tsx index 506c7cae79..835f4f38aa 100644 --- a/app/src/features/admin/users/access-requests/AccessRequestContainer.tsx +++ b/app/src/features/admin/users/access-requests/AccessRequestContainer.tsx @@ -1,4 +1,4 @@ -import { mdiCancel, mdiCheck, mdiExclamationThick } from '@mdi/js'; +import { mdiCancel, mdiCheck, mdiEmailPlusOutline, mdiExclamationThick } from '@mdi/js'; import Box from '@mui/material/Box'; import Divider from '@mui/material/Divider'; import Paper from '@mui/material/Paper'; @@ -8,6 +8,7 @@ import CustomToggleButtonGroup from 'components/toggle/CustomToggleButtonGroup'; import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; import { useState } from 'react'; import AccessRequestActionedList from './list/actioned/AccessRequestActionedList'; +import AccessRequestInvitedList from './list/invited/AccessRequestInvitedList'; import AccessRequestPendingList from './list/pending/AccessRequestPendingList'; import AccessRequestRejectedList from './list/rejected/AccessRequestRejectedList'; @@ -17,6 +18,7 @@ interface IAccessRequestContainerProps { } enum AccessRequestViewEnum { + INVITED = 'INVITED', ACTIONED = 'ACTIONED', PENDING = 'PENDING', REJECTED = 'REJECTED' @@ -32,18 +34,20 @@ const AccessRequestContainer = (props: IAccessRequestContainerProps) => { const [activeView, setActiveView] = useState(AccessRequestViewEnum.PENDING); const pendingRequests = accessRequests.filter((request) => request.status_name === 'Pending'); + const invitedRequests = accessRequests.filter((request) => request.status_name === 'Invited'); const actionedRequests = accessRequests.filter((request) => request.status_name === 'Actioned'); const rejectedRequests = accessRequests.filter((request) => request.status_name === 'Rejected'); const views = [ { value: AccessRequestViewEnum.PENDING, label: `Pending (${pendingRequests.length})`, icon: mdiExclamationThick }, + { value: AccessRequestViewEnum.INVITED, label: `Invited (${invitedRequests.length})`, icon: mdiEmailPlusOutline }, { value: AccessRequestViewEnum.ACTIONED, label: `Approved (${actionedRequests.length})`, icon: mdiCheck }, { value: AccessRequestViewEnum.REJECTED, label: `Rejected (${rejectedRequests.length})`, icon: mdiCancel } ]; return ( - + Access Requests @@ -64,6 +68,9 @@ const AccessRequestContainer = (props: IAccessRequestContainerProps) => { {activeView === AccessRequestViewEnum.PENDING && ( )} + {activeView === AccessRequestViewEnum.INVITED && ( + + )} {activeView === AccessRequestViewEnum.ACTIONED && ( )} diff --git a/app/src/features/admin/users/access-requests/list/invited/AccessRequestInvitedList.tsx b/app/src/features/admin/users/access-requests/list/invited/AccessRequestInvitedList.tsx new file mode 100644 index 0000000000..8b35720f39 --- /dev/null +++ b/app/src/features/admin/users/access-requests/list/invited/AccessRequestInvitedList.tsx @@ -0,0 +1,84 @@ +import { GridColDef } from '@mui/x-data-grid'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { getAccessRequestStatusColour } from 'constants/colours'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import dayjs from 'dayjs'; +import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; + +interface IAccessRequestInvitedListProps { + accessRequests: IGetAccessRequestsListResponse[]; + refresh: () => void; +} + +/** + * Returns a data grid component displaying invited access requests + * + * @param props {IAccessRequestInvitedListProps} + * @returns + */ +const AccessRequestInvitedList = (props: IAccessRequestInvitedListProps) => { + const { accessRequests } = props; + + const accessRequestsColumnDefs: GridColDef[] = [ + { + field: 'display_name', + headerName: 'Display Name', + flex: 1, + disableColumnMenu: true, + valueGetter: (params) => { + return params.row.data?.displayName; + } + }, + { + field: 'email', + headerName: 'Email', + flex: 1, + disableColumnMenu: true, + valueGetter: (params) => { + return params.row.data?.email; + } + }, + { + field: 'create_date', + flex: 1, + headerName: 'Date Invited', + disableColumnMenu: true, + valueFormatter: (params) => { + return dayjs(params.value).format(DATE_FORMAT.ShortMediumDateTimeFormat); + } + }, + { + field: 'status_name', + width: 170, + headerName: 'Status', + disableColumnMenu: true, + renderCell: (params) => { + return ( + + ); + } + } + ]; + + return ( + + ); +}; + +export default AccessRequestInvitedList; diff --git a/app/src/features/surveys/components/member/SurveyMembersEmailsForm.tsx b/app/src/features/surveys/components/member/SurveyMembersEmailsForm.tsx new file mode 100644 index 0000000000..2ce96ce951 --- /dev/null +++ b/app/src/features/surveys/components/member/SurveyMembersEmailsForm.tsx @@ -0,0 +1,223 @@ +import { mdiClose, mdiPlus } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Collapse from '@mui/material/Collapse'; +import IconButton from '@mui/material/IconButton'; +import MenuItem from '@mui/material/MenuItem'; +import Paper from '@mui/material/Paper'; +import Select from '@mui/material/Select'; +import Typography from '@mui/material/Typography'; +import grey from '@mui/material/colors/grey'; +import AlertBar from 'components/alert/AlertBar'; +import CustomTextField from 'components/fields/CustomTextField'; +import { useFormikContext } from 'formik'; +import { ICodeWithDescription } from 'interfaces/useCodesApi.interface'; +import { ICreateSurveyRequest } from 'interfaces/useSurveyApi.interface'; +import { TransitionGroup } from 'react-transition-group'; +import yup from 'utils/YupSchema'; + +export const SurveyMembersEmailYupSchema = yup.object().shape({ + members: yup.array().of( + yup.object().shape({ + email: yup.string().trim().email('Must be a valid email address').required('A valid email address is required'), + survey_role_name: yup.string().required('Select a survey role for this team member') + }) + ) +}); + +interface ISurveyMembersEmailFormProps { + roles: ICodeWithDescription[]; +} + +export const SurveyMembersFormInitialValues = { + members: [ + { + email: '', + survey_role_name: '' + } + ] +}; + +interface IMemberEmailRole { + email: string; + survey_role_name: string; +} + +/** + * Form for adding members to a survey by email, granting them permissions to view the survey + * + * @param {ISurveyMembersEmailFormProps} props + */ +export const SurveyMembersEmailsForm = (props: ISurveyMembersEmailFormProps) => { + const { handleSubmit, values, setFieldValue, errors, setErrors } = useFormikContext(); + + const handleAddMember = () => { + const newMember: IMemberEmailRole = { + email: '', + survey_role_name: '' + }; + setFieldValue(`members[${values.members.length}]`, newMember); + clearErrors(); + }; + + const handleUpdateEmail = (email: string, index: number) => { + setFieldValue(`members[${index}].email`, email); + clearErrors(); + }; + + const handleUpdateRole = (survey_role_name: string, index: number) => { + setFieldValue(`members[${index}].survey_role_name`, survey_role_name); + clearErrors(); + }; + + const handleRemoveMember = (index: number) => { + const filteredMembers = values.members.filter((_, memberIndex) => memberIndex !== index); + setFieldValue(`members`, filteredMembers); + clearErrors(); + }; + + const clearErrors = () => { + setErrors({ ...errors, members: undefined }); + }; + + const rowItemError = (index: number): JSX.Element | undefined => { + if (errors?.members && Array.isArray(errors.members)) { + const errorAtIndex = errors.members[index]; + if (errorAtIndex) { + return ( + + {typeof errorAtIndex === 'string' + ? errorAtIndex + : errorAtIndex.email || errorAtIndex.survey_role_name || 'Please fix the errors above.'} + + ); + } + } + }; + + const getSelectedRole = (index: number): string => { + return values.members?.[index]?.survey_role_name || ''; + }; + + return ( +
+ {errors?.['members'] && !Array.isArray(errors['members']) && ( + + + + )} + + + + + + + + + {values.members.map((member: IMemberEmailRole, index: number) => { + const error = rowItemError(index); + return ( + + + + + + ) => { + handleUpdateEmail(event.target.value, index); + } + }} + /> + + + + + + { + handleRemoveMember(index); + }}> + + + + + + {error} + + + ); + })} + + + +
+ ); +}; diff --git a/app/src/features/surveys/details/tabs/permissions/members/SurveyMembersContainer.tsx b/app/src/features/surveys/details/tabs/permissions/members/SurveyMembersContainer.tsx index d2eb34a171..ed17cb780b 100644 --- a/app/src/features/surveys/details/tabs/permissions/members/SurveyMembersContainer.tsx +++ b/app/src/features/surveys/details/tabs/permissions/members/SurveyMembersContainer.tsx @@ -1,5 +1,7 @@ -import { mdiArrowTopRight } from '@mdi/js'; +import { mdiArrowTopRight, mdiEmailPlus } from '@mdi/js'; +import Icon from '@mdi/react'; import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; import grey from '@mui/material/colors/grey'; import Divider from '@mui/material/Divider'; import Stack from '@mui/material/Stack'; @@ -26,6 +28,7 @@ import { useEffect, useState } from 'react'; import { StringValues } from 'types/misc'; import { getCodesName } from 'utils/Utils'; import SurveyMemberDialog from './dialog/SurveyMembersDialog'; +import SurveyMembersEmailDialog from './dialog/SurveyMembersEmailDialog'; type SurveyDataTableURLParams = { // filter @@ -46,6 +49,7 @@ const SurveyMembersContainer = () => { const { searchParams, setSearchParams } = useSearchParams>(); const [participantDialogIsOpen, setParticipantDialogIsOpen] = useState(false); + const [participantEmailDialogIsOpen, setParticipantEmailDialogIsOpen] = useState(false); const [advancedFiltersModel, setAdvancedFiltersModel] = useState({ keyword: searchParams.get('sm_keyword') ?? undefined, @@ -129,6 +133,13 @@ const SurveyMembersContainer = () => { setParticipantDialogIsOpen(true); }} /> +
@@ -184,6 +195,17 @@ const SurveyMembersContainer = () => { }} open={participantDialogIsOpen} /> + { + surveyMembersDataLoader.refresh(advancedFiltersModel); + setParticipantEmailDialogIsOpen(false); + }} + onClose={() => { + setParticipantEmailDialogIsOpen(false); + }} + open={participantEmailDialogIsOpen} + /> ); }; diff --git a/app/src/features/surveys/details/tabs/permissions/members/dialog/SurveyMembersEmailDialog.tsx b/app/src/features/surveys/details/tabs/permissions/members/dialog/SurveyMembersEmailDialog.tsx new file mode 100644 index 0000000000..af7ca04d75 --- /dev/null +++ b/app/src/features/surveys/details/tabs/permissions/members/dialog/SurveyMembersEmailDialog.tsx @@ -0,0 +1,147 @@ +import Typography from '@mui/material/Typography'; +import EditDialog from 'components/dialog/EditDialog'; +import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; +import { SYSTEM_IDENTITY_SOURCE } from 'constants/auth'; +import { CreateCollectionSurveyI18N } from 'constants/i18n'; +import { SURVEY_ROLE } from 'constants/roles'; +import { ISnackbarProps } from 'contexts/dialogContext'; +import { + SurveyMembersEmailsForm, + SurveyMembersEmailYupSchema, + SurveyMembersFormInitialValues +} from 'features/surveys/components/member/SurveyMembersEmailsForm'; +import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useCodesContext, useDialogContext } from 'hooks/useContext'; +import { IPostSurveyMember } from 'interfaces/useSurveyApi.interface'; +import { useState } from 'react'; +import { pluralize } from 'utils/Utils'; + +interface ISurveyMemberEmailDialogProps { + surveyId: number; + open: boolean; + onSubmit: () => void; + onClose?: (refresh?: boolean) => void; +} + +/** + * Dialog for inviting survey members via email + * + * NOTE: On naming conventions, SurveyMemberForm is from the perspective of a survey (adding one survey to multiple surveys). + * Whereas CollectionSurveyForm is from the perspective of a survey (adding multiple surveys to one survey) + * + * @param {ISurveyMemberEmailDialogProps} props + * @returns + */ +const SurveyMemberEmailDialog = (props: ISurveyMemberEmailDialogProps) => { + const [isSubmitting, setIsSubmitting] = useState(false); + const dialogContext = useDialogContext(); + const biohubApi = useBiohubApi(); + const codesContext = useCodesContext(); + + const showSnackBar = (textDialogProps?: Partial) => { + dialogContext.setSnackbar({ ...textDialogProps, open: true }); + }; + + const showCreateErrorDialog = (textDialogProps?: Partial) => { + dialogContext.setErrorDialog({ + dialogTitle: CreateCollectionSurveyI18N.createErrorTitle, + dialogText: CreateCollectionSurveyI18N.createErrorText, + onClose: () => dialogContext.setErrorDialog({ open: false }), + onOk: () => dialogContext.setErrorDialog({ open: false }), + ...textDialogProps, + open: true + }); + }; + + const handleSubmit = async (values: any) => { + try { + setIsSubmitting(true); + + const createdUsers: IPostSurveyMember[] = []; + + // Step 1: Create system users for each email + for (const member of values.members) { + try { + const response = await biohubApi.admin.addSystemUser( + member.email, // user identifier, using email as placeholder until we get name from keycloak + SYSTEM_IDENTITY_SOURCE.UNVERIFIED, // unverified source until keycloak confirms + member.email, // email as display name until keycloak overwrites it + member.email, // email + 2 // roleId (assign them a creator role) + ); + + createdUsers.push({ + system_user_id: response.system_user_id, + survey_role_name: member.survey_role_name as SURVEY_ROLE // Cast to SURVEY_ROLE enum + }); + + // Step 1.5: Create an "Invited" administrative activity record for the user + try { + await biohubApi.admin.createInvitedAccessRequest(response.system_user_id, { + email: member.email, + userGuid: '', // Will be populated when user actually logs in + name: member.email, // Using email as name placeholder + username: member.email, // Using email as username placeholder + identitySource: SYSTEM_IDENTITY_SOURCE.UNVERIFIED, + displayName: member.email, + reason: 'Invited via email to survey' + }); + } catch (activityError) { + console.error(`Failed to create administrative activity for ${member.email}:`, activityError); + // Continue even if activity creation fails + } + } catch (userError) { + console.error(`Failed to create user for ${member.email}:`, userError); + // Continue with other users even if one fails + } + } + + // Step 2: Add created users to the survey + if (createdUsers.length > 0) { + await biohubApi.survey.addSurveyMembers(props.surveyId, createdUsers); + } + + props.onSubmit(); + + showSnackBar({ + snackbarMessage: ( + <> + + Successfully invited {createdUsers.length} of {values.members.length}{' '} + {pluralize(values.members.length, 'user')} to survey + + + ), + open: true + }); + } catch (error: any) { + showCreateErrorDialog({ + dialogError: (error as APIError).message, + dialogErrorDetails: (error as APIError).errors + }); + } + setIsSubmitting(false); + }; + + return ( + , + initialValues: SurveyMembersFormInitialValues, + validationSchema: SurveyMembersEmailYupSchema + }} + dialogSaveButtonLabel="Add" + onCancel={() => props.onClose && props.onClose()} + onSave={(formValues) => { + handleSubmit(formValues); + }} + /> + ); +}; + +export default SurveyMemberEmailDialog; diff --git a/app/src/hooks/api/useAdminApi.ts b/app/src/hooks/api/useAdminApi.ts index 5355c0deb0..48f9903fbf 100644 --- a/app/src/hooks/api/useAdminApi.ts +++ b/app/src/hooks/api/useAdminApi.ts @@ -115,7 +115,7 @@ const useAdminApi = (axios: AxiosInstance) => { * @param {string} displayName * @param {string} email * @param {number} roleId - * @return {*} {boolean} True if the user is successfully added, false otherwise. + * @return {*} {{ system_user_id: number }} The created user data. */ const addSystemUser = async ( userIdentifier: string, @@ -123,8 +123,8 @@ const useAdminApi = (axios: AxiosInstance) => { displayName: string, email: string, roleId: number - ): Promise => { - const { status } = await axios.post(`/api/user/add`, { + ): Promise<{ system_user_id: number }> => { + const { data } = await axios.post(`/api/user/add`, { identitySource, userIdentifier, displayName, @@ -132,7 +132,35 @@ const useAdminApi = (axios: AxiosInstance) => { roleId }); - return status === 200; + return data; + }; + + /** + * Creates an invited access request administrative activity. + * + * @param {number} systemUserId + * @param {object} userData - User information (email, userGuid, name, etc.) + * @return {*} {Promise<{ id: number; date: string }>} + */ + const createInvitedAccessRequest = async ( + systemUserId: number, + userData: { + email: string; + userGuid?: string; + name?: string; + username?: string; + identitySource?: string; + company?: string; + displayName?: string; + reason?: string; + } + ): Promise<{ id: number; date: string }> => { + const { data: response } = await axios.post(`/api/administrative-activity/invited`, { + systemUserId, + ...userData + }); + + return response; }; return { @@ -142,7 +170,8 @@ const useAdminApi = (axios: AxiosInstance) => { denyAccessRequest, createAdministrativeActivity, getAdministrativeActivityStanding, - addSystemUser + addSystemUser, + createInvitedAccessRequest }; }; diff --git a/app/src/interfaces/useSurveyApi.interface.ts b/app/src/interfaces/useSurveyApi.interface.ts index 56859da885..13202af706 100644 --- a/app/src/interfaces/useSurveyApi.interface.ts +++ b/app/src/interfaces/useSurveyApi.interface.ts @@ -89,6 +89,11 @@ export interface ISurveyMemberResponse { export interface ISurveyMemberForm { members: ISurveyMember[]; } + +export interface ISurveyMemberEmailForm { + members: ISurveyMember[]; +} + export interface ISurveyMembersAdvancedFilters { system_user_id?: number; keyword?: string; diff --git a/database/src/migrations/20250806000000_invited_admin_status.ts b/database/src/migrations/20250806000000_invited_admin_status.ts new file mode 100644 index 0000000000..d12f0a30c0 --- /dev/null +++ b/database/src/migrations/20250806000000_invited_admin_status.ts @@ -0,0 +1,26 @@ +import { Knex } from 'knex'; + +/** + * Adding an option for invited admin status + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(` + set search_path=biohub; + + ---------------------------------------------------------------------------------------- + -- Invited status for administrative activities + ---------------------------------------------------------------------------------------- + + INSERT INTO administrative_activity_status_type (name, record_effective_date, description) +VALUES ('Invited', now(), 'User has been invited via email but has not yet requested access'); + + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +}