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 @@ + +
+ ++ 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/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 (
+