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

+ Hello {{ fullName }}, +

+ +

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

+ +

+ Accept invitation +

+ +

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

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

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

diff --git a/packages/web/src/graphql/mutations/create-user.ee.js b/packages/web/src/graphql/mutations/create-user.ee.js index 3945922e..7ee526fd 100644 --- a/packages/web/src/graphql/mutations/create-user.ee.js +++ b/packages/web/src/graphql/mutations/create-user.ee.js @@ -2,12 +2,15 @@ import { gql } from '@apollo/client'; export const CREATE_USER = gql` mutation CreateUser($input: CreateUserInput) { createUser(input: $input) { - id - email - fullName - role { + user { id + email + fullName + role { + id + } } + acceptInvitationUrl } } `; diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 20b423b0..82b4d9c3 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -198,6 +198,7 @@ "userForm.password": "Password", "createUser.submit": "Create", "createUser.successfullyCreated": "The user has been created.", + "createUser.invitationEmailInfo": "Invitation email will be sent if SMTP credentials are valid. Otherwise, you can share the invitation link manually: ", "editUser.submit": "Update", "editUser.successfullyUpdated": "The user has been updated.", "userList.fullName": "Full name", diff --git a/packages/web/src/pages/CreateUser/index.jsx b/packages/web/src/pages/CreateUser/index.jsx index 8033d3ab..b547e451 100644 --- a/packages/web/src/pages/CreateUser/index.jsx +++ b/packages/web/src/pages/CreateUser/index.jsx @@ -2,6 +2,7 @@ import { useMutation } from '@apollo/client'; import LoadingButton from '@mui/lab/LoadingButton'; import Grid from '@mui/material/Grid'; import Stack from '@mui/material/Stack'; +import Alert from '@mui/material/Alert'; import MuiTextField from '@mui/material/TextField'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import * as React from 'react'; @@ -26,9 +27,9 @@ function generateRoleOptions(roles) { export default function CreateUser() { const navigate = useNavigate(); const formatMessage = useFormatMessage(); - const [createUser, { loading }] = useMutation(CREATE_USER); - const { data, loading: isRolesLoading } = useRoles(); - const roles = data?.data; + const [createUser, { loading, data }] = useMutation(CREATE_USER); + const { data: rolesData, loading: isRolesLoading } = useRoles(); + const roles = rolesData?.data; const enqueueSnackbar = useEnqueueSnackbar(); const queryClient = useQueryClient(); @@ -38,7 +39,6 @@ export default function CreateUser() { variables: { input: { fullName: userData.fullName, - password: userData.password, email: userData.email, role: { id: userData.role?.id, @@ -54,8 +54,6 @@ export default function CreateUser() { 'data-test': 'snackbar-create-user-success', }, }); - - navigate(URLS.USERS); } catch (error) { throw new Error('Failed while creating!'); } @@ -89,15 +87,6 @@ export default function CreateUser() { fullWidth /> - - {formatMessage('createUser.submit')} + + {data && ( + + {formatMessage('createUser.invitationEmailInfo', { + link: () => ( + + {data.createUser.acceptInvitationUrl} + + ), + })} + + )} From 9922033d33d79fe593f20e4bd548fbffe338992a Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Tue, 9 Jul 2024 11:46:31 +0000 Subject: [PATCH 3/6] feat: add accept-invitation page --- .../api/v1/users/accept-invitation.js | 4 +- .../src/graphql/mutations/create-user.ee.js | 2 +- packages/backend/src/models/user.js | 7 +- packages/backend/src/serializers/user.js | 1 + .../components/AcceptInvitationForm/index.jsx | 134 ++++++++++++++++++ .../web/src/components/UserList/index.jsx | 16 +++ packages/web/src/config/urls.js | 1 + packages/web/src/hooks/useAcceptInvitation.js | 15 ++ packages/web/src/locales/en.json | 12 +- .../web/src/pages/AcceptInvitation/index.jsx | 14 ++ packages/web/src/pages/EditUser/index.jsx | 15 ++ .../web/src/pages/ForgotPassword/index.ee.jsx | 2 + packages/web/src/routes.jsx | 10 ++ 13 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 packages/web/src/components/AcceptInvitationForm/index.jsx create mode 100644 packages/web/src/hooks/useAcceptInvitation.js create mode 100644 packages/web/src/pages/AcceptInvitation/index.jsx diff --git a/packages/backend/src/controllers/api/v1/users/accept-invitation.js b/packages/backend/src/controllers/api/v1/users/accept-invitation.js index 9ff08b38..8c6763ac 100644 --- a/packages/backend/src/controllers/api/v1/users/accept-invitation.js +++ b/packages/backend/src/controllers/api/v1/users/accept-invitation.js @@ -12,9 +12,7 @@ export default async (request, response) => { .throwIfNotFound(); if (!user.isInvitationTokenValid()) { - throw new Error( - 'Invitation link is not valid or expired. You can use reset password to get a new link.' - ); + return response.status(422).end(); } await user.acceptInvitation(password); diff --git a/packages/backend/src/graphql/mutations/create-user.ee.js b/packages/backend/src/graphql/mutations/create-user.ee.js index 3649b041..13fd2f17 100644 --- a/packages/backend/src/graphql/mutations/create-user.ee.js +++ b/packages/backend/src/graphql/mutations/create-user.ee.js @@ -23,7 +23,7 @@ const createUser = async (_parent, params, context) => { const userPayload = { fullName, email, - status: 'pending', + status: 'invited', }; try { diff --git a/packages/backend/src/models/user.js b/packages/backend/src/models/user.js index 21816a39..54e79228 100644 --- a/packages/backend/src/models/user.js +++ b/packages/backend/src/models/user.js @@ -35,7 +35,7 @@ class User extends Base { password: { type: 'string' }, status: { type: 'string', - enum: ['active', 'pending'], + enum: ['active', 'invited'], default: 'active', }, resetPasswordToken: { type: ['string', 'null'] }, @@ -234,11 +234,12 @@ class User extends Base { return await this.$query().patch({ invitationToken: null, invitationTokenSentAt: null, + status: 'active', password, }); } - async isResetPasswordTokenValid() { + isResetPasswordTokenValid() { if (!this.resetPasswordTokenSentAt) { return false; } @@ -250,7 +251,7 @@ class User extends Base { return now.getTime() - sentAt.getTime() < fourHoursInMilliseconds; } - async isInvitationTokenValid() { + isInvitationTokenValid() { if (!this.invitationTokenSentAt) { return false; } 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/web/src/components/AcceptInvitationForm/index.jsx b/packages/web/src/components/AcceptInvitationForm/index.jsx new file mode 100644 index 00000000..bd98a918 --- /dev/null +++ b/packages/web/src/components/AcceptInvitationForm/index.jsx @@ -0,0 +1,134 @@ +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 + > + {formatMessage('acceptInvitationForm.title')} + + +

( + <> + + + + + {acceptInvitation.isError && ( + + {formatMessage('acceptInvitationForm.invalidToken')} + + )} + + + {formatMessage('acceptInvitationForm.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/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/locales/en.json b/packages/web/src/locales/en.json index 82b4d9c3..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", @@ -199,11 +206,14 @@ "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/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() { } /> + + + + } + /> + Date: Tue, 9 Jul 2024 11:48:52 +0000 Subject: [PATCH 4/6] test: cover user status in user serializer --- packages/backend/src/serializers/user.test.js | 1 + packages/backend/test/mocks/rest/api/v1/admin/users/get-user.js | 1 + packages/backend/test/mocks/rest/api/v1/admin/users/get-users.js | 1 + .../backend/test/mocks/rest/api/v1/users/get-current-user.js | 1 + 4 files changed, 4 insertions(+) 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/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(), }, From 2bd4dd3ab09d1db8ea2942688a3c6488801cba47 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Tue, 9 Jul 2024 13:21:02 +0000 Subject: [PATCH 5/6] test(e2e-tests): cover password field removal --- .../e2e-tests/fixtures/admin/create-user-page.js | 2 +- .../e2e-tests/tests/admin/manage-roles.spec.js | 16 +++++++++++----- .../e2e-tests/tests/admin/manage-users.spec.js | 16 ++++++++-------- packages/web/src/pages/CreateUser/index.jsx | 3 +-- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/packages/e2e-tests/fixtures/admin/create-user-page.js b/packages/e2e-tests/fixtures/admin/create-user-page.js index 222802cf..446423a2 100644 --- a/packages/e2e-tests/fixtures/admin/create-user-page.js +++ b/packages/e2e-tests/fixtures/admin/create-user-page.js @@ -11,10 +11,10 @@ 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'); } seed(seed) { diff --git a/packages/e2e-tests/tests/admin/manage-roles.spec.js b/packages/e2e-tests/tests/admin/manage-roles.spec.js index c823645b..ac67cb7d 100644 --- a/packages/e2e-tests/tests/admin/manage-roles.spec.js +++ b/packages/e2e-tests/tests/admin/manage-roles.spec.js @@ -190,13 +190,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 +294,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 +302,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 +337,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 +378,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 +386,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' ); diff --git a/packages/e2e-tests/tests/admin/manage-users.spec.js b/packages/e2e-tests/tests/admin/manage-users.spec.js index 7e6dab7d..56206aef 100644 --- a/packages/e2e-tests/tests/admin/manage-users.spec.js +++ b/packages/e2e-tests/tests/admin/manage-users.spec.js @@ -29,12 +29,15 @@ 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' ); @@ -57,7 +60,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 +88,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' ); @@ -108,7 +111,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 +181,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' } @@ -206,7 +207,7 @@ test.describe('User management page', () => { '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'); @@ -230,7 +231,6 @@ test.describe('User management page', () => { 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' } @@ -285,4 +285,4 @@ test.describe('User management page', () => { ); } ); -}); \ No newline at end of file +}); diff --git a/packages/web/src/pages/CreateUser/index.jsx b/packages/web/src/pages/CreateUser/index.jsx index b547e451..890f4be2 100644 --- a/packages/web/src/pages/CreateUser/index.jsx +++ b/packages/web/src/pages/CreateUser/index.jsx @@ -15,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'; @@ -25,7 +24,6 @@ function generateRoleOptions(roles) { } export default function CreateUser() { - const navigate = useNavigate(); const formatMessage = useFormatMessage(); const [createUser, { loading, data }] = useMutation(CREATE_USER); const { data: rolesData, loading: isRolesLoading } = useRoles(); @@ -120,6 +118,7 @@ export default function CreateUser() { severity="info" color="primary" sx={{ fontWeight: '500' }} + data-test="invitation-email-info-alert" > {formatMessage('createUser.invitationEmailInfo', { link: () => ( From a9f5736c12eaf733163504adce0a67c919498f88 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Wed, 10 Jul 2024 10:33:59 +0000 Subject: [PATCH 6/6] test: cover accept invitation flow --- .../fixtures/accept-invitation-page.js | 31 +++++++++++++++++++ .../fixtures/admin/create-user-page.js | 2 +- .../tests/admin/manage-roles.spec.js | 23 ++++++++++++-- .../tests/admin/manage-users.spec.js | 10 ++++-- .../connections/create-connection.spec.js | 1 + .../components/AcceptInvitationForm/index.jsx | 4 +++ .../src/components/AddAppConnection/index.jsx | 1 + packages/web/src/index.jsx | 1 + 8 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 packages/e2e-tests/fixtures/accept-invitation-page.js 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 446423a2..135b38fb 100644 --- a/packages/e2e-tests/fixtures/admin/create-user-page.js +++ b/packages/e2e-tests/fixtures/admin/create-user-page.js @@ -15,6 +15,7 @@ export class AdminCreateUserPage extends AuthenticatedPage { 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 ac67cb7d..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 }) => { @@ -397,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(); @@ -416,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 56206aef..fc3c324b 100644 --- a/packages/e2e-tests/tests/admin/manage-users.spec.js +++ b/packages/e2e-tests/tests/admin/manage-users.spec.js @@ -42,6 +42,7 @@ test.describe('User management page', () => { 'snackbar-create-user-success' ); await expect(snackbar.variant).toBe('success'); + await adminUsersPage.navigateTo(); await adminUsersPage.closeSnackbar(); } ); @@ -108,6 +109,7 @@ 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); @@ -127,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); @@ -148,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' } @@ -197,10 +199,10 @@ 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( @@ -228,6 +230,7 @@ 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); @@ -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); 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 index bd98a918..f967e031 100644 --- a/packages/web/src/components/AcceptInvitationForm/index.jsx +++ b/packages/web/src/components/AcceptInvitationForm/index.jsx @@ -57,6 +57,7 @@ export default function ResetPasswordForm() { mb: 2, }} gutterBottom + data-test="accept-invitation-form-title" > {formatMessage('acceptInvitationForm.title')} @@ -70,6 +71,7 @@ export default function ResetPasswordForm() { {formatMessage('addAppConnection.submit')} 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