Merge pull request #2085 from automatisch/aut-1259

feat: use REST API endpoint to create user
This commit is contained in:
Ömer Faruk Aydın
2024-09-19 13:58:13 +03:00
committed by GitHub
16 changed files with 121 additions and 126 deletions

View File

@@ -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) => {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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

View File

@@ -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,
}, },
}; };

View 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;

View 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
);
});
});

View File

@@ -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,

View File

@@ -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;

View File

@@ -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();
} }
); );

View File

@@ -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
}
}
`;

View File

@@ -12,7 +12,7 @@ export default function useAdminCreateRole() {
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['apps', 'roles'], queryKey: ['admin', 'roles'],
}); });
}, },
}); });

View 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;
}

View File

@@ -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",

View File

@@ -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>
), ),
})} })}