diff --git a/ee/query-service/app/api/api.go b/ee/query-service/app/api/api.go index 5efa43149a..5d7d6d2ffa 100644 --- a/ee/query-service/app/api/api.go +++ b/ee/query-service/app/api/api.go @@ -183,17 +183,10 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *baseapp.AuthMiddlew Methods(http.MethodGet) // v3 - router.HandleFunc("/api/v3/licenses", - am.ViewAccess(ah.listLicensesV3)). - Methods(http.MethodGet) - - router.HandleFunc("/api/v3/licenses", - am.AdminAccess(ah.applyLicenseV3)). - Methods(http.MethodPost) - - router.HandleFunc("/api/v3/licenses", - am.AdminAccess(ah.refreshLicensesV3)). - Methods(http.MethodPut) + router.HandleFunc("/api/v3/licenses", am.ViewAccess(ah.listLicensesV3)).Methods(http.MethodGet) + router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.applyLicenseV3)).Methods(http.MethodPost) + router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.refreshLicensesV3)).Methods(http.MethodPut) + router.HandleFunc("/api/v3/licenses/active", am.ViewAccess(ah.getActiveLicenseV3)).Methods(http.MethodGet) // v4 router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost) diff --git a/ee/query-service/app/api/license.go b/ee/query-service/app/api/license.go index f0272202be..7138e29f80 100644 --- a/ee/query-service/app/api/license.go +++ b/ee/query-service/app/api/license.go @@ -122,6 +122,23 @@ func (ah *APIHandler) listLicensesV3(w http.ResponseWriter, r *http.Request) { ah.Respond(w, convertLicenseV3ToListLicenseResponse(licenses)) } +func (ah *APIHandler) getActiveLicenseV3(w http.ResponseWriter, r *http.Request) { + activeLicense, err := ah.LM().GetRepo().GetActiveLicenseV3(r.Context()) + if err != nil { + RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil) + return + } + // return 404 not found if there is no active license + if activeLicense == nil { + RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("no active license found")}, nil) + return + } + + // TODO deprecate this when we move away from key for stripe + activeLicense.Data["key"] = activeLicense.Key + render.Success(w, http.StatusOK, activeLicense.Data) +} + // this function is called by zeus when inserting licenses in the query-service func (ah *APIHandler) applyLicenseV3(w http.ResponseWriter, r *http.Request) { var licenseKey ApplyLicenseRequest diff --git a/frontend/public/Images/feature-graphic-correlation.svg b/frontend/public/Images/feature-graphic-correlation.svg new file mode 100644 index 0000000000..1bbeefb264 --- /dev/null +++ b/frontend/public/Images/feature-graphic-correlation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/locales/en-GB/failedPayment.json b/frontend/public/locales/en-GB/failedPayment.json new file mode 100644 index 0000000000..a624e47c7d --- /dev/null +++ b/frontend/public/locales/en-GB/failedPayment.json @@ -0,0 +1,12 @@ +{ + "workspaceSuspended": "Your workspace is locked", + "gotQuestions": "Got Questions?", + "contactUs": "Contact Us", + "actionHeader": "Pay to continue", + "actionDescription": "Pay now to keep enjoying all the great features you’ve been using.", + "yourDataIsSafe": "Your data is safe with us until", + "actNow": "Act now to avoid any disruptions and continue where you left off.", + "contactAdmin": "Contact your admin to proceed with the upgrade.", + "continueMyJourney": "Settle your bill to continue", + "somethingWentWrong": "Something went wrong" +} diff --git a/frontend/public/locales/en-GB/titles.json b/frontend/public/locales/en-GB/titles.json index c7e8736aba..c74d82f028 100644 --- a/frontend/public/locales/en-GB/titles.json +++ b/frontend/public/locales/en-GB/titles.json @@ -37,6 +37,7 @@ "PASSWORD_RESET": "SigNoz | Password Reset", "LIST_LICENSES": "SigNoz | List of Licenses", "WORKSPACE_LOCKED": "SigNoz | Workspace Locked", + "WORKSPACE_SUSPENDED": "SigNoz | Workspace Suspended", "SUPPORT": "SigNoz | Support", "DEFAULT": "Open source Observability Platform | SigNoz", "ALERT_HISTORY": "SigNoz | Alert Rule History", diff --git a/frontend/public/locales/en/failedPayment.json b/frontend/public/locales/en/failedPayment.json new file mode 100644 index 0000000000..a624e47c7d --- /dev/null +++ b/frontend/public/locales/en/failedPayment.json @@ -0,0 +1,12 @@ +{ + "workspaceSuspended": "Your workspace is locked", + "gotQuestions": "Got Questions?", + "contactUs": "Contact Us", + "actionHeader": "Pay to continue", + "actionDescription": "Pay now to keep enjoying all the great features you’ve been using.", + "yourDataIsSafe": "Your data is safe with us until", + "actNow": "Act now to avoid any disruptions and continue where you left off.", + "contactAdmin": "Contact your admin to proceed with the upgrade.", + "continueMyJourney": "Settle your bill to continue", + "somethingWentWrong": "Something went wrong" +} diff --git a/frontend/public/locales/en/titles.json b/frontend/public/locales/en/titles.json index 978d1d3b13..4d903b7a40 100644 --- a/frontend/public/locales/en/titles.json +++ b/frontend/public/locales/en/titles.json @@ -45,6 +45,7 @@ "PASSWORD_RESET": "SigNoz | Password Reset", "LIST_LICENSES": "SigNoz | List of Licenses", "WORKSPACE_LOCKED": "SigNoz | Workspace Locked", + "WORKSPACE_SUSPENDED": "SigNoz | Workspace Suspended", "SUPPORT": "SigNoz | Support", "LOGS_SAVE_VIEWS": "SigNoz | Logs Saved Views", "TRACES_SAVE_VIEWS": "SigNoz | Traces Saved Views", diff --git a/frontend/src/AppRoutes/Private.tsx b/frontend/src/AppRoutes/Private.tsx index 5b70b8ea6f..77ec267922 100644 --- a/frontend/src/AppRoutes/Private.tsx +++ b/frontend/src/AppRoutes/Private.tsx @@ -10,6 +10,7 @@ import useLicense from 'hooks/useLicense'; import { useNotifications } from 'hooks/useNotifications'; import history from 'lib/history'; import { isEmpty, isNull } from 'lodash-es'; +import { useAppContext } from 'providers/App/App'; import { ReactChild, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from 'react-query'; @@ -20,6 +21,7 @@ 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 { LicenseState, LicenseStatus } from 'types/api/licensesV3/getActive'; import { Organization } from 'types/api/user/getOrganization'; import AppReducer from 'types/reducer/app'; import { isCloudUser } from 'utils/app'; @@ -49,6 +51,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { isFetchingOrgPreferences, } = useSelector((state) => state.app); + const { activeLicenseV3, isFetchingActiveLicenseV3 } = useAppContext(); + const mapRoutes = useMemo( () => new Map( @@ -249,6 +253,33 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { } }, [isFetchingLicensesData]); + const navigateToWorkSpaceSuspended = (route: any): void => { + const { path } = route; + + if (path && path !== ROUTES.WORKSPACE_SUSPENDED) { + history.push(ROUTES.WORKSPACE_SUSPENDED); + + dispatch({ + type: UPDATE_USER_IS_FETCH, + payload: { + isUserFetching: false, + }, + }); + } + }; + + useEffect(() => { + if (!isFetchingActiveLicenseV3 && activeLicenseV3) { + const shouldSuspendWorkspace = + activeLicenseV3.status === LicenseStatus.SUSPENDED && + activeLicenseV3.state === LicenseState.PAYMENT_FAILED; + + if (shouldSuspendWorkspace) { + navigateToWorkSpaceSuspended(currentRoute); + } + } + }, [isFetchingActiveLicenseV3, activeLicenseV3]); + useEffect(() => { if (org && org.length > 0 && org[0].id !== undefined) { setOrgData(org[0]); diff --git a/frontend/src/AppRoutes/index.tsx b/frontend/src/AppRoutes/index.tsx index c4cab69413..9fd759f40c 100644 --- a/frontend/src/AppRoutes/index.tsx +++ b/frontend/src/AppRoutes/index.tsx @@ -22,6 +22,7 @@ import history from 'lib/history'; import { identity, pick, pickBy } from 'lodash-es'; import posthog from 'posthog-js'; import AlertRuleProvider from 'providers/Alert'; +import { AppProvider } from 'providers/App/App'; import { DashboardProvider } from 'providers/Dashboard/Dashboard'; import { QueryBuilderProvider } from 'providers/QueryBuilder'; import { Suspense, useEffect, useState } from 'react'; @@ -291,42 +292,44 @@ function App(): JSX.Element { }, []); return ( - - - - - - - - - - - - }> - - {routes.map(({ path, component, exact }) => ( - - ))} + + + + + + + + + + + + + }> + + {routes.map(({ path, component, exact }) => ( + + ))} - - - - - - - - - - - - - - + + + + + + + + + + + + + + + ); } diff --git a/frontend/src/AppRoutes/pageComponents.ts b/frontend/src/AppRoutes/pageComponents.ts index 5d4729d9a3..e623357ab5 100644 --- a/frontend/src/AppRoutes/pageComponents.ts +++ b/frontend/src/AppRoutes/pageComponents.ts @@ -206,6 +206,13 @@ export const WorkspaceBlocked = Loadable( import(/* webpackChunkName: "WorkspaceLocked" */ 'pages/WorkspaceLocked'), ); +export const WorkspaceSuspended = Loadable( + () => + import( + /* webpackChunkName: "WorkspaceSuspended" */ 'pages/WorkspaceSuspended/WorkspaceSuspended' + ), +); + export const ShortcutsPage = Loadable( () => import(/* webpackChunkName: "ShortcutsPage" */ 'pages/Shortcuts'), ); diff --git a/frontend/src/AppRoutes/routes.ts b/frontend/src/AppRoutes/routes.ts index 55119db63e..480d03561b 100644 --- a/frontend/src/AppRoutes/routes.ts +++ b/frontend/src/AppRoutes/routes.ts @@ -53,6 +53,7 @@ import { UnAuthorized, UsageExplorerPage, WorkspaceBlocked, + WorkspaceSuspended, } from './pageComponents'; const routes: AppRoutes[] = [ @@ -364,6 +365,13 @@ const routes: AppRoutes[] = [ isPrivate: true, key: 'WORKSPACE_LOCKED', }, + { + path: ROUTES.WORKSPACE_SUSPENDED, + exact: true, + component: WorkspaceSuspended, + isPrivate: true, + key: 'WORKSPACE_SUSPENDED', + }, { path: ROUTES.SHORTCUTS, exact: true, diff --git a/frontend/src/api/licensesV3/getActive.ts b/frontend/src/api/licensesV3/getActive.ts new file mode 100644 index 0000000000..48dd0a3a43 --- /dev/null +++ b/frontend/src/api/licensesV3/getActive.ts @@ -0,0 +1,18 @@ +import { ApiV3Instance as axios } from 'api'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { LicenseV3EventQueueResModel } from 'types/api/licensesV3/getActive'; + +const getActive = async (): Promise< + SuccessResponse | ErrorResponse +> => { + const response = await axios.get('/licenses/active'); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; +}; + +export default getActive; diff --git a/frontend/src/constants/reactQueryKeys.ts b/frontend/src/constants/reactQueryKeys.ts index d25c180eaa..36cf99157e 100644 --- a/frontend/src/constants/reactQueryKeys.ts +++ b/frontend/src/constants/reactQueryKeys.ts @@ -20,4 +20,5 @@ export const REACT_QUERY_KEY = { DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE', GET_HOST_LIST: 'GET_HOST_LIST', UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE', + GET_ACTIVE_LICENSE_V3: 'GET_ACTIVE_LICENSE_V3', }; diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 0760c57074..7b2911dbd6 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -55,6 +55,7 @@ const ROUTES = { LOGS_SAVE_VIEWS: '/logs/saved-views', TRACES_SAVE_VIEWS: '/traces/saved-views', WORKSPACE_LOCKED: '/workspace-locked', + WORKSPACE_SUSPENDED: '/workspace-suspended', SHORTCUTS: '/shortcuts', INTEGRATIONS: '/integrations', MESSAGING_QUEUES: '/messaging-queues', diff --git a/frontend/src/container/AppLayout/AppLayout.styles.scss b/frontend/src/container/AppLayout/AppLayout.styles.scss index 98ca9084f2..6bd06caded 100644 --- a/frontend/src/container/AppLayout/AppLayout.styles.scss +++ b/frontend/src/container/AppLayout/AppLayout.styles.scss @@ -108,6 +108,13 @@ text-align: center; } +.payment-failed-banner { + padding: 8px; + background-color: var(--bg-sakura-500); + color: white; + text-align: center; +} + .upgrade-link { padding: 0px; padding-right: 4px; diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index 4d9e68f98a..264213b180 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -5,25 +5,30 @@ import './AppLayout.styles.scss'; import * as Sentry from '@sentry/react'; import { Flex } from 'antd'; +import manageCreditCardApi from 'api/billing/manage'; import getUserLatestVersion from 'api/user/getLatestVersion'; import getUserVersion from 'api/user/getVersion'; import cx from 'classnames'; import ChatSupportGateway from 'components/ChatSupportGateway/ChatSupportGateway'; import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar'; +import { SOMETHING_WENT_WRONG } from 'constants/api'; import { FeatureKeys } from 'constants/features'; import ROUTES from 'constants/routes'; import SideNav from 'container/SideNav'; import TopNav from 'container/TopNav'; +import dayjs from 'dayjs'; import { useIsDarkMode } from 'hooks/useDarkMode'; import useFeatureFlags from 'hooks/useFeatureFlag'; import useLicense from 'hooks/useLicense'; import { useNotifications } from 'hooks/useNotifications'; import history from 'lib/history'; +import { isNull } from 'lodash-es'; import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback'; +import { useAppContext } from 'providers/App/App'; import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { Helmet } from 'react-helmet-async'; import { useTranslation } from 'react-i18next'; -import { useQueries } from 'react-query'; +import { useMutation, useQueries } from 'react-query'; import { useDispatch, useSelector } from 'react-redux'; import { useLocation } from 'react-router-dom'; import { Dispatch } from 'redux'; @@ -35,9 +40,16 @@ import { UPDATE_LATEST_VERSION, UPDATE_LATEST_VERSION_ERROR, } from 'types/actions/app'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout'; +import { LicenseEvent } from 'types/api/licensesV3/getActive'; import AppReducer from 'types/reducer/app'; import { isCloudUser } from 'utils/app'; -import { getFormattedDate, getRemainingDays } from 'utils/timeUtils'; +import { + getFormattedDate, + getFormattedDateWithMinutes, + getRemainingDays, +} from 'utils/timeUtils'; import { ChildrenContainer, Layout, LayoutContent } from './styles'; import { getRouteKey } from './utils'; @@ -48,8 +60,42 @@ function AppLayout(props: AppLayoutProps): JSX.Element { (state) => state.app, ); + const { activeLicenseV3, isFetchingActiveLicenseV3 } = useAppContext(); const { notifications } = useNotifications(); + const [ + showPaymentFailedWarning, + setShowPaymentFailedWarning, + ] = useState(false); + + const handleBillingOnSuccess = ( + data: ErrorResponse | SuccessResponse, + ): void => { + if (data?.payload?.redirectURL) { + const newTab = document.createElement('a'); + newTab.href = data.payload.redirectURL; + newTab.target = '_blank'; + newTab.rel = 'noopener noreferrer'; + newTab.click(); + } + }; + + const handleBillingOnError = (): void => { + notifications.error({ + message: SOMETHING_WENT_WRONG, + }); + }; + + const { + mutate: manageCreditCard, + isLoading: isLoadingManageBilling, + } = useMutation(manageCreditCardApi, { + onSuccess: (data) => { + handleBillingOnSuccess(data); + }, + onError: handleBillingOnError, + }); + const isDarkMode = useIsDarkMode(); const { data: licenseData, isFetching } = useLicense(); @@ -212,6 +258,16 @@ function AppLayout(props: AppLayoutProps): JSX.Element { } }, [licenseData, isFetching]); + useEffect(() => { + if ( + !isFetchingActiveLicenseV3 && + !isNull(activeLicenseV3) && + activeLicenseV3?.event_queue?.event === LicenseEvent.FAILED_PAYMENT + ) { + setShowPaymentFailedWarning(true); + } + }, [activeLicenseV3, isFetchingActiveLicenseV3]); + useEffect(() => { // after logging out hide the trial expiry banner if (!isLoggedIn) { @@ -225,6 +281,14 @@ function AppLayout(props: AppLayoutProps): JSX.Element { } }; + const handleFailedPayment = (): void => { + manageCreditCard({ + licenseKey: activeLicenseV3?.key || '', + successURL: window.location.href, + cancelURL: window.location.href, + }); + }; + const isLogsView = (): boolean => routeKey === 'LOGS' || routeKey === 'LOGS_EXPLORER' || @@ -269,7 +333,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element { {pageTitle} - {showTrialExpiryBanner && ( + {showTrialExpiryBanner && !showPaymentFailedWarning && (
You are in free trial period. Your free trial will end on{' '} @@ -289,6 +353,36 @@ function AppLayout(props: AppLayoutProps): JSX.Element { )}
)} + {!showTrialExpiryBanner && showPaymentFailedWarning && ( +
+ Your bill payment has failed. Your workspace will get suspended on{' '} + + {getFormattedDateWithMinutes( + dayjs(activeLicenseV3?.event_queue?.scheduled_at).unix() || Date.now(), + )} + . + + {role === 'ADMIN' ? ( + + {' '} + Please{' '} + { + if (!isLoadingManageBilling) { + handleFailedPayment(); + } + }} + > + pay the bill + + to continue using SigNoz features. + + ) : ( + ' Please contact your administrator to pay the bill.' + )} +
+ )} {isToDisplayLayout && !renderFullScreen && ( diff --git a/frontend/src/container/TopNav/Breadcrumbs/index.tsx b/frontend/src/container/TopNav/Breadcrumbs/index.tsx index 9efd50d2c3..92f9bd37bb 100644 --- a/frontend/src/container/TopNav/Breadcrumbs/index.tsx +++ b/frontend/src/container/TopNav/Breadcrumbs/index.tsx @@ -27,6 +27,7 @@ const breadcrumbNameMap: Record = { [ROUTES.BILLING]: 'Billing', [ROUTES.SUPPORT]: 'Support', [ROUTES.WORKSPACE_LOCKED]: 'Workspace Locked', + [ROUTES.WORKSPACE_SUSPENDED]: 'Workspace Suspended', [ROUTES.MESSAGING_QUEUES]: 'Messaging Queues', }; diff --git a/frontend/src/container/TopNav/DateTimeSelection/config.ts b/frontend/src/container/TopNav/DateTimeSelection/config.ts index b46c60bab0..0b8aa90bff 100644 --- a/frontend/src/container/TopNav/DateTimeSelection/config.ts +++ b/frontend/src/container/TopNav/DateTimeSelection/config.ts @@ -122,6 +122,7 @@ export const routesToSkip = [ ROUTES.BILLING, ROUTES.SUPPORT, ROUTES.WORKSPACE_LOCKED, + ROUTES.WORKSPACE_SUSPENDED, ROUTES.LOGS, ROUTES.MY_SETTINGS, ROUTES.LIST_LICENSES, diff --git a/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts b/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts index 7624cda283..408ed6c11e 100644 --- a/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts +++ b/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts @@ -196,6 +196,7 @@ export const routesToSkip = [ ROUTES.BILLING, ROUTES.SUPPORT, ROUTES.WORKSPACE_LOCKED, + ROUTES.WORKSPACE_SUSPENDED, ROUTES.LOGS, ROUTES.MY_SETTINGS, ROUTES.LIST_LICENSES, diff --git a/frontend/src/hooks/useActiveLicenseV3/useActiveLicenseV3.tsx b/frontend/src/hooks/useActiveLicenseV3/useActiveLicenseV3.tsx new file mode 100644 index 0000000000..72fb4aa1b6 --- /dev/null +++ b/frontend/src/hooks/useActiveLicenseV3/useActiveLicenseV3.tsx @@ -0,0 +1,25 @@ +import getActive from 'api/licensesV3/getActive'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; +import { useQuery, UseQueryResult } from 'react-query'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { LicenseV3ResModel } from 'types/api/licensesV3/getActive'; +import AppReducer from 'types/reducer/app'; + +const useActiveLicenseV3 = (): UseLicense => { + const { user } = useSelector((state) => state.app); + + return useQuery({ + queryFn: getActive, + queryKey: [REACT_QUERY_KEY.GET_ACTIVE_LICENSE_V3, user?.email], + enabled: !!user?.email, + }); +}; + +type UseLicense = UseQueryResult< + SuccessResponse | ErrorResponse, + unknown +>; + +export default useActiveLicenseV3; diff --git a/frontend/src/pages/WorkspaceSuspended/WorkspaceSuspended.styles.scss b/frontend/src/pages/WorkspaceSuspended/WorkspaceSuspended.styles.scss new file mode 100644 index 0000000000..49cba71f0b --- /dev/null +++ b/frontend/src/pages/WorkspaceSuspended/WorkspaceSuspended.styles.scss @@ -0,0 +1,162 @@ +$light-theme: 'lightMode'; +$dark-theme: 'darkMode'; + +@keyframes gradientFlow { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +} + +.workspace-suspended { + &__modal { + .ant-modal-mask { + backdrop-filter: blur(2px); + } + } + + &__tabs { + margin-top: 148px; + + .ant-tabs { + &-nav { + &::before { + border-color: var(--bg-slate-500); + + .#{$light-theme} & { + border-color: var(--bg-vanilla-300); + } + } + } + &-nav-wrap { + justify-content: center; + } + } + } + + &__modal { + &__header { + display: flex; + justify-content: space-between; + align-items: center; + + &__actions { + display: flex; + align-items: center; + gap: 16px; + } + } + .ant-modal-content { + border-radius: 4px; + border: 1px solid var(--bg-slate-400); + background: linear-gradient( + 139deg, + rgba(18, 19, 23, 0.8) 0%, + rgba(18, 19, 23, 0.9) 98.68% + ); + box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2); + backdrop-filter: blur(20px); + + .#{$light-theme} & { + border: 1px solid var(--bg-vanilla-300); + background: var(--bg-vanilla-100); + } + } + + .ant-modal-header { + background: transparent; + } + + .ant-list { + &-item { + border-color: var(--bg-slate-500); + + .#{$light-theme} & { + border-color: var(--bg-vanilla-300); + } + + &-meta { + align-items: center !important; + + &-title { + margin-bottom: 0 !important; + } + + &-avatar { + display: flex; + } + } + } + } + &__title { + font-weight: 400; + color: var(--text-vanilla-400); + + .#{$light-theme} & { + color: var(--text-ink-200); + } + } + &__cta { + margin-top: 54px; + } + } + &__container { + padding-top: 64px; + } + &__details { + width: 80%; + margin: 0 auto; + color: var(--text-vanilla-400, #c0c1c3); + text-align: center; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 24px; /* 150% */ + + .#{$light-theme} & { + color: var(--text-ink-200); + } + + &__highlight { + color: var(--text-vanilla-100, #fff); + font-style: normal; + font-weight: 700; + line-height: 24px; + + .#{$light-theme} & { + color: var(--text-ink-100); + } + } + } + &__title { + background: linear-gradient( + 99deg, + #ead8fd 0%, + #7a97fa 33%, + #fd5ab2 66%, + #ead8fd 100% + ); + background-size: 300% 300%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: gradientFlow 24s ease infinite; + margin-bottom: 18px; + } + + &__creative { + display: flex; + justify-content: center; + align-items: center; + margin-top: 54px; + + img { + width: -webkit-fill-available; + } + } +} diff --git a/frontend/src/pages/WorkspaceSuspended/WorkspaceSuspended.tsx b/frontend/src/pages/WorkspaceSuspended/WorkspaceSuspended.tsx new file mode 100644 index 0000000000..073ef35636 --- /dev/null +++ b/frontend/src/pages/WorkspaceSuspended/WorkspaceSuspended.tsx @@ -0,0 +1,179 @@ +import './WorkspaceSuspended.styles.scss'; + +import { + Alert, + Button, + Col, + Modal, + Row, + Skeleton, + Space, + Typography, +} from 'antd'; +import manageCreditCardApi from 'api/billing/manage'; +import ROUTES from 'constants/routes'; +import dayjs from 'dayjs'; +import { useNotifications } from 'hooks/useNotifications'; +import history from 'lib/history'; +import { useAppContext } from 'providers/App/App'; +import { useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation } from 'react-query'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { LicenseState, LicenseStatus } from 'types/api/licensesV3/getActive'; +import AppReducer from 'types/reducer/app'; +import { getFormattedDateWithMinutes } from 'utils/timeUtils'; + +function WorkspaceSuspended(): JSX.Element { + const { role } = useSelector((state) => state.app); + const isAdmin = role === 'ADMIN'; + const { notifications } = useNotifications(); + const { activeLicenseV3, isFetchingActiveLicenseV3 } = useAppContext(); + + const { t } = useTranslation(['failedPayment']); + + const { mutate: manageCreditCard, isLoading } = useMutation( + manageCreditCardApi, + { + onSuccess: (data) => { + if (data.payload?.redirectURL) { + const newTab = document.createElement('a'); + newTab.href = data.payload.redirectURL; + newTab.target = '_blank'; + newTab.rel = 'noopener noreferrer'; + newTab.click(); + } + }, + onError: () => + notifications.error({ + message: t('somethingWentWrong'), + }), + }, + ); + + const handleUpdateCreditCard = useCallback(async () => { + manageCreditCard({ + licenseKey: activeLicenseV3?.key || '', + successURL: window.location.origin, + cancelURL: window.location.origin, + }); + }, [activeLicenseV3?.key, manageCreditCard]); + + useEffect(() => { + if (!isFetchingActiveLicenseV3 && activeLicenseV3) { + const shouldSuspendWorkspace = + activeLicenseV3.status === LicenseStatus.SUSPENDED && + activeLicenseV3.state === LicenseState.PAYMENT_FAILED; + + if (!shouldSuspendWorkspace) { + history.push(ROUTES.APPLICATION); + } + } + }, [isFetchingActiveLicenseV3, activeLicenseV3]); + return ( +
+ + + {t('workspaceSuspended')} + + + + Got Questions? + + + +
+ } + open + closable={false} + footer={null} + width="65%" + > +
+ {isFetchingActiveLicenseV3 || !activeLicenseV3 ? ( + + ) : ( + <> + + + + +
{t('actionHeader')}
+
+ + {t('actionDescription')} +
+ {t('yourDataIsSafe')}{' '} + + {getFormattedDateWithMinutes( + dayjs(activeLicenseV3?.event_queue?.scheduled_at).unix() || + Date.now(), + )} + {' '} + {t('actNow')} +
+
+ +
+ {!isAdmin && ( + + + + + + )} + {isAdmin && ( + + + + + + )} +
+ correlation-graphic +
+ + )} +
+ + + ); +} + +export default WorkspaceSuspended; diff --git a/frontend/src/providers/App/App.tsx b/frontend/src/providers/App/App.tsx new file mode 100644 index 0000000000..38110140d7 --- /dev/null +++ b/frontend/src/providers/App/App.tsx @@ -0,0 +1,48 @@ +import useActiveLicenseV3 from 'hooks/useActiveLicenseV3/useActiveLicenseV3'; +import { defaultTo } from 'lodash-es'; +import { + createContext, + PropsWithChildren, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { LicenseV3ResModel } from 'types/api/licensesV3/getActive'; + +interface IAppContext { + activeLicenseV3: LicenseV3ResModel | null; + isFetchingActiveLicenseV3: boolean; +} + +const AppContext = createContext(undefined); + +export function AppProvider({ children }: PropsWithChildren): JSX.Element { + const [activeLicenseV3, setActiveLicenseV3] = useState(); + + const { data, isFetching } = useActiveLicenseV3(); + + useEffect(() => { + if (!isFetching && data?.payload) { + setActiveLicenseV3(data.payload); + } + }, [data, isFetching]); + + const value: IAppContext = useMemo( + () => ({ + activeLicenseV3: defaultTo(activeLicenseV3, null), + isFetchingActiveLicenseV3: isFetching, + }), + [activeLicenseV3, isFetching], + ); + + return {children}; +} + +export const useAppContext = (): IAppContext => { + const context = useContext(AppContext); + if (context === undefined) { + throw new Error('useAppContext must be used within an AppProvider'); + } + return context; +}; diff --git a/frontend/src/types/api/licensesV3/getActive.ts b/frontend/src/types/api/licensesV3/getActive.ts new file mode 100644 index 0000000000..3590b0e40c --- /dev/null +++ b/frontend/src/types/api/licensesV3/getActive.ts @@ -0,0 +1,26 @@ +export enum LicenseEvent { + FAILED_PAYMENT = 'FAILED_PAYMENT', +} + +export enum LicenseStatus { + SUSPENDED = 'SUSPENDED', +} + +export enum LicenseState { + PAYMENT_FAILED = 'PAYMENT_FAILED', +} + +export type LicenseV3EventQueueResModel = { + event: LicenseEvent; + status: string; + scheduled_at: string; + created_at: string; + updated_at: string; +}; + +export type LicenseV3ResModel = { + key: string; + status: LicenseStatus; + state: LicenseState; + event_queue: LicenseV3EventQueueResModel; +}; diff --git a/frontend/src/utils/permission/index.ts b/frontend/src/utils/permission/index.ts index 3d260e1351..6728a4599c 100644 --- a/frontend/src/utils/permission/index.ts +++ b/frontend/src/utils/permission/index.ts @@ -93,6 +93,7 @@ export const routePermission: Record = { GET_STARTED_AWS_MONITORING: ['ADMIN', 'EDITOR', 'VIEWER'], GET_STARTED_AZURE_MONITORING: ['ADMIN', 'EDITOR', 'VIEWER'], WORKSPACE_LOCKED: ['ADMIN', 'EDITOR', 'VIEWER'], + WORKSPACE_SUSPENDED: ['ADMIN', 'EDITOR', 'VIEWER'], BILLING: ['ADMIN', 'EDITOR', 'VIEWER'], SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER'], SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'], diff --git a/frontend/src/utils/timeUtils.ts b/frontend/src/utils/timeUtils.ts index 5eb795bf45..67e668dc3c 100644 --- a/frontend/src/utils/timeUtils.ts +++ b/frontend/src/utils/timeUtils.ts @@ -19,6 +19,14 @@ export const getFormattedDate = (epochTimestamp: number): string => { return date.format('DD MMM YYYY'); }; +export const getFormattedDateWithMinutes = (epochTimestamp: number): string => { + // Convert epoch timestamp to a date + const date = dayjs.unix(epochTimestamp); + + // Format the date as "18 Nov 2013" + return date.format('DD MMM YYYY HH:mm'); +}; + export const getRemainingDays = (billingEndDate: number): number => { // Convert Epoch timestamps to Date objects const startDate = new Date(); // Convert seconds to milliseconds