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 @@ + +
+ ++ Hello {{ fullName }}, +
+ ++ You have been invited to join our platform. To accept the invitation, click the link below. +
+ + + ++ 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
/>
-