Merge pull request #2085 from automatisch/aut-1259
feat: use REST API endpoint to create user
This commit is contained in:
@@ -6,7 +6,7 @@ export default async (request, response) => {
|
|||||||
const user = await User.query().insertAndFetch(await userParams(request));
|
const user = await User.query().insertAndFetch(await userParams(request));
|
||||||
await user.sendInvitationEmail();
|
await user.sendInvitationEmail();
|
||||||
|
|
||||||
renderObject(response, user, { status: 201 });
|
renderObject(response, user, { status: 201, serializer: 'AdminUser' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const userParams = async (request) => {
|
const userParams = async (request) => {
|
||||||
|
@@ -6,7 +6,7 @@ import User from '../../../../../models/user.js';
|
|||||||
import Role from '../../../../../models/role.js';
|
import Role from '../../../../../models/role.js';
|
||||||
import { createUser } from '../../../../../../test/factories/user.js';
|
import { createUser } from '../../../../../../test/factories/user.js';
|
||||||
import { createRole } from '../../../../../../test/factories/role.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', () => {
|
describe('POST /api/v1/admin/users', () => {
|
||||||
let currentUser, adminRole, token;
|
let currentUser, adminRole, token;
|
||||||
|
@@ -7,12 +7,10 @@ import generateAuthUrl from './mutations/generate-auth-url.js';
|
|||||||
import createConnection from './mutations/create-connection.js';
|
import createConnection from './mutations/create-connection.js';
|
||||||
import resetConnection from './mutations/reset-connection.js';
|
import resetConnection from './mutations/reset-connection.js';
|
||||||
import updateConnection from './mutations/update-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';
|
import updateFlowStatus from './mutations/update-flow-status.js';
|
||||||
|
|
||||||
const mutationResolvers = {
|
const mutationResolvers = {
|
||||||
createConnection,
|
createConnection,
|
||||||
createUser,
|
|
||||||
executeFlow,
|
executeFlow,
|
||||||
generateAuthUrl,
|
generateAuthUrl,
|
||||||
resetConnection,
|
resetConnection,
|
||||||
|
@@ -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;
|
|
@@ -3,7 +3,6 @@ type Query {
|
|||||||
}
|
}
|
||||||
type Mutation {
|
type Mutation {
|
||||||
createConnection(input: CreateConnectionInput): Connection
|
createConnection(input: CreateConnectionInput): Connection
|
||||||
createUser(input: CreateUserInput): UserWithAcceptInvitationUrl
|
|
||||||
executeFlow(input: ExecuteFlowInput): executeFlowType
|
executeFlow(input: ExecuteFlowInput): executeFlowType
|
||||||
generateAuthUrl(input: GenerateAuthUrlInput): AuthLink
|
generateAuthUrl(input: GenerateAuthUrlInput): AuthLink
|
||||||
resetConnection(input: ResetConnectionInput): Connection
|
resetConnection(input: ResetConnectionInput): Connection
|
||||||
@@ -244,12 +243,6 @@ input ExecuteFlowInput {
|
|||||||
stepId: String!
|
stepId: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
input CreateUserInput {
|
|
||||||
fullName: String!
|
|
||||||
email: String!
|
|
||||||
role: UserRoleInput!
|
|
||||||
}
|
|
||||||
|
|
||||||
input UserRoleInput {
|
input UserRoleInput {
|
||||||
id: String
|
id: String
|
||||||
}
|
}
|
||||||
@@ -344,11 +337,6 @@ type User {
|
|||||||
updatedAt: String
|
updatedAt: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserWithAcceptInvitationUrl {
|
|
||||||
user: User
|
|
||||||
acceptInvitationUrl: String
|
|
||||||
}
|
|
||||||
|
|
||||||
type Role {
|
type Role {
|
||||||
id: String
|
id: String
|
||||||
name: String
|
name: String
|
||||||
|
@@ -180,6 +180,10 @@ class User extends Base {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static get virtualAttributes() {
|
||||||
|
return ['acceptInvitationUrl'];
|
||||||
|
}
|
||||||
|
|
||||||
get authorizedFlows() {
|
get authorizedFlows() {
|
||||||
const conditions = this.can('read', 'Flow');
|
const conditions = this.can('read', 'Flow');
|
||||||
return conditions.isCreator ? this.$relatedQuery('flows') : Flow.query();
|
return conditions.isCreator ? this.$relatedQuery('flows') : Flow.query();
|
||||||
@@ -204,6 +208,10 @@ class User extends Base {
|
|||||||
: Execution.query();
|
: Execution.query();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get acceptInvitationUrl() {
|
||||||
|
return `${appConfig.webAppUrl}/accept-invitation?token=${this.invitationToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
static async authenticate(email, password) {
|
static async authenticate(email, password) {
|
||||||
const user = await User.query().findOne({
|
const user = await User.query().findOne({
|
||||||
email: email?.toLowerCase() || null,
|
email: email?.toLowerCase() || null,
|
||||||
@@ -362,7 +370,6 @@ class User extends Base {
|
|||||||
await this.generateInvitationToken();
|
await this.generateInvitationToken();
|
||||||
|
|
||||||
const jobName = `Invitation Email - ${this.id}`;
|
const jobName = `Invitation Email - ${this.id}`;
|
||||||
const acceptInvitationUrl = `${appConfig.webAppUrl}/accept-invitation?token=${this.invitationToken}`;
|
|
||||||
|
|
||||||
const jobPayload = {
|
const jobPayload = {
|
||||||
email: this.email,
|
email: this.email,
|
||||||
@@ -370,7 +377,7 @@ class User extends Base {
|
|||||||
template: 'invitation-instructions',
|
template: 'invitation-instructions',
|
||||||
params: {
|
params: {
|
||||||
fullName: this.fullName,
|
fullName: this.fullName,
|
||||||
acceptInvitationUrl,
|
acceptInvitationUrl: this.acceptInvitationUrl,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
11
packages/backend/src/serializers/admin/user.js
Normal file
11
packages/backend/src/serializers/admin/user.js
Normal file
@@ -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;
|
19
packages/backend/src/serializers/admin/user.test.js
Normal file
19
packages/backend/src/serializers/admin/user.test.js
Normal file
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
@@ -16,8 +16,10 @@ import actionSerializer from './action.js';
|
|||||||
import executionSerializer from './execution.js';
|
import executionSerializer from './execution.js';
|
||||||
import executionStepSerializer from './execution-step.js';
|
import executionStepSerializer from './execution-step.js';
|
||||||
import subscriptionSerializer from './subscription.ee.js';
|
import subscriptionSerializer from './subscription.ee.js';
|
||||||
|
import adminUserSerializer from './admin/user.js';
|
||||||
|
|
||||||
const serializers = {
|
const serializers = {
|
||||||
|
AdminUser: adminUserSerializer,
|
||||||
User: userSerializer,
|
User: userSerializer,
|
||||||
Role: roleSerializer,
|
Role: roleSerializer,
|
||||||
Permission: permissionSerializer,
|
Permission: permissionSerializer,
|
||||||
|
@@ -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;
|
@@ -156,15 +156,8 @@ test.describe('User management page', () => {
|
|||||||
'option', { name: 'Admin' }
|
'option', { name: 'Admin' }
|
||||||
).click();
|
).click();
|
||||||
await adminCreateUserPage.createButton.click();
|
await adminCreateUserPage.createButton.click();
|
||||||
await adminUsersPage.snackbar.waitFor({
|
const snackbar = await adminUsersPage.getSnackbarData('snackbar-error');
|
||||||
state: 'attached'
|
await expect(snackbar.variant).toBe('error');
|
||||||
});
|
|
||||||
/*
|
|
||||||
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');
|
|
||||||
await adminUsersPage.closeSnackbar();
|
await adminUsersPage.closeSnackbar();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
@@ -12,7 +12,7 @@ export default function useAdminCreateRole() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ['apps', 'roles'],
|
queryKey: ['admin', 'roles'],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
21
packages/web/src/hooks/useAdminCreateUser.js
Normal file
21
packages/web/src/hooks/useAdminCreateUser.js
Normal file
@@ -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;
|
||||||
|
}
|
@@ -223,6 +223,7 @@
|
|||||||
"createUser.submit": "Create",
|
"createUser.submit": "Create",
|
||||||
"createUser.successfullyCreated": "The user has been created.",
|
"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: <link></link>",
|
"createUser.invitationEmailInfo": "Invitation email will be sent if SMTP credentials are valid. Otherwise, you can share the invitation link manually: <link></link>",
|
||||||
|
"createUser.error": "Error while creating the user",
|
||||||
"editUserPage.title": "Edit user",
|
"editUserPage.title": "Edit user",
|
||||||
"editUser.status": "Status",
|
"editUser.status": "Status",
|
||||||
"editUser.submit": "Update",
|
"editUser.submit": "Update",
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import { useMutation } from '@apollo/client';
|
|
||||||
import LoadingButton from '@mui/lab/LoadingButton';
|
import LoadingButton from '@mui/lab/LoadingButton';
|
||||||
import Grid from '@mui/material/Grid';
|
import Grid from '@mui/material/Grid';
|
||||||
import Stack from '@mui/material/Stack';
|
import Stack from '@mui/material/Stack';
|
||||||
@@ -14,9 +13,9 @@ import ControlledAutocomplete from 'components/ControlledAutocomplete';
|
|||||||
import Form from 'components/Form';
|
import Form from 'components/Form';
|
||||||
import PageTitle from 'components/PageTitle';
|
import PageTitle from 'components/PageTitle';
|
||||||
import TextField from 'components/TextField';
|
import TextField from 'components/TextField';
|
||||||
import { CREATE_USER } from 'graphql/mutations/create-user.ee';
|
|
||||||
import useFormatMessage from 'hooks/useFormatMessage';
|
import useFormatMessage from 'hooks/useFormatMessage';
|
||||||
import useRoles from 'hooks/useRoles.ee';
|
import useRoles from 'hooks/useRoles.ee';
|
||||||
|
import useAdminCreateUser from 'hooks/useAdminCreateUser';
|
||||||
|
|
||||||
function generateRoleOptions(roles) {
|
function generateRoleOptions(roles) {
|
||||||
return roles?.map(({ name: label, id: value }) => ({ label, value }));
|
return roles?.map(({ name: label, id: value }) => ({ label, value }));
|
||||||
@@ -24,7 +23,11 @@ function generateRoleOptions(roles) {
|
|||||||
|
|
||||||
export default function CreateUser() {
|
export default function CreateUser() {
|
||||||
const formatMessage = useFormatMessage();
|
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 { data: rolesData, loading: isRolesLoading } = useRoles();
|
||||||
const roles = rolesData?.data;
|
const roles = rolesData?.data;
|
||||||
const enqueueSnackbar = useEnqueueSnackbar();
|
const enqueueSnackbar = useEnqueueSnackbar();
|
||||||
@@ -33,17 +36,13 @@ export default function CreateUser() {
|
|||||||
const handleUserCreation = async (userData) => {
|
const handleUserCreation = async (userData) => {
|
||||||
try {
|
try {
|
||||||
await createUser({
|
await createUser({
|
||||||
variables: {
|
fullName: userData.fullName,
|
||||||
input: {
|
email: userData.email,
|
||||||
fullName: userData.fullName,
|
roleId: userData.role?.id,
|
||||||
email: userData.email,
|
|
||||||
role: {
|
|
||||||
id: userData.role?.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
|
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||||
|
|
||||||
enqueueSnackbar(formatMessage('createUser.successfullyCreated'), {
|
enqueueSnackbar(formatMessage('createUser.successfullyCreated'), {
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
persist: true,
|
persist: true,
|
||||||
@@ -52,6 +51,14 @@ export default function CreateUser() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
enqueueSnackbar(formatMessage('createUser.error'), {
|
||||||
|
variant: 'error',
|
||||||
|
persist: true,
|
||||||
|
SnackbarProps: {
|
||||||
|
'data-test': 'snackbar-error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
throw new Error('Failed while creating!');
|
throw new Error('Failed while creating!');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -107,13 +114,13 @@ export default function CreateUser() {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
sx={{ boxShadow: 2 }}
|
sx={{ boxShadow: 2 }}
|
||||||
loading={loading}
|
loading={isCreateUserPending}
|
||||||
data-test="create-button"
|
data-test="create-button"
|
||||||
>
|
>
|
||||||
{formatMessage('createUser.submit')}
|
{formatMessage('createUser.submit')}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
|
|
||||||
{data && (
|
{createdUser && (
|
||||||
<Alert
|
<Alert
|
||||||
severity="info"
|
severity="info"
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -123,11 +130,11 @@ export default function CreateUser() {
|
|||||||
{formatMessage('createUser.invitationEmailInfo', {
|
{formatMessage('createUser.invitationEmailInfo', {
|
||||||
link: () => (
|
link: () => (
|
||||||
<a
|
<a
|
||||||
href={data.createUser.acceptInvitationUrl}
|
href={createdUser.data.acceptInvitationUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
{data.createUser.acceptInvitationUrl}
|
{createdUser.data.acceptInvitationUrl}
|
||||||
</a>
|
</a>
|
||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
|
Reference in New Issue
Block a user