mirror of
https://git.mirrors.martin98.com/https://github.com/SigNoz/signoz
synced 2025-08-02 06:50:37 +08:00
fix: update logic to conditionally show Get Started and Billing routes (#3807)
This commit is contained in:
parent
856c04220f
commit
7de3cec477
@ -39,10 +39,12 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
|||||||
[pathname],
|
[pathname],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: licensesData } = useLicense();
|
const {
|
||||||
|
data: licensesData,
|
||||||
|
isFetching: isFetchingLicensesData,
|
||||||
|
} = useLicense();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
user,
|
|
||||||
isUserFetching,
|
isUserFetching,
|
||||||
isUserFetchingError,
|
isUserFetchingError,
|
||||||
isLoggedIn: isLoggedInState,
|
isLoggedIn: isLoggedInState,
|
||||||
@ -116,7 +118,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
|||||||
if (
|
if (
|
||||||
localStorageUserAuthToken &&
|
localStorageUserAuthToken &&
|
||||||
localStorageUserAuthToken.refreshJwt &&
|
localStorageUserAuthToken.refreshJwt &&
|
||||||
user?.userId === ''
|
isUserFetching
|
||||||
) {
|
) {
|
||||||
handleUserLoginIfTokenPresent(key);
|
handleUserLoginIfTokenPresent(key);
|
||||||
} else {
|
} else {
|
||||||
@ -131,28 +133,34 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
|||||||
|
|
||||||
if (path && path !== ROUTES.WORKSPACE_LOCKED) {
|
if (path && path !== ROUTES.WORKSPACE_LOCKED) {
|
||||||
history.push(ROUTES.WORKSPACE_LOCKED);
|
history.push(ROUTES.WORKSPACE_LOCKED);
|
||||||
}
|
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: UPDATE_USER_IS_FETCH,
|
type: UPDATE_USER_IS_FETCH,
|
||||||
payload: {
|
payload: {
|
||||||
isUserFetching: false,
|
isUserFetching: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isFetchingLicensesData) {
|
||||||
|
const shouldBlockWorkspace = licensesData?.payload?.workSpaceBlock;
|
||||||
|
|
||||||
|
if (shouldBlockWorkspace) {
|
||||||
|
navigateToWorkSpaceBlocked(currentRoute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isFetchingLicensesData]);
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async (): Promise<void> => {
|
(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const shouldBlockWorkspace = licensesData?.payload?.workSpaceBlock;
|
|
||||||
|
|
||||||
if (currentRoute) {
|
if (currentRoute) {
|
||||||
const { isPrivate, key } = currentRoute;
|
const { isPrivate, key } = currentRoute;
|
||||||
|
|
||||||
if (shouldBlockWorkspace) {
|
if (isPrivate && key !== ROUTES.WORKSPACE_LOCKED) {
|
||||||
navigateToWorkSpaceBlocked(currentRoute);
|
|
||||||
} else if (isPrivate) {
|
|
||||||
handlePrivateRoutes(key);
|
handlePrivateRoutes(key);
|
||||||
} else {
|
} else {
|
||||||
// no need to fetch the user and make user fetching false
|
// no need to fetch the user and make user fetching false
|
||||||
|
@ -299,7 +299,7 @@ const routes: AppRoutes[] = [
|
|||||||
path: ROUTES.WORKSPACE_LOCKED,
|
path: ROUTES.WORKSPACE_LOCKED,
|
||||||
exact: true,
|
exact: true,
|
||||||
component: WorkspaceBlocked,
|
component: WorkspaceBlocked,
|
||||||
isPrivate: false,
|
isPrivate: true,
|
||||||
key: 'WORKSPACE_LOCKED',
|
key: 'WORKSPACE_LOCKED',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
@ -7,13 +7,20 @@ import ROUTES from 'constants/routes';
|
|||||||
import useLicense, { LICENSE_PLAN_KEY } from 'hooks/useLicense';
|
import useLicense, { LICENSE_PLAN_KEY } from 'hooks/useLicense';
|
||||||
import history from 'lib/history';
|
import history from 'lib/history';
|
||||||
import { LifeBuoy } from 'lucide-react';
|
import { LifeBuoy } from 'lucide-react';
|
||||||
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { sideBarCollapse } from 'store/actions/app';
|
import { sideBarCollapse } from 'store/actions/app';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
import AppReducer from 'types/reducer/app';
|
import AppReducer from 'types/reducer/app';
|
||||||
|
import { USER_ROLES } from 'types/roles';
|
||||||
import { checkVersionState, isCloudUser, isEECloudUser } from 'utils/app';
|
import { checkVersionState, isCloudUser, isEECloudUser } from 'utils/app';
|
||||||
|
|
||||||
import { routeConfig, styles } from './config';
|
import { routeConfig, styles } from './config';
|
||||||
@ -33,6 +40,7 @@ import {
|
|||||||
|
|
||||||
function SideNav(): JSX.Element {
|
function SideNav(): JSX.Element {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const [menuItems, setMenuItems] = useState(defaultMenuItems);
|
||||||
const [collapsed, setCollapsed] = useState<boolean>(
|
const [collapsed, setCollapsed] = useState<boolean>(
|
||||||
getLocalStorageKey(IS_SIDEBAR_COLLAPSED) === 'true',
|
getLocalStorageKey(IS_SIDEBAR_COLLAPSED) === 'true',
|
||||||
);
|
);
|
||||||
@ -44,36 +52,45 @@ function SideNav(): JSX.Element {
|
|||||||
featureResponse,
|
featureResponse,
|
||||||
} = useSelector<AppState, AppReducer>((state) => state.app);
|
} = useSelector<AppState, AppReducer>((state) => state.app);
|
||||||
|
|
||||||
const { data } = useLicense();
|
const { data, isFetching } = useLicense();
|
||||||
|
|
||||||
let secondaryMenuItems: MenuItem[] = [];
|
let secondaryMenuItems: MenuItem[] = [];
|
||||||
|
|
||||||
const isOnBasicPlan =
|
useEffect((): void => {
|
||||||
data?.payload?.licenses?.some(
|
const isOnboardingEnabled =
|
||||||
(license) =>
|
featureResponse.data?.find(
|
||||||
license.isCurrent && license.planKey === LICENSE_PLAN_KEY.BASIC_PLAN,
|
(feature) => feature.name === FeatureKeys.ONBOARDING,
|
||||||
) || data?.payload?.licenses === null;
|
)?.active || false;
|
||||||
|
|
||||||
const menuItems = useMemo(
|
if (!isOnboardingEnabled || !isCloudUser()) {
|
||||||
() =>
|
let items = [...menuItems];
|
||||||
defaultMenuItems.filter((item) => {
|
|
||||||
const isOnboardingEnabled =
|
|
||||||
featureResponse.data?.find(
|
|
||||||
(feature) => feature.name === FeatureKeys.ONBOARDING,
|
|
||||||
)?.active || false;
|
|
||||||
|
|
||||||
if (role !== 'ADMIN' || isOnBasicPlan) {
|
items = items.filter((item) => item.key !== ROUTES.GET_STARTED);
|
||||||
return item.key !== ROUTES.BILLING;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOnboardingEnabled || !isCloudUser()) {
|
setMenuItems(items);
|
||||||
return item.key !== ROUTES.GET_STARTED;
|
}
|
||||||
}
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [featureResponse.data]);
|
||||||
|
|
||||||
return true;
|
// using a separate useEffect as the license fetching call takes few milliseconds
|
||||||
}),
|
useEffect(() => {
|
||||||
[featureResponse.data, isOnBasicPlan, role],
|
if (!isFetching) {
|
||||||
);
|
let items = [...menuItems];
|
||||||
|
|
||||||
|
const isOnBasicPlan =
|
||||||
|
data?.payload?.licenses?.some(
|
||||||
|
(license) =>
|
||||||
|
license.isCurrent && license.planKey === LICENSE_PLAN_KEY.BASIC_PLAN,
|
||||||
|
) || data?.payload?.licenses === null;
|
||||||
|
|
||||||
|
if (role !== USER_ROLES.ADMIN || isOnBasicPlan) {
|
||||||
|
items = items.filter((item) => item.key !== ROUTES.BILLING);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMenuItems(items);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [data?.payload?.licenses, isFetching, role]);
|
||||||
|
|
||||||
const { pathname, search } = useLocation();
|
const { pathname, search } = useLocation();
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
export const LICENSE_PLAN_KEY = {
|
export const LICENSE_PLAN_KEY = {
|
||||||
ENTERPRISE_PLAN: 'ENTERPRISE_PLAN',
|
ENTERPRISE_PLAN: 'ENTERPRISE_PLAN',
|
||||||
BASIC_PLAN: 'BASIC_PLAN ',
|
BASIC_PLAN: 'BASIC_PLAN',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LICENSE_PLAN_STATUS = {
|
export const LICENSE_PLAN_STATUS = {
|
||||||
|
@ -7,6 +7,40 @@ export const licensesSuccessResponse = {
|
|||||||
workSpaceBlock: false,
|
workSpaceBlock: false,
|
||||||
trialConvertedToSubscription: false,
|
trialConvertedToSubscription: false,
|
||||||
gracePeriodEnd: -1,
|
gracePeriodEnd: -1,
|
||||||
|
licenses: [
|
||||||
|
{
|
||||||
|
key: 'testKeyId1',
|
||||||
|
activationId: 'testActivationId1',
|
||||||
|
ValidationMessage: '',
|
||||||
|
isCurrent: false,
|
||||||
|
planKey: 'ENTERPRISE_PLAN',
|
||||||
|
ValidFrom: '2022-10-13T13:58:51Z',
|
||||||
|
ValidUntil: '2023-10-13T19:57:37Z',
|
||||||
|
status: 'VALID',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'testKeyId2',
|
||||||
|
activationId: 'testActivationId2',
|
||||||
|
ValidationMessage: '',
|
||||||
|
isCurrent: true,
|
||||||
|
planKey: 'ENTERPRISE_PLAN',
|
||||||
|
ValidFrom: '2023-09-12T11:55:43Z',
|
||||||
|
ValidUntil: '2024-09-11T17:34:29Z',
|
||||||
|
status: 'VALID',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const licensesSuccessWorkspaceLockedResponse = {
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
trialStart: 1695992049,
|
||||||
|
trialEnd: 1697806449,
|
||||||
|
onTrial: false,
|
||||||
|
workSpaceBlock: true,
|
||||||
|
trialConvertedToSubscription: false,
|
||||||
|
gracePeriodEnd: -1,
|
||||||
licenses: [
|
licenses: [
|
||||||
{
|
{
|
||||||
key: 'testKeyId1',
|
key: 'testKeyId1',
|
||||||
|
@ -1,46 +1,70 @@
|
|||||||
|
import { licensesSuccessWorkspaceLockedResponse } from 'mocks-server/__mockdata__/licenses';
|
||||||
|
import { server } from 'mocks-server/server';
|
||||||
|
import { rest } from 'msw';
|
||||||
import { act, render, screen } from 'tests/test-utils';
|
import { act, render, screen } from 'tests/test-utils';
|
||||||
|
|
||||||
import WorkspaceLocked from '.';
|
import WorkspaceLocked from '.';
|
||||||
|
|
||||||
describe('WorkspaceLocked', () => {
|
describe('WorkspaceLocked', () => {
|
||||||
|
const apiURL = 'http://localhost/api/v2/licenses';
|
||||||
|
|
||||||
test('Should render the component', async () => {
|
test('Should render the component', async () => {
|
||||||
|
server.use(
|
||||||
|
rest.get(apiURL, (req, res, ctx) =>
|
||||||
|
res(ctx.status(200), ctx.json(licensesSuccessWorkspaceLockedResponse)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
render(<WorkspaceLocked />);
|
render(<WorkspaceLocked />);
|
||||||
});
|
});
|
||||||
const workspaceLocked = screen.getByRole('heading', {
|
|
||||||
|
const workspaceLocked = await screen.findByRole('heading', {
|
||||||
name: /workspace locked/i,
|
name: /workspace locked/i,
|
||||||
});
|
});
|
||||||
expect(workspaceLocked).toBeInTheDocument();
|
expect(workspaceLocked).toBeInTheDocument();
|
||||||
|
|
||||||
const gotQuestionText = screen.getByText(/got question?/i);
|
const gotQuestionText = await screen.findByText(/got question?/i);
|
||||||
expect(gotQuestionText).toBeInTheDocument();
|
expect(gotQuestionText).toBeInTheDocument();
|
||||||
|
|
||||||
const contactUsLink = screen.getByRole('link', {
|
const contactUsLink = await screen.findByRole('link', {
|
||||||
name: /contact us/i,
|
name: /contact us/i,
|
||||||
});
|
});
|
||||||
expect(contactUsLink).toBeInTheDocument();
|
expect(contactUsLink).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Render for Admin', async () => {
|
test('Render for Admin', async () => {
|
||||||
|
server.use(
|
||||||
|
rest.get(apiURL, (req, res, ctx) =>
|
||||||
|
res(ctx.status(200), ctx.json(licensesSuccessWorkspaceLockedResponse)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
render(<WorkspaceLocked />);
|
render(<WorkspaceLocked />);
|
||||||
const contactAdminMessage = screen.queryByText(
|
const contactAdminMessage = await screen.queryByText(
|
||||||
/please contact your administrator for further help/i,
|
/please contact your administrator for further help/i,
|
||||||
);
|
);
|
||||||
expect(contactAdminMessage).not.toBeInTheDocument();
|
expect(contactAdminMessage).not.toBeInTheDocument();
|
||||||
const updateCreditCardBtn = screen.getByRole('button', {
|
const updateCreditCardBtn = await screen.findByRole('button', {
|
||||||
name: /update credit card/i,
|
name: /update credit card/i,
|
||||||
});
|
});
|
||||||
expect(updateCreditCardBtn).toBeInTheDocument();
|
expect(updateCreditCardBtn).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Render for non Admin', async () => {
|
test('Render for non Admin', async () => {
|
||||||
|
server.use(
|
||||||
|
rest.get(apiURL, (req, res, ctx) =>
|
||||||
|
res(ctx.status(200), ctx.json(licensesSuccessWorkspaceLockedResponse)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
render(<WorkspaceLocked />, {}, 'VIEWER');
|
render(<WorkspaceLocked />, {}, 'VIEWER');
|
||||||
const updateCreditCardBtn = screen.queryByRole('button', {
|
const updateCreditCardBtn = await screen.queryByRole('button', {
|
||||||
name: /update credit card/i,
|
name: /update credit card/i,
|
||||||
});
|
});
|
||||||
expect(updateCreditCardBtn).not.toBeInTheDocument();
|
expect(updateCreditCardBtn).not.toBeInTheDocument();
|
||||||
|
|
||||||
const contactAdminMessage = screen.getByText(
|
const contactAdminMessage = await screen.findByText(
|
||||||
/please contact your administrator for further help/i,
|
/please contact your administrator for further help/i,
|
||||||
);
|
);
|
||||||
expect(contactAdminMessage).toBeInTheDocument();
|
expect(contactAdminMessage).toBeInTheDocument();
|
||||||
|
@ -2,11 +2,13 @@
|
|||||||
import './WorkspaceLocked.styles.scss';
|
import './WorkspaceLocked.styles.scss';
|
||||||
|
|
||||||
import { CreditCardOutlined, LockOutlined } from '@ant-design/icons';
|
import { CreditCardOutlined, LockOutlined } from '@ant-design/icons';
|
||||||
import { Button, Card, Typography } from 'antd';
|
import { Button, Card, Skeleton, Typography } from 'antd';
|
||||||
import updateCreditCardApi from 'api/billing/checkout';
|
import updateCreditCardApi from 'api/billing/checkout';
|
||||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
import useLicense from 'hooks/useLicense';
|
import useLicense from 'hooks/useLicense';
|
||||||
import { useNotifications } from 'hooks/useNotifications';
|
import { useNotifications } from 'hooks/useNotifications';
|
||||||
|
import history from 'lib/history';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useMutation } from 'react-query';
|
import { useMutation } from 'react-query';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
@ -22,16 +24,28 @@ export default function WorkspaceBlocked(): JSX.Element {
|
|||||||
|
|
||||||
const { notifications } = useNotifications();
|
const { notifications } = useNotifications();
|
||||||
|
|
||||||
const { isFetching, data: licensesData } = useLicense();
|
const {
|
||||||
|
isFetching: isFetchingLicenseData,
|
||||||
|
isLoading: isLoadingLicenseData,
|
||||||
|
data: licensesData,
|
||||||
|
} = useLicense();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeValidLicense =
|
if (!isFetchingLicenseData) {
|
||||||
licensesData?.payload?.licenses?.find(
|
const shouldBlockWorkspace = licensesData?.payload?.workSpaceBlock;
|
||||||
(license) => license.isCurrent === true,
|
|
||||||
) || null;
|
|
||||||
|
|
||||||
setActiveLicense(activeValidLicense);
|
if (!shouldBlockWorkspace) {
|
||||||
}, [isFetching, licensesData]);
|
history.push(ROUTES.APPLICATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeValidLicense =
|
||||||
|
licensesData?.payload?.licenses?.find(
|
||||||
|
(license) => license.isCurrent === true,
|
||||||
|
) || null;
|
||||||
|
|
||||||
|
setActiveLicense(activeValidLicense);
|
||||||
|
}
|
||||||
|
}, [isFetchingLicenseData, licensesData]);
|
||||||
|
|
||||||
const { mutate: updateCreditCard, isLoading } = useMutation(
|
const { mutate: updateCreditCard, isLoading } = useMutation(
|
||||||
updateCreditCardApi,
|
updateCreditCardApi,
|
||||||
@ -62,36 +76,41 @@ export default function WorkspaceBlocked(): JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="workspace-locked-container">
|
<Card className="workspace-locked-container">
|
||||||
<LockOutlined style={{ fontSize: '36px', color: '#08c' }} />
|
{isLoadingLicenseData || !licensesData?.payload?.workSpaceBlock ? (
|
||||||
<Typography.Title level={4}> Workspace Locked </Typography.Title>
|
<Skeleton />
|
||||||
|
) : (
|
||||||
<Typography.Paragraph className="workpace-locked-details">
|
<>
|
||||||
You have been locked out of your workspace because your trial ended without
|
<LockOutlined style={{ fontSize: '36px', color: '#08c' }} />
|
||||||
an upgrade to a paid plan. Your data will continue to be ingested till{' '}
|
<Typography.Title level={4}> Workspace Locked </Typography.Title>
|
||||||
{getFormattedDate(licensesData?.payload?.gracePeriodEnd || Date.now())} , at
|
<Typography.Paragraph className="workpace-locked-details">
|
||||||
which point we will drop all the ingested data and terminate the account.
|
You have been locked out of your workspace because your trial ended
|
||||||
{!isAdmin && 'Please contact your administrator for further help'}
|
without an upgrade to a paid plan. Your data will continue to be ingested
|
||||||
</Typography.Paragraph>
|
till{' '}
|
||||||
|
{getFormattedDate(licensesData?.payload?.gracePeriodEnd || Date.now())} ,
|
||||||
{isAdmin && (
|
at which point we will drop all the ingested data and terminate the
|
||||||
<Button
|
account.
|
||||||
className="update-credit-card-btn"
|
{!isAdmin && 'Please contact your administrator for further help'}
|
||||||
type="primary"
|
</Typography.Paragraph>
|
||||||
icon={<CreditCardOutlined />}
|
{isAdmin && (
|
||||||
size="middle"
|
<Button
|
||||||
loading={isLoading}
|
className="update-credit-card-btn"
|
||||||
onClick={handleUpdateCreditCard}
|
type="primary"
|
||||||
>
|
icon={<CreditCardOutlined />}
|
||||||
Update Credit Card
|
size="middle"
|
||||||
</Button>
|
loading={isLoading}
|
||||||
|
onClick={handleUpdateCreditCard}
|
||||||
|
>
|
||||||
|
Update Credit Card
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="contact-us">
|
||||||
|
Got Questions?
|
||||||
|
<span>
|
||||||
|
<a href="mailto:support@signoz.io"> Contact Us </a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="contact-us">
|
|
||||||
Got Questions?
|
|
||||||
<span>
|
|
||||||
<a href="mailto:support@signoz.io"> Contact Us </a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -3,3 +3,9 @@ export type VIEWER = 'VIEWER';
|
|||||||
export type EDITOR = 'EDITOR';
|
export type EDITOR = 'EDITOR';
|
||||||
|
|
||||||
export type ROLES = ADMIN | VIEWER | EDITOR;
|
export type ROLES = ADMIN | VIEWER | EDITOR;
|
||||||
|
|
||||||
|
export const USER_ROLES = {
|
||||||
|
ADMIN: 'ADMIN',
|
||||||
|
VIEWER: 'VIEWER',
|
||||||
|
EDITOR: 'EDITOR',
|
||||||
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user