From 59f32884d26f3ca000f8591745841d49fdeb0201 Mon Sep 17 00:00:00 2001 From: palash-signoz Date: Tue, 3 May 2022 15:27:09 +0530 Subject: [PATCH] Feat(UI): Auth (#1018) * auth and rbac frontend changes --- frontend/public/locales/en-GB/common.json | 4 +- .../locales/en-GB/organizationsettings.json | 13 + frontend/public/locales/en-GB/routes.json | 6 + frontend/public/locales/en-GB/settings.json | 5 + frontend/public/locales/en/common.json | 4 +- .../locales/en/organizationsettings.json | 13 + frontend/public/locales/en/routes.json | 6 + frontend/public/locales/en/settings.json | 6 + frontend/public/signoz.svg | 9 +- frontend/src/AppRoutes/Private.tsx | 171 +++++++ frontend/src/AppRoutes/index.tsx | 50 +- frontend/src/AppRoutes/pageComponents.ts | 29 +- frontend/src/AppRoutes/routes.ts | 95 +++- frontend/src/AppRoutes/utils.ts | 97 ++++ frontend/src/ReactI18/index.tsx | 2 +- frontend/src/api/index.ts | 70 ++- frontend/src/api/user/changeMyPassword.ts | 26 + frontend/src/api/user/deleteInvite.ts | 24 + frontend/src/api/user/deleteUser.ts | 24 + frontend/src/api/user/editOrg.ts | 28 ++ frontend/src/api/user/editUser.ts | 26 + frontend/src/api/user/getInviteDetails.ts | 24 + frontend/src/api/user/getOrgUser.ts | 24 + frontend/src/api/user/getOrganization.ts | 28 ++ frontend/src/api/user/getPendingInvites.ts | 24 + .../src/api/user/getResetPasswordToken.ts | 24 + frontend/src/api/user/getRoles.ts | 28 ++ frontend/src/api/user/getUser.ts | 28 ++ frontend/src/api/user/login.ts | 26 + frontend/src/api/user/resetPassword.ts | 26 + .../user/{setPreference.ts => sendInvite.ts} | 8 +- frontend/src/api/user/signup.ts | 4 +- frontend/src/api/user/updateRole.ts | 26 + frontend/src/api/utils.ts | 29 ++ frontend/src/assets/SomethingWentWrong.tsx | 470 ++++++++++++++++++ frontend/src/assets/UnAuthorized.tsx | 28 ++ frontend/src/components/NotFound/index.tsx | 24 +- .../components/WelcomeLeftContainer/index.tsx | 40 ++ .../components/WelcomeLeftContainer/styles.ts | 22 + frontend/src/constants/app.ts | 1 + frontend/src/constants/auth.ts | 1 - frontend/src/constants/localStorage.ts | 3 + frontend/src/constants/routes.ts | 8 + .../AllAlertChannels/AlertChannels.tsx | 15 +- .../src/container/AllAlertChannels/index.tsx | 21 +- frontend/src/container/AppLayout/index.tsx | 39 +- frontend/src/container/AppLayout/styles.ts | 10 +- .../GeneralSettings/GeneralSettings.tsx | 18 +- .../Header/CurrentOrganization/index.tsx | 79 +++ .../src/container/Header/SignedInAs/index.tsx | 45 ++ frontend/src/container/Header/index.tsx | 199 ++++++-- frontend/src/container/Header/styles.ts | 66 ++- .../src/container/IsRouteAccessible/index.tsx | 7 + .../container/ListAlertRules/ListAlert.tsx | 27 +- .../src/container/ListOfDashboard/index.tsx | 35 +- frontend/src/container/Login/index.tsx | 123 +++++ frontend/src/container/Login/styles.ts | 28 ++ .../container/MySettings/Password/index.tsx | 147 ++++++ .../container/MySettings/UpdateName/index.tsx | 95 ++++ frontend/src/container/MySettings/index.tsx | 19 + frontend/src/container/MySettings/styles.ts | 14 + .../DeleteMembersDetails/index.tsx | 33 ++ .../DisplayName/index.tsx | 99 ++++ .../EditMembersDetails/index.tsx | 169 +++++++ .../EditMembersDetails/styles.ts | 16 + .../InviteTeamMembers/index.tsx | 96 ++++ .../InviteTeamMembers/styles.ts | 15 + .../OrganizationSettings/Members/index.tsx | 325 ++++++++++++ .../PendingInvitesContainer/index.tsx | 286 +++++++++++ .../PendingInvitesContainer/styles.tsx | 8 + .../container/OrganizationSettings/index.tsx | 38 ++ .../src/container/ResetPassword/index.tsx | 156 ++++++ .../src/container/ResetPassword/styles.ts | 16 + frontend/src/container/SideNav/index.tsx | 70 +-- frontend/src/container/SideNav/menuItems.ts | 2 +- frontend/src/container/SideNav/styles.ts | 26 +- .../{Header => TopNav}/Breadcrumbs/index.tsx | 2 + .../CustomDateTimeModal/index.tsx | 0 .../DateTimeSelection/Refresh.tsx | 0 .../DateTimeSelection/config.ts | 0 .../DateTimeSelection/index.tsx | 0 .../DateTimeSelection/styles.ts | 0 frontend/src/container/TopNav/index.tsx | 49 ++ frontend/src/container/TopNav/styles.ts | 9 + frontend/src/container/TraceDetail/index.tsx | 1 - frontend/src/hooks/useComponentPermission.ts | 22 + .../src/hooks/useIfNotLoggedInNavigate.ts | 27 + frontend/src/lib/getMinMax.ts | 2 +- frontend/src/pages/AllAlertChannels/index.tsx | 36 -- frontend/src/pages/Login/index.tsx | 51 ++ frontend/src/pages/MySettings/index.tsx | 7 + frontend/src/pages/ResetPassword/index.tsx | 52 ++ frontend/src/pages/Settings/index.tsx | 58 ++- frontend/src/pages/SignUp/SignUp.tsx | 345 ++++++++----- frontend/src/pages/SignUp/index.tsx | 27 +- frontend/src/pages/SignUp/styles.ts | 22 +- frontend/src/pages/SignUp/utils.ts | 15 + .../src/pages/SomethingWentWrong/index.tsx | 43 ++ frontend/src/pages/UnAuthorized/index.tsx | 23 + frontend/src/store/actions/global.ts | 2 +- frontend/src/store/reducers/app.ts | 125 ++++- frontend/src/store/reducers/global.ts | 2 +- frontend/src/store/utils.ts | 22 + frontend/src/types/actions/app.ts | 67 ++- frontend/src/types/actions/globalTime.ts | 2 +- .../src/types/api/user/changeMyPassword.ts | 11 + frontend/src/types/api/user/deleteInvite.ts | 9 + frontend/src/types/api/user/deleteUser.ts | 9 + frontend/src/types/api/user/editOrg.ts | 10 + frontend/src/types/api/user/editUser.ts | 10 + .../src/types/api/user/getInviteDetails.ts | 17 + frontend/src/types/api/user/getOrgMembers.ts | 18 + .../src/types/api/user/getOrganization.ts | 9 + .../src/types/api/user/getPendingInvites.ts | 12 + .../types/api/user/getResetPasswordToken.ts | 10 + frontend/src/types/api/user/getUser.ts | 18 + frontend/src/types/api/user/getUserRole.ts | 12 + frontend/src/types/api/user/login.ts | 13 + frontend/src/types/api/user/resetPassword.ts | 8 + frontend/src/types/api/user/setInvite.ts | 12 + frontend/src/types/api/user/signup.ts | 6 +- frontend/src/types/api/user/updateRole.ts | 10 + frontend/src/types/reducer/app.ts | 18 + frontend/src/types/reducer/globalTime.ts | 2 +- frontend/src/types/roles.ts | 5 + frontend/src/utils/permission/index.ts | 57 +++ 126 files changed, 4784 insertions(+), 449 deletions(-) create mode 100644 frontend/public/locales/en-GB/organizationsettings.json create mode 100644 frontend/public/locales/en-GB/routes.json create mode 100644 frontend/public/locales/en-GB/settings.json create mode 100644 frontend/public/locales/en/organizationsettings.json create mode 100644 frontend/public/locales/en/routes.json create mode 100644 frontend/public/locales/en/settings.json create mode 100644 frontend/src/AppRoutes/Private.tsx create mode 100644 frontend/src/AppRoutes/utils.ts create mode 100644 frontend/src/api/user/changeMyPassword.ts create mode 100644 frontend/src/api/user/deleteInvite.ts create mode 100644 frontend/src/api/user/deleteUser.ts create mode 100644 frontend/src/api/user/editOrg.ts create mode 100644 frontend/src/api/user/editUser.ts create mode 100644 frontend/src/api/user/getInviteDetails.ts create mode 100644 frontend/src/api/user/getOrgUser.ts create mode 100644 frontend/src/api/user/getOrganization.ts create mode 100644 frontend/src/api/user/getPendingInvites.ts create mode 100644 frontend/src/api/user/getResetPasswordToken.ts create mode 100644 frontend/src/api/user/getRoles.ts create mode 100644 frontend/src/api/user/getUser.ts create mode 100644 frontend/src/api/user/login.ts create mode 100644 frontend/src/api/user/resetPassword.ts rename frontend/src/api/user/{setPreference.ts => sendInvite.ts} (71%) create mode 100644 frontend/src/api/user/updateRole.ts create mode 100644 frontend/src/api/utils.ts create mode 100644 frontend/src/assets/SomethingWentWrong.tsx create mode 100644 frontend/src/assets/UnAuthorized.tsx create mode 100644 frontend/src/components/WelcomeLeftContainer/index.tsx create mode 100644 frontend/src/components/WelcomeLeftContainer/styles.ts delete mode 100644 frontend/src/constants/auth.ts create mode 100644 frontend/src/container/Header/CurrentOrganization/index.tsx create mode 100644 frontend/src/container/Header/SignedInAs/index.tsx create mode 100644 frontend/src/container/IsRouteAccessible/index.tsx create mode 100644 frontend/src/container/Login/index.tsx create mode 100644 frontend/src/container/Login/styles.ts create mode 100644 frontend/src/container/MySettings/Password/index.tsx create mode 100644 frontend/src/container/MySettings/UpdateName/index.tsx create mode 100644 frontend/src/container/MySettings/index.tsx create mode 100644 frontend/src/container/MySettings/styles.ts create mode 100644 frontend/src/container/OrganizationSettings/DeleteMembersDetails/index.tsx create mode 100644 frontend/src/container/OrganizationSettings/DisplayName/index.tsx create mode 100644 frontend/src/container/OrganizationSettings/EditMembersDetails/index.tsx create mode 100644 frontend/src/container/OrganizationSettings/EditMembersDetails/styles.ts create mode 100644 frontend/src/container/OrganizationSettings/InviteTeamMembers/index.tsx create mode 100644 frontend/src/container/OrganizationSettings/InviteTeamMembers/styles.ts create mode 100644 frontend/src/container/OrganizationSettings/Members/index.tsx create mode 100644 frontend/src/container/OrganizationSettings/PendingInvitesContainer/index.tsx create mode 100644 frontend/src/container/OrganizationSettings/PendingInvitesContainer/styles.tsx create mode 100644 frontend/src/container/OrganizationSettings/index.tsx create mode 100644 frontend/src/container/ResetPassword/index.tsx create mode 100644 frontend/src/container/ResetPassword/styles.ts rename frontend/src/container/{Header => TopNav}/Breadcrumbs/index.tsx (94%) rename frontend/src/container/{Header => TopNav}/CustomDateTimeModal/index.tsx (100%) rename frontend/src/container/{Header => TopNav}/DateTimeSelection/Refresh.tsx (100%) rename frontend/src/container/{Header => TopNav}/DateTimeSelection/config.ts (100%) rename frontend/src/container/{Header => TopNav}/DateTimeSelection/index.tsx (100%) rename frontend/src/container/{Header => TopNav}/DateTimeSelection/styles.ts (100%) create mode 100644 frontend/src/container/TopNav/index.tsx create mode 100644 frontend/src/container/TopNav/styles.ts create mode 100644 frontend/src/hooks/useComponentPermission.ts create mode 100644 frontend/src/hooks/useIfNotLoggedInNavigate.ts delete mode 100644 frontend/src/pages/AllAlertChannels/index.tsx create mode 100644 frontend/src/pages/Login/index.tsx create mode 100644 frontend/src/pages/MySettings/index.tsx create mode 100644 frontend/src/pages/ResetPassword/index.tsx create mode 100644 frontend/src/pages/SignUp/utils.ts create mode 100644 frontend/src/pages/SomethingWentWrong/index.tsx create mode 100644 frontend/src/pages/UnAuthorized/index.tsx create mode 100644 frontend/src/store/utils.ts create mode 100644 frontend/src/types/api/user/changeMyPassword.ts create mode 100644 frontend/src/types/api/user/deleteInvite.ts create mode 100644 frontend/src/types/api/user/deleteUser.ts create mode 100644 frontend/src/types/api/user/editOrg.ts create mode 100644 frontend/src/types/api/user/editUser.ts create mode 100644 frontend/src/types/api/user/getInviteDetails.ts create mode 100644 frontend/src/types/api/user/getOrgMembers.ts create mode 100644 frontend/src/types/api/user/getOrganization.ts create mode 100644 frontend/src/types/api/user/getPendingInvites.ts create mode 100644 frontend/src/types/api/user/getResetPasswordToken.ts create mode 100644 frontend/src/types/api/user/getUser.ts create mode 100644 frontend/src/types/api/user/getUserRole.ts create mode 100644 frontend/src/types/api/user/login.ts create mode 100644 frontend/src/types/api/user/resetPassword.ts create mode 100644 frontend/src/types/api/user/setInvite.ts create mode 100644 frontend/src/types/api/user/updateRole.ts create mode 100644 frontend/src/types/roles.ts create mode 100644 frontend/src/utils/permission/index.ts diff --git a/frontend/public/locales/en-GB/common.json b/frontend/public/locales/en-GB/common.json index c2ee199592..f167aecffc 100644 --- a/frontend/public/locales/en-GB/common.json +++ b/frontend/public/locales/en-GB/common.json @@ -1,8 +1,10 @@ { "something_went_wrong": "Something went wrong", + "already_logged_in": "Already Logged In", "success": "Success", "cancel": "Cancel", "share": "Share", "save": "Save", - "edit": "Edit" + "edit": "Edit", + "logged_in": "Logged In" } diff --git a/frontend/public/locales/en-GB/organizationsettings.json b/frontend/public/locales/en-GB/organizationsettings.json new file mode 100644 index 0000000000..74797b447b --- /dev/null +++ b/frontend/public/locales/en-GB/organizationsettings.json @@ -0,0 +1,13 @@ +{ + "display_name": "Display Name", + "signoz": "SigNoz", + "email_address": "Email address", + "name_optional": "Name (optional)", + "role": "Role", + "email_placeholder": "john@signoz.io", + "name_placeholder": "John", + "add_another_team_member": "Add another team member", + "invite_team_members": "Invite team members", + "invite_members": "Invite Members", + "pending_invites": "Pending Invites" +} diff --git a/frontend/public/locales/en-GB/routes.json b/frontend/public/locales/en-GB/routes.json new file mode 100644 index 0000000000..f3df8f6c9d --- /dev/null +++ b/frontend/public/locales/en-GB/routes.json @@ -0,0 +1,6 @@ +{ + "general": "General", + "alert_channels": "Alert Channels", + "organization_settings": "Organization Settings", + "my_settings": "My Settings" +} diff --git a/frontend/public/locales/en-GB/settings.json b/frontend/public/locales/en-GB/settings.json new file mode 100644 index 0000000000..b5041b3136 --- /dev/null +++ b/frontend/public/locales/en-GB/settings.json @@ -0,0 +1,5 @@ +{ + "current_password": "Current Password", + "new_password": "New Password", + "change_password": "Change Password" +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index c2ee199592..f167aecffc 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -1,8 +1,10 @@ { "something_went_wrong": "Something went wrong", + "already_logged_in": "Already Logged In", "success": "Success", "cancel": "Cancel", "share": "Share", "save": "Save", - "edit": "Edit" + "edit": "Edit", + "logged_in": "Logged In" } diff --git a/frontend/public/locales/en/organizationsettings.json b/frontend/public/locales/en/organizationsettings.json new file mode 100644 index 0000000000..74797b447b --- /dev/null +++ b/frontend/public/locales/en/organizationsettings.json @@ -0,0 +1,13 @@ +{ + "display_name": "Display Name", + "signoz": "SigNoz", + "email_address": "Email address", + "name_optional": "Name (optional)", + "role": "Role", + "email_placeholder": "john@signoz.io", + "name_placeholder": "John", + "add_another_team_member": "Add another team member", + "invite_team_members": "Invite team members", + "invite_members": "Invite Members", + "pending_invites": "Pending Invites" +} diff --git a/frontend/public/locales/en/routes.json b/frontend/public/locales/en/routes.json new file mode 100644 index 0000000000..f3df8f6c9d --- /dev/null +++ b/frontend/public/locales/en/routes.json @@ -0,0 +1,6 @@ +{ + "general": "General", + "alert_channels": "Alert Channels", + "organization_settings": "Organization Settings", + "my_settings": "My Settings" +} diff --git a/frontend/public/locales/en/settings.json b/frontend/public/locales/en/settings.json new file mode 100644 index 0000000000..94a4f71407 --- /dev/null +++ b/frontend/public/locales/en/settings.json @@ -0,0 +1,6 @@ +{ + "current_password": "Current Password", + "new_password": "New Password", + "change_password": "Change Password", + "input_password": "input password" +} diff --git a/frontend/public/signoz.svg b/frontend/public/signoz.svg index 53a3a23754..cdfe945052 100644 --- a/frontend/public/signoz.svg +++ b/frontend/public/signoz.svg @@ -1,5 +1,4 @@ - - - - - \ No newline at end of file + + + + diff --git a/frontend/src/AppRoutes/Private.tsx b/frontend/src/AppRoutes/Private.tsx new file mode 100644 index 0000000000..399c96290d --- /dev/null +++ b/frontend/src/AppRoutes/Private.tsx @@ -0,0 +1,171 @@ +import { notification } from 'antd'; +import getLocalStorageApi from 'api/browser/localstorage/get'; +import loginApi from 'api/user/login'; +import Spinner from 'components/Spinner'; +import { LOCALSTORAGE } from 'constants/localStorage'; +import ROUTES from 'constants/routes'; +import history from 'lib/history'; +import React, { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { matchPath, Redirect } from 'react-router-dom'; +import { Dispatch } from 'redux'; +import { AppState } from 'store/reducers'; +import { getInitialUserTokenRefreshToken } from 'store/utils'; +import AppActions from 'types/actions'; +import { UPDATE_USER_IS_FETCH } from 'types/actions/app'; +import AppReducer from 'types/reducer/app'; +import { routePermission } from 'utils/permission'; + +import routes from './routes'; +import afterLogin from './utils'; + +function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { + const mapRoutes = useMemo( + () => + new Map( + routes.map((e) => { + const currentPath = matchPath(history.location.pathname, { + path: e.path, + }); + return [currentPath === null ? null : 'current', e]; + }), + ), + [], + ); + const { isUserFetching, isUserFetchingError } = useSelector< + AppState, + AppReducer + >((state) => state.app); + + const { t } = useTranslation(['common']); + + const currentRoute = mapRoutes.get('current'); + const dispatch = useDispatch>(); + + const isLoggedIn = getLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN); + + // eslint-disable-next-line sonarjs/cognitive-complexity + useEffect(() => { + (async (): Promise => { + try { + console.log('asdasd'); + + if (currentRoute) { + const { isPrivate, key } = currentRoute; + + if (isPrivate) { + const localStorageUserAuthToken = getInitialUserTokenRefreshToken(); + + if (isLoggedIn) { + if (localStorageUserAuthToken && localStorageUserAuthToken.refreshJwt) { + // localstorage token is present + const { refreshJwt } = localStorageUserAuthToken; + + // renew web access token + const response = await loginApi({ + refreshToken: refreshJwt, + }); + + if (response.statusCode === 200) { + const route = routePermission[key]; + + // get all resource and put it over redux + const userResponse = await afterLogin( + response.payload.userId, + response.payload.accessJwt, + response.payload.refreshJwt, + ); + + if ( + userResponse && + route.find((e) => e === userResponse.payload.role) === undefined + ) { + history.push(ROUTES.UN_AUTHORIZED); + } + } else { + notification.error({ + message: response.error || t('something_went_wrong'), + }); + } + } else { + // user is not logged in + dispatch({ + type: UPDATE_USER_IS_FETCH, + payload: { + isUserFetching: false, + }, + }); + history.push(ROUTES.LOGIN); + } + } else { + dispatch({ + type: UPDATE_USER_IS_FETCH, + payload: { + isUserFetching: false, + }, + }); + + history.push(ROUTES.LOGIN); + } + } else { + // no need to fetch the user and make user fetching false + dispatch({ + type: UPDATE_USER_IS_FETCH, + payload: { + isUserFetching: false, + }, + }); + } + } else if (history.location.pathname === ROUTES.HOME_PAGE) { + // routing to application page over root page + if (isLoggedIn) { + history.push(ROUTES.APPLICATION); + } else { + dispatch({ + type: UPDATE_USER_IS_FETCH, + payload: { + isUserFetching: false, + }, + }); + + history.push(ROUTES.LOGIN); + } + } else { + dispatch({ + type: UPDATE_USER_IS_FETCH, + payload: { + isUserFetching: false, + }, + }); + if (!isLoggedIn) { + history.push(ROUTES.LOGIN); + } + } + } catch (error) { + // something went wrong + history.push(ROUTES.SOMETHING_WENT_WRONG); + } + })(); + // need to run over mount only + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dispatch, currentRoute, isLoggedIn]); + + if (isUserFetchingError) { + return ; + } + + if (isUserFetching) { + return ; + } + + // NOTE: disabling this rule as there is no need to have div + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>{children}; +} + +interface PrivateRouteProps { + children: React.ReactChild; +} + +export default PrivateRoute; diff --git a/frontend/src/AppRoutes/index.tsx b/frontend/src/AppRoutes/index.tsx index 018708c1d0..968ac0b2ff 100644 --- a/frontend/src/AppRoutes/index.tsx +++ b/frontend/src/AppRoutes/index.tsx @@ -1,42 +1,36 @@ import NotFound from 'components/NotFound'; import Spinner from 'components/Spinner'; -import ROUTES from 'constants/routes'; import AppLayout from 'container/AppLayout'; import history from 'lib/history'; import React, { Suspense } from 'react'; -import { useSelector } from 'react-redux'; -import { Redirect, Route, Router, Switch } from 'react-router-dom'; -import { AppState } from 'store/reducers'; -import AppReducer from 'types/reducer/app'; +import { Route, Router, Switch } from 'react-router-dom'; +import PrivateRoute from './Private'; import routes from './routes'; function App(): JSX.Element { - const { isLoggedIn } = useSelector((state) => state.app); - return ( - - }> - - {routes.map(({ path, component, exact }) => ( - - ))} - - isLoggedIn ? ( - - ) : ( - - ) - } - /> - - - - + + + }> + + {routes.map(({ path, component, exact }) => { + return ( + + ); + })} + + + + + + ); } diff --git a/frontend/src/AppRoutes/pageComponents.ts b/frontend/src/AppRoutes/pageComponents.ts index 8bc37dcac2..c7340f6a82 100644 --- a/frontend/src/AppRoutes/pageComponents.ts +++ b/frontend/src/AppRoutes/pageComponents.ts @@ -83,7 +83,7 @@ export const EditAlertChannelsAlerts = Loadable( ); export const AllAlertChannels = Loadable( - () => import(/* webpackChunkName: "All Channels" */ 'pages/AllAlertChannels'), + () => import(/* webpackChunkName: "All Channels" */ 'pages/Settings'), ); export const AllErrors = Loadable( @@ -97,3 +97,30 @@ export const ErrorDetails = Loadable( export const StatusPage = Loadable( () => import(/* webpackChunkName: "All Status" */ 'pages/Status'), ); + +export const OrganizationSettings = Loadable( + () => import(/* webpackChunkName: "All Settings" */ 'pages/Settings'), +); + +export const MySettings = Loadable( + () => import(/* webpackChunkName: "All MySettings" */ 'pages/MySettings'), +); + +export const Login = Loadable( + () => import(/* webpackChunkName: "Login" */ 'pages/Login'), +); + +export const UnAuthorized = Loadable( + () => import(/* webpackChunkName: "UnAuthorized" */ 'pages/UnAuthorized'), +); + +export const PasswordReset = Loadable( + () => import(/* webpackChunkName: "ResetPassword" */ 'pages/ResetPassword'), +); + +export const SomethingWentWrong = Loadable( + () => + import( + /* webpackChunkName: "SomethingWentWrong" */ 'pages/SomethingWentWrong' + ), +); diff --git a/frontend/src/AppRoutes/routes.ts b/frontend/src/AppRoutes/routes.ts index 4ac7a3e116..5958d93b11 100644 --- a/frontend/src/AppRoutes/routes.ts +++ b/frontend/src/AppRoutes/routes.ts @@ -13,15 +13,21 @@ import { ErrorDetails, InstrumentationPage, ListAllALertsPage, + Login, + MySettings, NewDashboardPage, + OrganizationSettings, + PasswordReset, ServiceMapPage, ServiceMetricsPage, ServicesTablePage, SettingsPage, SignupPage, + SomethingWentWrong, StatusPage, TraceDetail, TraceFilter, + UnAuthorized, UsageExplorerPage, } from './pageComponents'; @@ -30,114 +36,199 @@ const routes: AppRoutes[] = [ component: SignupPage, path: ROUTES.SIGN_UP, exact: true, + isPrivate: false, + key: 'SIGN_UP', }, { component: ServicesTablePage, path: ROUTES.APPLICATION, exact: true, + isPrivate: true, + key: 'APPLICATION', }, { path: ROUTES.SERVICE_METRICS, exact: true, component: ServiceMetricsPage, + isPrivate: true, + key: 'SERVICE_METRICS', }, { path: ROUTES.SERVICE_MAP, component: ServiceMapPage, + isPrivate: true, exact: true, + key: 'SERVICE_MAP', }, { path: ROUTES.TRACE_DETAIL, exact: true, component: TraceDetail, + isPrivate: true, + key: 'TRACE_DETAIL', }, { path: ROUTES.SETTINGS, exact: true, component: SettingsPage, + isPrivate: true, + key: 'SETTINGS', }, { path: ROUTES.USAGE_EXPLORER, exact: true, component: UsageExplorerPage, + isPrivate: true, + key: 'USAGE_EXPLORER', }, { path: ROUTES.INSTRUMENTATION, exact: true, component: InstrumentationPage, + isPrivate: true, + key: 'INSTRUMENTATION', }, { path: ROUTES.ALL_DASHBOARD, exact: true, component: DashboardPage, + isPrivate: true, + key: 'ALL_DASHBOARD', }, { path: ROUTES.DASHBOARD, exact: true, component: NewDashboardPage, + isPrivate: true, + key: 'DASHBOARD', }, { path: ROUTES.DASHBOARD_WIDGET, exact: true, component: DashboardWidget, + isPrivate: true, + key: 'DASHBOARD_WIDGET', }, { path: ROUTES.EDIT_ALERTS, exact: true, component: EditRulesPage, + isPrivate: true, + key: 'EDIT_ALERTS', }, { path: ROUTES.LIST_ALL_ALERT, exact: true, component: ListAllALertsPage, + isPrivate: true, + key: 'LIST_ALL_ALERT', }, { path: ROUTES.ALERTS_NEW, exact: true, component: CreateNewAlerts, + isPrivate: true, + key: 'ALERTS_NEW', }, { path: ROUTES.TRACE, exact: true, component: TraceFilter, + isPrivate: true, + key: 'TRACE', }, { path: ROUTES.CHANNELS_NEW, exact: true, component: CreateAlertChannelAlerts, + isPrivate: true, + key: 'CHANNELS_NEW', }, { path: ROUTES.CHANNELS_EDIT, exact: true, component: EditAlertChannelsAlerts, + isPrivate: true, + key: 'CHANNELS_EDIT', }, { path: ROUTES.ALL_CHANNELS, exact: true, component: AllAlertChannels, + isPrivate: true, + key: 'ALL_CHANNELS', }, { path: ROUTES.ALL_ERROR, exact: true, + isPrivate: true, component: AllErrors, + key: 'ALL_ERROR', }, { path: ROUTES.ERROR_DETAIL, exact: true, component: ErrorDetails, + isPrivate: true, + key: 'ERROR_DETAIL', }, { path: ROUTES.VERSION, exact: true, component: StatusPage, + isPrivate: true, + key: 'VERSION', + }, + { + path: ROUTES.ORG_SETTINGS, + exact: true, + component: OrganizationSettings, + isPrivate: true, + key: 'ORG_SETTINGS', + }, + { + path: ROUTES.MY_SETTINGS, + exact: true, + component: MySettings, + isPrivate: true, + key: 'MY_SETTINGS', + }, + { + path: ROUTES.LOGIN, + exact: true, + component: Login, + isPrivate: false, + key: 'LOGIN', + }, + { + path: ROUTES.UN_AUTHORIZED, + exact: true, + component: UnAuthorized, + key: 'UN_AUTHORIZED', + isPrivate: true, + }, + { + path: ROUTES.PASSWORD_RESET, + exact: true, + component: PasswordReset, + key: 'PASSWORD_RESET', + isPrivate: false, + }, + { + path: ROUTES.SOMETHING_WENT_WRONG, + exact: true, + component: SomethingWentWrong, + key: 'SOMETHING_WENT_WRONG', + isPrivate: false, }, ]; -interface AppRoutes { +export interface AppRoutes { component: RouteProps['component']; path: RouteProps['path']; exact: RouteProps['exact']; - isPrivate?: boolean; + isPrivate: boolean; + key: keyof typeof ROUTES; } export default routes; diff --git a/frontend/src/AppRoutes/utils.ts b/frontend/src/AppRoutes/utils.ts new file mode 100644 index 0000000000..222f4c02de --- /dev/null +++ b/frontend/src/AppRoutes/utils.ts @@ -0,0 +1,97 @@ +import { notification } from 'antd'; +import getLocalStorageApi from 'api/browser/localstorage/get'; +import setLocalStorageApi from 'api/browser/localstorage/set'; +import getUserApi from 'api/user/getUser'; +import { LOCALSTORAGE } from 'constants/localStorage'; +import ROUTES from 'constants/routes'; +import { t } from 'i18next'; +import history from 'lib/history'; +import store from 'store'; +import AppActions from 'types/actions'; +import { + LOGGED_IN, + UPDATE_USER, + UPDATE_USER_ACCESS_REFRESH_ACCESS_TOKEN, + UPDATE_USER_IS_FETCH, +} from 'types/actions/app'; +import { SuccessResponse } from 'types/api'; +import { PayloadProps } from 'types/api/user/getUser'; + +const afterLogin = async ( + userId: string, + authToken: string, + refreshToken: string, +): Promise | undefined> => { + setLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN, authToken); + setLocalStorageApi(LOCALSTORAGE.REFRESH_AUTH_TOKEN, refreshToken); + + store.dispatch({ + type: UPDATE_USER_ACCESS_REFRESH_ACCESS_TOKEN, + payload: { + accessJwt: authToken, + refreshJwt: refreshToken, + }, + }); + + const [getUserResponse] = await Promise.all([ + getUserApi({ + userId, + token: authToken, + }), + ]); + + if (getUserResponse.statusCode === 200) { + store.dispatch({ + type: LOGGED_IN, + payload: { + isLoggedIn: true, + }, + }); + + const { payload } = getUserResponse; + + store.dispatch({ + type: UPDATE_USER, + payload: { + ROLE: payload.role, + email: payload.email, + name: payload.name, + orgName: payload.organization, + profilePictureURL: payload.profilePictureURL, + userId: payload.id, + orgId: payload.orgId, + }, + }); + + const isLoggedInLocalStorage = getLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN); + + if (isLoggedInLocalStorage === null) { + setLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN, 'true'); + } + + store.dispatch({ + type: UPDATE_USER_IS_FETCH, + payload: { + isUserFetching: false, + }, + }); + + return getUserResponse; + } + + store.dispatch({ + type: UPDATE_USER_IS_FETCH, + payload: { + isUserFetching: false, + }, + }); + + notification.error({ + message: getUserResponse.error || t('something_went_wrong'), + }); + + history.push(ROUTES.SOMETHING_WENT_WRONG); + return undefined; +}; + +export default afterLogin; diff --git a/frontend/src/ReactI18/index.tsx b/frontend/src/ReactI18/index.tsx index f452ed227b..3b37751caf 100644 --- a/frontend/src/ReactI18/index.tsx +++ b/frontend/src/ReactI18/index.tsx @@ -12,7 +12,7 @@ i18n .use(initReactI18next) // init i18next .init({ - debug: true, + debug: false, fallbackLng: 'en', interpolation: { escapeValue: false, // not needed for react as it escapes by default diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index feaac180e4..fd8e77c84a 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,14 +1,80 @@ -import axios from 'axios'; +/* eslint-disable no-param-reassign */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import getLocalStorageApi from 'api/browser/localstorage/get'; +import loginApi from 'api/user/login'; +import afterLogin from 'AppRoutes/utils'; +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; import { ENVIRONMENT } from 'constants/env'; +import { LOCALSTORAGE } from 'constants/localStorage'; +import store from 'store'; import apiV1, { apiV2 } from './apiV1'; +import { Logout } from './utils'; -export default axios.create({ +const interceptorsResponse = ( + value: AxiosResponse, +): Promise> => Promise.resolve(value); + +const interceptorsRequestResponse = ( + value: AxiosRequestConfig, +): AxiosRequestConfig => { + const token = + store.getState().app.user?.accessJwt || + getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN) || + ''; + + value.headers.Authorization = token ? `Bearer ${token}` : ''; + + return value; +}; + +const interceptorRejected = async ( + value: AxiosResponse, +): Promise> => { + if (axios.isAxiosError(value) && value.response) { + const { response } = value; + console.log(response); + // reject the refresh token error + if (response.status === 401 && response.config.url !== '/login') { + const response = await loginApi({ + refreshToken: store.getState().app.user?.accessJwt, + }); + + if (response.statusCode === 200) { + await afterLogin( + response.payload.userId, + response.payload.accessJwt, + response.payload.refreshJwt, + ); + } else { + Logout(); + } + } + + // when refresh token is expired + if (response.status === 401 && response.config.url === '/login') { + Logout(); + } + } + return Promise.reject(value); +}; + +const instance = axios.create({ baseURL: `${ENVIRONMENT.baseURL}${apiV1}`, }); +instance.interceptors.response.use(interceptorsResponse, interceptorRejected); +instance.interceptors.request.use(interceptorsRequestResponse); + export const AxiosAlertManagerInstance = axios.create({ baseURL: `${ENVIRONMENT.baseURL}${apiV2}`, }); +AxiosAlertManagerInstance.interceptors.response.use( + interceptorsResponse, + interceptorRejected, +); +AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestResponse); + export { apiV1 }; +export default instance; diff --git a/frontend/src/api/user/changeMyPassword.ts b/frontend/src/api/user/changeMyPassword.ts new file mode 100644 index 0000000000..cdca2cd6bb --- /dev/null +++ b/frontend/src/api/user/changeMyPassword.ts @@ -0,0 +1,26 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/user/changeMyPassword'; + +const changeMyPassword = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.post(`/changePassword/${props.userId}`, { + ...props, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default changeMyPassword; diff --git a/frontend/src/api/user/deleteInvite.ts b/frontend/src/api/user/deleteInvite.ts new file mode 100644 index 0000000000..16233ef97f --- /dev/null +++ b/frontend/src/api/user/deleteInvite.ts @@ -0,0 +1,24 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/user/deleteInvite'; + +const deleteInvite = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.delete(`/invite/${props.email}`); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default deleteInvite; diff --git a/frontend/src/api/user/deleteUser.ts b/frontend/src/api/user/deleteUser.ts new file mode 100644 index 0000000000..4eb2694782 --- /dev/null +++ b/frontend/src/api/user/deleteUser.ts @@ -0,0 +1,24 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/user/deleteUser'; + +const deleteUser = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.delete(`/user/${props.userId}`); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default deleteUser; diff --git a/frontend/src/api/user/editOrg.ts b/frontend/src/api/user/editOrg.ts new file mode 100644 index 0000000000..da980acea0 --- /dev/null +++ b/frontend/src/api/user/editOrg.ts @@ -0,0 +1,28 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/user/editOrg'; + +const editOrg = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.put(`/org/${props.orgId}`, { + name: props.name, + isAnonymous: props.isAnonymous, + hasOptedUpdates: props.hasOptedUpdates, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default editOrg; diff --git a/frontend/src/api/user/editUser.ts b/frontend/src/api/user/editUser.ts new file mode 100644 index 0000000000..88f7c40a25 --- /dev/null +++ b/frontend/src/api/user/editUser.ts @@ -0,0 +1,26 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/user/editUser'; + +const editUser = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.put(`/user/${props.userId}`, { + Name: props.name, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default editUser; diff --git a/frontend/src/api/user/getInviteDetails.ts b/frontend/src/api/user/getInviteDetails.ts new file mode 100644 index 0000000000..b1e4ad7ae5 --- /dev/null +++ b/frontend/src/api/user/getInviteDetails.ts @@ -0,0 +1,24 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/user/getInviteDetails'; + +const getInviteDetails = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.get(`/invite/${props.inviteId}`); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getInviteDetails; diff --git a/frontend/src/api/user/getOrgUser.ts b/frontend/src/api/user/getOrgUser.ts new file mode 100644 index 0000000000..8956adc1ba --- /dev/null +++ b/frontend/src/api/user/getOrgUser.ts @@ -0,0 +1,24 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/user/getOrgMembers'; + +const getOrgUser = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.get(`/orgUsers/${props.orgId}`); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getOrgUser; diff --git a/frontend/src/api/user/getOrganization.ts b/frontend/src/api/user/getOrganization.ts new file mode 100644 index 0000000000..dfda5e44e6 --- /dev/null +++ b/frontend/src/api/user/getOrganization.ts @@ -0,0 +1,28 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps } from 'types/api/user/getOrganization'; + +const getOrganization = async ( + token?: string, +): Promise | ErrorResponse> => { + try { + const response = await axios.get(`/org`, { + headers: { + Authorization: `bearer ${token}`, + }, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getOrganization; diff --git a/frontend/src/api/user/getPendingInvites.ts b/frontend/src/api/user/getPendingInvites.ts new file mode 100644 index 0000000000..947b7bf755 --- /dev/null +++ b/frontend/src/api/user/getPendingInvites.ts @@ -0,0 +1,24 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps } from 'types/api/user/getPendingInvites'; + +const getPendingInvites = async (): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await axios.get(`/invite`); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getPendingInvites; diff --git a/frontend/src/api/user/getResetPasswordToken.ts b/frontend/src/api/user/getResetPasswordToken.ts new file mode 100644 index 0000000000..845826ed70 --- /dev/null +++ b/frontend/src/api/user/getResetPasswordToken.ts @@ -0,0 +1,24 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/user/getResetPasswordToken'; + +const getResetPasswordToken = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.get(`/getResetPasswordToken/${props.userId}`); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getResetPasswordToken; diff --git a/frontend/src/api/user/getRoles.ts b/frontend/src/api/user/getRoles.ts new file mode 100644 index 0000000000..0602a0aa63 --- /dev/null +++ b/frontend/src/api/user/getRoles.ts @@ -0,0 +1,28 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/user/getUserRole'; + +const getRoles = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.get(`/rbac/role/${props.userId}`, { + headers: { + Authorization: `bearer ${props.token}`, + }, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getRoles; diff --git a/frontend/src/api/user/getUser.ts b/frontend/src/api/user/getUser.ts new file mode 100644 index 0000000000..8046e85de2 --- /dev/null +++ b/frontend/src/api/user/getUser.ts @@ -0,0 +1,28 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/user/getUser'; + +const getUser = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.get(`/user/${props.userId}`, { + headers: { + Authorization: `bearer ${props.token}`, + }, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getUser; diff --git a/frontend/src/api/user/login.ts b/frontend/src/api/user/login.ts new file mode 100644 index 0000000000..4eff88337b --- /dev/null +++ b/frontend/src/api/user/login.ts @@ -0,0 +1,26 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/user/login'; + +const login = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.post(`/login`, { + ...props, + }); + + return { + statusCode: 200, + error: null, + message: response.statusText, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default login; diff --git a/frontend/src/api/user/resetPassword.ts b/frontend/src/api/user/resetPassword.ts new file mode 100644 index 0000000000..eb6d2752c7 --- /dev/null +++ b/frontend/src/api/user/resetPassword.ts @@ -0,0 +1,26 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/user/resetPassword'; + +const resetPassword = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.post(`/resetPassword`, { + ...props, + }); + + return { + statusCode: 200, + error: null, + message: response.statusText, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default resetPassword; diff --git a/frontend/src/api/user/setPreference.ts b/frontend/src/api/user/sendInvite.ts similarity index 71% rename from frontend/src/api/user/setPreference.ts rename to frontend/src/api/user/sendInvite.ts index de8e309b65..9835588907 100644 --- a/frontend/src/api/user/setPreference.ts +++ b/frontend/src/api/user/sendInvite.ts @@ -2,13 +2,13 @@ import axios from 'api'; import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; -import { PayloadProps, Props } from 'types/api/user/setUserPreference'; +import { PayloadProps, Props } from 'types/api/user/setInvite'; -const setPreference = async ( +const sendInvite = async ( props: Props, ): Promise | ErrorResponse> => { try { - const response = await axios.post(`/userPreferences`, { + const response = await axios.post(`/invite`, { ...props, }); @@ -23,4 +23,4 @@ const setPreference = async ( } }; -export default setPreference; +export default sendInvite; diff --git a/frontend/src/api/user/signup.ts b/frontend/src/api/user/signup.ts index 8778b5c037..9d7ff78fa4 100644 --- a/frontend/src/api/user/signup.ts +++ b/frontend/src/api/user/signup.ts @@ -6,9 +6,9 @@ import { Props } from 'types/api/user/signup'; const signup = async ( props: Props, -): Promise | ErrorResponse> => { +): Promise | ErrorResponse> => { try { - const response = await axios.post(`/user`, { + const response = await axios.post(`/register`, { ...props, }); diff --git a/frontend/src/api/user/updateRole.ts b/frontend/src/api/user/updateRole.ts new file mode 100644 index 0000000000..5d82a3d991 --- /dev/null +++ b/frontend/src/api/user/updateRole.ts @@ -0,0 +1,26 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/user/updateRole'; + +const updateRole = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.put(`/rbac/role/${props.userId}`, { + group_name: props.group_name, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default updateRole; diff --git a/frontend/src/api/utils.ts b/frontend/src/api/utils.ts new file mode 100644 index 0000000000..3e3d720477 --- /dev/null +++ b/frontend/src/api/utils.ts @@ -0,0 +1,29 @@ +import deleteLocalStorageKey from 'api/browser/localstorage/remove'; +import { LOCALSTORAGE } from 'constants/localStorage'; +import ROUTES from 'constants/routes'; +import history from 'lib/history'; +import store from 'store'; +import { LOGGED_IN, UPDATE_USER_IS_FETCH } from 'types/actions/app'; + +export const Logout = (): void => { + deleteLocalStorageKey(LOCALSTORAGE.REFRESH_AUTH_TOKEN); + deleteLocalStorageKey(LOCALSTORAGE.AUTH_TOKEN); + deleteLocalStorageKey(LOCALSTORAGE.IS_LOGGED_IN); + + store.dispatch({ + type: UPDATE_USER_IS_FETCH, + payload: { + isUserFetching: false, + }, + }); + + store.dispatch({ + type: LOGGED_IN, + payload: { + isLoggedIn: false, + }, + }); + + // navigate to login + history.push(ROUTES.LOGIN); +}; diff --git a/frontend/src/assets/SomethingWentWrong.tsx b/frontend/src/assets/SomethingWentWrong.tsx new file mode 100644 index 0000000000..874515d96a --- /dev/null +++ b/frontend/src/assets/SomethingWentWrong.tsx @@ -0,0 +1,470 @@ +import React from 'react'; + +function SomethingWentWrong(): JSX.Element { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default SomethingWentWrong; diff --git a/frontend/src/assets/UnAuthorized.tsx b/frontend/src/assets/UnAuthorized.tsx new file mode 100644 index 0000000000..53a9977400 --- /dev/null +++ b/frontend/src/assets/UnAuthorized.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +function UnAuthorized(): JSX.Element { + return ( + + + + + + ); +} + +export default UnAuthorized; diff --git a/frontend/src/components/NotFound/index.tsx b/frontend/src/components/NotFound/index.tsx index 85be7276b8..ffe7f30cdc 100644 --- a/frontend/src/components/NotFound/index.tsx +++ b/frontend/src/components/NotFound/index.tsx @@ -1,10 +1,19 @@ +import getLocalStorageKey from 'api/browser/localstorage/get'; import NotFoundImage from 'assets/NotFound'; +import { LOCALSTORAGE } from 'constants/localStorage'; import ROUTES from 'constants/routes'; import React from 'react'; +import { useDispatch } from 'react-redux'; +import { Dispatch } from 'redux'; +import AppActions from 'types/actions'; +import { LOGGED_IN } from 'types/actions/app'; import { Button, Container, Text, TextContainer } from './styles'; function NotFound(): JSX.Element { + const dispatch = useDispatch>(); + const isLoggedIn = getLocalStorageKey(LOCALSTORAGE.IS_LOGGED_IN); + return ( @@ -14,7 +23,20 @@ function NotFound(): JSX.Element { Page Not Found - diff --git a/frontend/src/components/WelcomeLeftContainer/index.tsx b/frontend/src/components/WelcomeLeftContainer/index.tsx new file mode 100644 index 0000000000..ef69a4599d --- /dev/null +++ b/frontend/src/components/WelcomeLeftContainer/index.tsx @@ -0,0 +1,40 @@ +import { Card, Space, Typography } from 'antd'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Container, LeftContainer, Logo } from './styles'; + +const { Title } = Typography; + +function WelcomeLeftContainer({ + version, + children, +}: WelcomeLeftContainerProps): JSX.Element { + const { t } = useTranslation(); + + return ( + + + + + SigNoz + + {t('monitor_signup')} + + SigNoz {version} + + + {children} + + ); +} + +interface WelcomeLeftContainerProps { + version: string; + children: React.ReactChild; +} + +export default WelcomeLeftContainer; diff --git a/frontend/src/components/WelcomeLeftContainer/styles.ts b/frontend/src/components/WelcomeLeftContainer/styles.ts new file mode 100644 index 0000000000..70428a7f1d --- /dev/null +++ b/frontend/src/components/WelcomeLeftContainer/styles.ts @@ -0,0 +1,22 @@ +import { Space } from 'antd'; +import styled from 'styled-components'; + +export const LeftContainer = styled(Space)` + flex: 1; +`; + +export const Logo = styled.img` + width: 60px; +`; + +export const Container = styled.div` + &&& { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + + max-width: 1024px; + margin: 0 auto; + } +`; diff --git a/frontend/src/constants/app.ts b/frontend/src/constants/app.ts index 90d6222689..35ae663592 100644 --- a/frontend/src/constants/app.ts +++ b/frontend/src/constants/app.ts @@ -7,3 +7,4 @@ export const AUTH0_REDIRECT_PATH = '/redirect'; export const DEFAULT_AUTH0_APP_REDIRECTION_PATH = ROUTES.APPLICATION; export const IS_SIDEBAR_COLLAPSED = 'isSideBarCollapsed'; +export const INVITE_MEMBERS_HASH = '#invite-team-members'; diff --git a/frontend/src/constants/auth.ts b/frontend/src/constants/auth.ts deleted file mode 100644 index ff93594646..0000000000 --- a/frontend/src/constants/auth.ts +++ /dev/null @@ -1 +0,0 @@ -export const IS_LOGGED_IN = 'isLoggedIn'; diff --git a/frontend/src/constants/localStorage.ts b/frontend/src/constants/localStorage.ts index 86d879122d..d45dbc6cec 100644 --- a/frontend/src/constants/localStorage.ts +++ b/frontend/src/constants/localStorage.ts @@ -1,3 +1,6 @@ export enum LOCALSTORAGE { METRICS_TIME_IN_DURATION = 'metricsTimeDurations', + IS_LOGGED_IN = 'IS_LOGGED_IN', + AUTH_TOKEN = 'AUTH_TOKEN', + REFRESH_AUTH_TOKEN = 'REFRESH_AUTH_TOKEN', } diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 4663ced750..9aac1a42df 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -1,5 +1,6 @@ const ROUTES = { SIGN_UP: '/signup', + LOGIN: '/login', SERVICE_METRICS: '/application/:servicename', SERVICE_MAP: '/service-map', TRACE: '/trace', @@ -20,6 +21,13 @@ const ROUTES = { ALL_ERROR: '/errors', ERROR_DETAIL: '/errors/:serviceName/:errorType', VERSION: '/status', + MY_SETTINGS: '/my-settings', + ORG_SETTINGS: '/settings/org-settings', + SOMETHING_WENT_WRONG: '/something-went-wrong', + UN_AUTHORIZED: '/un-authorized', + NOT_FOUND: '/not-found', + HOME_PAGE: '/', + PASSWORD_RESET: '/password-reset', }; export default ROUTES; diff --git a/frontend/src/container/AllAlertChannels/AlertChannels.tsx b/frontend/src/container/AllAlertChannels/AlertChannels.tsx index f537e969e1..000937b8cb 100644 --- a/frontend/src/container/AllAlertChannels/AlertChannels.tsx +++ b/frontend/src/container/AllAlertChannels/AlertChannels.tsx @@ -2,16 +2,22 @@ import { Button, notification, Table } from 'antd'; import { ColumnsType } from 'antd/lib/table'; import ROUTES from 'constants/routes'; +import useComponentPermission from 'hooks/useComponentPermission'; import history from 'lib/history'; import React, { useCallback, useState } from 'react'; +import { useSelector } from 'react-redux'; import { generatePath } from 'react-router-dom'; +import { AppState } from 'store/reducers'; import { Channels, PayloadProps } from 'types/api/channels/getAll'; +import AppReducer from 'types/reducer/app'; import Delete from './Delete'; function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element { const [notifications, Element] = notification.useNotification(); const [channels, setChannels] = useState(allChannels); + const { role } = useSelector((state) => state.app); + const [action] = useComponentPermission(['action'], role); const onClickEditHandler = useCallback((id: string) => { history.replace( @@ -32,7 +38,10 @@ function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element { dataIndex: 'type', key: 'type', }, - { + ]; + + if (action) { + columns.push({ title: 'Action', dataIndex: 'id', key: 'action', @@ -45,8 +54,8 @@ function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element { ), - }, - ]; + }); + } return ( <> diff --git a/frontend/src/container/AllAlertChannels/index.tsx b/frontend/src/container/AllAlertChannels/index.tsx index 4aeaba3354..44ab948f0b 100644 --- a/frontend/src/container/AllAlertChannels/index.tsx +++ b/frontend/src/container/AllAlertChannels/index.tsx @@ -4,16 +4,25 @@ import getAll from 'api/channels/getAll'; import Spinner from 'components/Spinner'; import TextToolTip from 'components/TextToolTip'; import ROUTES from 'constants/routes'; +import useComponentPermission from 'hooks/useComponentPermission'; import useFetch from 'hooks/useFetch'; import history from 'lib/history'; import React, { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import AppReducer from 'types/reducer/app'; -import AlertChannlesComponent from './AlertChannels'; +import AlertChannelsComponent from './AlertChannels'; import { Button, ButtonContainer } from './styles'; const { Paragraph } = Typography; function AlertChannels(): JSX.Element { + const { role } = useSelector((state) => state.app); + const [addNewChannelPermission] = useComponentPermission( + ['add_new_channel'], + role, + ); const onToggleHandler = useCallback(() => { history.push(ROUTES.CHANNELS_NEW); }, []); @@ -41,13 +50,15 @@ function AlertChannels(): JSX.Element { url="https://signoz.io/docs/userguide/alerts-management/#setting-notification-channel" /> - + {addNewChannelPermission && ( + + )} - + ); } diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index 98910c60c5..5abb5f4d6f 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -1,11 +1,10 @@ import { notification } from 'antd'; import getUserLatestVersion from 'api/user/getLatestVersion'; import getUserVersion from 'api/user/getVersion'; -import ROUTES from 'constants/routes'; -import TopNav from 'container/Header'; +import Header from 'container/Header'; import SideNav from 'container/SideNav'; -import history from 'lib/history'; -import React, { ReactNode, useEffect, useRef, useState } from 'react'; +import TopNav from 'container/TopNav'; +import React, { ReactNode, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useQueries } from 'react-query'; import { useDispatch, useSelector } from 'react-redux'; @@ -21,15 +20,13 @@ import { } from 'types/actions/app'; import AppReducer from 'types/reducer/app'; -import { Content, Layout } from './styles'; +import { ChildrenContainer, Layout } from './styles'; function AppLayout(props: AppLayoutProps): JSX.Element { const { isLoggedIn } = useSelector((state) => state.app); const { pathname } = useLocation(); const { t } = useTranslation(); - const [isSignUpPage, setIsSignUpPage] = useState(ROUTES.SIGN_UP === pathname); - const [getUserVersionResponse, getUserLatestVersionResponse] = useQueries([ { queryFn: getUserVersion, @@ -57,23 +54,10 @@ function AppLayout(props: AppLayoutProps): JSX.Element { const dispatch = useDispatch>(); - useEffect(() => { - if (!isLoggedIn) { - setIsSignUpPage(true); - history.push(ROUTES.SIGN_UP); - } else if (isSignUpPage) { - setIsSignUpPage(false); - } - }, [isLoggedIn, isSignUpPage]); - const latestCurrentCounter = useRef(0); const latestVersionCounter = useRef(0); useEffect(() => { - if (isLoggedIn && pathname === ROUTES.SIGN_UP) { - history.push(ROUTES.APPLICATION); - } - if ( getUserLatestVersionResponse.isFetched && getUserLatestVersionResponse.isError && @@ -153,14 +137,19 @@ function AppLayout(props: AppLayoutProps): JSX.Element { getUserLatestVersionResponse.isSuccess, ]); + const isToDisplayLayout = isLoggedIn; + return ( - {!isSignUpPage && } + {isToDisplayLayout &&
} - - {!isSignUpPage && } - {children} - + {isToDisplayLayout && } + + + {isToDisplayLayout && } + {children} + + ); diff --git a/frontend/src/container/AppLayout/styles.ts b/frontend/src/container/AppLayout/styles.ts index f3e9d573b0..adce85e295 100644 --- a/frontend/src/container/AppLayout/styles.ts +++ b/frontend/src/container/AppLayout/styles.ts @@ -3,16 +3,12 @@ import styled from 'styled-components'; export const Layout = styled(LayoutComponent)` &&& { - min-height: 100vh; + min-height: 91vh; display: flex; position: relative; } `; -export const Content = styled(LayoutComponent.Content)` - &&& { - margin: 0 1rem; - display: flex; - flex-direction: column; - } +export const ChildrenContainer = styled.div` + margin: 0 1rem; `; diff --git a/frontend/src/container/GeneralSettings/GeneralSettings.tsx b/frontend/src/container/GeneralSettings/GeneralSettings.tsx index 768322d91d..10c9a1ac58 100644 --- a/frontend/src/container/GeneralSettings/GeneralSettings.tsx +++ b/frontend/src/container/GeneralSettings/GeneralSettings.tsx @@ -1,14 +1,18 @@ import { Button, Col, Modal, notification, Row, Typography } from 'antd'; import setRetentionApi from 'api/settings/setRetention'; import TextToolTip from 'components/TextToolTip'; +import useComponentPermission from 'hooks/useComponentPermission'; import find from 'lodash-es/find'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; import { IDiskType, PayloadProps as GetDisksPayload, } from 'types/api/disks/getDisks'; import { PayloadProps as GetRetentionPayload } from 'types/api/settings/getRetention'; +import AppReducer from 'types/reducer/app'; import Retention from './Retention'; import { ButtonContainer, ErrorText, ErrorTextContainer } from './styles'; @@ -26,6 +30,12 @@ function GeneralSettings({ const [availableDisks] = useState(getAvailableDiskPayload); const [currentTTLValues, setCurrentTTLValues] = useState(ttlValuesPayload); + const { role } = useSelector((state) => state.app); + + const [setRetentionPermission] = useComponentPermission( + ['set_retention_period'], + role, + ); const [ metricsTotalRetentionPeriod, @@ -66,8 +76,14 @@ function GeneralSettings({ }; const onClickSaveHandler = useCallback(() => { + if (!setRetentionPermission) { + notification.error({ + message: `Sorry you don't have permission to make these changes`, + }); + return; + } onModalToggleHandler(); - }, []); + }, [setRetentionPermission]); const s3Enabled = useMemo( () => !!find(availableDisks, (disks: IDiskType) => disks?.type === 's3'), diff --git a/frontend/src/container/Header/CurrentOrganization/index.tsx b/frontend/src/container/Header/CurrentOrganization/index.tsx new file mode 100644 index 0000000000..7b32c3f913 --- /dev/null +++ b/frontend/src/container/Header/CurrentOrganization/index.tsx @@ -0,0 +1,79 @@ +import { PlusSquareOutlined } from '@ant-design/icons'; +import { Avatar, Typography } from 'antd'; +import { INVITE_MEMBERS_HASH } from 'constants/app'; +import ROUTES from 'constants/routes'; +import useComponentPermission from 'hooks/useComponentPermission'; +import history from 'lib/history'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import AppReducer from 'types/reducer/app'; + +import { + InviteMembersContainer, + OrganizationContainer, + OrganizationWrapper, +} from '../styles'; + +function CurrentOrganization({ + onToggle, +}: CurrentOrganizationProps): JSX.Element { + const { org, role } = useSelector((state) => state.app); + const [currentOrgSettings, inviteMembers] = useComponentPermission( + ['current_org_settings', 'invite_members'], + role, + ); + + // just to make sure role and org are present in the reducer + if (!org || !role) { + return
; + } + + const orgName = org[0].name; + + return ( + <> + CURRENT ORGANIZATION + + + + + {orgName} + + {orgName} + + + {currentOrgSettings && ( + { + onToggle(); + history.push(ROUTES.ORG_SETTINGS); + }} + > + Settings + + )} + + + {inviteMembers && ( + + + { + onToggle(); + history.push(`${ROUTES.ORG_SETTINGS}${INVITE_MEMBERS_HASH}`); + }} + > + Invite Members + + + )} + + ); +} + +interface CurrentOrganizationProps { + onToggle: VoidFunction; +} + +export default CurrentOrganization; diff --git a/frontend/src/container/Header/SignedInAs/index.tsx b/frontend/src/container/Header/SignedInAs/index.tsx new file mode 100644 index 0000000000..a9fab7507a --- /dev/null +++ b/frontend/src/container/Header/SignedInAs/index.tsx @@ -0,0 +1,45 @@ +import { Avatar, Typography } from 'antd'; +import ROUTES from 'constants/routes'; +import history from 'lib/history'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import AppReducer from 'types/reducer/app'; + +import { AvatarContainer, ManageAccountLink, Wrapper } from '../styles'; + +function SignedInAS(): JSX.Element { + const { user } = useSelector((state) => state.app); + + if (!user) { + return
; + } + + const { name, email } = user; + + return ( +
+ SIGNED IN AS + + + + {name[0]} + +
+ {name} + {email} +
+
+ { + history.push(ROUTES.MY_SETTINGS); + }} + > + Manage Account + +
+
+ ); +} + +export default SignedInAS; diff --git a/frontend/src/container/Header/index.tsx b/frontend/src/container/Header/index.tsx index b78275e28b..5704a5d4af 100644 --- a/frontend/src/container/Header/index.tsx +++ b/frontend/src/container/Header/index.tsx @@ -1,49 +1,174 @@ -import { Col } from 'antd'; +import { + CaretDownFilled, + CaretUpFilled, + LogoutOutlined, +} from '@ant-design/icons'; +import { + Avatar, + Divider, + Dropdown, + Layout, + Menu, + Space, + Typography, +} from 'antd'; +import remove from 'api/browser/localstorage/remove'; +import { LOCALSTORAGE } from 'constants/localStorage'; import ROUTES from 'constants/routes'; import history from 'lib/history'; -import React from 'react'; -import { matchPath } from 'react-router-dom'; +import setTheme, { AppMode } from 'lib/theme/setTheme'; +import React, { useCallback, useState } from 'react'; +import { connect, useDispatch, useSelector } from 'react-redux'; +import { NavLink } from 'react-router-dom'; +import { bindActionCreators, Dispatch } from 'redux'; +import { ThunkDispatch } from 'redux-thunk'; +import { ToggleDarkMode } from 'store/actions'; +import { AppState } from 'store/reducers'; +import AppActions from 'types/actions'; +import { LOGGED_IN, UPDATE_USER_ORG_ROLE } from 'types/actions/app'; +import AppReducer from 'types/reducer/app'; -import ShowBreadcrumbs from './Breadcrumbs'; -import DateTimeSelector from './DateTimeSelection'; -import { Container } from './styles'; +import CurrentOrganization from './CurrentOrganization'; +import SignedInAS from './SignedInAs'; +import { + Container, + LogoutContainer, + MenuContainer, + ToggleButton, +} from './styles'; -const routesToSkip = [ - ROUTES.SETTINGS, - ROUTES.LIST_ALL_ALERT, - ROUTES.TRACE_DETAIL, - ROUTES.ALL_CHANNELS, -]; +function HeaderContainer({ toggleDarkMode }: Props): JSX.Element { + const { isDarkMode, user } = useSelector( + (state) => state.app, + ); + const [isUserDropDownOpen, setIsUserDropDownOpen] = useState(); + const dispatch = useDispatch>(); -function TopNav(): JSX.Element | null { - if (history.location.pathname === ROUTES.SIGN_UP) { - return null; - } + const onToggleThemeHandler = useCallback(() => { + const preMode: AppMode = isDarkMode ? 'lightMode' : 'darkMode'; + setTheme(preMode); - const checkRouteExists = (currentPath: string): boolean => { - for (let i = 0; i < routesToSkip.length; i += 1) { - if ( - matchPath(currentPath, { path: routesToSkip[i], exact: true, strict: true }) - ) { - return true; - } - } - return false; + const id: AppMode = preMode; + const { head } = document; + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.type = 'text/css'; + link.href = !isDarkMode ? '/css/antd.dark.min.css' : '/css/antd.min.css'; + link.media = 'all'; + link.id = id; + head.appendChild(link); + + link.onload = (): void => { + toggleDarkMode(); + const prevNode = document.getElementById('appMode'); + prevNode?.remove(); + }; + }, [toggleDarkMode, isDarkMode]); + + const onArrowClickHandler: VoidFunction = () => { + setIsUserDropDownOpen((state) => !state); }; - return ( - - - - + const onClickLogoutHandler = (): void => { + remove(LOCALSTORAGE.AUTH_TOKEN); + remove(LOCALSTORAGE.IS_LOGGED_IN); + remove(LOCALSTORAGE.REFRESH_AUTH_TOKEN); + dispatch({ + type: LOGGED_IN, + payload: { + isLoggedIn: false, + }, + }); + dispatch({ + type: UPDATE_USER_ORG_ROLE, + payload: { + org: null, + role: null, + }, + }); - {!checkRouteExists(history.location.pathname) && ( - - - - )} - + history.push(ROUTES.LOGIN); + }; + + const menu = ( + + + + + + + + +
{ + if (e.key === 'Enter' || e.key === 'Space') { + onClickLogoutHandler(); + } + }} + role="button" + onClick={onClickLogoutHandler} + > + Logout +
+
+
+
+ ); + + return ( + + + + SigNoz + + SigNoz + + + + + + + + {user?.name[0]} + {!isUserDropDownOpen ? : } + + + + + ); } -export default TopNav; +interface DispatchProps { + toggleDarkMode: () => void; +} + +const mapDispatchToProps = ( + dispatch: ThunkDispatch, +): DispatchProps => ({ + toggleDarkMode: bindActionCreators(ToggleDarkMode, dispatch), +}); + +type Props = DispatchProps; + +export default connect(null, mapDispatchToProps)(HeaderContainer); diff --git a/frontend/src/container/Header/styles.ts b/frontend/src/container/Header/styles.ts index feda027d24..6355c0c8c7 100644 --- a/frontend/src/container/Header/styles.ts +++ b/frontend/src/container/Header/styles.ts @@ -1,9 +1,67 @@ -import { Row } from 'antd'; +import { Menu, Switch, Typography } from 'antd'; import styled from 'styled-components'; -export const Container = styled(Row)` +export const Container = styled.div` + display: flex; + justify-content: space-between; +`; + +export const AvatarContainer = styled.div` + display: flex; + gap: 1rem; +`; + +export const Wrapper = styled.div` + display: flex; + justify-content: space-between; + margin-top: 1rem; +`; + +export const ManageAccountLink = styled(Typography.Link)` + width: 6rem; + text-align: end; +`; + +export const OrganizationWrapper = styled.div` + display: flex; + gap: 1rem; + align-items: center; + margin-top: 1rem; +`; + +export const OrganizationContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const InviteMembersContainer = styled.div` + display: flex; + gap: 0.5rem; + align-items: center; + margin-top: 1.25rem; +`; + +export const LogoutContainer = styled.div` + display: flex; + gap: 0.5rem; + align-items: center; +`; + +export const MenuContainer = styled(Menu)` + padding: 1rem; +`; + +export interface DarkModeProps { + checked?: boolean; + defaultChecked?: boolean; +} + +export const ToggleButton = styled(Switch)` &&& { - margin-top: 2rem; - min-height: 8vh; + background: ${({ checked }): string => (checked === false ? 'grey' : '')}; + } + .ant-switch-inner { + font-size: 1rem !important; } `; diff --git a/frontend/src/container/IsRouteAccessible/index.tsx b/frontend/src/container/IsRouteAccessible/index.tsx new file mode 100644 index 0000000000..3e3b246288 --- /dev/null +++ b/frontend/src/container/IsRouteAccessible/index.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +function IsRouteAccessible(): JSX.Element { + return
asd
; +} + +export default IsRouteAccessible; diff --git a/frontend/src/container/ListAlertRules/ListAlert.tsx b/frontend/src/container/ListAlertRules/ListAlert.tsx index 22d5315b09..dd6d2e878c 100644 --- a/frontend/src/container/ListAlertRules/ListAlert.tsx +++ b/frontend/src/container/ListAlertRules/ListAlert.tsx @@ -4,14 +4,18 @@ import { notification, Tag, Typography } from 'antd'; import Table, { ColumnsType } from 'antd/lib/table'; import TextToolTip from 'components/TextToolTip'; import ROUTES from 'constants/routes'; +import useComponentPermission from 'hooks/useComponentPermission'; import useInterval from 'hooks/useInterval'; import history from 'lib/history'; import React, { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { UseQueryResult } from 'react-query'; +import { useSelector } from 'react-redux'; import { generatePath } from 'react-router-dom'; +import { AppState } from 'store/reducers'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { Alerts } from 'types/api/alerts/getAll'; +import AppReducer from 'types/reducer/app'; import DeleteAlert from './DeleteAlert'; import { Button, ButtonContainer } from './styles'; @@ -20,6 +24,11 @@ import Status from './TableComponents/Status'; function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element { const [data, setData] = useState(allAlertRules || []); const { t } = useTranslation('common'); + const { role } = useSelector((state) => state.app); + const [addNewAlert, action] = useComponentPermission( + ['add_new_alert', 'action'], + role, + ); useInterval(() => { (async (): Promise => { @@ -112,7 +121,10 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element { ); }, }, - { + ]; + + if (action) { + columns.push({ title: 'Action', dataIndex: 'id', key: 'action', @@ -124,12 +136,11 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element { - {/* */} ); }, - }, - ]; + }); + } return ( <> @@ -143,9 +154,11 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element { }} /> - + {addNewAlert && ( + + )} diff --git a/frontend/src/container/ListOfDashboard/index.tsx b/frontend/src/container/ListOfDashboard/index.tsx index 98e29887bb..9711a47f8d 100644 --- a/frontend/src/container/ListOfDashboard/index.tsx +++ b/frontend/src/container/ListOfDashboard/index.tsx @@ -13,6 +13,7 @@ import { AxiosError } from 'axios'; import TextToolTip from 'components/TextToolTip'; import ROUTES from 'constants/routes'; import SearchFilter from 'container/ListOfDashboard/SearchFilter'; +import useComponentPermission from 'hooks/useComponentPermission'; import history from 'lib/history'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -20,6 +21,7 @@ import { useSelector } from 'react-redux'; import { generatePath } from 'react-router-dom'; import { AppState } from 'store/reducers'; import { Dashboard } from 'types/api/dashboard/getAll'; +import AppReducer from 'types/reducer/app'; import DashboardReducer from 'types/reducer/dashboards'; import ImportJSON from './ImportJSON'; @@ -34,6 +36,12 @@ function ListOfAllDashboard(): JSX.Element { const { dashboards, loading } = useSelector( (state) => state.dashboards, ); + const { role } = useSelector((state) => state.app); + + const [action, createNewDashboard] = useComponentPermission( + ['action', 'create_new_dashboards'], + role, + ); const { t } = useTranslation('dashboard'); const [ @@ -89,13 +97,16 @@ function ListOfAllDashboard(): JSX.Element { }, render: DateComponent, }, - { + ]; + + if (action) { + columns.push({ title: 'Action', dataIndex: '', key: 'x', render: DeleteButton, - }, - ]; + }); + } const data: Data[] = (filteredDashboards || dashboards).map((e) => ({ createdBy: e.created_at, @@ -165,19 +176,21 @@ function ListOfAllDashboard(): JSX.Element { const menu = useMemo( () => ( - - {t('create_dashboard')} - + {createNewDashboard && ( + + {t('create_dashboard')} + + )} {t('import_json')} ), - [loading, onNewDashboardHandler, t], + [createNewDashboard, loading, onNewDashboardHandler, t], ); const GetHeader = useMemo( diff --git a/frontend/src/container/Login/index.tsx b/frontend/src/container/Login/index.tsx new file mode 100644 index 0000000000..62844262b0 --- /dev/null +++ b/frontend/src/container/Login/index.tsx @@ -0,0 +1,123 @@ +import { Button, Input, notification, Space, Typography } from 'antd'; +import loginApi from 'api/user/login'; +import afterLogin from 'AppRoutes/utils'; +import ROUTES from 'constants/routes'; +import history from 'lib/history'; +import React, { useState } from 'react'; + +import { FormContainer, FormWrapper, Label, ParentContainer } from './styles'; + +const { Title } = Typography; + +function Login(): JSX.Element { + const [isLoading, setIsLoading] = useState(false); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + const onChangeHandler = ( + setFunc: React.Dispatch>, + value: string, + ): void => { + setFunc(value); + }; + + const onSubmitHandler: React.FormEventHandler = async ( + event, + ) => { + try { + event.preventDefault(); + event.persist(); + setIsLoading(true); + + const response = await loginApi({ + email, + password, + }); + if (response.statusCode === 200) { + await afterLogin( + response.payload.userId, + response.payload.accessJwt, + response.payload.refreshJwt, + ); + history.push(ROUTES.APPLICATION); + } else { + notification.error({ + message: response.error || 'Something went wrong', + }); + } + setIsLoading(false); + } catch (error) { + setIsLoading(false); + notification.error({ + message: 'Something went wrong', + }); + } + }; + + return ( + + + Login to SigNoz + + + onChangeHandler(setEmail, event.target.value)} + value={email} + disabled={isLoading} + /> + + + + + onChangeHandler(setPassword, event.target.value) + } + disabled={isLoading} + value={password} + /> + + + + { + history.push(ROUTES.SIGN_UP); + }} + style={{ fontWeight: 700 }} + > + Create an account + + + + Forgot Password? + + + Ask your admin to reset password and send a new invite link + + + + + + ); +} + +export default Login; diff --git a/frontend/src/container/Login/styles.ts b/frontend/src/container/Login/styles.ts new file mode 100644 index 0000000000..8a635b487d --- /dev/null +++ b/frontend/src/container/Login/styles.ts @@ -0,0 +1,28 @@ +import { Card } from 'antd'; +import styled from 'styled-components'; + +export const FormWrapper = styled(Card)` + display: flex; + justify-content: center; + max-width: 432px; + flex: 1; + align-items: flex-start; +`; + +export const Label = styled.label` + margin-bottom: 11px; + margin-top: 19px; + display: inline-block; + font-size: 1rem; + line-height: 24px; +`; + +export const FormContainer = styled.form` + display: flex; + flex-direction: column; + align-items: flex-start; +`; + +export const ParentContainer = styled.div` + width: 100%; +`; diff --git a/frontend/src/container/MySettings/Password/index.tsx b/frontend/src/container/MySettings/Password/index.tsx new file mode 100644 index 0000000000..524e17304a --- /dev/null +++ b/frontend/src/container/MySettings/Password/index.tsx @@ -0,0 +1,147 @@ +import { Button, notification, Space, Typography } from 'antd'; +import changeMyPassword from 'api/user/changeMyPassword'; +import { isPasswordNotValidMessage, isPasswordValid } from 'pages/SignUp/utils'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import AppReducer from 'types/reducer/app'; + +import { Password } from '../styles'; + +function PasswordContainer(): JSX.Element { + const [currentPassword, setCurrentPassword] = useState(''); + const [updatePassword, setUpdatePassword] = useState(''); + const { t } = useTranslation(['routes', 'settings', 'common']); + const { user } = useSelector((state) => state.app); + const [isLoading, setIsLoading] = useState(false); + const [isPasswordPolicyError, setIsPasswordPolicyError] = useState( + false, + ); + + const defaultPlaceHolder = t('input_password', { + ns: 'settings', + }); + + if (!user) { + return
; + } + + const onChangePasswordClickHandler = async (): Promise => { + try { + setIsLoading(true); + + if (!isPasswordValid(currentPassword)) { + setIsPasswordPolicyError(true); + setIsLoading(false); + return; + } + + const { statusCode, error } = await changeMyPassword({ + newPassword: updatePassword, + oldPassword: currentPassword, + userId: user.userId, + }); + + if (statusCode === 200) { + notification.success({ + message: t('success', { + ns: 'common', + }), + }); + } else { + notification.error({ + message: + error || + t('something_went_wrong', { + ns: 'common', + }), + }); + } + setIsLoading(false); + } catch (error) { + setIsLoading(false); + + notification.error({ + message: t('something_went_wrong', { + ns: 'common', + }), + }); + } + }; + + return ( + + + {t('change_password', { + ns: 'settings', + })} + + + + {t('current_password', { + ns: 'settings', + })} + + { + setCurrentPassword(event.target.value); + }} + value={currentPassword} + /> + + + + {t('new_password', { + ns: 'settings', + })} + + { + const updatedValue = event.target.value; + setUpdatePassword(updatedValue); + if (!isPasswordValid(updatedValue)) { + setIsPasswordPolicyError(true); + } else { + setIsPasswordPolicyError(false); + } + }} + value={updatePassword} + /> + + + {isPasswordPolicyError && ( + + {isPasswordNotValidMessage} + + )} + + + + ); +} + +export default PasswordContainer; diff --git a/frontend/src/container/MySettings/UpdateName/index.tsx b/frontend/src/container/MySettings/UpdateName/index.tsx new file mode 100644 index 0000000000..bc42075b04 --- /dev/null +++ b/frontend/src/container/MySettings/UpdateName/index.tsx @@ -0,0 +1,95 @@ +import { Button, notification, Space, Typography } from 'antd'; +import editUser from 'api/user/editUser'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { Dispatch } from 'redux'; +import { AppState } from 'store/reducers'; +import AppActions from 'types/actions'; +import { UPDATE_USER } from 'types/actions/app'; +import AppReducer from 'types/reducer/app'; + +import { NameInput } from '../styles'; + +function UpdateName(): JSX.Element { + const { user, role, org } = useSelector( + (state) => state.app, + ); + const { t } = useTranslation(); + const dispatch = useDispatch>(); + + const [changedName, setChangedName] = useState(user?.name || ''); + const [loading, setLoading] = useState(false); + + if (!user || !org) { + return
; + } + + const onClickUpdateHandler = async (): Promise => { + try { + setLoading(true); + const { statusCode } = await editUser({ + name: changedName, + userId: user.userId, + }); + + if (statusCode === 200) { + notification.success({ + message: t('success', { + ns: 'common', + }), + }); + dispatch({ + type: UPDATE_USER, + payload: { + ...user, + name: changedName, + ROLE: role || 'ADMIN', + orgId: org[0].id, + orgName: org[0].name, + }, + }); + } else { + notification.error({ + message: t('something_went_wrong', { + ns: 'common', + }), + }); + } + setLoading(false); + } catch (error) { + notification.error({ + message: t('something_went_wrong', { + ns: 'common', + }), + }); + } + setLoading(false); + }; + + return ( +
+ + Name + { + setChangedName(event.target.value); + }} + value={changedName} + disabled={loading} + /> + + +
+ ); +} + +export default UpdateName; diff --git a/frontend/src/container/MySettings/index.tsx b/frontend/src/container/MySettings/index.tsx new file mode 100644 index 0000000000..7ce9e02e42 --- /dev/null +++ b/frontend/src/container/MySettings/index.tsx @@ -0,0 +1,19 @@ +import { Space, Typography } from 'antd'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import Password from './Password'; +import UpdateName from './UpdateName'; + +function MySettings(): JSX.Element { + const { t } = useTranslation(['routes']); + return ( + + {t('my_settings')} + + + + ); +} + +export default MySettings; diff --git a/frontend/src/container/MySettings/styles.ts b/frontend/src/container/MySettings/styles.ts new file mode 100644 index 0000000000..689f3176d0 --- /dev/null +++ b/frontend/src/container/MySettings/styles.ts @@ -0,0 +1,14 @@ +import { Input } from 'antd'; +import styled from 'styled-components'; + +export const Password = styled(Input.Password)` + &&& { + width: 20rem; + } +`; + +export const NameInput = styled(Input)` + &&& { + width: 20rem; + } +`; diff --git a/frontend/src/container/OrganizationSettings/DeleteMembersDetails/index.tsx b/frontend/src/container/OrganizationSettings/DeleteMembersDetails/index.tsx new file mode 100644 index 0000000000..db51e685b0 --- /dev/null +++ b/frontend/src/container/OrganizationSettings/DeleteMembersDetails/index.tsx @@ -0,0 +1,33 @@ +import { gold } from '@ant-design/colors'; +import { ExclamationCircleTwoTone } from '@ant-design/icons'; +import { Space, Typography } from 'antd'; +import React from 'react'; + +function DeleteMembersDetails({ + name, +}: DeleteMembersDetailsProps): JSX.Element { + return ( +
+ + + + Are you sure you want to delete {name} + + This will remove all access from dashboards and other features in SigNoz + + + +
+ ); +} + +interface DeleteMembersDetailsProps { + name: string; +} + +export default DeleteMembersDetails; diff --git a/frontend/src/container/OrganizationSettings/DisplayName/index.tsx b/frontend/src/container/OrganizationSettings/DisplayName/index.tsx new file mode 100644 index 0000000000..394d42b76c --- /dev/null +++ b/frontend/src/container/OrganizationSettings/DisplayName/index.tsx @@ -0,0 +1,99 @@ +import { Button, Input, notification, Space, Typography } from 'antd'; +import editOrg from 'api/user/editOrg'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { Dispatch } from 'redux'; +import { AppState } from 'store/reducers'; +import AppActions from 'types/actions'; +import { UPDATE_ORG_NAME } from 'types/actions/app'; +import AppReducer, { User } from 'types/reducer/app'; + +function DisplayName({ + index, + id: orgId, + isAnonymous, +}: DisplayNameProps): JSX.Element { + const { t } = useTranslation(['organizationsettings', 'common']); + const { org } = useSelector((state) => state.app); + const { name } = (org || [])[index]; + const [orgName, setOrgName] = useState(name); + const [isLoading, setIsLoading] = useState(false); + const dispatch = useDispatch>(); + + const onClickHandler = async (): Promise => { + try { + setIsLoading(true); + const { statusCode, error } = await editOrg({ + isAnonymous, + name: orgName, + orgId, + }); + if (statusCode === 200) { + notification.success({ + message: t('success', { + ns: 'common', + }), + }); + dispatch({ + type: UPDATE_ORG_NAME, + payload: { + index, + name: orgName, + }, + }); + } else { + notification.error({ + message: + error || + t('something_went_wrong', { + ns: 'common', + }), + }); + } + setIsLoading(false); + } catch (error) { + setIsLoading(false); + notification.error({ + message: t('something_went_wrong', { + ns: 'common', + }), + }); + } + }; + + if (!org) { + return
; + } + + return ( + + {t('display_name')} + + setOrgName(e.target.value)} + size="large" + placeholder={t('signoz')} + disabled={isLoading} + /> + + + + ); +} + +interface DisplayNameProps { + index: number; + id: User['userId']; + isAnonymous: boolean; +} + +export default DisplayName; diff --git a/frontend/src/container/OrganizationSettings/EditMembersDetails/index.tsx b/frontend/src/container/OrganizationSettings/EditMembersDetails/index.tsx new file mode 100644 index 0000000000..efe4689217 --- /dev/null +++ b/frontend/src/container/OrganizationSettings/EditMembersDetails/index.tsx @@ -0,0 +1,169 @@ +import { CopyOutlined } from '@ant-design/icons'; +import { Button, Input, notification, Select, Space, Tooltip } from 'antd'; +import getResetPasswordToken from 'api/user/getResetPasswordToken'; +import ROUTES from 'constants/routes'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import { useCopyToClipboard } from 'react-use'; +import { AppState } from 'store/reducers'; +import AppReducer from 'types/reducer/app'; +import { ROLES } from 'types/roles'; + +import { InputGroup, SelectDrawer, Title } from './styles'; + +const { Option } = Select; + +function EditMembersDetails({ + emailAddress, + name, + role, + setEmailAddress, + setName, + setRole, +}: EditMembersDetailsProps): JSX.Element { + const [passwordLink, setPasswordLink] = useState(''); + + const { t } = useTranslation(['common']); + const { user } = useSelector((state) => state.app); + const [isLoading, setIsLoading] = useState(false); + const [state, copyToClipboard] = useCopyToClipboard(); + + const getPasswordLink = (token: string): string => { + return `${window.location.origin}${ROUTES.PASSWORD_RESET}?token=${token}`; + }; + + const onChangeHandler = useCallback( + (setFunc: React.Dispatch>, value: string) => { + setFunc(value); + }, + [], + ); + + useEffect(() => { + if (state.error) { + notification.error({ + message: t('something_went_wrong'), + }); + } + + if (state.value) { + notification.success({ + message: t('success'), + }); + } + }, [state.error, state.value, t]); + + const onPasswordChangeHandler = useCallback((event) => { + setPasswordLink(event.target.value); + }, []); + + const onGeneratePasswordHandler = async (): Promise => { + try { + setIsLoading(true); + const response = await getResetPasswordToken({ + userId: user?.userId || '', + }); + + if (response.statusCode === 200) { + setPasswordLink(getPasswordLink(response.payload.token)); + } else { + notification.error({ + message: + response.error || + t('something_went_wrong', { + ns: 'common', + }), + }); + } + setIsLoading(false); + } catch (error) { + setIsLoading(false); + + notification.error({ + message: t('something_went_wrong', { + ns: 'common', + }), + }); + } + }; + + return ( + + + Email address + + onChangeHandler(setEmailAddress, event.target.value) + } + disabled={isLoading} + value={emailAddress} + /> + + + Name (optional) + onChangeHandler(setName, event.target.value)} + value={name} + disabled={isLoading} + /> + + + Role + { + if (typeof value === 'string') { + setRole(value as ROLES); + } + }} + disabled={isLoading} + > + + + + + + + + {passwordLink && ( + + + + + + + + ); +} + +interface Props { + allMembers: InviteTeamMembersProps[]; + setAllMembers: React.Dispatch>; +} + +export default InviteTeamMembers; diff --git a/frontend/src/container/OrganizationSettings/InviteTeamMembers/styles.ts b/frontend/src/container/OrganizationSettings/InviteTeamMembers/styles.ts new file mode 100644 index 0000000000..1426130972 --- /dev/null +++ b/frontend/src/container/OrganizationSettings/InviteTeamMembers/styles.ts @@ -0,0 +1,15 @@ +import { Select } from 'antd'; +import styled from 'styled-components'; + +export const SelectDrawer = styled(Select)` + width: 120px; +`; + +export const TitleWrapper = styled.div` + display: flex; + margin-bottom: 1rem; + + > article { + min-width: 11rem; + } +`; diff --git a/frontend/src/container/OrganizationSettings/Members/index.tsx b/frontend/src/container/OrganizationSettings/Members/index.tsx new file mode 100644 index 0000000000..618b9c8222 --- /dev/null +++ b/frontend/src/container/OrganizationSettings/Members/index.tsx @@ -0,0 +1,325 @@ +import { Button, Modal, notification, Space, Typography } from 'antd'; +import Table, { ColumnsType } from 'antd/lib/table'; +import deleteUser from 'api/user/deleteUser'; +import editUserApi from 'api/user/editUser'; +import getOrgUser from 'api/user/getOrgUser'; +import updateRole from 'api/user/updateRole'; +import dayjs from 'dayjs'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from 'react-query'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import AppReducer from 'types/reducer/app'; +import { ROLES } from 'types/roles'; + +import DeleteMembersDetails from '../DeleteMembersDetails'; +import EditMembersDetails from '../EditMembersDetails'; + +function UserFunction({ + setDataSource, + accessLevel, + name, + email, + id, +}: UserFunctionProps): JSX.Element { + const [isModalVisible, setIsModalVisible] = useState(false); + const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); + + const onModalToggleHandler = ( + func: React.Dispatch>, + value: boolean, + ): void => { + func(value); + }; + + const [emailAddress, setEmailAddress] = useState(email); + const [updatedName, setUpdatedName] = useState(name); + const [role, setRole] = useState(accessLevel); + const { t } = useTranslation(['common']); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const [isUpdateLoading, setIsUpdateLoading] = useState(false); + + const onUpdateDetailsHandler = (): void => { + setDataSource((data) => { + const index = data.findIndex((e) => e.id === id); + if (index !== -1) { + const current = data[index]; + + const updatedData: DataType[] = [ + ...data.slice(0, index), + { + ...current, + name: updatedName, + accessLevel: role, + email: emailAddress, + }, + ...data.slice(index + 1, data.length), + ]; + + return updatedData; + } + return data; + }); + }; + + const onDelete = (): void => { + setDataSource((source) => { + const index = source.findIndex((e) => e.id === id); + + if (index !== -1) { + const updatedData: DataType[] = [ + ...source.slice(0, index), + ...source.slice(index + 1, source.length), + ]; + + return updatedData; + } + return source; + }); + }; + + const onDeleteHandler = async (): Promise => { + try { + setIsDeleteLoading(true); + const response = await deleteUser({ + userId: id, + }); + + if (response.statusCode === 200) { + onDelete(); + notification.success({ + message: t('success', { + ns: 'common', + }), + }); + setIsDeleteModalVisible(false); + } else { + notification.error({ + message: + response.error || + t('something_went_wrong', { + ns: 'common', + }), + }); + } + setIsDeleteLoading(false); + } catch (error) { + setIsDeleteLoading(false); + + notification.error({ + message: t('something_went_wrong', { + ns: 'common', + }), + }); + } + }; + + const onInviteMemberHandler = async (): Promise => { + try { + setIsUpdateLoading(true); + const [editUserResponse, updateRoleResponse] = await Promise.all([ + editUserApi({ + userId: id, + name: updatedName, + }), + updateRole({ + group_name: role, + userId: id, + }), + ]); + + if ( + editUserResponse.statusCode === 200 && + updateRoleResponse.statusCode === 200 + ) { + onUpdateDetailsHandler(); + notification.success({ + message: t('success', { + ns: 'common', + }), + }); + } else { + notification.error({ + message: + editUserResponse.error || + updateRoleResponse.error || + t('something_went_wrong', { + ns: 'common', + }), + }); + } + setIsUpdateLoading(false); + } catch (error) { + notification.error({ + message: t('something_went_wrong', { + ns: 'common', + }), + }); + setIsUpdateLoading(false); + } + }; + + return ( + <> + + onModalToggleHandler(setIsModalVisible, true)} + > + Edit + + onModalToggleHandler(setIsDeleteModalVisible, true)} + > + Delete + + + onModalToggleHandler(setIsModalVisible, false)} + onCancel={(): void => onModalToggleHandler(setIsModalVisible, false)} + centered + footer={[ + , + , + ]} + > + + + onModalToggleHandler(setIsDeleteModalVisible, false)} + centered + confirmLoading={isDeleteLoading} + > + + + + ); +} + +function Members(): JSX.Element { + const { org } = useSelector((state) => state.app); + const { status, data } = useQuery({ + queryFn: () => + getOrgUser({ + orgId: (org || [])[0].id, + }), + queryKey: 'getOrgUser', + }); + + const [dataSource, setDataSource] = useState([]); + + useEffect(() => { + if (status === 'success' && data?.payload && Array.isArray(data.payload)) { + const updatedData: DataType[] = data?.payload?.map((e) => ({ + accessLevel: e.role, + email: e.email, + id: String(e.id), + joinedOn: String(e.createdAt), + name: e.name, + })); + setDataSource(updatedData); + } + }, [data?.payload, status]); + + const columns: ColumnsType = [ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + }, + { + title: 'Emails', + dataIndex: 'email', + key: 'email', + }, + { + title: 'Access Level', + dataIndex: 'accessLevel', + key: 'accessLevel', + }, + { + title: 'Joined On', + dataIndex: 'joinedOn', + key: 'joinedOn', + render: (_, record): JSX.Element => { + const { joinedOn } = record; + return ( + + {dayjs.unix(Number(joinedOn)).format('MMMM DD,YYYY')} + + ); + }, + }, + { + title: 'Action', + dataIndex: 'action', + render: (_, record): JSX.Element => ( + + ), + }, + ]; + + return ( + + Members +
+ + ); +} + +interface DataType { + id: string; + name: string; + email: string; + accessLevel: ROLES; + joinedOn: string; +} + +interface UserFunctionProps extends DataType { + setDataSource: React.Dispatch>; +} + +export default Members; diff --git a/frontend/src/container/OrganizationSettings/PendingInvitesContainer/index.tsx b/frontend/src/container/OrganizationSettings/PendingInvitesContainer/index.tsx new file mode 100644 index 0000000000..639e845898 --- /dev/null +++ b/frontend/src/container/OrganizationSettings/PendingInvitesContainer/index.tsx @@ -0,0 +1,286 @@ +import { PlusOutlined } from '@ant-design/icons'; +import { Button, Modal, notification, Space, Typography } from 'antd'; +import Table, { ColumnsType } from 'antd/lib/table'; +import deleteInvite from 'api/user/deleteInvite'; +import getPendingInvites from 'api/user/getPendingInvites'; +import sendInvite from 'api/user/sendInvite'; +import { INVITE_MEMBERS_HASH } from 'constants/app'; +import ROUTES from 'constants/routes'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from 'react-query'; +import { useLocation } from 'react-router-dom'; +import { useCopyToClipboard } from 'react-use'; +import { PayloadProps } from 'types/api/user/getPendingInvites'; +import { ROLES } from 'types/roles'; + +import InviteTeamMembers from '../InviteTeamMembers'; +import { TitleWrapper } from './styles'; + +function PendingInvitesContainer(): JSX.Element { + const [ + isInviteTeamMemberModalOpen, + setIsInviteTeamMemberModalOpen, + ] = useState(false); + const [isInvitingMembers, setIsInvitingMembers] = useState(false); + const { t } = useTranslation(['organizationsettings', 'common']); + const [state, setText] = useCopyToClipboard(); + + useEffect(() => { + if (state.error) { + notification.error({ + message: state.error.message, + }); + } + + if (state.value) { + notification.success({ + message: t('success', { + ns: 'common', + }), + }); + } + }, [state.error, state.value, t]); + + const getPendingInvitesResponse = useQuery({ + queryFn: () => getPendingInvites(), + queryKey: 'getPendingInvites', + }); + + const toggleModal = (value: boolean): void => { + setIsInviteTeamMemberModalOpen(value); + }; + + const [allMembers, setAllMembers] = useState([ + { + email: '', + name: '', + role: 'VIEWER', + }, + ]); + + const [dataSource, setDataSource] = useState([]); + + const { hash } = useLocation(); + + const getParsedInviteData = useCallback((payload: PayloadProps = []) => { + return payload?.map((data) => ({ + key: data.createdAt, + name: data.name, + email: data.email, + accessLevel: data.role, + inviteLink: `${window.location.origin}${ROUTES.SIGN_UP}?token=${data.token}`, + })); + }, []); + + useEffect(() => { + if (hash === INVITE_MEMBERS_HASH) { + toggleModal(true); + } + }, [hash]); + + useEffect(() => { + if ( + getPendingInvitesResponse.status === 'success' && + getPendingInvitesResponse?.data?.payload + ) { + const data = getParsedInviteData( + getPendingInvitesResponse?.data?.payload || [], + ); + setDataSource(data); + } + }, [ + getParsedInviteData, + getPendingInvitesResponse?.data?.payload, + getPendingInvitesResponse.status, + ]); + + const onRevokeHandler = async (email: string): Promise => { + try { + const response = await deleteInvite({ + email, + }); + if (response.statusCode === 200) { + // remove from the client data + const index = dataSource.findIndex((e) => e.email === email); + + if (index !== -1) { + setDataSource([ + ...dataSource.slice(0, index), + ...dataSource.slice(index + 1, dataSource.length), + ]); + } + notification.success({ + message: t('success', { + ns: 'common', + }), + }); + } else { + notification.error({ + message: + response.error || + t('something_went_wrong', { + ns: 'common', + }), + }); + } + } catch (error) { + notification.error({ + message: t('something_went_wrong', { + ns: 'common', + }), + }); + } + }; + + const columns: ColumnsType = [ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + }, + { + title: 'Emails', + dataIndex: 'email', + key: 'email', + }, + { + title: 'Access Level', + dataIndex: 'accessLevel', + key: 'accessLevel', + }, + { + title: 'Invite Link', + dataIndex: 'inviteLink', + key: 'Invite Link', + ellipsis: true, + }, + { + title: 'Action', + dataIndex: 'action', + key: 'Action', + render: (_, record): JSX.Element => ( + + => onRevokeHandler(record.email)} + > + Revoke + + { + setText(record.inviteLink); + }} + > + Copy Invite Link + + + ), + }, + ]; + + const onInviteClickHandler = async (): Promise => { + try { + setIsInvitingMembers(true); + allMembers.forEach( + async (members): Promise => { + const { error, statusCode } = await sendInvite({ + email: members.email, + name: members.name, + role: members.role, + }); + + if (statusCode !== 200) { + notification.error({ + message: + error || + t('something_went_wrong', { + ns: 'common', + }), + }); + } + }, + ); + + setTimeout(async () => { + const { data, status } = await getPendingInvitesResponse.refetch(); + if (status === 'success' && data.payload) { + setDataSource(getParsedInviteData(data?.payload || [])); + } + setIsInvitingMembers(false); + toggleModal(false); + }, 2000); + } catch (error) { + notification.error({ + message: t('something_went_wrong', { + ns: 'common', + }), + }); + } + }; + + return ( +
+ toggleModal(false)} + centered + footer={[ + , + , + ]} + > + + + + + + {t('pending_invites')} + + +
+ + + ); +} + +export interface InviteTeamMembersProps { + email: string; + name: string; + role: ROLES; +} + +interface DataProps { + key: number; + name: string; + email: string; + accessLevel: ROLES; + inviteLink: string; +} +export default PendingInvitesContainer; diff --git a/frontend/src/container/OrganizationSettings/PendingInvitesContainer/styles.tsx b/frontend/src/container/OrganizationSettings/PendingInvitesContainer/styles.tsx new file mode 100644 index 0000000000..1bd17cf950 --- /dev/null +++ b/frontend/src/container/OrganizationSettings/PendingInvitesContainer/styles.tsx @@ -0,0 +1,8 @@ +import styled from 'styled-components'; + +export const TitleWrapper = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +`; diff --git a/frontend/src/container/OrganizationSettings/index.tsx b/frontend/src/container/OrganizationSettings/index.tsx new file mode 100644 index 0000000000..327495ab2a --- /dev/null +++ b/frontend/src/container/OrganizationSettings/index.tsx @@ -0,0 +1,38 @@ +import { Divider, Space } from 'antd'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import AppReducer from 'types/reducer/app'; + +import DisplayName from './DisplayName'; +import Members from './Members'; +import PendingInvitesContainer from './PendingInvitesContainer'; + +function OrganizationSettings(): JSX.Element { + const { org } = useSelector((state) => state.app); + + if (!org) { + return
; + } + + return ( + <> + + {org.map((e, index) => ( + + ))} + + + + + + + ); +} + +export default OrganizationSettings; diff --git a/frontend/src/container/ResetPassword/index.tsx b/frontend/src/container/ResetPassword/index.tsx new file mode 100644 index 0000000000..29ece6563e --- /dev/null +++ b/frontend/src/container/ResetPassword/index.tsx @@ -0,0 +1,156 @@ +import { Button, Input, notification, Typography } from 'antd'; +import resetPasswordApi from 'api/user/resetPassword'; +import { Logout } from 'api/utils'; +import WelcomeLeftContainer from 'components/WelcomeLeftContainer'; +import ROUTES from 'constants/routes'; +import history from 'lib/history'; +import { Label } from 'pages/SignUp/styles'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useLocation } from 'react-use'; + +import { ButtonContainer, FormWrapper } from './styles'; + +const { Title } = Typography; + +function ResetPassword({ version }: ResetPasswordProps): JSX.Element { + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [confirmPasswordError, setConfirmPasswordError] = useState( + false, + ); + const [loading, setLoading] = useState(false); + const { t } = useTranslation(['common']); + const { search } = useLocation(); + const params = new URLSearchParams(search); + const token = params.get('token'); + + useEffect(() => { + if (!token) { + Logout(); + history.push(ROUTES.LOGIN); + } + }, [token]); + + const setState = ( + value: string, + setFunction: React.Dispatch>, + ): void => { + setFunction(value); + }; + + const handleSubmit: React.FormEventHandler = async ( + event, + ): Promise => { + try { + setLoading(true); + event.preventDefault(); + event.persist(); + + const response = await resetPasswordApi({ + password, + token: token || '', + }); + + if (response.statusCode === 200) { + notification.success({ + message: t('success', { + ns: 'common', + }), + }); + } else { + notification.error({ + message: + response.error || + t('something_went_wrong', { + ns: 'common', + }), + }); + } + + setLoading(false); + } catch (error) { + setLoading(false); + notification.error({ + message: t('something_went_wrong', { + ns: 'common', + }), + }); + } + }; + + return ( + + +
+ Reset Your Password + +
+ + { + setState(e.target.value, setPassword); + }} + required + id="currentPassword" + /> +
+
+ + { + const updateValue = e.target.value; + setState(updateValue, setConfirmPassword); + if (password !== updateValue) { + setConfirmPasswordError(true); + } else { + setConfirmPasswordError(false); + } + }} + required + id="UpdatePassword" + /> + + {confirmPasswordError && ( + + Passwords don’t match. Please try again + + )} +
+ + + + + +
+
+ ); +} + +interface ResetPasswordProps { + version: string; +} + +export default ResetPassword; diff --git a/frontend/src/container/ResetPassword/styles.ts b/frontend/src/container/ResetPassword/styles.ts new file mode 100644 index 0000000000..8405f31669 --- /dev/null +++ b/frontend/src/container/ResetPassword/styles.ts @@ -0,0 +1,16 @@ +import { Card } from 'antd'; +import styled from 'styled-components'; + +export const FormWrapper = styled(Card)` + display: flex; + justify-content: center; + max-width: 432px; + flex: 1; +`; + +export const ButtonContainer = styled.div` + margin-top: 1.8125rem; + display: flex; + justify-content: center; + align-items: center; +`; diff --git a/frontend/src/container/SideNav/index.tsx b/frontend/src/container/SideNav/index.tsx index 44bb2201e8..a9fe78ce07 100644 --- a/frontend/src/container/SideNav/index.tsx +++ b/frontend/src/container/SideNav/index.tsx @@ -4,68 +4,37 @@ import getLocalStorageKey from 'api/browser/localstorage/get'; import { IS_SIDEBAR_COLLAPSED } from 'constants/app'; import ROUTES from 'constants/routes'; import history from 'lib/history'; -import setTheme, { AppMode } from 'lib/theme/setTheme'; import React, { useCallback, useLayoutEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { connect, useDispatch, useSelector } from 'react-redux'; -import { NavLink, useLocation } from 'react-router-dom'; -import { bindActionCreators } from 'redux'; -import { ThunkDispatch } from 'redux-thunk'; -import { ToggleDarkMode } from 'store/actions'; +import { useDispatch, useSelector } from 'react-redux'; +import { useLocation } from 'react-router-dom'; import { SideBarCollapse } from 'store/actions/app'; import { AppState } from 'store/reducers'; -import AppActions from 'types/actions'; import AppReducer from 'types/reducer/app'; import menus from './menuItems'; import Slack from './Slack'; import { - Logo, RedDot, Sider, SlackButton, SlackMenuItemContainer, - ThemeSwitcherWrapper, - ToggleButton, VersionContainer, } from './styles'; -function SideNav({ toggleDarkMode }: Props): JSX.Element { +function SideNav(): JSX.Element { const dispatch = useDispatch(); const [collapsed, setCollapsed] = useState( getLocalStorageKey(IS_SIDEBAR_COLLAPSED) === 'true', ); - const { - isDarkMode, - currentVersion, - latestVersion, - isCurrentVersionError, - } = useSelector((state) => state.app); + const { currentVersion, latestVersion, isCurrentVersionError } = useSelector< + AppState, + AppReducer + >((state) => state.app); const { pathname } = useLocation(); const { t } = useTranslation(''); - const toggleTheme = useCallback(() => { - const preMode: AppMode = isDarkMode ? 'lightMode' : 'darkMode'; - setTheme(preMode); - - const id: AppMode = preMode; - const { head } = document; - const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.type = 'text/css'; - link.href = !isDarkMode ? '/css/antd.dark.min.css' : '/css/antd.min.css'; - link.media = 'all'; - link.id = id; - head.appendChild(link); - - link.onload = (): void => { - toggleDarkMode(); - const prevNode = document.getElementById('appMode'); - prevNode?.remove(); - }; - }, [toggleDarkMode, isDarkMode]); - const onCollapse = useCallback(() => { setCollapsed((collapsed) => !collapsed); }, []); @@ -121,17 +90,6 @@ function SideNav({ toggleDarkMode }: Props): JSX.Element { return ( - - - - - - - void; -} - -const mapDispatchToProps = ( - dispatch: ThunkDispatch, -): DispatchProps => ({ - toggleDarkMode: bindActionCreators(ToggleDarkMode, dispatch), -}); - -type Props = DispatchProps; - -export default connect(null, mapDispatchToProps)(SideNav); +export default SideNav; diff --git a/frontend/src/container/SideNav/menuItems.ts b/frontend/src/container/SideNav/menuItems.ts index 5fcd630e2b..cc2d2e1ae8 100644 --- a/frontend/src/container/SideNav/menuItems.ts +++ b/frontend/src/container/SideNav/menuItems.ts @@ -25,7 +25,7 @@ const menus: SidebarMenu[] = [ { Icon: DashboardFilled, to: ROUTES.ALL_DASHBOARD, - name: 'Dashboard', + name: 'Dashboards', }, { Icon: AlertOutlined, diff --git a/frontend/src/container/SideNav/styles.ts b/frontend/src/container/SideNav/styles.ts index 975aa65ba9..3d72951c99 100644 --- a/frontend/src/container/SideNav/styles.ts +++ b/frontend/src/container/SideNav/styles.ts @@ -1,22 +1,9 @@ -import { Layout, Switch, Typography } from 'antd'; +import { Layout, Typography } from 'antd'; import { StyledCSS } from 'container/GantChart/Trace/styles'; import styled, { css } from 'styled-components'; const { Sider: SiderComponent } = Layout; -export const ThemeSwitcherWrapper = styled.div` - display: flex; - justify-content: center; - margin-top: 24px; - margin-bottom: 16px; -`; - -export const Logo = styled.img` - width: 100px; - margin: 9% 5% 5% 10%; - display: ${({ collapsed }): string => (!collapsed ? 'block' : 'none')}; -`; - interface LogoProps { collapsed: boolean; index: number; @@ -32,17 +19,6 @@ export const Sider = styled(SiderComponent)` } `; -interface DarkModeProps { - checked?: boolean; - defaultChecked?: boolean; -} - -export const ToggleButton = styled(Switch)` - &&& { - background: ${({ checked }): string => (checked === false ? 'grey' : '')}; - } -`; - export const SlackButton = styled(Typography)` &&& { margin-left: 1rem; diff --git a/frontend/src/container/Header/Breadcrumbs/index.tsx b/frontend/src/container/TopNav/Breadcrumbs/index.tsx similarity index 94% rename from frontend/src/container/Header/Breadcrumbs/index.tsx rename to frontend/src/container/TopNav/Breadcrumbs/index.tsx index 362122d2fa..d3919ac49e 100644 --- a/frontend/src/container/Header/Breadcrumbs/index.tsx +++ b/frontend/src/container/TopNav/Breadcrumbs/index.tsx @@ -13,6 +13,8 @@ const breadcrumbNameMap = { [ROUTES.DASHBOARD]: 'Dashboard', [ROUTES.ALL_ERROR]: 'Errors', [ROUTES.VERSION]: 'Status', + [ROUTES.ORG_SETTINGS]: 'Organization Settings', + [ROUTES.MY_SETTINGS]: 'My Settings', }; function ShowBreadcrumbs(props: RouteComponentProps): JSX.Element { diff --git a/frontend/src/container/Header/CustomDateTimeModal/index.tsx b/frontend/src/container/TopNav/CustomDateTimeModal/index.tsx similarity index 100% rename from frontend/src/container/Header/CustomDateTimeModal/index.tsx rename to frontend/src/container/TopNav/CustomDateTimeModal/index.tsx diff --git a/frontend/src/container/Header/DateTimeSelection/Refresh.tsx b/frontend/src/container/TopNav/DateTimeSelection/Refresh.tsx similarity index 100% rename from frontend/src/container/Header/DateTimeSelection/Refresh.tsx rename to frontend/src/container/TopNav/DateTimeSelection/Refresh.tsx diff --git a/frontend/src/container/Header/DateTimeSelection/config.ts b/frontend/src/container/TopNav/DateTimeSelection/config.ts similarity index 100% rename from frontend/src/container/Header/DateTimeSelection/config.ts rename to frontend/src/container/TopNav/DateTimeSelection/config.ts diff --git a/frontend/src/container/Header/DateTimeSelection/index.tsx b/frontend/src/container/TopNav/DateTimeSelection/index.tsx similarity index 100% rename from frontend/src/container/Header/DateTimeSelection/index.tsx rename to frontend/src/container/TopNav/DateTimeSelection/index.tsx diff --git a/frontend/src/container/Header/DateTimeSelection/styles.ts b/frontend/src/container/TopNav/DateTimeSelection/styles.ts similarity index 100% rename from frontend/src/container/Header/DateTimeSelection/styles.ts rename to frontend/src/container/TopNav/DateTimeSelection/styles.ts diff --git a/frontend/src/container/TopNav/index.tsx b/frontend/src/container/TopNav/index.tsx new file mode 100644 index 0000000000..b78275e28b --- /dev/null +++ b/frontend/src/container/TopNav/index.tsx @@ -0,0 +1,49 @@ +import { Col } from 'antd'; +import ROUTES from 'constants/routes'; +import history from 'lib/history'; +import React from 'react'; +import { matchPath } from 'react-router-dom'; + +import ShowBreadcrumbs from './Breadcrumbs'; +import DateTimeSelector from './DateTimeSelection'; +import { Container } from './styles'; + +const routesToSkip = [ + ROUTES.SETTINGS, + ROUTES.LIST_ALL_ALERT, + ROUTES.TRACE_DETAIL, + ROUTES.ALL_CHANNELS, +]; + +function TopNav(): JSX.Element | null { + if (history.location.pathname === ROUTES.SIGN_UP) { + return null; + } + + const checkRouteExists = (currentPath: string): boolean => { + for (let i = 0; i < routesToSkip.length; i += 1) { + if ( + matchPath(currentPath, { path: routesToSkip[i], exact: true, strict: true }) + ) { + return true; + } + } + return false; + }; + + return ( + +
+ + + + {!checkRouteExists(history.location.pathname) && ( + + + + )} + + ); +} + +export default TopNav; diff --git a/frontend/src/container/TopNav/styles.ts b/frontend/src/container/TopNav/styles.ts new file mode 100644 index 0000000000..feda027d24 --- /dev/null +++ b/frontend/src/container/TopNav/styles.ts @@ -0,0 +1,9 @@ +import { Row } from 'antd'; +import styled from 'styled-components'; + +export const Container = styled(Row)` + &&& { + margin-top: 2rem; + min-height: 8vh; + } +`; diff --git a/frontend/src/container/TraceDetail/index.tsx b/frontend/src/container/TraceDetail/index.tsx index 489eaa4f26..c705123b50 100644 --- a/frontend/src/container/TraceDetail/index.tsx +++ b/frontend/src/container/TraceDetail/index.tsx @@ -110,7 +110,6 @@ function TraceDetail({ response }: TraceDetailProps): JSX.Element { selectedSpanId={activeSelectedId} onSpanHover={setActiveHoverId} onSpanSelect={setActiveSelectedId} - intervalUnit={intervalUnit} /> diff --git a/frontend/src/hooks/useComponentPermission.ts b/frontend/src/hooks/useComponentPermission.ts new file mode 100644 index 0000000000..5294dd3f05 --- /dev/null +++ b/frontend/src/hooks/useComponentPermission.ts @@ -0,0 +1,22 @@ +import { useCallback, useMemo } from 'react'; +import { ROLES } from 'types/roles'; +import { componentPermission, ComponentTypes } from 'utils/permission'; + +const useComponentPermission = ( + component: ComponentTypes[], + role: ROLES | null, +): boolean[] => { + const getComponentPermission = useCallback( + (component: ComponentTypes): boolean => { + return !!componentPermission[component].find((roles) => role === roles); + }, + [role], + ); + + return useMemo(() => component.map((e) => getComponentPermission(e)), [ + component, + getComponentPermission, + ]); +}; + +export default useComponentPermission; diff --git a/frontend/src/hooks/useIfNotLoggedInNavigate.ts b/frontend/src/hooks/useIfNotLoggedInNavigate.ts new file mode 100644 index 0000000000..3da7fcf3da --- /dev/null +++ b/frontend/src/hooks/useIfNotLoggedInNavigate.ts @@ -0,0 +1,27 @@ +import { notification } from 'antd'; +import history from 'lib/history'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import { useLocation } from 'react-router-dom'; +import { AppState } from 'store/reducers'; +import AppReducer from 'types/reducer/app'; + +const useLoggedInNavigate = (navigateTo: string): void => { + const { isLoggedIn } = useSelector((state) => state.app); + const { pathname } = useLocation(); + const { t } = useTranslation(); + + useEffect(() => { + if (isLoggedIn && navigateTo !== pathname) { + notification.success({ + message: t('logged_in', { + ns: 'common', + }), + }); + history.push(navigateTo); + } + }, [isLoggedIn, navigateTo, pathname, t]); +}; + +export default useLoggedInNavigate; diff --git a/frontend/src/lib/getMinMax.ts b/frontend/src/lib/getMinMax.ts index ee369d9fba..9c1fab94c3 100644 --- a/frontend/src/lib/getMinMax.ts +++ b/frontend/src/lib/getMinMax.ts @@ -1,4 +1,4 @@ -import { Time } from 'container/Header/DateTimeSelection/config'; +import { Time } from 'container/TopNav/DateTimeSelection/config'; import { GlobalReducer } from 'types/reducer/globalTime'; import getMinAgo from './getStartAndEndTime/getMinAgo'; diff --git a/frontend/src/pages/AllAlertChannels/index.tsx b/frontend/src/pages/AllAlertChannels/index.tsx deleted file mode 100644 index 1ab97d0db1..0000000000 --- a/frontend/src/pages/AllAlertChannels/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import RouteTab from 'components/RouteTab'; -import ROUTES from 'constants/routes'; -import AlertChannels from 'container/AllAlertChannels'; -import GeneralSettings from 'container/GeneralSettings'; -import history from 'lib/history'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -function AllAlertChannels(): JSX.Element { - const pathName = history.location.pathname; - const { t } = useTranslation(); - return ( - - ); -} - -export default AllAlertChannels; diff --git a/frontend/src/pages/Login/index.tsx b/frontend/src/pages/Login/index.tsx new file mode 100644 index 0000000000..95d869adce --- /dev/null +++ b/frontend/src/pages/Login/index.tsx @@ -0,0 +1,51 @@ +import { Typography } from 'antd'; +import getUserVersion from 'api/user/getVersion'; +import Spinner from 'components/Spinner'; +import WelcomeLeftContainer from 'components/WelcomeLeftContainer'; +import ROUTES from 'constants/routes'; +import LoginContainer from 'container/Login'; +import useLoggedInNavigate from 'hooks/useIfNotLoggedInNavigate'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from 'react-query'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import AppReducer from 'types/reducer/app'; + +function Login(): JSX.Element { + const { isLoggedIn } = useSelector((state) => state.app); + const { t } = useTranslation(); + + useLoggedInNavigate(ROUTES.APPLICATION); + + const versionResult = useQuery({ + queryFn: getUserVersion, + queryKey: 'getUserVersion', + enabled: !isLoggedIn, + }); + + if (versionResult.status === 'error') { + return ( + + {versionResult.data?.error || t('something_went_wrong')} + + ); + } + + if ( + versionResult.status === 'loading' || + !(versionResult.data && versionResult.data.payload) + ) { + return ; + } + + const { version } = versionResult.data.payload; + + return ( + + + + ); +} + +export default Login; diff --git a/frontend/src/pages/MySettings/index.tsx b/frontend/src/pages/MySettings/index.tsx new file mode 100644 index 0000000000..7029560078 --- /dev/null +++ b/frontend/src/pages/MySettings/index.tsx @@ -0,0 +1,7 @@ +import MySettingsContainer from 'container/MySettings'; +import React from 'react'; + +function MySettings(): JSX.Element { + return ; +} +export default MySettings; diff --git a/frontend/src/pages/ResetPassword/index.tsx b/frontend/src/pages/ResetPassword/index.tsx new file mode 100644 index 0000000000..a3e14b5375 --- /dev/null +++ b/frontend/src/pages/ResetPassword/index.tsx @@ -0,0 +1,52 @@ +import { Typography } from 'antd'; +import getUserVersion from 'api/user/getVersion'; +import Spinner from 'components/Spinner'; +import ROUTES from 'constants/routes'; +import ResetPasswordContainer from 'container/ResetPassword'; +import useLoggedInNavigate from 'hooks/useIfNotLoggedInNavigate'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQueries } from 'react-query'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import AppReducer from 'types/reducer/app'; + +function ResetPassword(): JSX.Element { + const { t } = useTranslation('common'); + const { isLoggedIn } = useSelector((state) => state.app); + + useLoggedInNavigate(ROUTES.APPLICATION); + + const [versionResponse] = useQueries([ + { + queryFn: getUserVersion, + queryKey: 'getUserVersion', + enabled: !isLoggedIn, + }, + ]); + + if ( + versionResponse.status === 'error' || + (versionResponse.status === 'success' && + versionResponse.data?.statusCode !== 200) + ) { + return ( + + {versionResponse.data?.error || t('something_went_wrong')} + + ); + } + + if ( + versionResponse.status === 'loading' || + !(versionResponse.data && versionResponse.data.payload) + ) { + return ; + } + + const { version } = versionResponse.data.payload; + + return ; +} + +export default ResetPassword; diff --git a/frontend/src/pages/Settings/index.tsx b/frontend/src/pages/Settings/index.tsx index 208fcde7c1..20b76122aa 100644 --- a/frontend/src/pages/Settings/index.tsx +++ b/frontend/src/pages/Settings/index.tsx @@ -2,28 +2,60 @@ import RouteTab from 'components/RouteTab'; import ROUTES from 'constants/routes'; import AlertChannels from 'container/AllAlertChannels'; import GeneralSettings from 'container/GeneralSettings'; +import OrganizationSettings from 'container/OrganizationSettings'; +import useComponentPermission from 'hooks/useComponentPermission'; import history from 'lib/history'; import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import AppReducer from 'types/reducer/app'; function SettingsPage(): JSX.Element { const pathName = history.location.pathname; + const { t } = useTranslation(['routes']); + const { role } = useSelector((state) => state.app); + const [currentOrgSettings] = useComponentPermission( + ['current_org_settings'], + role, + ); + + const getActiveKey = (pathname: string): string => { + if (pathname === ROUTES.SETTINGS) { + return t('general'); + } + if (pathname === ROUTES.ORG_SETTINGS && currentOrgSettings) { + return t('organization_settings'); + } + return t('alert_channels'); + }; + + const common = [ + { + Component: GeneralSettings, + name: t('general'), + route: ROUTES.SETTINGS, + }, + { + Component: AlertChannels, + name: t('alert_channels'), + route: ROUTES.ALL_CHANNELS, + }, + ]; + + if (currentOrgSettings) { + common.push({ + Component: OrganizationSettings, + name: t('organization_settings'), + route: ROUTES.ORG_SETTINGS, + }); + } return ( ); diff --git a/frontend/src/pages/SignUp/SignUp.tsx b/frontend/src/pages/SignUp/SignUp.tsx index 88f92660f1..d45b14b72e 100644 --- a/frontend/src/pages/SignUp/SignUp.tsx +++ b/frontend/src/pages/SignUp/SignUp.tsx @@ -1,55 +1,65 @@ -import { - Button, - Card, - Input, - notification, - Space, - Switch, - Typography, -} from 'antd'; -import setLocalStorageKey from 'api/browser/localstorage/set'; -import setPreference from 'api/user/setPreference'; -import signup from 'api/user/signup'; -import { IS_LOGGED_IN } from 'constants/auth'; +import { Button, Input, notification, Space, Switch, Typography } from 'antd'; +import editOrg from 'api/user/editOrg'; +import getInviteDetails from 'api/user/getInviteDetails'; +import loginApi from 'api/user/login'; +import signUpApi from 'api/user/signup'; +import afterLogin from 'AppRoutes/utils'; +import WelcomeLeftContainer from 'components/WelcomeLeftContainer'; import ROUTES from 'constants/routes'; import history from 'lib/history'; import React, { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useDispatch } from 'react-redux'; -import { Dispatch } from 'redux'; -import AppActions from 'types/actions'; -import { PayloadProps } from 'types/api/user/getUserPreference'; +import { useQuery } from 'react-query'; +import { useLocation } from 'react-router-dom'; +import { SuccessResponse } from 'types/api'; +import { PayloadProps } from 'types/api/user/getUser'; -import { - ButtonContainer, - Container, - FormWrapper, - Label, - LeftContainer, - Logo, - MarginTop, -} from './styles'; +import { ButtonContainer, FormWrapper, Label, MarginTop } from './styles'; +import { isPasswordNotValidMessage, isPasswordValid } from './utils'; const { Title } = Typography; -function Signup({ version, userpref }: SignupProps): JSX.Element { +function SignUp({ version }: SignUpProps): JSX.Element { const [loading, setLoading] = useState(false); - const { t } = useTranslation(); const [firstName, setFirstName] = useState(''); const [email, setEmail] = useState(''); - const [organizationName, setOrganisationName] = useState(''); - const [hasOptedUpdates, setHasOptedUpdates] = useState( - userpref.hasOptedUpdates, + const [organizationName, setOrganizationName] = useState(''); + const [hasOptedUpdates, setHasOptedUpdates] = useState(true); + const [isAnonymous, setIsAnonymous] = useState(false); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [confirmPasswordError, setConfirmPasswordError] = useState( + false, ); - const [isAnonymous, setisAnonymous] = useState(userpref.isAnonymous); + const [isPasswordPolicyError, setIsPasswordPolicyError] = useState( + false, + ); + const { search } = useLocation(); + const params = new URLSearchParams(search); + const token = params.get('token'); + const [isDetailsDisable, setIsDetailsDisable] = useState(false); - const dispatch = useDispatch>(); + const getInviteDetailsResponse = useQuery({ + queryFn: () => + getInviteDetails({ + inviteId: token || '', + }), + queryKey: 'getInviteDetails', + enabled: token !== null, + }); useEffect(() => { - setisAnonymous(userpref.isAnonymous); - setHasOptedUpdates(userpref.hasOptedUpdates); - }, [userpref]); + if ( + getInviteDetailsResponse.status === 'success' && + getInviteDetailsResponse.data.payload + ) { + const responseDetails = getInviteDetailsResponse.data.payload; + setFirstName(responseDetails.name); + setEmail(responseDetails.email); + setOrganizationName(responseDetails.organization); + setIsDetailsDisable(true); + } + }, [getInviteDetailsResponse?.data?.payload, getInviteDetailsResponse.status]); const setState = ( value: string, @@ -59,6 +69,70 @@ function Signup({ version, userpref }: SignupProps): JSX.Element { }; const defaultError = 'Something went wrong'; + const isPreferenceVisible = token === null; + + const commonHandler = async ( + callback: (e: SuccessResponse) => Promise | VoidFunction, + ): Promise => { + try { + const response = await signUpApi({ + email, + name: firstName, + orgName: organizationName, + password, + token: params.get('token') || undefined, + }); + + if (response.statusCode === 200) { + const loginResponse = await loginApi({ + email, + password, + }); + + if (loginResponse.statusCode === 200) { + const { payload } = loginResponse; + const userResponse = await afterLogin( + payload.userId, + payload.accessJwt, + payload.refreshJwt, + ); + if (userResponse) { + callback(userResponse); + } + } else { + notification.error({ + message: loginResponse.error || defaultError, + }); + } + } else { + notification.error({ + message: defaultError, + }); + } + } catch (error) { + notification.error({ + message: defaultError, + }); + } + }; + + const onAdminAfterLogin = async ( + userResponse: SuccessResponse, + ): Promise => { + const editResponse = await editOrg({ + isAnonymous, + name: organizationName, + hasOptedUpdates, + orgId: userResponse.payload.orgId, + }); + if (editResponse.statusCode === 200) { + history.push(ROUTES.APPLICATION); + } else { + notification.error({ + message: editResponse.error || defaultError, + }); + } + }; const handleSubmit = (e: React.FormEvent): void => { (async (): Promise => { @@ -66,39 +140,23 @@ function Signup({ version, userpref }: SignupProps): JSX.Element { e.preventDefault(); setLoading(true); - const userPrefernceResponse = await setPreference({ - isAnonymous, - hasOptedUpdates, - }); - - if (userPrefernceResponse.statusCode === 200) { - const response = await signup({ - email, - name: firstName, - organizationName, - }); - - if (response.statusCode === 200) { - setLocalStorageKey(IS_LOGGED_IN, 'yes'); - dispatch({ - type: 'LOGGED_IN', - }); - - history.push(ROUTES.APPLICATION); - } else { - setLoading(false); - - notification.error({ - message: defaultError, - }); - } - } else { + if (!isPasswordValid(password)) { + setIsPasswordPolicyError(true); setLoading(false); - - notification.error({ - message: defaultError, - }); + return; } + + if (isPreferenceVisible) { + await commonHandler(onAdminAfterLogin); + } else { + await commonHandler( + async (): Promise => { + history.push(ROUTES.APPLICATION); + }, + ); + } + + setLoading(false); } catch (error) { notification.error({ message: defaultError, @@ -108,8 +166,6 @@ function Signup({ version, userpref }: SignupProps): JSX.Element { })(); }; - console.log(userpref); - const onSwitchHandler = ( value: boolean, setFunction: React.Dispatch>, @@ -118,21 +174,7 @@ function Signup({ version, userpref }: SignupProps): JSX.Element { }; return ( - - - - - SigNoz - - {t('monitor_signup')} - - SigNoz {version} - - - +
Create your account @@ -148,6 +190,7 @@ function Signup({ version, userpref }: SignupProps): JSX.Element { }} required id="signupEmail" + disabled={isDetailsDisable} /> @@ -161,6 +204,7 @@ function Signup({ version, userpref }: SignupProps): JSX.Element { }} required id="signupFirstName" + disabled={isDetailsDisable} />
@@ -169,34 +213,106 @@ function Signup({ version, userpref }: SignupProps): JSX.Element { placeholder="Netflix" value={organizationName} onChange={(e): void => { - setState(e.target.value, setOrganisationName); + setState(e.target.value, setOrganizationName); }} required id="organizationName" + disabled={isDetailsDisable} />
+
+ + { + setState(e.target.value, setPassword); + }} + required + id="currentPassword" + /> +
+
+ + { + const updateValue = e.target.value; + setState(updateValue, setConfirmPassword); + if (password !== updateValue) { + setConfirmPasswordError(true); + } else { + setConfirmPasswordError(false); + } + if (!isPasswordValid(updateValue)) { + setIsPasswordPolicyError(true); + } else { + setIsPasswordPolicyError(false); + } + }} + required + id="UpdatePassword" + /> - - - onSwitchHandler(value, setHasOptedUpdates)} - checked={hasOptedUpdates} - /> - Keep me updated on new SigNoz features - - + {confirmPasswordError && ( + + Passwords don’t match. Please try again + + )} + {isPasswordPolicyError && ( + + {isPasswordNotValidMessage} + + )} +
- - - onSwitchHandler(value, setisAnonymous)} - checked={isAnonymous} - /> - - Anonymise my usage date. We collect data to measure product usage - - - + {isPreferenceVisible && ( + <> + + + onSwitchHandler(value, setHasOptedUpdates)} + checked={hasOptedUpdates} + /> + Keep me updated on new SigNoz features + + + + + + onSwitchHandler(value, setIsAnonymous)} + checked={isAnonymous} + /> + + Anonymise my usage date. We collect data to measure product usage + + + + + )} + + + This will create an admin account. If you are not an admin, please ask + your admin for an invite link +
-
+ ); } -interface SignupProps { +interface SignUpProps { version: string; - userpref: PayloadProps; } -export default Signup; +export default SignUp; diff --git a/frontend/src/pages/SignUp/index.tsx b/frontend/src/pages/SignUp/index.tsx index 2518567adc..de821bfaf5 100644 --- a/frontend/src/pages/SignUp/index.tsx +++ b/frontend/src/pages/SignUp/index.tsx @@ -1,7 +1,8 @@ import { Typography } from 'antd'; -import getUserPreference from 'api/user/getPreference'; import getUserVersion from 'api/user/getVersion'; import Spinner from 'components/Spinner'; +import ROUTES from 'constants/routes'; +import useLoggedInNavigate from 'hooks/useIfNotLoggedInNavigate'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { useQueries } from 'react-query'; @@ -15,46 +16,38 @@ function SignUp(): JSX.Element { const { t } = useTranslation('common'); const { isLoggedIn } = useSelector((state) => state.app); - const [versionResponse, userPrefResponse] = useQueries([ + useLoggedInNavigate(ROUTES.APPLICATION); + + const [versionResponse] = useQueries([ { queryFn: getUserVersion, queryKey: 'getUserVersion', enabled: !isLoggedIn, }, - { - queryFn: getUserPreference, - queryKey: 'getUserPreference', - enabled: !isLoggedIn, - }, ]); if ( versionResponse.status === 'error' || - userPrefResponse.status === 'error' + (versionResponse.status === 'success' && + versionResponse.data?.statusCode !== 200) ) { return ( - {versionResponse.data?.error || - userPrefResponse.data?.error || - t('something_went_wrong')} + {versionResponse.data?.error || t('something_went_wrong')} ); } if ( versionResponse.status === 'loading' || - userPrefResponse.status === 'loading' || - !(versionResponse.data && versionResponse.data.payload) || - !(userPrefResponse.data && userPrefResponse.data.payload) + !(versionResponse.data && versionResponse.data.payload) ) { return ; } const { version } = versionResponse.data.payload; - const userpref = userPrefResponse.data.payload; - - return ; + return ; } export default SignUp; diff --git a/frontend/src/pages/SignUp/styles.ts b/frontend/src/pages/SignUp/styles.ts index 3bdfc77389..3e6064f6f7 100644 --- a/frontend/src/pages/SignUp/styles.ts +++ b/frontend/src/pages/SignUp/styles.ts @@ -1,19 +1,7 @@ -import { Card, Space } from 'antd'; +import { Card } from 'antd'; import React from 'react'; import styled from 'styled-components'; -export const Container = styled.div` - &&& { - display: flex; - justify-content: center; - align-items: center; - min-height: 100vh; - - max-width: 1024px; - margin: 0 auto; - } -`; - export const FormWrapper = styled(Card)` display: flex; justify-content: center; @@ -29,10 +17,6 @@ export const Label = styled.label` line-height: 24px; `; -export const LeftContainer = styled(Space)` - flex: 1; -`; - export const ButtonContainer = styled.div` margin-top: 1.8125rem; display: flex; @@ -47,7 +31,3 @@ interface Props { export const MarginTop = styled.div` margin-top: ${({ marginTop = 0 }): number | string => marginTop}; `; - -export const Logo = styled.img` - width: 60px; -`; diff --git a/frontend/src/pages/SignUp/utils.ts b/frontend/src/pages/SignUp/utils.ts new file mode 100644 index 0000000000..9c09fe1164 --- /dev/null +++ b/frontend/src/pages/SignUp/utils.ts @@ -0,0 +1,15 @@ +/** + * @function + * @description to check whether password is valid or not + * @reference stackoverflow.com/a/69807687 + * @returns Boolean + */ +export const isPasswordValid = (value: string): boolean => { + // eslint-disable-next-line prefer-regex-literals + const pattern = new RegExp( + '^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$', + ); + return pattern.test(value); +}; + +export const isPasswordNotValidMessage = `Password must a have minimum of 8 characters with at least one lower case, one upper case and one special character`; diff --git a/frontend/src/pages/SomethingWentWrong/index.tsx b/frontend/src/pages/SomethingWentWrong/index.tsx new file mode 100644 index 0000000000..8897cda49d --- /dev/null +++ b/frontend/src/pages/SomethingWentWrong/index.tsx @@ -0,0 +1,43 @@ +import { Button, Typography } from 'antd'; +import getLocalStorageKey from 'api/browser/localstorage/get'; +import SomethingWentWrongAsset from 'assets/SomethingWentWrong'; +import { Container } from 'components/NotFound/styles'; +import { LOCALSTORAGE } from 'constants/localStorage'; +import ROUTES from 'constants/routes'; +import history from 'lib/history'; +import React from 'react'; +import { useDispatch } from 'react-redux'; +import { Dispatch } from 'redux'; +import AppActions from 'types/actions'; +import { LOGGED_IN } from 'types/actions/app'; + +function SomethingWentWrong(): JSX.Element { + const dispatch = useDispatch>(); + const isLoggedIn = getLocalStorageKey(LOCALSTORAGE.IS_LOGGED_IN); + + return ( + + + Oops! Something went wrong + + + ); +} + +export default SomethingWentWrong; diff --git a/frontend/src/pages/UnAuthorized/index.tsx b/frontend/src/pages/UnAuthorized/index.tsx new file mode 100644 index 0000000000..d856e26477 --- /dev/null +++ b/frontend/src/pages/UnAuthorized/index.tsx @@ -0,0 +1,23 @@ +import { Space, Typography } from 'antd'; +import UnAuthorized from 'assets/UnAuthorized'; +import { Button, Container } from 'components/NotFound/styles'; +import ROUTES from 'constants/routes'; +import React from 'react'; + +function UnAuthorizePage(): JSX.Element { + return ( + + + + + Oops.. you don't have permission to view this page + + + + + ); +} + +export default UnAuthorizePage; diff --git a/frontend/src/store/actions/global.ts b/frontend/src/store/actions/global.ts index c3f3180567..c2b88919da 100644 --- a/frontend/src/store/actions/global.ts +++ b/frontend/src/store/actions/global.ts @@ -1,4 +1,4 @@ -import { Time } from 'container/Header/DateTimeSelection/config'; +import { Time } from 'container/TopNav/DateTimeSelection/config'; import GetMinMax from 'lib/getMinMax'; import { Dispatch } from 'redux'; import AppActions from 'types/actions'; diff --git a/frontend/src/store/reducers/app.ts b/frontend/src/store/reducers/app.ts index a8feece5a4..d5877427ef 100644 --- a/frontend/src/store/reducers/app.ts +++ b/frontend/src/store/reducers/app.ts @@ -1,7 +1,7 @@ import getLocalStorageKey from 'api/browser/localstorage/get'; import { IS_SIDEBAR_COLLAPSED } from 'constants/app'; -import { IS_LOGGED_IN } from 'constants/auth'; import getTheme from 'lib/theme/getTheme'; +import { getInitialUserTokenRefreshToken } from 'store/utils'; import { AppAction, LOGGED_IN, @@ -11,17 +11,47 @@ import { UPDATE_CURRENT_VERSION, UPDATE_LATEST_VERSION, UPDATE_LATEST_VERSION_ERROR, + UPDATE_ORG_NAME, + UPDATE_USER, + UPDATE_USER_ACCESS_REFRESH_ACCESS_TOKEN, + UPDATE_USER_IS_FETCH, + UPDATE_USER_ORG_ROLE, } from 'types/actions/app'; -import InitialValueTypes from 'types/reducer/app'; +import { + Organization, + PayloadProps as OrgPayload, +} from 'types/api/user/getOrganization'; +import InitialValueTypes, { User } from 'types/reducer/app'; + +const getInitialUser = (): User | null => { + const response = getInitialUserTokenRefreshToken(); + + if (response) { + return { + accessJwt: response.accessJwt, + refreshJwt: response.refreshJwt, + userId: '', + email: '', + name: '', + profilePictureURL: '', + }; + } + return null; +}; const InitialValue: InitialValueTypes = { isDarkMode: getTheme() === 'darkMode', - isLoggedIn: getLocalStorageKey(IS_LOGGED_IN) === 'yes', + isLoggedIn: false, isSideBarCollapsed: getLocalStorageKey(IS_SIDEBAR_COLLAPSED) === 'true', currentVersion: '', latestVersion: '', isCurrentVersionError: false, isLatestVersionError: false, + user: getInitialUser(), + isUserFetching: true, + isUserFetchingError: false, + org: null, + role: null, }; const appReducer = ( @@ -39,7 +69,7 @@ const appReducer = ( case LOGGED_IN: { return { ...state, - isLoggedIn: true, + isLoggedIn: action.payload.isLoggedIn, }; } @@ -72,6 +102,93 @@ const appReducer = ( }; } + case UPDATE_USER_ACCESS_REFRESH_ACCESS_TOKEN: { + return { + ...state, + user: { + userId: '', + email: '', + name: '', + profilePictureURL: '', + ...action.payload, + }, + }; + } + + case UPDATE_USER_IS_FETCH: { + return { + ...state, + isUserFetching: action.payload.isUserFetching, + }; + } + + case UPDATE_USER_ORG_ROLE: { + return { + ...state, + ...action.payload, + }; + } + + case UPDATE_USER: { + const user = state.user || ({} as User); + const org = state.org || ([] as Organization[]); + const { + email, + name, + profilePictureURL, + userId, + ROLE, + orgId, + orgName, + } = action.payload; + const orgIndex = org.findIndex((e) => e.id === orgId); + + const updatedOrg: OrgPayload = [ + ...org.slice(0, orgIndex), + { + createdAt: 0, + hasOptedUpdates: false, + id: orgId, + isAnonymous: false, + name: orgName, + }, + ...org.slice(orgIndex + 1, org.length), + ]; + + return { + ...state, + user: { + ...user, + email, + name, + profilePictureURL, + userId, + }, + org: [...updatedOrg], + role: ROLE, + }; + } + + case UPDATE_ORG_NAME: { + const stateOrg = state.org || ({} as OrgPayload); + const { index, name: updatedName } = action.payload; + const current = stateOrg[index]; + + const updatedOrg: OrgPayload = [ + ...stateOrg.slice(0, index), + { + ...current, + name: updatedName, + }, + ...stateOrg.slice(index + 1, stateOrg.length), + ]; + + return { + ...state, + org: updatedOrg, + }; + } + default: return state; } diff --git a/frontend/src/store/reducers/global.ts b/frontend/src/store/reducers/global.ts index 1ba2e246e7..084e7cd377 100644 --- a/frontend/src/store/reducers/global.ts +++ b/frontend/src/store/reducers/global.ts @@ -1,4 +1,4 @@ -import { getDefaultOption } from 'container/Header/DateTimeSelection/config'; +import { getDefaultOption } from 'container/TopNav/DateTimeSelection/config'; import { GLOBAL_TIME_LOADING_START, GlobalTimeAction, diff --git a/frontend/src/store/utils.ts b/frontend/src/store/utils.ts new file mode 100644 index 0000000000..22e3942151 --- /dev/null +++ b/frontend/src/store/utils.ts @@ -0,0 +1,22 @@ +import getLocalStorageKey from 'api/browser/localstorage/get'; +import { LOCALSTORAGE } from 'constants/localStorage'; +import { User } from 'types/reducer/app'; + +export const getInitialUserTokenRefreshToken = (): AuthTokenProps | null => { + const accessJwt = getLocalStorageKey(LOCALSTORAGE.AUTH_TOKEN); + const refreshJwt = getLocalStorageKey(LOCALSTORAGE.REFRESH_AUTH_TOKEN); + + if (accessJwt && refreshJwt) { + return { + accessJwt, + refreshJwt, + }; + } + + return null; +}; + +interface AuthTokenProps { + accessJwt: User['accessJwt']; + refreshJwt: User['refreshJwt']; +} diff --git a/frontend/src/types/actions/app.ts b/frontend/src/types/actions/app.ts index b07cde36d4..67036478c6 100644 --- a/frontend/src/types/actions/app.ts +++ b/frontend/src/types/actions/app.ts @@ -1,4 +1,9 @@ -import AppReducer from 'types/reducer/app'; +import { + Organization, + PayloadProps as OrgPayload, +} from 'types/api/user/getOrganization'; +import AppReducer, { User } from 'types/reducer/app'; +import { ROLES } from 'types/roles'; export const SWITCH_DARK_MODE = 'SWITCH_DARK_MODE'; export const LOGGED_IN = 'LOGGED_IN'; @@ -9,6 +14,12 @@ export const UPDATE_LATEST_VERSION = 'UPDATE_LATEST_VERSION'; export const UPDATE_CURRENT_ERROR = 'UPDATE_CURRENT_ERROR'; export const UPDATE_LATEST_VERSION_ERROR = 'UPDATE_LATEST_VERSION_ERROR'; +export const UPDATE_USER_ACCESS_REFRESH_ACCESS_TOKEN = + 'UPDATE_USER_ACCESS_REFRESH_ACCESS_TOKEN'; +export const UPDATE_USER_IS_FETCH = 'UPDATE_USER_IS_FETCH'; +export const UPDATE_USER_ORG_ROLE = 'UPDATE_USER_ORG_ROLE'; +export const UPDATE_USER = 'UPDATE_USER'; +export const UPDATE_ORG_NAME = 'UPDATE_ORG_NAME'; export interface SwitchDarkMode { type: typeof SWITCH_DARK_MODE; @@ -16,6 +27,9 @@ export interface SwitchDarkMode { export interface LoggedInUser { type: typeof LOGGED_IN; + payload: { + isLoggedIn: boolean; + }; } export interface SideBarCollapse { @@ -44,10 +58,59 @@ export interface UpdateVersionError { }; } +export interface UpdateUserOrgRole { + type: typeof UPDATE_USER_ORG_ROLE; + payload: { + role: ROLES | null; + org: OrgPayload | null; + }; +} + +export interface UpdateAccessRenewToken { + type: typeof UPDATE_USER_ACCESS_REFRESH_ACCESS_TOKEN; + payload: { + accessJwt: User['accessJwt']; + refreshJwt: User['refreshJwt']; + }; +} + +export interface UpdateUser { + type: typeof UPDATE_USER; + payload: { + email: User['email']; + name: User['name']; + profilePictureURL: User['profilePictureURL']; + userId: User['userId']; + orgName: Organization['name']; + ROLE: ROLES; + orgId: Organization['id']; + }; +} + +export interface UpdateUserIsFetched { + type: typeof UPDATE_USER_IS_FETCH; + payload: { + isUserFetching: AppReducer['isUserFetching']; + }; +} + +export interface UpdateOrgName { + type: typeof UPDATE_ORG_NAME; + payload: { + name: string; + index: number; + }; +} + export type AppAction = | SwitchDarkMode | LoggedInUser | SideBarCollapse | UpdateAppVersion | UpdateLatestVersion - | UpdateVersionError; + | UpdateVersionError + | UpdateAccessRenewToken + | UpdateUserIsFetched + | UpdateUserOrgRole + | UpdateUser + | UpdateOrgName; diff --git a/frontend/src/types/actions/globalTime.ts b/frontend/src/types/actions/globalTime.ts index 8bc148fa1b..8e384fb569 100644 --- a/frontend/src/types/actions/globalTime.ts +++ b/frontend/src/types/actions/globalTime.ts @@ -1,4 +1,4 @@ -import { Time } from 'container/Header/DateTimeSelection/config'; +import { Time } from 'container/TopNav/DateTimeSelection/config'; export const UPDATE_TIME_INTERVAL = 'UPDATE_TIME_INTERVAL'; export const GLOBAL_TIME_LOADING_START = 'GLOBAL_TIME_LOADING_START'; diff --git a/frontend/src/types/api/user/changeMyPassword.ts b/frontend/src/types/api/user/changeMyPassword.ts new file mode 100644 index 0000000000..154d0b819a --- /dev/null +++ b/frontend/src/types/api/user/changeMyPassword.ts @@ -0,0 +1,11 @@ +import { User } from 'types/reducer/app'; + +export interface Props { + oldPassword: string; + newPassword: string; + userId: User['userId']; +} + +export interface PayloadProps { + data: string; +} diff --git a/frontend/src/types/api/user/deleteInvite.ts b/frontend/src/types/api/user/deleteInvite.ts new file mode 100644 index 0000000000..1caab7e694 --- /dev/null +++ b/frontend/src/types/api/user/deleteInvite.ts @@ -0,0 +1,9 @@ +import { User } from 'types/reducer/app'; + +export interface Props { + email: User['email']; +} + +export interface PayloadProps { + data: string; +} diff --git a/frontend/src/types/api/user/deleteUser.ts b/frontend/src/types/api/user/deleteUser.ts new file mode 100644 index 0000000000..a3eae2b19f --- /dev/null +++ b/frontend/src/types/api/user/deleteUser.ts @@ -0,0 +1,9 @@ +import { User } from 'types/reducer/app'; + +export interface Props { + userId: User['userId']; +} + +export interface PayloadProps { + data: string; +} diff --git a/frontend/src/types/api/user/editOrg.ts b/frontend/src/types/api/user/editOrg.ts new file mode 100644 index 0000000000..126211d7f5 --- /dev/null +++ b/frontend/src/types/api/user/editOrg.ts @@ -0,0 +1,10 @@ +export interface Props { + name: string; + isAnonymous: boolean; + orgId: string; + hasOptedUpdates?: boolean; +} + +export interface PayloadProps { + data: string; +} diff --git a/frontend/src/types/api/user/editUser.ts b/frontend/src/types/api/user/editUser.ts new file mode 100644 index 0000000000..a080214158 --- /dev/null +++ b/frontend/src/types/api/user/editUser.ts @@ -0,0 +1,10 @@ +import { User } from 'types/reducer/app'; + +import { PayloadProps as Payload } from './getUser'; + +export type PayloadProps = Payload; + +export interface Props { + userId: User['userId']; + name: User['name']; +} diff --git a/frontend/src/types/api/user/getInviteDetails.ts b/frontend/src/types/api/user/getInviteDetails.ts new file mode 100644 index 0000000000..224c73ca84 --- /dev/null +++ b/frontend/src/types/api/user/getInviteDetails.ts @@ -0,0 +1,17 @@ +import { User } from 'types/reducer/app'; +import { ROLES } from 'types/roles'; + +import { Organization } from './getOrganization'; + +export interface Props { + inviteId: string; +} + +export interface PayloadProps { + createdAt: number; + email: User['email']; + name: User['name']; + role: ROLES; + token: string; + organization: Organization['name']; +} diff --git a/frontend/src/types/api/user/getOrgMembers.ts b/frontend/src/types/api/user/getOrgMembers.ts new file mode 100644 index 0000000000..23b9dd2d30 --- /dev/null +++ b/frontend/src/types/api/user/getOrgMembers.ts @@ -0,0 +1,18 @@ +import { ROLES } from 'types/roles'; + +import { Organization } from './getOrganization'; + +export interface Props { + orgId: Organization['id']; +} + +interface OrgMembers { + createdAt: number; + email: string; + name: string; + role: ROLES; + token: string; + id: string; +} + +export type PayloadProps = OrgMembers[]; diff --git a/frontend/src/types/api/user/getOrganization.ts b/frontend/src/types/api/user/getOrganization.ts new file mode 100644 index 0000000000..efc855a82a --- /dev/null +++ b/frontend/src/types/api/user/getOrganization.ts @@ -0,0 +1,9 @@ +export interface Organization { + createdAt: number; + hasOptedUpdates: boolean; + id: string; + isAnonymous: boolean; + name: string; +} + +export type PayloadProps = Organization[]; diff --git a/frontend/src/types/api/user/getPendingInvites.ts b/frontend/src/types/api/user/getPendingInvites.ts new file mode 100644 index 0000000000..4b64bf79af --- /dev/null +++ b/frontend/src/types/api/user/getPendingInvites.ts @@ -0,0 +1,12 @@ +import { User } from 'types/reducer/app'; +import { ROLES } from 'types/roles'; + +export interface PendingInvite { + createdAt: number; + email: User['email']; + name: User['name']; + role: ROLES; + token: string; +} + +export type PayloadProps = PendingInvite[]; diff --git a/frontend/src/types/api/user/getResetPasswordToken.ts b/frontend/src/types/api/user/getResetPasswordToken.ts new file mode 100644 index 0000000000..dee9863006 --- /dev/null +++ b/frontend/src/types/api/user/getResetPasswordToken.ts @@ -0,0 +1,10 @@ +import { User } from 'types/reducer/app'; + +export interface Props { + userId: User['userId']; +} + +export interface PayloadProps { + token: string; + userId: string; +} diff --git a/frontend/src/types/api/user/getUser.ts b/frontend/src/types/api/user/getUser.ts new file mode 100644 index 0000000000..e066ec3103 --- /dev/null +++ b/frontend/src/types/api/user/getUser.ts @@ -0,0 +1,18 @@ +import { User } from 'types/reducer/app'; +import { ROLES } from 'types/roles'; + +export interface Props { + userId: User['userId']; + token?: string; +} + +export interface PayloadProps { + createdAt: number; + email: string; + id: string; + name: string; + orgId: string; + profilePictureURL: string; + organization: string; + role: ROLES; +} diff --git a/frontend/src/types/api/user/getUserRole.ts b/frontend/src/types/api/user/getUserRole.ts new file mode 100644 index 0000000000..b9a6b8dd80 --- /dev/null +++ b/frontend/src/types/api/user/getUserRole.ts @@ -0,0 +1,12 @@ +import { User } from 'types/reducer/app'; +import { ROLES } from 'types/roles'; + +export interface Props { + userId: User['userId']; + token?: string; +} + +export interface PayloadProps { + group_name: ROLES; + user_id: string; +} diff --git a/frontend/src/types/api/user/login.ts b/frontend/src/types/api/user/login.ts new file mode 100644 index 0000000000..db5f1875e9 --- /dev/null +++ b/frontend/src/types/api/user/login.ts @@ -0,0 +1,13 @@ +export interface PayloadProps { + accessJwt: string; + accessJwtExpiry: number; + refreshJwt: string; + refreshJwtExpiry: number; + userId: string; +} + +export interface Props { + email?: string; + password?: string; + refreshToken?: PayloadProps['refreshJwt']; +} diff --git a/frontend/src/types/api/user/resetPassword.ts b/frontend/src/types/api/user/resetPassword.ts new file mode 100644 index 0000000000..e97197acf0 --- /dev/null +++ b/frontend/src/types/api/user/resetPassword.ts @@ -0,0 +1,8 @@ +export interface Props { + token: string; + password: string; +} + +export interface PayloadProps { + data: string; +} diff --git a/frontend/src/types/api/user/setInvite.ts b/frontend/src/types/api/user/setInvite.ts new file mode 100644 index 0000000000..d9112e76b5 --- /dev/null +++ b/frontend/src/types/api/user/setInvite.ts @@ -0,0 +1,12 @@ +import { User } from 'types/reducer/app'; +import { ROLES } from 'types/roles'; + +export interface Props { + name: User['name']; + email: User['email']; + role: ROLES; +} + +export interface PayloadProps { + data: string; +} diff --git a/frontend/src/types/api/user/signup.ts b/frontend/src/types/api/user/signup.ts index 8e6feca0d6..809792042a 100644 --- a/frontend/src/types/api/user/signup.ts +++ b/frontend/src/types/api/user/signup.ts @@ -1,5 +1,7 @@ export interface Props { - email: string; name: string; - organizationName: string; + orgName: string; + email: string; + password: string; + token?: string; } diff --git a/frontend/src/types/api/user/updateRole.ts b/frontend/src/types/api/user/updateRole.ts new file mode 100644 index 0000000000..796ec2ed1d --- /dev/null +++ b/frontend/src/types/api/user/updateRole.ts @@ -0,0 +1,10 @@ +import { ROLES } from 'types/roles'; + +export interface Props { + group_name: ROLES; + userId: string; +} + +export interface PayloadProps { + data: string; +} diff --git a/frontend/src/types/reducer/app.ts b/frontend/src/types/reducer/app.ts index 68af65d075..9c1f16180e 100644 --- a/frontend/src/types/reducer/app.ts +++ b/frontend/src/types/reducer/app.ts @@ -1,3 +1,16 @@ +import { PayloadProps as OrgPayload } from 'types/api/user/getOrganization'; +import { PayloadProps as UserPayload } from 'types/api/user/getUser'; +import { ROLES } from 'types/roles'; + +export interface User { + accessJwt: string; + refreshJwt: string; + userId: string; + email: UserPayload['email']; + name: UserPayload['name']; + profilePictureURL: UserPayload['profilePictureURL']; +} + export default interface AppReducer { isDarkMode: boolean; isLoggedIn: boolean; @@ -6,4 +19,9 @@ export default interface AppReducer { latestVersion: string; isCurrentVersionError: boolean; isLatestVersionError: boolean; + user: null | User; + isUserFetching: boolean; + isUserFetchingError: boolean; + role: ROLES | null; + org: OrgPayload | null; } diff --git a/frontend/src/types/reducer/globalTime.ts b/frontend/src/types/reducer/globalTime.ts index 788b8447b4..cb55524c75 100644 --- a/frontend/src/types/reducer/globalTime.ts +++ b/frontend/src/types/reducer/globalTime.ts @@ -1,4 +1,4 @@ -import { Time } from 'container/Header/DateTimeSelection/config'; +import { Time } from 'container/TopNav/DateTimeSelection/config'; import { GlobalTime } from 'types/actions/globalTime'; export interface GlobalReducer { diff --git a/frontend/src/types/roles.ts b/frontend/src/types/roles.ts new file mode 100644 index 0000000000..02216f6d74 --- /dev/null +++ b/frontend/src/types/roles.ts @@ -0,0 +1,5 @@ +export type ADMIN = 'ADMIN'; +export type VIEWER = 'VIEWER'; +export type EDITOR = 'EDITOR'; + +export type ROLES = ADMIN | VIEWER | EDITOR; diff --git a/frontend/src/utils/permission/index.ts b/frontend/src/utils/permission/index.ts new file mode 100644 index 0000000000..cb85c997ba --- /dev/null +++ b/frontend/src/utils/permission/index.ts @@ -0,0 +1,57 @@ +import ROUTES from 'constants/routes'; +import { ROLES } from 'types/roles'; + +export type ComponentTypes = + | 'current_org_settings' + | 'invite_members' + | 'create_new_dashboards' + | 'import_dashboard' + | 'export_dashboard' + | 'add_new_alert' + | 'add_new_channel' + | 'set_retention_period' + | 'action'; + +export const componentPermission: Record = { + current_org_settings: ['ADMIN'], + invite_members: ['ADMIN'], + create_new_dashboards: ['ADMIN', 'EDITOR'], + import_dashboard: ['ADMIN', 'EDITOR'], + export_dashboard: ['ADMIN', 'EDITOR', 'VIEWER'], + add_new_alert: ['ADMIN', 'EDITOR'], + add_new_channel: ['ADMIN'], + set_retention_period: ['ADMIN'], + action: ['ADMIN', 'EDITOR'], +}; + +export const routePermission: Record = { + ALERTS_NEW: ['ADMIN', 'EDITOR'], + ORG_SETTINGS: ['ADMIN'], + MY_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'], + SERVICE_MAP: ['ADMIN', 'EDITOR', 'VIEWER'], + ALL_CHANNELS: ['ADMIN', 'EDITOR', 'VIEWER'], + ALL_DASHBOARD: ['ADMIN', 'EDITOR', 'VIEWER'], + ALL_ERROR: ['ADMIN', 'EDITOR', 'VIEWER'], + APPLICATION: ['ADMIN', 'EDITOR', 'VIEWER'], + CHANNELS_EDIT: ['ADMIN'], + CHANNELS_NEW: ['ADMIN'], + DASHBOARD: ['ADMIN', 'EDITOR', 'EDITOR'], + DASHBOARD_WIDGET: ['ADMIN', 'EDITOR', 'VIEWER'], + EDIT_ALERTS: ['ADMIN'], + ERROR_DETAIL: ['ADMIN', 'EDITOR', 'VIEWER'], + HOME_PAGE: ['ADMIN', 'EDITOR', 'VIEWER'], + INSTRUMENTATION: ['ADMIN', 'EDITOR', 'VIEWER'], + LIST_ALL_ALERT: ['ADMIN', 'EDITOR', 'VIEWER'], + LOGIN: ['ADMIN', 'EDITOR', 'VIEWER'], + NOT_FOUND: ['ADMIN', 'VIEWER', 'EDITOR'], + PASSWORD_RESET: ['ADMIN', 'EDITOR', 'VIEWER'], + SERVICE_METRICS: ['ADMIN', 'EDITOR', 'VIEWER'], + SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'], + SIGN_UP: ['ADMIN', 'EDITOR', 'VIEWER'], + SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'], + TRACE: ['ADMIN', 'EDITOR', 'VIEWER'], + TRACE_DETAIL: ['ADMIN', 'EDITOR', 'VIEWER'], + UN_AUTHORIZED: ['ADMIN', 'EDITOR', 'VIEWER'], + USAGE_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'], + VERSION: ['ADMIN', 'EDITOR', 'VIEWER'], +};