diff --git a/packages/backend/src/controllers/api/v1/admin/users/create-user.js b/packages/backend/src/controllers/api/v1/admin/users/create-user.js index 9eacd69a..07951bef 100644 --- a/packages/backend/src/controllers/api/v1/admin/users/create-user.js +++ b/packages/backend/src/controllers/api/v1/admin/users/create-user.js @@ -6,7 +6,7 @@ export default async (request, response) => { const user = await User.query().insertAndFetch(await userParams(request)); await user.sendInvitationEmail(); - renderObject(response, user, { status: 201 }); + renderObject(response, user, { status: 201, serializer: 'AdminUser' }); }; const userParams = async (request) => { diff --git a/packages/backend/src/controllers/api/v1/admin/users/create-user.test.js b/packages/backend/src/controllers/api/v1/admin/users/create-user.test.js index 39a85940..b199eeff 100644 --- a/packages/backend/src/controllers/api/v1/admin/users/create-user.test.js +++ b/packages/backend/src/controllers/api/v1/admin/users/create-user.test.js @@ -6,7 +6,7 @@ import User from '../../../../../models/user.js'; import Role from '../../../../../models/role.js'; import { createUser } from '../../../../../../test/factories/user.js'; import { createRole } from '../../../../../../test/factories/role.js'; -import createUserMock from '../../../../../../test/mocks/rest/api/v1/users/create-user.js'; +import createUserMock from '../../../../../../test/mocks/rest/api/v1/admin/users/create-user.js'; describe('POST /api/v1/admin/users', () => { let currentUser, adminRole, token; diff --git a/packages/backend/src/graphql/mutation-resolvers.js b/packages/backend/src/graphql/mutation-resolvers.js index 45c1d913..7305b068 100644 --- a/packages/backend/src/graphql/mutation-resolvers.js +++ b/packages/backend/src/graphql/mutation-resolvers.js @@ -7,12 +7,10 @@ import generateAuthUrl from './mutations/generate-auth-url.js'; import createConnection from './mutations/create-connection.js'; import resetConnection from './mutations/reset-connection.js'; import updateConnection from './mutations/update-connection.js'; -import createUser from './mutations/create-user.ee.js'; import updateFlowStatus from './mutations/update-flow-status.js'; const mutationResolvers = { createConnection, - createUser, executeFlow, generateAuthUrl, resetConnection, diff --git a/packages/backend/src/graphql/mutations/create-user.ee.js b/packages/backend/src/graphql/mutations/create-user.ee.js deleted file mode 100644 index 2c3fd3fc..00000000 --- a/packages/backend/src/graphql/mutations/create-user.ee.js +++ /dev/null @@ -1,66 +0,0 @@ -import appConfig from '../../config/app.js'; -import User from '../../models/user.js'; -import Role from '../../models/role.js'; -import emailQueue from '../../queues/email.js'; -import { - REMOVE_AFTER_30_DAYS_OR_150_JOBS, - REMOVE_AFTER_7_DAYS_OR_50_JOBS, -} from '../../helpers/remove-job-configuration.js'; - -const createUser = async (_parent, params, context) => { - context.currentUser.can('create', 'User'); - - const { fullName, email } = params.input; - - const existingUser = await User.query().findOne({ - email: email.toLowerCase(), - }); - - if (existingUser) { - throw new Error('User already exists!'); - } - - const userPayload = { - fullName, - email, - status: 'invited', - }; - - try { - context.currentUser.can('update', 'Role'); - - userPayload.roleId = params.input.role.id; - } catch { - // void - const role = await Role.findAdmin(); - userPayload.roleId = role.id; - } - - const user = await User.query().insert(userPayload); - - await user.generateInvitationToken(); - - const jobName = `Invitation Email - ${user.id}`; - const acceptInvitationUrl = `${appConfig.webAppUrl}/accept-invitation?token=${user.invitationToken}`; - - const jobPayload = { - email: user.email, - subject: 'You are invited!', - template: 'invitation-instructions', - params: { - fullName: user.fullName, - acceptInvitationUrl, - }, - }; - - const jobOptions = { - removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, - removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS, - }; - - await emailQueue.add(jobName, jobPayload, jobOptions); - - return { user, acceptInvitationUrl }; -}; - -export default createUser; diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index a96811b3..680cd798 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -3,7 +3,6 @@ type Query { } type Mutation { createConnection(input: CreateConnectionInput): Connection - createUser(input: CreateUserInput): UserWithAcceptInvitationUrl executeFlow(input: ExecuteFlowInput): executeFlowType generateAuthUrl(input: GenerateAuthUrlInput): AuthLink resetConnection(input: ResetConnectionInput): Connection @@ -244,12 +243,6 @@ input ExecuteFlowInput { stepId: String! } -input CreateUserInput { - fullName: String! - email: String! - role: UserRoleInput! -} - input UserRoleInput { id: String } @@ -344,11 +337,6 @@ type User { updatedAt: String } -type UserWithAcceptInvitationUrl { - user: User - acceptInvitationUrl: String -} - type Role { id: String name: String diff --git a/packages/backend/src/models/user.js b/packages/backend/src/models/user.js index c351fb91..38960fbd 100644 --- a/packages/backend/src/models/user.js +++ b/packages/backend/src/models/user.js @@ -180,6 +180,10 @@ class User extends Base { }, }); + static get virtualAttributes() { + return ['acceptInvitationUrl']; + } + get authorizedFlows() { const conditions = this.can('read', 'Flow'); return conditions.isCreator ? this.$relatedQuery('flows') : Flow.query(); @@ -204,6 +208,10 @@ class User extends Base { : Execution.query(); } + get acceptInvitationUrl() { + return `${appConfig.webAppUrl}/accept-invitation?token=${this.invitationToken}`; + } + static async authenticate(email, password) { const user = await User.query().findOne({ email: email?.toLowerCase() || null, @@ -362,7 +370,6 @@ class User extends Base { await this.generateInvitationToken(); const jobName = `Invitation Email - ${this.id}`; - const acceptInvitationUrl = `${appConfig.webAppUrl}/accept-invitation?token=${this.invitationToken}`; const jobPayload = { email: this.email, @@ -370,7 +377,7 @@ class User extends Base { template: 'invitation-instructions', params: { fullName: this.fullName, - acceptInvitationUrl, + acceptInvitationUrl: this.acceptInvitationUrl, }, }; diff --git a/packages/backend/src/serializers/admin/user.js b/packages/backend/src/serializers/admin/user.js new file mode 100644 index 00000000..7620c3f1 --- /dev/null +++ b/packages/backend/src/serializers/admin/user.js @@ -0,0 +1,11 @@ +import userSerializer from '../user.js'; + +const adminUserSerializer = (user) => { + const userData = userSerializer(user); + + userData.acceptInvitationUrl = user.acceptInvitationUrl; + + return userData; +}; + +export default adminUserSerializer; diff --git a/packages/backend/src/serializers/admin/user.test.js b/packages/backend/src/serializers/admin/user.test.js new file mode 100644 index 00000000..4f7528e1 --- /dev/null +++ b/packages/backend/src/serializers/admin/user.test.js @@ -0,0 +1,19 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createUser } from '../../../test/factories/user'; +import adminUserSerializer from './user.js'; + +describe('adminUserSerializer', () => { + let user; + + beforeEach(async () => { + user = await createUser(); + }); + + it('should return user data with accept invitation url', async () => { + const serializedUser = adminUserSerializer(user); + + expect(serializedUser.acceptInvitationUrl).toEqual( + user.acceptInvitationUrl + ); + }); +}); diff --git a/packages/backend/src/serializers/index.js b/packages/backend/src/serializers/index.js index 28b98e1e..971c4ded 100644 --- a/packages/backend/src/serializers/index.js +++ b/packages/backend/src/serializers/index.js @@ -16,8 +16,10 @@ import actionSerializer from './action.js'; import executionSerializer from './execution.js'; import executionStepSerializer from './execution-step.js'; import subscriptionSerializer from './subscription.ee.js'; +import adminUserSerializer from './admin/user.js'; const serializers = { + AdminUser: adminUserSerializer, User: userSerializer, Role: roleSerializer, Permission: permissionSerializer, diff --git a/packages/backend/test/mocks/rest/api/v1/admin/users/create-user.js b/packages/backend/test/mocks/rest/api/v1/admin/users/create-user.js new file mode 100644 index 00000000..30aaef28 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/users/create-user.js @@ -0,0 +1,30 @@ +import appConfig from '../../../../../../../src/config/app.js'; + +const createUserMock = (user) => { + const userData = { + createdAt: user.createdAt.getTime(), + email: user.email, + fullName: user.fullName, + id: user.id, + status: user.status, + updatedAt: user.updatedAt.getTime(), + acceptInvitationUrl: user.acceptInvitationUrl, + }; + + if (appConfig.isCloud && user.trialExpiryDate) { + userData.trialExpiryDate = user.trialExpiryDate.toISOString(); + } + + return { + data: userData, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'User', + }, + }; +}; + +export default createUserMock; diff --git a/packages/e2e-tests/tests/admin/manage-users.spec.js b/packages/e2e-tests/tests/admin/manage-users.spec.js index 80ebfc07..d6fc1507 100644 --- a/packages/e2e-tests/tests/admin/manage-users.spec.js +++ b/packages/e2e-tests/tests/admin/manage-users.spec.js @@ -156,15 +156,8 @@ test.describe('User management page', () => { 'option', { name: 'Admin' } ).click(); await adminCreateUserPage.createButton.click(); - await adminUsersPage.snackbar.waitFor({ - state: 'attached' - }); - /* - TODO: assert snackbar behavior after deciding what should - happen here, i.e. if this should create a new user, stay the - same, un-delete the user, or something else - */ - // await adminUsersPage.getSnackbarData('snackbar-error'); + const snackbar = await adminUsersPage.getSnackbarData('snackbar-error'); + await expect(snackbar.variant).toBe('error'); await adminUsersPage.closeSnackbar(); } ); diff --git a/packages/web/src/graphql/mutations/create-user.ee.js b/packages/web/src/graphql/mutations/create-user.ee.js deleted file mode 100644 index 7ee526fd..00000000 --- a/packages/web/src/graphql/mutations/create-user.ee.js +++ /dev/null @@ -1,16 +0,0 @@ -import { gql } from '@apollo/client'; -export const CREATE_USER = gql` - mutation CreateUser($input: CreateUserInput) { - createUser(input: $input) { - user { - id - email - fullName - role { - id - } - } - acceptInvitationUrl - } - } -`; diff --git a/packages/web/src/hooks/useAdminCreateRole.js b/packages/web/src/hooks/useAdminCreateRole.js index 756d4cc5..ad5a9fe8 100644 --- a/packages/web/src/hooks/useAdminCreateRole.js +++ b/packages/web/src/hooks/useAdminCreateRole.js @@ -12,7 +12,7 @@ export default function useAdminCreateRole() { }, onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ['apps', 'roles'], + queryKey: ['admin', 'roles'], }); }, }); diff --git a/packages/web/src/hooks/useAdminCreateUser.js b/packages/web/src/hooks/useAdminCreateUser.js new file mode 100644 index 00000000..ff601825 --- /dev/null +++ b/packages/web/src/hooks/useAdminCreateUser.js @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import api from 'helpers/api'; + +export default function useAdminCreateUser() { + const queryClient = useQueryClient(); + + const query = useMutation({ + mutationFn: async (payload) => { + const { data } = await api.post('/v1/admin/users', payload); + + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['admin', 'users'], + }); + }, + }); + + return query; +} diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index d4f9592a..95576fde 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -223,6 +223,7 @@ "createUser.submit": "Create", "createUser.successfullyCreated": "The user has been created.", "createUser.invitationEmailInfo": "Invitation email will be sent if SMTP credentials are valid. Otherwise, you can share the invitation link manually: ", + "createUser.error": "Error while creating the user", "editUserPage.title": "Edit user", "editUser.status": "Status", "editUser.submit": "Update", diff --git a/packages/web/src/pages/CreateUser/index.jsx b/packages/web/src/pages/CreateUser/index.jsx index 00eb626d..42abe936 100644 --- a/packages/web/src/pages/CreateUser/index.jsx +++ b/packages/web/src/pages/CreateUser/index.jsx @@ -1,4 +1,3 @@ -import { useMutation } from '@apollo/client'; import LoadingButton from '@mui/lab/LoadingButton'; import Grid from '@mui/material/Grid'; import Stack from '@mui/material/Stack'; @@ -14,9 +13,9 @@ import ControlledAutocomplete from 'components/ControlledAutocomplete'; import Form from 'components/Form'; import PageTitle from 'components/PageTitle'; import TextField from 'components/TextField'; -import { CREATE_USER } from 'graphql/mutations/create-user.ee'; import useFormatMessage from 'hooks/useFormatMessage'; import useRoles from 'hooks/useRoles.ee'; +import useAdminCreateUser from 'hooks/useAdminCreateUser'; function generateRoleOptions(roles) { return roles?.map(({ name: label, id: value }) => ({ label, value })); @@ -24,7 +23,11 @@ function generateRoleOptions(roles) { export default function CreateUser() { const formatMessage = useFormatMessage(); - const [createUser, { loading, data }] = useMutation(CREATE_USER); + const { + mutateAsync: createUser, + isPending: isCreateUserPending, + data: createdUser, + } = useAdminCreateUser(); const { data: rolesData, loading: isRolesLoading } = useRoles(); const roles = rolesData?.data; const enqueueSnackbar = useEnqueueSnackbar(); @@ -33,17 +36,13 @@ export default function CreateUser() { const handleUserCreation = async (userData) => { try { await createUser({ - variables: { - input: { - fullName: userData.fullName, - email: userData.email, - role: { - id: userData.role?.id, - }, - }, - }, + fullName: userData.fullName, + email: userData.email, + roleId: userData.role?.id, }); + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }); + enqueueSnackbar(formatMessage('createUser.successfullyCreated'), { variant: 'success', persist: true, @@ -52,6 +51,14 @@ export default function CreateUser() { }, }); } catch (error) { + enqueueSnackbar(formatMessage('createUser.error'), { + variant: 'error', + persist: true, + SnackbarProps: { + 'data-test': 'snackbar-error', + }, + }); + throw new Error('Failed while creating!'); } }; @@ -107,13 +114,13 @@ export default function CreateUser() { variant="contained" color="primary" sx={{ boxShadow: 2 }} - loading={loading} + loading={isCreateUserPending} data-test="create-button" > {formatMessage('createUser.submit')} - {data && ( + {createdUser && ( ( - {data.createUser.acceptInvitationUrl} + {createdUser.data.acceptInvitationUrl} ), })}