diff --git a/packages/backend/src/controllers/api/v1/users/register-user.ee.js b/packages/backend/src/controllers/api/v1/users/register-user.ee.js new file mode 100644 index 00000000..6ab54021 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/register-user.ee.js @@ -0,0 +1,18 @@ +import User from '../../../../models/user.js'; +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const user = await User.registerUser(userParams(request)); + + renderObject(response, user, { status: 201 }); +}; + +const userParams = (request) => { + const { fullName, email, password } = request.body; + + return { + fullName, + email, + password, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/users/register-user.ee.test.js b/packages/backend/src/controllers/api/v1/users/register-user.ee.test.js new file mode 100644 index 00000000..5220efde --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/register-user.ee.test.js @@ -0,0 +1,96 @@ +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import User from '../../../../models/user.js'; +import appConfig from '../../../../config/app.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createRole } from '../../../../../test/factories/role.js'; +import registerUserMock from '../../../../../test/mocks/rest/api/v1/users/register-user.ee.js'; + +describe('POST /api/v1/users/register', () => { + beforeEach(async () => { + vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(true); + }); + + it('should return registered user with valid data', async () => { + await createRole({ name: 'User' }); + + const userData = { + email: 'registered@sample.com', + fullName: 'Full Name', + password: 'samplePassword123', + }; + + const response = await request(app) + .post('/api/v1/users/register') + .send(userData) + .expect(201); + + const refetchedRegisteredUser = await User.query() + .findById(response.body.data.id) + .throwIfNotFound(); + + const expectedPayload = registerUserMock(refetchedRegisteredUser); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response without user role existing', async () => { + const userData = { + email: 'registered@sample.com', + fullName: 'Full Name', + password: 'samplePassword123', + }; + + await request(app) + .post('/api/v1/users/register') + .send(userData) + .expect(404); + }); + + it('should return unprocessable entity response with already used email', async () => { + await createRole({ name: 'User' }); + await createUser({ + email: 'registered@sample.com', + }); + + const userData = { + email: 'registered@sample.com', + fullName: 'Full Name', + password: 'samplePassword123', + }; + + const response = await request(app) + .post('/api/v1/users/register') + .send(userData) + .expect(422); + + expect(response.body.errors).toStrictEqual({ + email: ["'email' must be unique."], + }); + + expect(response.body.meta).toStrictEqual({ + type: 'UniqueViolationError', + }); + }); + + it('should return unprocessable entity response with invalid user data', async () => { + await createRole({ name: 'User' }); + + const userData = { + email: null, + fullName: null, + }; + + const response = await request(app) + .post('/api/v1/users/register') + .send(userData) + .expect(422); + + expect(response.body.meta.type).toStrictEqual('ModelValidation'); + expect(response.body.errors).toStrictEqual({ + email: ['must be string'], + fullName: ['must be string'], + }); + }); +}); diff --git a/packages/backend/src/graphql/mutation-resolvers.js b/packages/backend/src/graphql/mutation-resolvers.js index 49b5e898..4dfe49a4 100644 --- a/packages/backend/src/graphql/mutation-resolvers.js +++ b/packages/backend/src/graphql/mutation-resolvers.js @@ -3,7 +3,6 @@ import createUser from './mutations/create-user.ee.js'; import deleteFlow from './mutations/delete-flow.js'; import duplicateFlow from './mutations/duplicate-flow.js'; import generateAuthUrl from './mutations/generate-auth-url.js'; -import registerUser from './mutations/register-user.ee.js'; import resetConnection from './mutations/reset-connection.js'; import updateConnection from './mutations/update-connection.js'; import updateFlowStatus from './mutations/update-flow-status.js'; @@ -28,7 +27,6 @@ const mutationResolvers = { duplicateFlow, executeFlow, generateAuthUrl, - registerUser, resetConnection, updateConnection, updateCurrentUser, diff --git a/packages/backend/src/graphql/mutations/register-user.ee.js b/packages/backend/src/graphql/mutations/register-user.ee.js deleted file mode 100644 index c084af9b..00000000 --- a/packages/backend/src/graphql/mutations/register-user.ee.js +++ /dev/null @@ -1,30 +0,0 @@ -import appConfig from '../../config/app.js'; -import User from '../../models/user.js'; -import Role from '../../models/role.js'; - -const registerUser = async (_parent, params) => { - if (!appConfig.isCloud) return; - - const { fullName, email, password } = params.input; - - const existingUser = await User.query().findOne({ - email: email.toLowerCase(), - }); - - if (existingUser) { - throw new Error('User already exists!'); - } - - const role = await Role.query().findOne({ name: 'User' }); - - const user = await User.query().insert({ - fullName, - email, - password, - roleId: role.id, - }); - - return user; -}; - -export default registerUser; diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index c28f46e6..5b4520b5 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -11,7 +11,6 @@ type Mutation { duplicateFlow(input: DuplicateFlowInput): Flow executeFlow(input: ExecuteFlowInput): executeFlowType generateAuthUrl(input: GenerateAuthUrlInput): AuthLink - registerUser(input: RegisterUserInput): User resetConnection(input: ResetConnectionInput): Connection updateConnection(input: UpdateConnectionInput): Connection updateCurrentUser(input: UpdateCurrentUserInput): User @@ -296,12 +295,6 @@ input UpdateUserInput { role: UserRoleInput } -input RegisterUserInput { - fullName: String! - email: String! - password: String! -} - input UpdateCurrentUserInput { email: String password: String diff --git a/packages/backend/src/helpers/authentication.js b/packages/backend/src/helpers/authentication.js index 9f01d21d..b2eeeffa 100644 --- a/packages/backend/src/helpers/authentication.js +++ b/packages/backend/src/helpers/authentication.js @@ -1,4 +1,4 @@ -import { allow, rule, shield } from 'graphql-shield'; +import { rule, shield } from 'graphql-shield'; import User from '../models/user.js'; import AccessToken from '../models/access-token.js'; @@ -53,7 +53,6 @@ const isAuthenticatedRule = rule()(isAuthenticated); export const authenticationRules = { Mutation: { '*': isAuthenticatedRule, - registerUser: allow, }, }; diff --git a/packages/backend/src/models/user.js b/packages/backend/src/models/user.js index 58ecb533..aafbd010 100644 --- a/packages/backend/src/models/user.js +++ b/packages/backend/src/models/user.js @@ -534,6 +534,21 @@ class User extends Base { return adminUser; } + static async registerUser(userData) { + const { fullName, email, password } = userData; + + const role = await Role.query().findOne({ name: 'User' }).throwIfNotFound(); + + const user = await User.query().insertAndFetch({ + fullName, + email, + password, + roleId: role.id, + }); + + return user; + } + async $beforeInsert(queryContext) { await super.$beforeInsert(queryContext); diff --git a/packages/backend/src/routes/api/v1/users.js b/packages/backend/src/routes/api/v1/users.js index 672b7d35..3619a96c 100644 --- a/packages/backend/src/routes/api/v1/users.js +++ b/packages/backend/src/routes/api/v1/users.js @@ -14,6 +14,7 @@ import getPlanAndUsageAction from '../../../controllers/api/v1/users/get-plan-an import acceptInvitationAction from '../../../controllers/api/v1/users/accept-invitation.js'; import forgotPasswordAction from '../../../controllers/api/v1/users/forgot-password.js'; import resetPasswordAction from '../../../controllers/api/v1/users/reset-password.js'; +import registerUserAction from '../../../controllers/api/v1/users/register-user.ee.js'; const router = Router(); @@ -54,5 +55,6 @@ router.get( router.post('/invitation', acceptInvitationAction); router.post('/forgot-password', forgotPasswordAction); router.post('/reset-password', resetPasswordAction); +router.post('/register', checkIsCloud, registerUserAction); export default router; diff --git a/packages/backend/test/mocks/rest/api/v1/users/register-user.ee.js b/packages/backend/test/mocks/rest/api/v1/users/register-user.ee.js new file mode 100644 index 00000000..bd06bbbc --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/users/register-user.ee.js @@ -0,0 +1,29 @@ +import appConfig from '../../../../../../src/config/app.js'; + +const registerUserMock = (user) => { + const userData = { + createdAt: user.createdAt.getTime(), + email: user.email, + fullName: user.fullName, + id: user.id, + status: user.status, + updatedAt: user.updatedAt.getTime(), + }; + + 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 registerUserMock; diff --git a/packages/web/src/components/SignUpForm/index.ee.jsx b/packages/web/src/components/SignUpForm/index.ee.jsx index 3ae6d2bc..a9f140c2 100644 --- a/packages/web/src/components/SignUpForm/index.ee.jsx +++ b/packages/web/src/components/SignUpForm/index.ee.jsx @@ -6,14 +6,15 @@ import Typography from '@mui/material/Typography'; import LoadingButton from '@mui/lab/LoadingButton'; import * as yup from 'yup'; import { yupResolver } from '@hookform/resolvers/yup'; + import useAuthentication from 'hooks/useAuthentication'; import * as URLS from 'config/urls'; -import { REGISTER_USER } from 'graphql/mutations/register-user.ee'; import Form from 'components/Form'; import TextField from 'components/TextField'; import useFormatMessage from 'hooks/useFormatMessage'; import useCreateAccessToken from 'hooks/useCreateAccessToken'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; +import useRegisterUser from 'hooks/useRegisterUser'; const validationSchema = yup.object().shape({ fullName: yup.string().trim().required('signupForm.mandatoryInput'), @@ -41,8 +42,8 @@ function SignUpForm() { const authentication = useAuthentication(); const formatMessage = useFormatMessage(); const enqueueSnackbar = useEnqueueSnackbar(); - const [registerUser, { loading: registerUserLoading }] = - useMutation(REGISTER_USER); + const { mutateAsync: registerUser, isPending: isRegisterUserPending } = + useRegisterUser(); const { mutateAsync: createAccessToken, isPending: loginLoading } = useCreateAccessToken(); @@ -56,13 +57,9 @@ function SignUpForm() { const { fullName, email, password } = values; await registerUser({ - variables: { - input: { - fullName, - email, - password, - }, - }, + fullName, + email, + password, }); try { @@ -176,7 +173,7 @@ function SignUpForm() { variant="contained" color="primary" sx={{ boxShadow: 2, mt: 3 }} - loading={registerUserLoading || loginLoading} + loading={isRegisterUserPending || loginLoading} fullWidth data-test="signUp-button" > diff --git a/packages/web/src/graphql/mutations/register-user.ee.js b/packages/web/src/graphql/mutations/register-user.ee.js deleted file mode 100644 index 54f852ac..00000000 --- a/packages/web/src/graphql/mutations/register-user.ee.js +++ /dev/null @@ -1,10 +0,0 @@ -import { gql } from '@apollo/client'; -export const REGISTER_USER = gql` - mutation RegisterUser($input: RegisterUserInput) { - registerUser(input: $input) { - id - email - fullName - } - } -`; diff --git a/packages/web/src/hooks/useRegisterUser.js b/packages/web/src/hooks/useRegisterUser.js new file mode 100644 index 00000000..5b0eeccd --- /dev/null +++ b/packages/web/src/hooks/useRegisterUser.js @@ -0,0 +1,19 @@ +import { useMutation } from '@tanstack/react-query'; + +import api from 'helpers/api'; + +export default function useRegisterUser() { + const query = useMutation({ + mutationFn: async ({ fullName, email, password }) => { + const { data } = await api.post(`/v1/users/register`, { + fullName, + email, + password, + }); + + return data; + }, + }); + + return query; +}