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() { } /> + + + + } + /> +