Merge pull request #1954 from automatisch/user-invitation

feat: Implement user invitation functionality
This commit is contained in:
Ömer Faruk Aydın
2024-07-11 12:18:20 +02:00
committed by GitHub
34 changed files with 498 additions and 53 deletions

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,16 @@
import appConfig from '../../config/app.js';
import User from '../../models/user.js'; import User from '../../models/user.js';
import Role from '../../models/role.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) => { const createUser = async (_parent, params, context) => {
context.currentUser.can('create', 'User'); context.currentUser.can('create', 'User');
const { fullName, email, password } = params.input; const { fullName, email } = params.input;
const existingUser = await User.query().findOne({ const existingUser = await User.query().findOne({
email: email.toLowerCase(), email: email.toLowerCase(),
@@ -17,7 +23,7 @@ const createUser = async (_parent, params, context) => {
const userPayload = { const userPayload = {
fullName, fullName,
email, email,
password, status: 'invited',
}; };
try { try {
@@ -32,7 +38,29 @@ const createUser = async (_parent, params, context) => {
const user = await User.query().insert(userPayload); 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; export default createUser;

View File

@@ -22,7 +22,7 @@ const forgotPassword = async (_parent, params) => {
const jobPayload = { const jobPayload = {
email: user.email, email: user.email,
subject: 'Reset Password', subject: 'Reset Password',
template: 'reset-password-instructions', template: 'reset-password-instructions.ee',
params: { params: {
token: user.resetPasswordToken, token: user.resetPasswordToken,
webAppUrl: appConfig.webAppUrl, webAppUrl: appConfig.webAppUrl,

View File

@@ -8,7 +8,7 @@ type Mutation {
createFlow(input: CreateFlowInput): Flow createFlow(input: CreateFlowInput): Flow
createRole(input: CreateRoleInput): Role createRole(input: CreateRoleInput): Role
createStep(input: CreateStepInput): Step createStep(input: CreateStepInput): Step
createUser(input: CreateUserInput): User createUser(input: CreateUserInput): UserWithAcceptInvitationUrl
deleteConnection(input: DeleteConnectionInput): Boolean deleteConnection(input: DeleteConnectionInput): Boolean
deleteCurrentUser: Boolean deleteCurrentUser: Boolean
deleteFlow(input: DeleteFlowInput): Boolean deleteFlow(input: DeleteFlowInput): Boolean
@@ -375,7 +375,6 @@ input DeleteStepInput {
input CreateUserInput { input CreateUserInput {
fullName: String! fullName: String!
email: String! email: String!
password: String!
role: UserRoleInput! role: UserRoleInput!
} }
@@ -520,6 +519,11 @@ 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

@@ -6,7 +6,7 @@ import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const compileEmail = (emailPath, replacements = {}) => { 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 source = fs.readFileSync(filePath, 'utf-8').toString();
const template = handlebars.compile(source); const template = handlebars.compile(source);
return template(replacements); return template(replacements);

View File

@@ -33,8 +33,21 @@ class User extends Base {
fullName: { type: 'string', minLength: 1 }, fullName: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 }, email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 },
password: { type: 'string' }, password: { type: 'string' },
status: {
type: 'string',
enum: ['active', 'invited'],
default: 'active',
},
resetPasswordToken: { type: ['string', 'null'] }, 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' }, trialExpiryDate: { type: 'string' },
roleId: { type: 'string', format: 'uuid' }, roleId: { type: 'string', format: 'uuid' },
deletedAt: { type: 'string' }, deletedAt: { type: 'string' },
@@ -202,6 +215,13 @@ class User extends Base {
await this.$query().patch({ resetPasswordToken, resetPasswordTokenSentAt }); 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) { async resetPassword(password) {
return await this.$query().patch({ return await this.$query().patch({
resetPasswordToken: null, 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) { if (!this.resetPasswordTokenSentAt) {
return false; return false;
} }
@@ -222,6 +251,18 @@ class User extends Base {
return now.getTime() - sentAt.getTime() < fourHoursInMilliseconds; 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() { async generateHash() {
if (this.password) { if (this.password) {
this.password = await bcrypt.hash(this.password, 10); this.password = await bcrypt.hash(this.password, 10);
@@ -381,7 +422,7 @@ class User extends Base {
email, email,
password, password,
fullName, fullName,
roleId: adminRole.id roleId: adminRole.id,
}); });
await Config.markInstallationCompleted(); await Config.markInstallationCompleted();

View File

@@ -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 getInvoicesAction from '../../../controllers/api/v1/users/get-invoices.ee.js';
import getSubscriptionAction from '../../../controllers/api/v1/users/get-subscription.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 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(); const router = Router();
@@ -49,4 +50,6 @@ router.get(
asyncHandler(getPlanAndUsageAction) asyncHandler(getPlanAndUsageAction)
); );
router.post('/invitation', asyncHandler(acceptInvitationAction));
export default router; export default router;

View File

@@ -8,6 +8,7 @@ const userSerializer = (user) => {
email: user.email, email: user.email,
createdAt: user.createdAt.getTime(), createdAt: user.createdAt.getTime(),
updatedAt: user.updatedAt.getTime(), updatedAt: user.updatedAt.getTime(),
status: user.status,
fullName: user.fullName, fullName: user.fullName,
}; };

View File

@@ -35,6 +35,7 @@ describe('userSerializer', () => {
email: user.email, email: user.email,
fullName: user.fullName, fullName: user.fullName,
id: user.id, id: user.id,
status: user.status,
updatedAt: user.updatedAt.getTime(), updatedAt: user.updatedAt.getTime(),
}; };

View File

@@ -0,0 +1,23 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Invitation instructions</title>
</head>
<body>
<p>
Hello {{ fullName }},
</p>
<p>
You have been invited to join our platform. To accept the invitation, click the link below.
</p>
<p>
<a href="{{ acceptInvitationUrl }}">Accept invitation</a>
</p>
<p>
If you did not expect this invitation, you can ignore this email.
</p>
</body>
</html>

View File

@@ -9,7 +9,7 @@
</p> </p>
<p> <p>
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.
</p> </p>
<p> <p>

View File

@@ -14,6 +14,7 @@ const getUserMock = (currentUser, role) => {
name: role.name, name: role.name,
updatedAt: role.updatedAt.getTime(), updatedAt: role.updatedAt.getTime(),
}, },
status: currentUser.status,
trialExpiryDate: currentUser.trialExpiryDate.toISOString(), trialExpiryDate: currentUser.trialExpiryDate.toISOString(),
updatedAt: currentUser.updatedAt.getTime(), updatedAt: currentUser.updatedAt.getTime(),
}, },

View File

@@ -18,6 +18,7 @@ const getUsersMock = async (users, roles) => {
updatedAt: role.updatedAt.getTime(), updatedAt: role.updatedAt.getTime(),
} }
: null, : null,
status: user.status,
trialExpiryDate: user.trialExpiryDate.toISOString(), trialExpiryDate: user.trialExpiryDate.toISOString(),
updatedAt: user.updatedAt.getTime(), updatedAt: user.updatedAt.getTime(),
}; };

View File

@@ -23,6 +23,7 @@ const getCurrentUserMock = (currentUser, role, permissions) => {
name: role.name, name: role.name,
updatedAt: role.updatedAt.getTime(), updatedAt: role.updatedAt.getTime(),
}, },
status: currentUser.status,
trialExpiryDate: currentUser.trialExpiryDate.toISOString(), trialExpiryDate: currentUser.trialExpiryDate.toISOString(),
updatedAt: currentUser.updatedAt.getTime(), updatedAt: currentUser.updatedAt.getTime(),
}, },

View File

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

View File

@@ -11,10 +11,11 @@ export class AdminCreateUserPage extends AuthenticatedPage {
super(page); super(page);
this.fullNameInput = page.getByTestId('full-name-input'); this.fullNameInput = page.getByTestId('full-name-input');
this.emailInput = page.getByTestId('email-input'); this.emailInput = page.getByTestId('email-input');
this.passwordInput = page.getByTestId('password-input');
this.roleInput = page.getByTestId('role.id-autocomplete'); this.roleInput = page.getByTestId('role.id-autocomplete');
this.createButton = page.getByTestId('create-button'); this.createButton = page.getByTestId('create-button');
this.pageTitle = page.getByTestId('create-user-title'); 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) { seed(seed) {
@@ -25,7 +26,6 @@ export class AdminCreateUserPage extends AuthenticatedPage {
return { return {
fullName: faker.person.fullName(), fullName: faker.person.fullName(),
email: faker.internet.email().toLowerCase(), email: faker.internet.email().toLowerCase(),
password: faker.internet.password(),
}; };
} }
} }

View File

@@ -1,5 +1,6 @@
const { test, expect } = require('../../fixtures/index'); const { test, expect } = require('../../fixtures/index');
const { LoginPage } = require('../../fixtures/login-page'); const { LoginPage } = require('../../fixtures/login-page');
const { AcceptInvitation } = require('../../fixtures/accept-invitation-page');
test.describe('Role management page', () => { test.describe('Role management page', () => {
test('Admin role is not deletable', async ({ adminRolesPage }) => { test('Admin role is not deletable', async ({ adminRolesPage }) => {
@@ -190,13 +191,15 @@ test.describe('Role management page', () => {
await adminCreateUserPage.emailInput.fill( await adminCreateUserPage.emailInput.fill(
'user-role-test@automatisch.io' 'user-role-test@automatisch.io'
); );
await adminCreateUserPage.passwordInput.fill('sample');
await adminCreateUserPage.roleInput.click(); await adminCreateUserPage.roleInput.click();
await adminCreateUserPage.page await adminCreateUserPage.page
.getByRole('option', { name: 'Delete Role', exact: true }) .getByRole('option', { name: 'Delete Role', exact: true })
.click(); .click();
await adminCreateUserPage.createButton.click(); await adminCreateUserPage.createButton.click();
await adminUsersPage.snackbar.waitFor({ await adminCreateUserPage.snackbar.waitFor({
state: 'attached',
});
await adminCreateUserPage.invitationEmailInfoAlert.waitFor({
state: 'attached', state: 'attached',
}); });
const snackbar = await adminUsersPage.getSnackbarData( const snackbar = await adminUsersPage.getSnackbarData(
@@ -292,7 +295,6 @@ test.describe('Role management page', () => {
await adminCreateUserPage.emailInput.fill( await adminCreateUserPage.emailInput.fill(
'user-delete-role-test@automatisch.io' 'user-delete-role-test@automatisch.io'
); );
await adminCreateUserPage.passwordInput.fill('sample');
await adminCreateUserPage.roleInput.click(); await adminCreateUserPage.roleInput.click();
await adminCreateUserPage.page await adminCreateUserPage.page
.getByRole('option', { name: 'Cannot Delete Role' }) .getByRole('option', { name: 'Cannot Delete Role' })
@@ -301,6 +303,9 @@ test.describe('Role management page', () => {
await adminCreateUserPage.snackbar.waitFor({ await adminCreateUserPage.snackbar.waitFor({
state: 'attached', state: 'attached',
}); });
await adminCreateUserPage.invitationEmailInfoAlert.waitFor({
state: 'attached',
});
const snackbar = await adminCreateUserPage.getSnackbarData( const snackbar = await adminCreateUserPage.getSnackbarData(
'snackbar-create-user-success' 'snackbar-create-user-success'
); );
@@ -333,7 +338,7 @@ test.describe('Role management page', () => {
state: 'attached', state: 'attached',
}); });
/* /*
* TODO: await snackbar - make assertions based on product * TODO: await snackbar - make assertions based on product
* decisions * decisions
const snackbar = await adminRolesPage.getSnackbarData(); const snackbar = await adminRolesPage.getSnackbarData();
await expect(snackbar.variant).toBe('...'); await expect(snackbar.variant).toBe('...');
@@ -374,7 +379,6 @@ test('Accessibility of role management page', async ({
await adminCreateUserPage.isMounted(); await adminCreateUserPage.isMounted();
await adminCreateUserPage.fullNameInput.fill('Role Test'); await adminCreateUserPage.fullNameInput.fill('Role Test');
await adminCreateUserPage.emailInput.fill('basic-role-test@automatisch.io'); await adminCreateUserPage.emailInput.fill('basic-role-test@automatisch.io');
await adminCreateUserPage.passwordInput.fill('sample');
await adminCreateUserPage.roleInput.click(); await adminCreateUserPage.roleInput.click();
await adminCreateUserPage.page await adminCreateUserPage.page
.getByRole('option', { name: 'Basic Test' }) .getByRole('option', { name: 'Basic Test' })
@@ -383,6 +387,9 @@ test('Accessibility of role management page', async ({
await adminCreateUserPage.snackbar.waitFor({ await adminCreateUserPage.snackbar.waitFor({
state: 'attached', state: 'attached',
}); });
await adminCreateUserPage.invitationEmailInfoAlert.waitFor({
state: 'attached',
});
const snackbar = await adminCreateUserPage.getSnackbarData( const snackbar = await adminCreateUserPage.getSnackbarData(
'snackbar-create-user-success' '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 () => { 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('profile-menu-button').click();
await page.getByTestId('logout-item').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); const loginPage = new LoginPage(page);
// await loginPage.isMounted(); // await loginPage.isMounted();
await loginPage.login('basic-role-test@automatisch.io', 'sample'); await loginPage.login('basic-role-test@automatisch.io', 'sample');
await expect(loginPage.loginButton).not.toBeVisible(); await expect(loginPage.loginButton).not.toBeVisible();
@@ -410,9 +430,14 @@ test('Accessibility of role management page', async ({
await page.waitForTimeout(750); await page.waitForTimeout(750);
const isUnmounted = await page.evaluate(() => { const isUnmounted = await page.evaluate(() => {
const root = document.querySelector('#root'); const root = document.querySelector('#root');
if (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; return false;
}); });
await expect(isUnmounted).toBe(true); await expect(isUnmounted).toBe(true);

View File

@@ -29,16 +29,20 @@ test.describe('User management page', () => {
await adminUsersPage.createUserButton.click(); await adminUsersPage.createUserButton.click();
await adminCreateUserPage.fullNameInput.fill(user.fullName); await adminCreateUserPage.fullNameInput.fill(user.fullName);
await adminCreateUserPage.emailInput.fill(user.email); await adminCreateUserPage.emailInput.fill(user.email);
await adminCreateUserPage.passwordInput.fill(user.password);
await adminCreateUserPage.roleInput.click(); await adminCreateUserPage.roleInput.click();
await adminCreateUserPage.page.getByRole( await adminCreateUserPage.page.getByRole(
'option', { name: 'Admin' } 'option', { name: 'Admin' }
).click(); ).click();
await adminCreateUserPage.createButton.click(); await adminCreateUserPage.createButton.click();
await adminCreateUserPage.invitationEmailInfoAlert.waitFor({
state: 'attached'
});
const snackbar = await adminUsersPage.getSnackbarData( const snackbar = await adminUsersPage.getSnackbarData(
'snackbar-create-user-success' 'snackbar-create-user-success'
); );
await expect(snackbar.variant).toBe('success'); await expect(snackbar.variant).toBe('success');
await adminUsersPage.navigateTo();
await adminUsersPage.closeSnackbar(); await adminUsersPage.closeSnackbar();
} }
); );
@@ -57,7 +61,7 @@ test.describe('User management page', () => {
'Edit user info and make sure the edit works correctly', 'Edit user info and make sure the edit works correctly',
async () => { async () => {
await adminUsersPage.findUserPageWithEmail(user.email); await adminUsersPage.findUserPageWithEmail(user.email);
let userRow = await adminUsersPage.getUserRowByEmail(user.email); let userRow = await adminUsersPage.getUserRowByEmail(user.email);
await adminUsersPage.clickEditUser(userRow); await adminUsersPage.clickEditUser(userRow);
await adminEditUserPage.waitForLoad(user.fullName); await adminEditUserPage.waitForLoad(user.fullName);
@@ -85,7 +89,7 @@ test.describe('User management page', () => {
await adminUsersPage.clickDeleteUser(userRow); await adminUsersPage.clickDeleteUser(userRow);
const modal = adminUsersPage.deleteUserModal; const modal = adminUsersPage.deleteUserModal;
await modal.deleteButton.click(); await modal.deleteButton.click();
const snackbar = await adminUsersPage.getSnackbarData( const snackbar = await adminUsersPage.getSnackbarData(
'snackbar-delete-user-success' 'snackbar-delete-user-success'
); );
@@ -105,10 +109,10 @@ test.describe('User management page', () => {
await test.step( await test.step(
'Create the test user', 'Create the test user',
async () => { async () => {
await adminUsersPage.navigateTo();
await adminUsersPage.createUserButton.click(); await adminUsersPage.createUserButton.click();
await adminCreateUserPage.fullNameInput.fill(testUser.fullName); await adminCreateUserPage.fullNameInput.fill(testUser.fullName);
await adminCreateUserPage.emailInput.fill(testUser.email); await adminCreateUserPage.emailInput.fill(testUser.email);
await adminCreateUserPage.passwordInput.fill(testUser.password);
await adminCreateUserPage.roleInput.click(); await adminCreateUserPage.roleInput.click();
await adminCreateUserPage.page.getByRole( await adminCreateUserPage.page.getByRole(
'option', { name: 'Admin' } 'option', { name: 'Admin' }
@@ -125,6 +129,7 @@ test.describe('User management page', () => {
await test.step( await test.step(
'Delete the created user', 'Delete the created user',
async () => { async () => {
await adminUsersPage.navigateTo();
await adminUsersPage.findUserPageWithEmail(testUser.email); await adminUsersPage.findUserPageWithEmail(testUser.email);
const userRow = await adminUsersPage.getUserRowByEmail(testUser.email); const userRow = await adminUsersPage.getUserRowByEmail(testUser.email);
await adminUsersPage.clickDeleteUser(userRow); await adminUsersPage.clickDeleteUser(userRow);
@@ -146,7 +151,6 @@ test.describe('User management page', () => {
await adminUsersPage.createUserButton.click(); await adminUsersPage.createUserButton.click();
await adminCreateUserPage.fullNameInput.fill(testUser.fullName); await adminCreateUserPage.fullNameInput.fill(testUser.fullName);
await adminCreateUserPage.emailInput.fill(testUser.email); await adminCreateUserPage.emailInput.fill(testUser.email);
await adminCreateUserPage.passwordInput.fill(testUser.password);
await adminCreateUserPage.roleInput.click(); await adminCreateUserPage.roleInput.click();
await adminCreateUserPage.page.getByRole( await adminCreateUserPage.page.getByRole(
'option', { name: 'Admin' } 'option', { name: 'Admin' }
@@ -179,7 +183,6 @@ test.describe('User management page', () => {
await adminUsersPage.createUserButton.click(); await adminUsersPage.createUserButton.click();
await adminCreateUserPage.fullNameInput.fill(testUser.fullName); await adminCreateUserPage.fullNameInput.fill(testUser.fullName);
await adminCreateUserPage.emailInput.fill(testUser.email); await adminCreateUserPage.emailInput.fill(testUser.email);
await adminCreateUserPage.passwordInput.fill(testUser.password);
await adminCreateUserPage.roleInput.click(); await adminCreateUserPage.roleInput.click();
await adminCreateUserPage.page.getByRole( await adminCreateUserPage.page.getByRole(
'option', { name: 'Admin' } 'option', { name: 'Admin' }
@@ -196,17 +199,17 @@ test.describe('User management page', () => {
await test.step( await test.step(
'Create the user again', 'Create the user again',
async () => { async () => {
await adminUsersPage.navigateTo();
await adminUsersPage.createUserButton.click(); await adminUsersPage.createUserButton.click();
await adminCreateUserPage.fullNameInput.fill(testUser.fullName); await adminCreateUserPage.fullNameInput.fill(testUser.fullName);
await adminCreateUserPage.emailInput.fill(testUser.email); await adminCreateUserPage.emailInput.fill(testUser.email);
await adminCreateUserPage.passwordInput.fill(testUser.password);
const createUserPageUrl = page.url(); const createUserPageUrl = page.url();
await adminCreateUserPage.roleInput.click(); await adminCreateUserPage.roleInput.click();
await adminCreateUserPage.page.getByRole( await adminCreateUserPage.page.getByRole(
'option', { name: 'Admin' } 'option', { name: 'Admin' }
).click(); ).click();
await adminCreateUserPage.createButton.click(); await adminCreateUserPage.createButton.click();
await expect(page.url()).toBe(createUserPageUrl); await expect(page.url()).toBe(createUserPageUrl);
const snackbar = await adminUsersPage.getSnackbarData('snackbar-error'); const snackbar = await adminUsersPage.getSnackbarData('snackbar-error');
await expect(snackbar.variant).toBe('error'); await expect(snackbar.variant).toBe('error');
@@ -227,10 +230,10 @@ test.describe('User management page', () => {
await test.step( await test.step(
'Create the first user', 'Create the first user',
async () => { async () => {
await adminUsersPage.navigateTo();
await adminUsersPage.createUserButton.click(); await adminUsersPage.createUserButton.click();
await adminCreateUserPage.fullNameInput.fill(user1.fullName); await adminCreateUserPage.fullNameInput.fill(user1.fullName);
await adminCreateUserPage.emailInput.fill(user1.email); await adminCreateUserPage.emailInput.fill(user1.email);
await adminCreateUserPage.passwordInput.fill(user1.password);
await adminCreateUserPage.roleInput.click(); await adminCreateUserPage.roleInput.click();
await adminCreateUserPage.page.getByRole( await adminCreateUserPage.page.getByRole(
'option', { name: 'Admin' } 'option', { name: 'Admin' }
@@ -247,10 +250,10 @@ test.describe('User management page', () => {
await test.step( await test.step(
'Create the second user', 'Create the second user',
async () => { async () => {
await adminUsersPage.navigateTo();
await adminUsersPage.createUserButton.click(); await adminUsersPage.createUserButton.click();
await adminCreateUserPage.fullNameInput.fill(user2.fullName); await adminCreateUserPage.fullNameInput.fill(user2.fullName);
await adminCreateUserPage.emailInput.fill(user2.email); await adminCreateUserPage.emailInput.fill(user2.email);
await adminCreateUserPage.passwordInput.fill(user2.password);
await adminCreateUserPage.roleInput.click(); await adminCreateUserPage.roleInput.click();
await adminCreateUserPage.page.getByRole( await adminCreateUserPage.page.getByRole(
'option', { name: 'Admin' } 'option', { name: 'Admin' }
@@ -267,6 +270,7 @@ test.describe('User management page', () => {
await test.step( await test.step(
'Try editing the second user to have the email of the first user', 'Try editing the second user to have the email of the first user',
async () => { async () => {
await adminUsersPage.navigateTo();
await adminUsersPage.findUserPageWithEmail(user2.email); await adminUsersPage.findUserPageWithEmail(user2.email);
let userRow = await adminUsersPage.getUserRowByEmail(user2.email); let userRow = await adminUsersPage.getUserRowByEmail(user2.email);
await adminUsersPage.clickEditUser(userRow); await adminUsersPage.clickEditUser(userRow);
@@ -285,4 +289,4 @@ test.describe('User management page', () => {
); );
} }
); );
}); });

View File

@@ -36,6 +36,7 @@ test.describe('Connections page', () => {
}) => { }) => {
await connectionsPage.clickAddConnectionButton(); await connectionsPage.clickAddConnectionButton();
await expect(page).toHaveURL('/app/ntfy/connections/add?shared=false'); 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 page.getByTestId('create-connection-button').click();
await expect( await expect(
page.getByTestId('create-connection-button') page.getByTestId('create-connection-button')

View File

@@ -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 (
<Paper sx={{ px: 2, py: 4 }}>
<Typography
variant="h3"
align="center"
sx={{
borderBottom: '1px solid',
borderColor: (theme) => theme.palette.text.disabled,
pb: 2,
mb: 2,
}}
gutterBottom
data-test="accept-invitation-form-title"
>
{formatMessage('acceptInvitationForm.title')}
</Typography>
<Form
onSubmit={handleSubmit}
resolver={yupResolver(validationSchema)}
mode="onChange"
render={({ formState: { errors, touchedFields } }) => (
<>
<TextField
label={formatMessage('acceptInvitationForm.passwordFieldLabel')}
name="password"
data-test="password-text-field"
fullWidth
margin="dense"
type="password"
error={touchedFields.password && !!errors?.password}
helperText={
touchedFields.password && errors?.password?.message
? formatMessage(errors?.password?.message, {
inputName: formatMessage(
'acceptInvitationForm.passwordFieldLabel',
),
})
: ''
}
/>
<TextField
label={formatMessage(
'acceptInvitationForm.confirmPasswordFieldLabel',
)}
name="confirmPassword"
data-test="confirm-password-text-field"
fullWidth
margin="dense"
type="password"
error={touchedFields.confirmPassword && !!errors?.confirmPassword}
helperText={
touchedFields.confirmPassword &&
errors?.confirmPassword?.message
? formatMessage(errors?.confirmPassword?.message, {
inputName: formatMessage(
'acceptInvitationForm.confirmPasswordFieldLabel',
),
})
: ''
}
/>
{acceptInvitation.isError && (
<Alert
severity="error"
sx={{ mt: 1, fontWeight: 500 }}
>
{formatMessage('acceptInvitationForm.invalidToken')}
</Alert>
)}
<LoadingButton
type="submit"
variant="contained"
data-test="submit-button"
color="primary"
sx={{ boxShadow: 2, my: 3 }}
loading={acceptInvitation.isPending}
disabled={!token}
fullWidth
>
{formatMessage('acceptInvitationForm.submit')}
</LoadingButton>
</>
)}
/>
</Paper>
);
}

View File

@@ -151,6 +151,7 @@ function AddAppConnection(props) {
color="primary" color="primary"
sx={{ boxShadow: 2 }} sx={{ boxShadow: 2 }}
loading={inProgress} loading={inProgress}
disabled={!authenticate}
data-test="create-connection-button" data-test="create-connection-button"
> >
{formatMessage('addAppConnection.submit')} {formatMessage('addAppConnection.submit')}

View File

@@ -1,6 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Chip from '@mui/material/Chip';
import Table from '@mui/material/Table'; import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody'; import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell'; import TableCell from '@mui/material/TableCell';
@@ -64,6 +65,15 @@ export default function UserList() {
</Typography> </Typography>
</TableCell> </TableCell>
<TableCell component="th">
<Typography
variant="subtitle1"
sx={{ color: 'text.secondary', fontWeight: 700 }}
>
{formatMessage('userList.status')}
</Typography>
</TableCell>
<TableCell component="th" /> <TableCell component="th" />
</TableRow> </TableRow>
</TableHead> </TableHead>
@@ -100,6 +110,12 @@ export default function UserList() {
</Typography> </Typography>
</TableCell> </TableCell>
<TableCell>
<Typography variant="subtitle2" data-test="user-status">
<Chip label={user.status} variant="outlined" color={user.status === 'active' ? 'success' : 'warning'} />
</Typography>
</TableCell>
<TableCell> <TableCell>
<Stack direction="row" gap={1} justifyContent="right"> <Stack direction="row" gap={1} justifyContent="right">
<IconButton <IconButton

View File

@@ -5,6 +5,7 @@ export const EXECUTION = (executionId) => `/executions/${executionId}`;
export const LOGIN = '/login'; export const LOGIN = '/login';
export const LOGIN_CALLBACK = `${LOGIN}/callback`; export const LOGIN_CALLBACK = `${LOGIN}/callback`;
export const SIGNUP = '/sign-up'; export const SIGNUP = '/sign-up';
export const ACCEPT_INVITATON = '/accept-invitation';
export const FORGOT_PASSWORD = '/forgot-password'; export const FORGOT_PASSWORD = '/forgot-password';
export const RESET_PASSWORD = '/reset-password'; export const RESET_PASSWORD = '/reset-password';
export const APPS = '/apps'; export const APPS = '/apps';

View File

@@ -2,12 +2,15 @@ import { gql } from '@apollo/client';
export const CREATE_USER = gql` export const CREATE_USER = gql`
mutation CreateUser($input: CreateUserInput) { mutation CreateUser($input: CreateUserInput) {
createUser(input: $input) { createUser(input: $input) {
id user {
email
fullName
role {
id id
email
fullName
role {
id
}
} }
acceptInvitationUrl
} }
} }
`; `;

View File

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

View File

@@ -35,6 +35,7 @@ root.render(
</SnackbarProvider> </SnackbarProvider>
</Router>, </Router>,
); );
// If you want to start measuring performance in your app, pass a function // If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log)) // to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals

View File

@@ -153,6 +153,14 @@
"resetPasswordForm.passwordFieldLabel": "Password", "resetPasswordForm.passwordFieldLabel": "Password",
"resetPasswordForm.confirmPasswordFieldLabel": "Confirm password", "resetPasswordForm.confirmPasswordFieldLabel": "Confirm password",
"resetPasswordForm.passwordUpdated": "The password has been updated. Now, you can login.", "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.informationText": "Tasks: {consumedTaskCount}/{allowedTaskCount} (Resets {relativeResetDate})",
"usageAlert.viewPlans": "View plans", "usageAlert.viewPlans": "View plans",
"jsonViewer.noDataFound": "We couldn't find anything matching your search", "jsonViewer.noDataFound": "We couldn't find anything matching your search",
@@ -190,7 +198,6 @@
"deleteUserButton.cancel": "Cancel", "deleteUserButton.cancel": "Cancel",
"deleteUserButton.confirm": "Delete", "deleteUserButton.confirm": "Delete",
"deleteUserButton.successfullyDeleted": "The user has been deleted.", "deleteUserButton.successfullyDeleted": "The user has been deleted.",
"editUserPage.title": "Edit user",
"createUserPage.title": "Create user", "createUserPage.title": "Create user",
"userForm.fullName": "Full name", "userForm.fullName": "Full name",
"userForm.email": "Email", "userForm.email": "Email",
@@ -198,11 +205,15 @@
"userForm.password": "Password", "userForm.password": "Password",
"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>",
"editUserPage.title": "Edit user",
"editUser.status": "Status",
"editUser.submit": "Update", "editUser.submit": "Update",
"editUser.successfullyUpdated": "The user has been updated.", "editUser.successfullyUpdated": "The user has been updated.",
"userList.fullName": "Full name", "userList.fullName": "Full name",
"userList.email": "Email", "userList.email": "Email",
"userList.role": "Role", "userList.role": "Role",
"userList.status": "Status",
"rolesPage.title": "Role management", "rolesPage.title": "Role management",
"rolesPage.createRole": "Create role", "rolesPage.createRole": "Create role",
"deleteRoleButton.title": "Delete role", "deleteRoleButton.title": "Delete role",

View File

@@ -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 (
<Box sx={{ display: 'flex', flex: 1, alignItems: 'center' }}>
<Container maxWidth="sm">
<AcceptInvitationForm />
</Container>
</Box>
);
}

View File

@@ -2,6 +2,7 @@ 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';
import Alert from '@mui/material/Alert';
import MuiTextField from '@mui/material/TextField'; import MuiTextField from '@mui/material/TextField';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import * as React from 'react'; import * as React from 'react';
@@ -14,7 +15,6 @@ 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 * as URLS from 'config/urls';
import { CREATE_USER } from 'graphql/mutations/create-user.ee'; 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';
@@ -24,11 +24,10 @@ function generateRoleOptions(roles) {
} }
export default function CreateUser() { export default function CreateUser() {
const navigate = useNavigate();
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const [createUser, { loading }] = useMutation(CREATE_USER); const [createUser, { loading, data }] = useMutation(CREATE_USER);
const { data, loading: isRolesLoading } = useRoles(); const { data: rolesData, loading: isRolesLoading } = useRoles();
const roles = data?.data; const roles = rolesData?.data;
const enqueueSnackbar = useEnqueueSnackbar(); const enqueueSnackbar = useEnqueueSnackbar();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -38,7 +37,6 @@ export default function CreateUser() {
variables: { variables: {
input: { input: {
fullName: userData.fullName, fullName: userData.fullName,
password: userData.password,
email: userData.email, email: userData.email,
role: { role: {
id: userData.role?.id, id: userData.role?.id,
@@ -54,8 +52,6 @@ export default function CreateUser() {
'data-test': 'snackbar-create-user-success', 'data-test': 'snackbar-create-user-success',
}, },
}); });
navigate(URLS.USERS);
} catch (error) { } catch (error) {
throw new Error('Failed while creating!'); throw new Error('Failed while creating!');
} }
@@ -89,15 +85,6 @@ export default function CreateUser() {
fullWidth fullWidth
/> />
<TextField
required={true}
name="password"
label={formatMessage('userForm.password')}
type="password"
data-test="password-input"
fullWidth
/>
<Can I="update" a="Role"> <Can I="update" a="Role">
<ControlledAutocomplete <ControlledAutocomplete
name="role.id" name="role.id"
@@ -125,6 +112,27 @@ export default function CreateUser() {
> >
{formatMessage('createUser.submit')} {formatMessage('createUser.submit')}
</LoadingButton> </LoadingButton>
{data && (
<Alert
severity="info"
color="primary"
sx={{ fontWeight: '500' }}
data-test="invitation-email-info-alert"
>
{formatMessage('createUser.invitationEmailInfo', {
link: () => (
<a
href={data.createUser.acceptInvitationUrl}
target="_blank"
rel="noreferrer"
>
{data.createUser.acceptInvitationUrl}
</a>
),
})}
</Alert>
)}
</Stack> </Stack>
</Form> </Form>
</Grid> </Grid>

View File

@@ -3,6 +3,8 @@ import LoadingButton from '@mui/lab/LoadingButton';
import Grid from '@mui/material/Grid'; import Grid from '@mui/material/Grid';
import Skeleton from '@mui/material/Skeleton'; import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack'; 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 MuiTextField from '@mui/material/TextField';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import * as React from 'react'; import * as React from 'react';
@@ -82,6 +84,7 @@ export default function EditUser() {
<Skeleton variant="rounded" height={55} /> <Skeleton variant="rounded" height={55} />
<Skeleton variant="rounded" height={55} /> <Skeleton variant="rounded" height={55} />
<Skeleton variant="rounded" height={55} /> <Skeleton variant="rounded" height={55} />
<Skeleton variant="rounded" height={55} />
<Skeleton variant="rounded" height={45} /> <Skeleton variant="rounded" height={45} />
</Stack> </Stack>
)} )}
@@ -89,6 +92,18 @@ export default function EditUser() {
{!isUserLoading && ( {!isUserLoading && (
<Form defaultValues={user} onSubmit={handleUserUpdate}> <Form defaultValues={user} onSubmit={handleUserUpdate}>
<Stack direction="column" gap={2}> <Stack direction="column" gap={2}>
<Stack direction="row" gap={2} mb={2} alignItems="center">
<Typography variant="h6" noWrap>
{formatMessage('editUser.status')}
</Typography>
<Chip
label={user.status}
variant="outlined"
color={user.status === 'active' ? 'success' : 'warning'}
/>
</Stack>
<TextField <TextField
required={true} required={true}
name="fullName" name="fullName"

View File

@@ -3,8 +3,10 @@ import Box from '@mui/material/Box';
import useCloud from 'hooks/useCloud'; import useCloud from 'hooks/useCloud';
import Container from 'components/Container'; import Container from 'components/Container';
import ForgotPasswordForm from 'components/ForgotPasswordForm/index.ee'; import ForgotPasswordForm from 'components/ForgotPasswordForm/index.ee';
export default function ForgotPassword() { export default function ForgotPassword() {
useCloud({ redirect: true }); useCloud({ redirect: true });
return ( return (
<Box sx={{ display: 'flex', flex: 1, alignItems: 'center' }}> <Box sx={{ display: 'flex', flex: 1, alignItems: 'center' }}>
<Container maxWidth="sm"> <Container maxWidth="sm">

View File

@@ -11,6 +11,7 @@ import Execution from 'pages/Execution';
import Flows from 'pages/Flows'; import Flows from 'pages/Flows';
import Flow from 'pages/Flow'; import Flow from 'pages/Flow';
import Login from 'pages/Login'; import Login from 'pages/Login';
import AcceptInvitation from 'pages/AcceptInvitation';
import LoginCallback from 'pages/LoginCallback'; import LoginCallback from 'pages/LoginCallback';
import SignUp from 'pages/SignUp/index.ee'; import SignUp from 'pages/SignUp/index.ee';
import ForgotPassword from 'pages/ForgotPassword/index.ee'; import ForgotPassword from 'pages/ForgotPassword/index.ee';
@@ -106,6 +107,15 @@ function Routes() {
} }
/> />
<Route
path={URLS.ACCEPT_INVITATON}
element={
<PublicLayout>
<AcceptInvitation />
</PublicLayout>
}
/>
<Route <Route
path={URLS.FORGOT_PASSWORD} path={URLS.FORGOT_PASSWORD}
element={ element={