diff --git a/packages/backend/src/controllers/api/v1/users/accept-invitation.js b/packages/backend/src/controllers/api/v1/users/accept-invitation.js new file mode 100644 index 00000000..9ff08b38 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/accept-invitation.js @@ -0,0 +1,23 @@ +import User from '../../../../models/user.js'; + +export default async (request, response) => { + const { token, password } = request.body; + + if (!token) { + throw new Error('Invitation token is required!'); + } + + const user = await User.query() + .findOne({ invitation_token: token }) + .throwIfNotFound(); + + if (!user.isInvitationTokenValid()) { + throw new Error( + 'Invitation link is not valid or expired. You can use reset password to get a new link.' + ); + } + + await user.acceptInvitation(password); + + response.status(204).end(); +}; diff --git a/packages/backend/src/db/migrations/20240708141218_add_invitation_token_to_users.js b/packages/backend/src/db/migrations/20240708141218_add_invitation_token_to_users.js new file mode 100644 index 00000000..a60168e8 --- /dev/null +++ b/packages/backend/src/db/migrations/20240708141218_add_invitation_token_to_users.js @@ -0,0 +1,13 @@ +export async function up(knex) { + return knex.schema.table('users', (table) => { + table.string('invitation_token'); + table.timestamp('invitation_token_sent_at'); + }); +} + +export async function down(knex) { + return knex.schema.table('users', (table) => { + table.dropColumn('invitation_token'); + table.dropColumn('invitation_token_sent_at'); + }); +} diff --git a/packages/backend/src/graphql/mutations/create-user.ee.js b/packages/backend/src/graphql/mutations/create-user.ee.js index 2bd732a1..3649b041 100644 --- a/packages/backend/src/graphql/mutations/create-user.ee.js +++ b/packages/backend/src/graphql/mutations/create-user.ee.js @@ -1,10 +1,16 @@ +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, password } = params.input; + const { fullName, email } = params.input; const existingUser = await User.query().findOne({ email: email.toLowerCase(), @@ -17,7 +23,7 @@ const createUser = async (_parent, params, context) => { const userPayload = { fullName, email, - password, + status: 'pending', }; try { @@ -32,7 +38,29 @@ const createUser = async (_parent, params, context) => { const user = await User.query().insert(userPayload); - return user; + 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/mutations/forgot-password.ee.js b/packages/backend/src/graphql/mutations/forgot-password.ee.js index da5446d9..60cb4bbc 100644 --- a/packages/backend/src/graphql/mutations/forgot-password.ee.js +++ b/packages/backend/src/graphql/mutations/forgot-password.ee.js @@ -22,7 +22,7 @@ const forgotPassword = async (_parent, params) => { const jobPayload = { email: user.email, subject: 'Reset Password', - template: 'reset-password-instructions', + template: 'reset-password-instructions.ee', params: { token: user.resetPasswordToken, webAppUrl: appConfig.webAppUrl, diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 2a5454d3..3944e1c5 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -8,7 +8,7 @@ type Mutation { createFlow(input: CreateFlowInput): Flow createRole(input: CreateRoleInput): Role createStep(input: CreateStepInput): Step - createUser(input: CreateUserInput): User + createUser(input: CreateUserInput): UserWithAcceptInvitationUrl deleteConnection(input: DeleteConnectionInput): Boolean deleteCurrentUser: Boolean deleteFlow(input: DeleteFlowInput): Boolean @@ -375,7 +375,6 @@ input DeleteStepInput { input CreateUserInput { fullName: String! email: String! - password: String! role: UserRoleInput! } @@ -520,6 +519,11 @@ type User { updatedAt: String } +type UserWithAcceptInvitationUrl { + user: User + acceptInvitationUrl: String +} + type Role { id: String name: String diff --git a/packages/backend/src/helpers/compile-email.ee.js b/packages/backend/src/helpers/compile-email.ee.js index ae866a36..305f87ed 100644 --- a/packages/backend/src/helpers/compile-email.ee.js +++ b/packages/backend/src/helpers/compile-email.ee.js @@ -6,7 +6,7 @@ import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const compileEmail = (emailPath, replacements = {}) => { - const filePath = path.join(__dirname, `../views/emails/${emailPath}.ee.hbs`); + const filePath = path.join(__dirname, `../views/emails/${emailPath}.hbs`); const source = fs.readFileSync(filePath, 'utf-8').toString(); const template = handlebars.compile(source); return template(replacements); diff --git a/packages/backend/src/models/user.js b/packages/backend/src/models/user.js index ceb4dd71..21816a39 100644 --- a/packages/backend/src/models/user.js +++ b/packages/backend/src/models/user.js @@ -43,6 +43,11 @@ class User extends Base { type: ['string', 'null'], format: 'date-time', }, + invitationToken: { type: ['string', 'null'] }, + invitationTokenSentAt: { + type: ['string', 'null'], + format: 'date-time', + }, trialExpiryDate: { type: 'string' }, roleId: { type: 'string', format: 'uuid' }, deletedAt: { type: 'string' }, @@ -210,6 +215,13 @@ class User extends Base { await this.$query().patch({ resetPasswordToken, resetPasswordTokenSentAt }); } + async generateInvitationToken() { + const invitationToken = crypto.randomBytes(64).toString('hex'); + const invitationTokenSentAt = new Date().toISOString(); + + await this.$query().patch({ invitationToken, invitationTokenSentAt }); + } + async resetPassword(password) { return await this.$query().patch({ resetPasswordToken: null, @@ -218,6 +230,14 @@ class User extends Base { }); } + async acceptInvitation(password) { + return await this.$query().patch({ + invitationToken: null, + invitationTokenSentAt: null, + password, + }); + } + async isResetPasswordTokenValid() { if (!this.resetPasswordTokenSentAt) { return false; @@ -230,6 +250,18 @@ class User extends Base { return now.getTime() - sentAt.getTime() < fourHoursInMilliseconds; } + async isInvitationTokenValid() { + if (!this.invitationTokenSentAt) { + return false; + } + + const sentAt = new Date(this.invitationTokenSentAt); + const now = new Date(); + const seventyTwoHoursInMilliseconds = 1000 * 60 * 60 * 72; + + return now.getTime() - sentAt.getTime() < seventyTwoHoursInMilliseconds; + } + async generateHash() { if (this.password) { this.password = await bcrypt.hash(this.password, 10); diff --git a/packages/backend/src/routes/api/v1/users.js b/packages/backend/src/routes/api/v1/users.js index 2755c3b6..c1432f64 100644 --- a/packages/backend/src/routes/api/v1/users.js +++ b/packages/backend/src/routes/api/v1/users.js @@ -9,6 +9,7 @@ import getAppsAction from '../../../controllers/api/v1/users/get-apps.js'; import getInvoicesAction from '../../../controllers/api/v1/users/get-invoices.ee.js'; import getSubscriptionAction from '../../../controllers/api/v1/users/get-subscription.ee.js'; import getPlanAndUsageAction from '../../../controllers/api/v1/users/get-plan-and-usage.ee.js'; +import acceptInvitationAction from '../../../controllers/api/v1/users/accept-invitation.js'; const router = Router(); @@ -49,4 +50,6 @@ router.get( asyncHandler(getPlanAndUsageAction) ); +router.post('/invitation', asyncHandler(acceptInvitationAction)); + export default router; diff --git a/packages/backend/src/views/emails/invitation-instructions.hbs b/packages/backend/src/views/emails/invitation-instructions.hbs new file mode 100644 index 00000000..aa7d7924 --- /dev/null +++ b/packages/backend/src/views/emails/invitation-instructions.hbs @@ -0,0 +1,23 @@ + + + + Invitation instructions + + +

+ Hello {{ fullName }}, +

+ +

+ You have been invited to join our platform. To accept the invitation, click the link below. +

+ +

+ Accept invitation +

+ +

+ If you did not expect this invitation, you can ignore this email. +

+ + diff --git a/packages/backend/src/views/emails/reset-password-instructions.ee.hbs b/packages/backend/src/views/emails/reset-password-instructions.ee.hbs index 8397c863..5392cf01 100644 --- a/packages/backend/src/views/emails/reset-password-instructions.ee.hbs +++ b/packages/backend/src/views/emails/reset-password-instructions.ee.hbs @@ -9,7 +9,7 @@

- Someone has requested a link to change your password, and you can do this through the link below. + Someone has requested a link to change your password, and you can do this through the link below within 72 hours.

diff --git a/packages/web/src/graphql/mutations/create-user.ee.js b/packages/web/src/graphql/mutations/create-user.ee.js index 3945922e..7ee526fd 100644 --- a/packages/web/src/graphql/mutations/create-user.ee.js +++ b/packages/web/src/graphql/mutations/create-user.ee.js @@ -2,12 +2,15 @@ import { gql } from '@apollo/client'; export const CREATE_USER = gql` mutation CreateUser($input: CreateUserInput) { createUser(input: $input) { - id - email - fullName - role { + user { id + email + fullName + role { + id + } } + acceptInvitationUrl } } `; diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 20b423b0..82b4d9c3 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -198,6 +198,7 @@ "userForm.password": "Password", "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: ", "editUser.submit": "Update", "editUser.successfullyUpdated": "The user has been updated.", "userList.fullName": "Full name", diff --git a/packages/web/src/pages/CreateUser/index.jsx b/packages/web/src/pages/CreateUser/index.jsx index 8033d3ab..b547e451 100644 --- a/packages/web/src/pages/CreateUser/index.jsx +++ b/packages/web/src/pages/CreateUser/index.jsx @@ -2,6 +2,7 @@ import { useMutation } from '@apollo/client'; import LoadingButton from '@mui/lab/LoadingButton'; import Grid from '@mui/material/Grid'; import Stack from '@mui/material/Stack'; +import Alert from '@mui/material/Alert'; import MuiTextField from '@mui/material/TextField'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import * as React from 'react'; @@ -26,9 +27,9 @@ function generateRoleOptions(roles) { export default function CreateUser() { const navigate = useNavigate(); const formatMessage = useFormatMessage(); - const [createUser, { loading }] = useMutation(CREATE_USER); - const { data, loading: isRolesLoading } = useRoles(); - const roles = data?.data; + const [createUser, { loading, data }] = useMutation(CREATE_USER); + const { data: rolesData, loading: isRolesLoading } = useRoles(); + const roles = rolesData?.data; const enqueueSnackbar = useEnqueueSnackbar(); const queryClient = useQueryClient(); @@ -38,7 +39,6 @@ export default function CreateUser() { variables: { input: { fullName: userData.fullName, - password: userData.password, email: userData.email, role: { id: userData.role?.id, @@ -54,8 +54,6 @@ export default function CreateUser() { 'data-test': 'snackbar-create-user-success', }, }); - - navigate(URLS.USERS); } catch (error) { throw new Error('Failed while creating!'); } @@ -89,15 +87,6 @@ export default function CreateUser() { fullWidth /> - - {formatMessage('createUser.submit')} + + {data && ( + + {formatMessage('createUser.invitationEmailInfo', { + link: () => ( + + {data.createUser.acceptInvitationUrl} + + ), + })} + + )}