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..8c6763ac --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/accept-invitation.js @@ -0,0 +1,21 @@ +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()) { + return response.status(422).end(); + } + + await user.acceptInvitation(password); + + response.status(204).end(); +}; diff --git a/packages/backend/src/db/migrations/20240708140250_add_status_to_users.js b/packages/backend/src/db/migrations/20240708140250_add_status_to_users.js new file mode 100644 index 00000000..c47daf2a --- /dev/null +++ b/packages/backend/src/db/migrations/20240708140250_add_status_to_users.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return knex.schema.table('users', (table) => { + table.string('status').defaultTo('active'); + }); +} + +export async function down(knex) { + return knex.schema.table('users', (table) => { + table.dropColumn('status'); + }); +} 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..13fd2f17 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: 'invited', }; 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 72dee71a..54e79228 100644 --- a/packages/backend/src/models/user.js +++ b/packages/backend/src/models/user.js @@ -33,8 +33,21 @@ class User extends Base { fullName: { type: 'string', minLength: 1 }, email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 }, password: { type: 'string' }, + status: { + type: 'string', + enum: ['active', 'invited'], + default: 'active', + }, resetPasswordToken: { type: ['string', 'null'] }, - resetPasswordTokenSentAt: { type: ['string', 'null'], format: 'date-time' }, + resetPasswordTokenSentAt: { + 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' }, @@ -202,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, @@ -210,7 +230,16 @@ class User extends Base { }); } - async isResetPasswordTokenValid() { + async acceptInvitation(password) { + return await this.$query().patch({ + invitationToken: null, + invitationTokenSentAt: null, + status: 'active', + password, + }); + } + + isResetPasswordTokenValid() { if (!this.resetPasswordTokenSentAt) { return false; } @@ -222,6 +251,18 @@ class User extends Base { return now.getTime() - sentAt.getTime() < fourHoursInMilliseconds; } + 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); @@ -381,7 +422,7 @@ class User extends Base { email, password, fullName, - roleId: adminRole.id + roleId: adminRole.id, }); await Config.markInstallationCompleted(); 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/serializers/user.js b/packages/backend/src/serializers/user.js index 2cdec6d5..d6a147d8 100644 --- a/packages/backend/src/serializers/user.js +++ b/packages/backend/src/serializers/user.js @@ -8,6 +8,7 @@ const userSerializer = (user) => { email: user.email, createdAt: user.createdAt.getTime(), updatedAt: user.updatedAt.getTime(), + status: user.status, fullName: user.fullName, }; diff --git a/packages/backend/src/serializers/user.test.js b/packages/backend/src/serializers/user.test.js index 7a80cb9a..f084836a 100644 --- a/packages/backend/src/serializers/user.test.js +++ b/packages/backend/src/serializers/user.test.js @@ -35,6 +35,7 @@ describe('userSerializer', () => { email: user.email, fullName: user.fullName, id: user.id, + status: user.status, updatedAt: user.updatedAt.getTime(), }; 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/backend/test/mocks/rest/api/v1/admin/users/get-user.js b/packages/backend/test/mocks/rest/api/v1/admin/users/get-user.js index d917f16f..0ce3dd26 100644 --- a/packages/backend/test/mocks/rest/api/v1/admin/users/get-user.js +++ b/packages/backend/test/mocks/rest/api/v1/admin/users/get-user.js @@ -14,6 +14,7 @@ const getUserMock = (currentUser, role) => { name: role.name, updatedAt: role.updatedAt.getTime(), }, + status: currentUser.status, trialExpiryDate: currentUser.trialExpiryDate.toISOString(), updatedAt: currentUser.updatedAt.getTime(), }, diff --git a/packages/backend/test/mocks/rest/api/v1/admin/users/get-users.js b/packages/backend/test/mocks/rest/api/v1/admin/users/get-users.js index 0ae51773..e135217b 100644 --- a/packages/backend/test/mocks/rest/api/v1/admin/users/get-users.js +++ b/packages/backend/test/mocks/rest/api/v1/admin/users/get-users.js @@ -18,6 +18,7 @@ const getUsersMock = async (users, roles) => { updatedAt: role.updatedAt.getTime(), } : null, + status: user.status, trialExpiryDate: user.trialExpiryDate.toISOString(), updatedAt: user.updatedAt.getTime(), }; diff --git a/packages/backend/test/mocks/rest/api/v1/users/get-current-user.js b/packages/backend/test/mocks/rest/api/v1/users/get-current-user.js index cfbd0f54..2802290d 100644 --- a/packages/backend/test/mocks/rest/api/v1/users/get-current-user.js +++ b/packages/backend/test/mocks/rest/api/v1/users/get-current-user.js @@ -23,6 +23,7 @@ const getCurrentUserMock = (currentUser, role, permissions) => { name: role.name, updatedAt: role.updatedAt.getTime(), }, + status: currentUser.status, trialExpiryDate: currentUser.trialExpiryDate.toISOString(), updatedAt: currentUser.updatedAt.getTime(), }, diff --git a/packages/e2e-tests/fixtures/accept-invitation-page.js b/packages/e2e-tests/fixtures/accept-invitation-page.js new file mode 100644 index 00000000..72bef636 --- /dev/null +++ b/packages/e2e-tests/fixtures/accept-invitation-page.js @@ -0,0 +1,31 @@ +const { BasePage } = require('./base-page'); + +export class AcceptInvitation extends BasePage { + path = '/accept-invitation'; + + /** + * @param {import('@playwright/test').Page} page + */ + constructor(page) { + super(page); + + this.page = page; + this.passwordTextField = this.page.getByTestId('password-text-field'); + this.passwordConfirmationTextField = this.page.getByTestId('confirm-password-text-field'); + this.submitButton = this.page.getByTestId('submit-button'); + this.pageTitle = this.page.getByTestId('accept-invitation-form-title'); + } + + async open(token) { + return await this.page.goto(`${this.path}?token=${token}`); + } + + async acceptInvitation( + password + ) { + await this.passwordTextField.fill(password); + await this.passwordConfirmationTextField.fill(password); + + await this.submitButton.click(); + } +} diff --git a/packages/e2e-tests/fixtures/admin/create-user-page.js b/packages/e2e-tests/fixtures/admin/create-user-page.js index 222802cf..135b38fb 100644 --- a/packages/e2e-tests/fixtures/admin/create-user-page.js +++ b/packages/e2e-tests/fixtures/admin/create-user-page.js @@ -11,10 +11,11 @@ export class AdminCreateUserPage extends AuthenticatedPage { super(page); this.fullNameInput = page.getByTestId('full-name-input'); this.emailInput = page.getByTestId('email-input'); - this.passwordInput = page.getByTestId('password-input'); this.roleInput = page.getByTestId('role.id-autocomplete'); this.createButton = page.getByTestId('create-button'); this.pageTitle = page.getByTestId('create-user-title'); + this.invitationEmailInfoAlert = page.getByTestId('invitation-email-info-alert'); + this.acceptInvitationLink = page.getByTestId('invitation-email-info-alert').getByRole('link'); } seed(seed) { @@ -25,7 +26,6 @@ export class AdminCreateUserPage extends AuthenticatedPage { return { fullName: faker.person.fullName(), email: faker.internet.email().toLowerCase(), - password: faker.internet.password(), }; } } diff --git a/packages/e2e-tests/tests/admin/manage-roles.spec.js b/packages/e2e-tests/tests/admin/manage-roles.spec.js index c823645b..7a4317d6 100644 --- a/packages/e2e-tests/tests/admin/manage-roles.spec.js +++ b/packages/e2e-tests/tests/admin/manage-roles.spec.js @@ -1,5 +1,6 @@ const { test, expect } = require('../../fixtures/index'); const { LoginPage } = require('../../fixtures/login-page'); +const { AcceptInvitation } = require('../../fixtures/accept-invitation-page'); test.describe('Role management page', () => { test('Admin role is not deletable', async ({ adminRolesPage }) => { @@ -190,13 +191,15 @@ test.describe('Role management page', () => { await adminCreateUserPage.emailInput.fill( 'user-role-test@automatisch.io' ); - await adminCreateUserPage.passwordInput.fill('sample'); await adminCreateUserPage.roleInput.click(); await adminCreateUserPage.page .getByRole('option', { name: 'Delete Role', exact: true }) .click(); await adminCreateUserPage.createButton.click(); - await adminUsersPage.snackbar.waitFor({ + await adminCreateUserPage.snackbar.waitFor({ + state: 'attached', + }); + await adminCreateUserPage.invitationEmailInfoAlert.waitFor({ state: 'attached', }); const snackbar = await adminUsersPage.getSnackbarData( @@ -292,7 +295,6 @@ test.describe('Role management page', () => { await adminCreateUserPage.emailInput.fill( 'user-delete-role-test@automatisch.io' ); - await adminCreateUserPage.passwordInput.fill('sample'); await adminCreateUserPage.roleInput.click(); await adminCreateUserPage.page .getByRole('option', { name: 'Cannot Delete Role' }) @@ -301,6 +303,9 @@ test.describe('Role management page', () => { await adminCreateUserPage.snackbar.waitFor({ state: 'attached', }); + await adminCreateUserPage.invitationEmailInfoAlert.waitFor({ + state: 'attached', + }); const snackbar = await adminCreateUserPage.getSnackbarData( 'snackbar-create-user-success' ); @@ -333,7 +338,7 @@ test.describe('Role management page', () => { state: 'attached', }); /* - * TODO: await snackbar - make assertions based on product + * TODO: await snackbar - make assertions based on product * decisions const snackbar = await adminRolesPage.getSnackbarData(); await expect(snackbar.variant).toBe('...'); @@ -374,7 +379,6 @@ test('Accessibility of role management page', async ({ await adminCreateUserPage.isMounted(); await adminCreateUserPage.fullNameInput.fill('Role Test'); await adminCreateUserPage.emailInput.fill('basic-role-test@automatisch.io'); - await adminCreateUserPage.passwordInput.fill('sample'); await adminCreateUserPage.roleInput.click(); await adminCreateUserPage.page .getByRole('option', { name: 'Basic Test' }) @@ -383,6 +387,9 @@ test('Accessibility of role management page', async ({ await adminCreateUserPage.snackbar.waitFor({ state: 'attached', }); + await adminCreateUserPage.invitationEmailInfoAlert.waitFor({ + state: 'attached', + }); const snackbar = await adminCreateUserPage.getSnackbarData( 'snackbar-create-user-success' ); @@ -391,10 +398,23 @@ test('Accessibility of role management page', async ({ }); await test.step('Logout and login to the basic role user', async () => { + const acceptInvitationLink = await adminCreateUserPage.acceptInvitationLink; + console.log(acceptInvitationLink); + const acceptInvitationUrl = await acceptInvitationLink.textContent(); + console.log(acceptInvitationUrl); + const acceptInvitatonToken = acceptInvitationUrl.split('?token=')[1]; + await page.getByTestId('profile-menu-button').click(); await page.getByTestId('logout-item').click(); - // await page.reload({ waitUntil: 'networkidle' }); + + const acceptInvitationPage = new AcceptInvitation(page); + + await acceptInvitationPage.open(acceptInvitatonToken); + + await acceptInvitationPage.acceptInvitation('sample'); + const loginPage = new LoginPage(page); + // await loginPage.isMounted(); await loginPage.login('basic-role-test@automatisch.io', 'sample'); await expect(loginPage.loginButton).not.toBeVisible(); @@ -410,9 +430,14 @@ test('Accessibility of role management page', async ({ await page.waitForTimeout(750); const isUnmounted = await page.evaluate(() => { const root = document.querySelector('#root'); + if (root) { - return root.children.length === 0; + // We have react query devtools only in dev env. + // In production, there is nothing in root. + // That's why `<= 1`. + return root.children.length <= 1; } + return false; }); await expect(isUnmounted).toBe(true); diff --git a/packages/e2e-tests/tests/admin/manage-users.spec.js b/packages/e2e-tests/tests/admin/manage-users.spec.js index 7e6dab7d..fc3c324b 100644 --- a/packages/e2e-tests/tests/admin/manage-users.spec.js +++ b/packages/e2e-tests/tests/admin/manage-users.spec.js @@ -29,16 +29,20 @@ test.describe('User management page', () => { await adminUsersPage.createUserButton.click(); await adminCreateUserPage.fullNameInput.fill(user.fullName); await adminCreateUserPage.emailInput.fill(user.email); - await adminCreateUserPage.passwordInput.fill(user.password); await adminCreateUserPage.roleInput.click(); await adminCreateUserPage.page.getByRole( 'option', { name: 'Admin' } ).click(); await adminCreateUserPage.createButton.click(); + await adminCreateUserPage.invitationEmailInfoAlert.waitFor({ + state: 'attached' + }); + const snackbar = await adminUsersPage.getSnackbarData( 'snackbar-create-user-success' ); await expect(snackbar.variant).toBe('success'); + await adminUsersPage.navigateTo(); await adminUsersPage.closeSnackbar(); } ); @@ -57,7 +61,7 @@ test.describe('User management page', () => { 'Edit user info and make sure the edit works correctly', async () => { await adminUsersPage.findUserPageWithEmail(user.email); - + let userRow = await adminUsersPage.getUserRowByEmail(user.email); await adminUsersPage.clickEditUser(userRow); await adminEditUserPage.waitForLoad(user.fullName); @@ -85,7 +89,7 @@ test.describe('User management page', () => { await adminUsersPage.clickDeleteUser(userRow); const modal = adminUsersPage.deleteUserModal; await modal.deleteButton.click(); - + const snackbar = await adminUsersPage.getSnackbarData( 'snackbar-delete-user-success' ); @@ -105,10 +109,10 @@ test.describe('User management page', () => { await test.step( 'Create the test user', async () => { + await adminUsersPage.navigateTo(); await adminUsersPage.createUserButton.click(); await adminCreateUserPage.fullNameInput.fill(testUser.fullName); await adminCreateUserPage.emailInput.fill(testUser.email); - await adminCreateUserPage.passwordInput.fill(testUser.password); await adminCreateUserPage.roleInput.click(); await adminCreateUserPage.page.getByRole( 'option', { name: 'Admin' } @@ -125,6 +129,7 @@ test.describe('User management page', () => { await test.step( 'Delete the created user', async () => { + await adminUsersPage.navigateTo(); await adminUsersPage.findUserPageWithEmail(testUser.email); const userRow = await adminUsersPage.getUserRowByEmail(testUser.email); await adminUsersPage.clickDeleteUser(userRow); @@ -146,7 +151,6 @@ test.describe('User management page', () => { await adminUsersPage.createUserButton.click(); await adminCreateUserPage.fullNameInput.fill(testUser.fullName); await adminCreateUserPage.emailInput.fill(testUser.email); - await adminCreateUserPage.passwordInput.fill(testUser.password); await adminCreateUserPage.roleInput.click(); await adminCreateUserPage.page.getByRole( 'option', { name: 'Admin' } @@ -179,7 +183,6 @@ test.describe('User management page', () => { await adminUsersPage.createUserButton.click(); await adminCreateUserPage.fullNameInput.fill(testUser.fullName); await adminCreateUserPage.emailInput.fill(testUser.email); - await adminCreateUserPage.passwordInput.fill(testUser.password); await adminCreateUserPage.roleInput.click(); await adminCreateUserPage.page.getByRole( 'option', { name: 'Admin' } @@ -196,17 +199,17 @@ test.describe('User management page', () => { await test.step( 'Create the user again', async () => { + await adminUsersPage.navigateTo(); await adminUsersPage.createUserButton.click(); await adminCreateUserPage.fullNameInput.fill(testUser.fullName); await adminCreateUserPage.emailInput.fill(testUser.email); - await adminCreateUserPage.passwordInput.fill(testUser.password); const createUserPageUrl = page.url(); await adminCreateUserPage.roleInput.click(); await adminCreateUserPage.page.getByRole( 'option', { name: 'Admin' } ).click(); await adminCreateUserPage.createButton.click(); - + await expect(page.url()).toBe(createUserPageUrl); const snackbar = await adminUsersPage.getSnackbarData('snackbar-error'); await expect(snackbar.variant).toBe('error'); @@ -227,10 +230,10 @@ test.describe('User management page', () => { await test.step( 'Create the first user', async () => { + await adminUsersPage.navigateTo(); await adminUsersPage.createUserButton.click(); await adminCreateUserPage.fullNameInput.fill(user1.fullName); await adminCreateUserPage.emailInput.fill(user1.email); - await adminCreateUserPage.passwordInput.fill(user1.password); await adminCreateUserPage.roleInput.click(); await adminCreateUserPage.page.getByRole( 'option', { name: 'Admin' } @@ -247,10 +250,10 @@ test.describe('User management page', () => { await test.step( 'Create the second user', async () => { + await adminUsersPage.navigateTo(); await adminUsersPage.createUserButton.click(); await adminCreateUserPage.fullNameInput.fill(user2.fullName); await adminCreateUserPage.emailInput.fill(user2.email); - await adminCreateUserPage.passwordInput.fill(user2.password); await adminCreateUserPage.roleInput.click(); await adminCreateUserPage.page.getByRole( 'option', { name: 'Admin' } @@ -267,6 +270,7 @@ test.describe('User management page', () => { await test.step( 'Try editing the second user to have the email of the first user', async () => { + await adminUsersPage.navigateTo(); await adminUsersPage.findUserPageWithEmail(user2.email); let userRow = await adminUsersPage.getUserRowByEmail(user2.email); await adminUsersPage.clickEditUser(userRow); @@ -285,4 +289,4 @@ test.describe('User management page', () => { ); } ); -}); \ No newline at end of file +}); diff --git a/packages/e2e-tests/tests/connections/create-connection.spec.js b/packages/e2e-tests/tests/connections/create-connection.spec.js index f9f4669c..062d8ddd 100644 --- a/packages/e2e-tests/tests/connections/create-connection.spec.js +++ b/packages/e2e-tests/tests/connections/create-connection.spec.js @@ -36,6 +36,7 @@ test.describe('Connections page', () => { }) => { await connectionsPage.clickAddConnectionButton(); await expect(page).toHaveURL('/app/ntfy/connections/add?shared=false'); + await expect(page.getByTestId('create-connection-button')).not.toBeDisabled(); await page.getByTestId('create-connection-button').click(); await expect( page.getByTestId('create-connection-button') diff --git a/packages/web/src/components/AcceptInvitationForm/index.jsx b/packages/web/src/components/AcceptInvitationForm/index.jsx new file mode 100644 index 00000000..f967e031 --- /dev/null +++ b/packages/web/src/components/AcceptInvitationForm/index.jsx @@ -0,0 +1,138 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import LoadingButton from '@mui/lab/LoadingButton'; +import Paper from '@mui/material/Paper'; +import Alert from '@mui/material/Alert'; +import Typography from '@mui/material/Typography'; +import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; +import * as React from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import * as yup from 'yup'; +import Form from 'components/Form'; +import TextField from 'components/TextField'; +import * as URLS from 'config/urls'; +import useAcceptInvitation from 'hooks/useAcceptInvitation'; +import useFormatMessage from 'hooks/useFormatMessage'; + +const validationSchema = yup.object().shape({ + password: yup.string().required('acceptInvitationForm.mandatoryInput'), + confirmPassword: yup + .string() + .required('acceptInvitationForm.mandatoryInput') + .oneOf([yup.ref('password')], 'acceptInvitationForm.passwordsMustMatch'), +}); + +export default function ResetPasswordForm() { + const enqueueSnackbar = useEnqueueSnackbar(); + const formatMessage = useFormatMessage(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const acceptInvitation = useAcceptInvitation(); + const token = searchParams.get('token'); + + const handleSubmit = async (values) => { + await acceptInvitation.mutateAsync({ + password: values.password, + token, + }); + + enqueueSnackbar(formatMessage('acceptInvitationForm.invitationAccepted'), { + variant: 'success', + SnackbarProps: { + 'data-test': 'snackbar-accept-invitation-success', + }, + }); + + navigate(URLS.LOGIN); + }; + + return ( + + theme.palette.text.disabled, + pb: 2, + mb: 2, + }} + gutterBottom + data-test="accept-invitation-form-title" + > + {formatMessage('acceptInvitationForm.title')} + + +

( + <> + + + + + {acceptInvitation.isError && ( + + {formatMessage('acceptInvitationForm.invalidToken')} + + )} + + + {formatMessage('acceptInvitationForm.submit')} + + + )} + /> + + ); +} diff --git a/packages/web/src/components/AddAppConnection/index.jsx b/packages/web/src/components/AddAppConnection/index.jsx index 7c6f3e23..7187624e 100644 --- a/packages/web/src/components/AddAppConnection/index.jsx +++ b/packages/web/src/components/AddAppConnection/index.jsx @@ -151,6 +151,7 @@ function AddAppConnection(props) { color="primary" sx={{ boxShadow: 2 }} loading={inProgress} + disabled={!authenticate} data-test="create-connection-button" > {formatMessage('addAppConnection.submit')} diff --git a/packages/web/src/components/UserList/index.jsx b/packages/web/src/components/UserList/index.jsx index 7c383fae..0427a898 100644 --- a/packages/web/src/components/UserList/index.jsx +++ b/packages/web/src/components/UserList/index.jsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { Link } from 'react-router-dom'; import Stack from '@mui/material/Stack'; +import Chip from '@mui/material/Chip'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; import TableCell from '@mui/material/TableCell'; @@ -64,6 +65,15 @@ export default function UserList() { + + + {formatMessage('userList.status')} + + + @@ -100,6 +110,12 @@ export default function UserList() { + + + + + + `/executions/${executionId}`; export const LOGIN = '/login'; export const LOGIN_CALLBACK = `${LOGIN}/callback`; export const SIGNUP = '/sign-up'; +export const ACCEPT_INVITATON = '/accept-invitation'; export const FORGOT_PASSWORD = '/forgot-password'; export const RESET_PASSWORD = '/reset-password'; export const APPS = '/apps'; 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/hooks/useAcceptInvitation.js b/packages/web/src/hooks/useAcceptInvitation.js new file mode 100644 index 00000000..5628f559 --- /dev/null +++ b/packages/web/src/hooks/useAcceptInvitation.js @@ -0,0 +1,15 @@ +import { useMutation } from '@tanstack/react-query'; + +import api from 'helpers/api'; + +export default function useAcceptInvitation() { + const mutation = useMutation({ + mutationFn: async (payload) => { + const { data } = await api.post('/v1/users/invitation', payload); + + return data; + }, + }); + + return mutation; +} diff --git a/packages/web/src/index.jsx b/packages/web/src/index.jsx index 5f55d06c..608e89d0 100644 --- a/packages/web/src/index.jsx +++ b/packages/web/src/index.jsx @@ -35,6 +35,7 @@ root.render( , ); + // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 20b423b0..26d0c233 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -153,6 +153,14 @@ "resetPasswordForm.passwordFieldLabel": "Password", "resetPasswordForm.confirmPasswordFieldLabel": "Confirm password", "resetPasswordForm.passwordUpdated": "The password has been updated. Now, you can login.", + "acceptInvitationForm.passwordsMustMatch": "Passwords must match.", + "acceptInvitationForm.mandatoryInput": "{inputName} is required.", + "acceptInvitationForm.title": "Accept invitation", + "acceptInvitationForm.submit": "Set your password", + "acceptInvitationForm.passwordFieldLabel": "Password", + "acceptInvitationForm.confirmPasswordFieldLabel": "Confirm password", + "acceptInvitationForm.invitationAccepted": "The password has been set. Now, you can login.", + "acceptInvitationForm.invalidToken": "Invitation link is not valid or expired. You can use reset password to get a new link.", "usageAlert.informationText": "Tasks: {consumedTaskCount}/{allowedTaskCount} (Resets {relativeResetDate})", "usageAlert.viewPlans": "View plans", "jsonViewer.noDataFound": "We couldn't find anything matching your search", @@ -190,7 +198,6 @@ "deleteUserButton.cancel": "Cancel", "deleteUserButton.confirm": "Delete", "deleteUserButton.successfullyDeleted": "The user has been deleted.", - "editUserPage.title": "Edit user", "createUserPage.title": "Create user", "userForm.fullName": "Full name", "userForm.email": "Email", @@ -198,11 +205,15 @@ "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: ", + "editUserPage.title": "Edit user", + "editUser.status": "Status", "editUser.submit": "Update", "editUser.successfullyUpdated": "The user has been updated.", "userList.fullName": "Full name", "userList.email": "Email", "userList.role": "Role", + "userList.status": "Status", "rolesPage.title": "Role management", "rolesPage.createRole": "Create role", "deleteRoleButton.title": "Delete role", diff --git a/packages/web/src/pages/AcceptInvitation/index.jsx b/packages/web/src/pages/AcceptInvitation/index.jsx new file mode 100644 index 00000000..42b1d72e --- /dev/null +++ b/packages/web/src/pages/AcceptInvitation/index.jsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Container from 'components/Container'; +import AcceptInvitationForm from 'components/AcceptInvitationForm'; + +export default function AcceptInvitation() { + return ( + + + + + + ); +} diff --git a/packages/web/src/pages/CreateUser/index.jsx b/packages/web/src/pages/CreateUser/index.jsx index 8033d3ab..890f4be2 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'; @@ -14,7 +15,6 @@ import ControlledAutocomplete from 'components/ControlledAutocomplete'; import Form from 'components/Form'; import PageTitle from 'components/PageTitle'; import TextField from 'components/TextField'; -import * as URLS from 'config/urls'; import { CREATE_USER } from 'graphql/mutations/create-user.ee'; import useFormatMessage from 'hooks/useFormatMessage'; import useRoles from 'hooks/useRoles.ee'; @@ -24,11 +24,10 @@ 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 +37,6 @@ export default function CreateUser() { variables: { input: { fullName: userData.fullName, - password: userData.password, email: userData.email, role: { id: userData.role?.id, @@ -54,8 +52,6 @@ export default function CreateUser() { 'data-test': 'snackbar-create-user-success', }, }); - - navigate(URLS.USERS); } catch (error) { throw new Error('Failed while creating!'); } @@ -89,15 +85,6 @@ export default function CreateUser() { fullWidth /> - - {formatMessage('createUser.submit')} + + {data && ( + + {formatMessage('createUser.invitationEmailInfo', { + link: () => ( + + {data.createUser.acceptInvitationUrl} + + ), + })} + + )} diff --git a/packages/web/src/pages/EditUser/index.jsx b/packages/web/src/pages/EditUser/index.jsx index 72094845..779badb2 100644 --- a/packages/web/src/pages/EditUser/index.jsx +++ b/packages/web/src/pages/EditUser/index.jsx @@ -3,6 +3,8 @@ import LoadingButton from '@mui/lab/LoadingButton'; import Grid from '@mui/material/Grid'; import Skeleton from '@mui/material/Skeleton'; import Stack from '@mui/material/Stack'; +import Chip from '@mui/material/Chip'; +import Typography from '@mui/material/Typography'; import MuiTextField from '@mui/material/TextField'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import * as React from 'react'; @@ -82,6 +84,7 @@ export default function EditUser() { + )} @@ -89,6 +92,18 @@ export default function EditUser() { {!isUserLoading && (
+ + + {formatMessage('editUser.status')} + + + + + diff --git a/packages/web/src/routes.jsx b/packages/web/src/routes.jsx index 152b4e7a..3ed2dab0 100644 --- a/packages/web/src/routes.jsx +++ b/packages/web/src/routes.jsx @@ -11,6 +11,7 @@ import Execution from 'pages/Execution'; import Flows from 'pages/Flows'; import Flow from 'pages/Flow'; import Login from 'pages/Login'; +import AcceptInvitation from 'pages/AcceptInvitation'; import LoginCallback from 'pages/LoginCallback'; import SignUp from 'pages/SignUp/index.ee'; import ForgotPassword from 'pages/ForgotPassword/index.ee'; @@ -106,6 +107,15 @@ function Routes() { } /> + + + + } + /> +