Compare commits

...

18 Commits

Author SHA1 Message Date
vikrantgupta25
25e49800ed fix(billing): address review findings on subscription gating
The usage query follows the guard's fail-open behavior, the past-due link is gated like the manage
button, the header period text waits for data, and the manage permissions are shared. The roles
panel is named Subscription and the panels are sorted by label.
2026-09-04 17:25:55 +05:30
vikrantgupta25
c948246782 fix(billing): gate payment refresh on license update and open billing while blocked
The refresh payment status control checks license update for the active license, the billing and
settings routes stay reachable for every role while the workspace is blocked, and the upgrade
benefits box lines up with the rest of the billing column.
2026-09-04 16:58:30 +05:30
vikrantgupta25
bc227d4e6b fix(authz): declare the subscription verbs explicitly
The subscription resource lists create, list, read, update, and delete instead of inheriting the
metaresource default, so attach and detach are no longer advertised. The generated permission
types pick up the full list.
2026-09-04 16:16:46 +05:30
Vikrant Gupta
314f96bfe0 Merge branch 'main' into platform-pod/issues/3091-frontend 2026-09-04 16:04:37 +05:30
vikrantgupta25
b378ece26d feat(billing): move billing to the subscriptions client
Billing calls the /api/v1/subscriptions endpoints through the generated subscriptions client now
that they no longer live under zeus.
2026-09-04 15:48:18 +05:30
vikrantgupta25
aacbc2daf7 Merge remote-tracking branch 'origin/platform-pod/issues/3091-zeus-subscriptions' into platform-pod/issues/3091-frontend 2026-09-04 15:46:01 +05:30
vikrantgupta25
dc6fa409a9 chore(frontend): regenerate subscription client for the updated descriptions 2026-09-04 15:22:32 +05:30
vikrantgupta25
055e5d8d66 chore(openapi): regenerate spec for the subscription descriptions 2026-09-04 15:21:43 +05:30
vikrantgupta25
24585d3891 feat(subscription): move billing behind a subscription domain
Subscriptions get their own package with a Subscription interface, a noop implementation for
community, and an enterprise implementation over zeus. The routes collapse onto
/api/v1/subscriptions with POST, PUT, and GET, and zeus no longer serves them.
2026-09-04 15:14:47 +05:30
vikrantgupta25
f52e71354c feat(billing): match portal and cancel controls to subscription permissions
Portal controls require subscription list and update together, matching the backend check, and
the cancel subscription button is gated on subscription delete.
2026-09-04 14:31:26 +05:30
Vikrant Gupta
ff00e58b69 Merge branch 'platform-pod/issues/3091-zeus-subscriptions' into platform-pod/issues/3091-frontend 2026-09-04 14:29:13 +05:30
vikrantgupta25
78ed45b02f feat(authz): restore subscription verbs and gate the portal on list and update
The subscription resource keeps the full metaresource verb set and the admin grants for delete
and list are back, so migration 125 only inserts the five admin tuples. The portal session now
requires both subscription:list and subscription:update.
2026-09-04 14:17:05 +05:30
vikrantgupta25
d43b29da4a feat(billing): gate billing on subscription permissions
Billing moves to the generated zeus client and every billing surface checks subscription
create/read/update instead of the admin role. Nothing is hidden: usage renders the denied
callout and checkout/portal controls are disabled with a tooltip without permission.
2026-09-04 13:48:07 +05:30
vikrantgupta25
7620765edf feat(authz): restrict the subscription resource to create, read, and update
The admin delete and list grants are dropped from the managed role registry. Migration 125
removes any stray subscription tuples for those verbs, backfills the three admin tuples for
existing organizations, and refreshes the managed role transaction groups. The subscription
session payload fields are marked required in the spec.
2026-09-04 13:26:21 +05:30
vikrantgupta25
9c5664cda5 fix(zeus): return 200 from the portal session and drop redundant error codes
The portal session is a PUT, so it answers 200 instead of 201. The 401/403 entries are removed
from the new routes since handler.New appends them itself. The usage handler is renamed to
GetMeters to match the route.
2026-09-03 20:04:43 +05:30
Vikrant Gupta
7fdbf5bd48 Merge branch 'main' into platform-pod/issues/3091-zeus-subscriptions 2026-09-03 18:32:49 +05:30
vikrantgupta25
fb02a86a3c feat(zeus): add subscription endpoints with resource authz
Checkout, portal, and usage are served from the zeus handler under /api/v2/zeus with
CheckResources on the subscription metaresource. Migration 124 backfills the admin tuples for
existing organizations. The legacy /api/v1 routes are untouched.
2026-09-03 18:05:27 +05:30
vikrantgupta25
1d9975c061 chore(flagger): remove get_meters_from_zeus feature flag
Billing usage is always fetched from Zeus now. The legacy license.signoz.io/usage fallback,
its secret-key header, and the related constants and ldflags are removed.
2026-09-03 17:21:28 +05:30
35 changed files with 660 additions and 558 deletions

View File

@@ -97,6 +97,7 @@ func runGenerateAuthz(_ context.Context) error {
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceLicense).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSubscription).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,

View File

@@ -169,12 +169,12 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
// Check for workspace blocked (trial expired)
if (!isFetchingActiveLicense && isCloudPlatform && trialInfo?.workSpaceBlock) {
const isRouteEnabledForWorkspaceBlockedState =
isAdmin &&
(pathname === ROUTES.SETTINGS ||
pathname === ROUTES.ORG_SETTINGS ||
pathname === ROUTES.MEMBERS_SETTINGS ||
pathname === ROUTES.BILLING ||
pathname === ROUTES.MY_SETTINGS);
pathname === ROUTES.SETTINGS ||
pathname === ROUTES.BILLING ||
(isAdmin &&
(pathname === ROUTES.ORG_SETTINGS ||
pathname === ROUTES.MEMBERS_SETTINGS ||
pathname === ROUTES.MY_SETTINGS));
if (
pathname !== ROUTES.WORKSPACE_LOCKED &&

View File

@@ -739,7 +739,7 @@ describe('PrivateRoute', () => {
assertStaysOnRoute(ROUTES.MY_SETTINGS);
});
it('should redirect VIEWER to workspace locked even when trying to access settings', async () => {
it('should allow VIEWER to access /settings when workspace is blocked', () => {
renderPrivateRoute({
initialRoute: ROUTES.SETTINGS,
appContext: {
@@ -752,10 +752,10 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
assertStaysOnRoute(ROUTES.SETTINGS);
});
it('should redirect VIEWER to workspace locked when trying to access billing', async () => {
it('should allow VIEWER to access /settings/billing when workspace is blocked', () => {
renderPrivateRoute({
initialRoute: ROUTES.BILLING,
appContext: {
@@ -768,7 +768,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
assertStaysOnRoute(ROUTES.BILLING);
});
it('should redirect VIEWER to workspace locked when trying to access org-settings', async () => {
@@ -819,7 +819,7 @@ describe('PrivateRoute', () => {
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
});
it('should redirect EDITOR to workspace locked when trying to access settings', async () => {
it('should allow EDITOR to access /settings when workspace is blocked', () => {
renderPrivateRoute({
initialRoute: ROUTES.SETTINGS,
appContext: {
@@ -832,7 +832,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
assertStaysOnRoute(ROUTES.SETTINGS);
});
it('should not redirect when already on workspace locked page', () => {
@@ -1626,6 +1626,7 @@ describe('PrivateRoute', () => {
path: ROUTES.WORKSPACE_ACCESS_RESTRICTED,
deniedRoles: DENIED_ROLES,
},
BILLING: { path: ROUTES.BILLING, deniedRoles: DENIED_ROLES },
};
const authzRouteRolePairs: [string, string, ROLES][] = Object.entries(

View File

@@ -1,59 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
export interface DayBreakdownEntry {
timestamp: number;
total: number;
quantity: number;
count: number;
size: number;
}
export interface TierEntry {
quantity: number;
unitPrice: number;
tierCost: number;
}
export interface BreakdownEntry {
type: string;
unit: string;
dayWiseBreakdown: {
breakdown: DayBreakdownEntry[];
};
tiers?: TierEntry[];
}
export interface UsageResponsePayloadProps {
billingPeriodStart: number;
billingPeriodEnd: number;
details: {
total: number;
baseFee: number;
breakdown: BreakdownEntry[];
billTotal: number;
};
discount: number;
subscriptionStatus?: string;
}
const getUsage = async (
licenseKey: string,
): Promise<SuccessResponse<UsageResponsePayloadProps> | ErrorResponse> => {
try {
const response = await axios.get(`/billing?licenseKey=${licenseKey}`);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getUsage;

View File

@@ -1,28 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import {
CheckoutRequestPayloadProps,
CheckoutSuccessPayloadProps,
PayloadProps,
} from 'types/api/billing/checkout';
const updateCreditCardApi = async (
props: CheckoutRequestPayloadProps,
): Promise<SuccessResponseV2<CheckoutSuccessPayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/checkout', {
url: props.url,
});
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default updateCreditCardApi;

View File

@@ -1,28 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import {
CheckoutRequestPayloadProps,
CheckoutSuccessPayloadProps,
PayloadProps,
} from 'types/api/billing/checkout';
const manageCreditCardApi = async (
props: CheckoutRequestPayloadProps,
): Promise<SuccessResponseV2<CheckoutSuccessPayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/portal', {
url: props.url,
});
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default manageCreditCardApi;

View File

@@ -4,11 +4,12 @@ import { useLocation } from 'react-router-dom';
import { Button, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
import { createSubscription } from 'api/generated/services/subscriptions';
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
import { useNotifications } from 'hooks/useNotifications';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { CreditCard, MessageSquareText, X } from '@signozhq/icons';
import { SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
@@ -18,9 +19,7 @@ export default function ChatSupportGateway(): JSX.Element {
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
useState(false);
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -38,7 +37,7 @@ export default function ChatSupportGateway(): JSX.Element {
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
updateCreditCardApi,
createSubscription,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
@@ -94,18 +93,23 @@ export default function ChatSupportGateway(): JSX.Element {
>
Cancel
</Button>,
<Button
<AuthZTooltip
key="submit"
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
Add Credit Card
</Button>,
<Button
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
>
Add Credit Card
</Button>
</AuthZTooltip>,
]}
>
<Typography.Text className="add-credit-card-text">

View File

@@ -4,16 +4,17 @@ import { useLocation } from 'react-router-dom';
import { Button, Modal, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
import { createSubscription } from 'api/generated/services/subscriptions';
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { FeatureKeys } from 'constants/features';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { defaultTo } from 'lodash-es';
import { CircleHelp, CreditCard, X } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
@@ -118,9 +119,7 @@ function LaunchChatSupport({
}
};
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -138,7 +137,7 @@ function LaunchChatSupport({
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
updateCreditCardApi,
createSubscription,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
@@ -193,18 +192,23 @@ function LaunchChatSupport({
>
Cancel
</Button>,
<Button
<AuthZTooltip
key="submit"
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
Add Credit Card
</Button>,
<Button
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
>
Add Credit Card
</Button>
</AuthZTooltip>,
]}
>
<Typography.Text className="add-credit-card-text">

View File

@@ -4,14 +4,18 @@ import { refreshLicense } from 'api/generated/services/licenses';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { RefreshCcw } from '@signozhq/icons';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { buildLicenseUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/license.permissions';
import { useAppContext } from 'providers/App/App';
function RefreshPaymentStatus({
type,
className,
withPortal,
}: {
type?: 'button' | 'text' | 'tooltip';
className?: string;
withPortal?: false;
}): JSX.Element {
const { t } = useTranslation(['failedPayment']);
const { activeLicense, activeLicenseRefetch } = useAppContext();
@@ -36,17 +40,25 @@ function RefreshPaymentStatus({
};
const button = (
<Button
variant="link"
color={type === 'text' ? 'none' : 'secondary'}
size="md"
className={className}
onClick={handleRefreshPaymentStatus}
prefix={<RefreshCcw size={14} />}
loading={isLoading}
<AuthZTooltip
checks={
activeLicense ? [buildLicenseUpdatePermission(activeLicense.id)] : []
}
enabled={!!activeLicense}
withPortal={withPortal}
>
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
</Button>
<Button
variant="link"
color={type === 'text' ? 'none' : 'secondary'}
size="md"
className={className}
onClick={handleRefreshPaymentStatus}
prefix={<RefreshCcw size={14} />}
loading={isLoading}
>
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
</Button>
</AuthZTooltip>
);
return (
@@ -62,6 +74,7 @@ function RefreshPaymentStatus({
RefreshPaymentStatus.defaultProps = {
type: 'button',
className: undefined,
withPortal: undefined,
};
export default RefreshPaymentStatus;

View File

@@ -15,7 +15,6 @@ export const REACT_QUERY_KEY = {
GET_ALL_DASHBOARDS: 'GET_ALL_DASHBOARDS',
GET_TRIGGERED_ALERTS: 'GET_TRIGGERED_ALERTS',
DASHBOARD_BY_ID: 'DASHBOARD_BY_ID',
GET_BILLING_USAGE: 'GET_BILLING_USAGE',
GET_FEATURES_FLAGS: 'GET_FEATURES_FLAGS',
DELETE_DASHBOARD: 'DELETE_DASHBOARD',
LOGS_PIPELINE_PREVIEW: 'LOGS_PIPELINE_PREVIEW',

View File

@@ -16,11 +16,13 @@ import * as Sentry from '@sentry/react';
import { Toaster } from '@signozhq/ui/sonner';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { Flex } from 'antd';
import { Button } from '@signozhq/ui/button';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import getChangelogByVersion from 'api/changelog/getChangelogByVersion';
import logEvent from 'api/common/logEvent';
import manageCreditCardApi from 'api/v1/portal/create';
import { updateSubscription } from 'api/generated/services/subscriptions';
import type { UpdateSubscription200 } from 'api/generated/services/sigNoz.schemas';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import getUserVersion from 'api/v1/version/get';
import getUserLatestVersion from 'api/v1/version/getLatestVersion';
@@ -30,6 +32,8 @@ import ChangelogModal from 'components/ChangelogModal/ChangelogModal';
import ChatSupportGateway from 'components/ChatSupportGateway/ChatSupportGateway';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionManagePermissions } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { MIN_ACCOUNT_AGE_FOR_CHANGELOG } from 'constants/changelog';
import { Events } from 'constants/events';
import { FeatureKeys } from 'constants/features';
@@ -63,8 +67,7 @@ import {
UPDATE_LATEST_VERSION,
UPDATE_LATEST_VERSION_ERROR,
} from 'types/actions/app';
import { ErrorResponse, SuccessResponse, SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
ChangelogSchema,
DeploymentType,
@@ -77,7 +80,6 @@ import {
} from 'types/api/licensesV3/getActive';
import { UserPreference } from 'types/api/preferences/preference';
import AppReducer from 'types/reducer/app';
import { USER_ROLES } from 'types/roles';
import { getBaseUrl } from 'utils/basePath';
import { showErrorNotification } from 'utils/error';
import { eventEmitter } from 'utils/getEventEmitter';
@@ -166,9 +168,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
return Math.abs(currentDate.diff(userCreationDate, 'day'));
}, [user.createdAt]);
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
const handleBillingOnSuccess = (data: UpdateSubscription200): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -186,7 +186,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
};
const { mutate: manageCreditCard, isLoading: isLoadingManageBilling } =
useMutation(manageCreditCardApi, {
useMutation(updateSubscription, {
onSuccess: (data) => {
handleBillingOnSuccess(data);
},
@@ -469,10 +469,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}, [isLoggedIn]);
const handleUpgrade = useCallback((): void => {
if (user.role === USER_ROLES.ADMIN) {
history.push(ROUTES.BILLING);
}
}, [user.role]);
history.push(ROUTES.BILLING);
}, []);
const handleFailedPayment = useCallback((): void => {
manageCreditCard({
@@ -586,25 +584,21 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
<div>
Our systems are taking longer than expected for your trial workspace.
Please{' '}
{user.role === USER_ROLES.ADMIN ? (
<span>
<a
className="upgrade-link"
onClick={(): void => {
notifications.destroy('slow-api-warning');
<span>
<a
className="upgrade-link"
onClick={(): void => {
notifications.destroy('slow-api-warning');
logEvent(`Slow API Banner: Upgrade clicked`, {});
logEvent(`Slow API Banner: Upgrade clicked`, {});
handleUpgrade();
}}
>
upgrade
</a>
your workspace for a smoother experience.
</span>
) : (
'contact your administrator for upgrading to a paid plan for a smoother experience.'
)}
handleUpgrade();
}}
>
upgrade
</a>
your workspace for a smoother experience.
</span>
</div>
),
duration: 60000,
@@ -794,22 +788,18 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
<div className="trial-expiry-banner">
You are in free trial period. Your free trial will end on{' '}
<span>{getFormattedDate(trialInfo?.trialEnd || Date.now())}.</span>
{user.role === USER_ROLES.ADMIN ? (
<span>
<span>
{' '}
Please{' '}
<a className="upgrade-link" onClick={handleUpgrade}>
upgrade
</a>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
Please{' '}
<a className="upgrade-link" onClick={handleUpgrade}>
upgrade
</a>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
| Already upgraded? <RefreshPaymentStatus type="text" />
</span>
| Already upgraded? <RefreshPaymentStatus type="text" />
</span>
) : (
'Please contact your administrator for upgrading to a paid plan.'
)}
</span>
</div>
)}
@@ -826,22 +816,25 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
)}
.
</span>
{user.role === USER_ROLES.ADMIN ? (
<span>
{' '}
Please{' '}
<a className="upgrade-link" onClick={handleFailedPayment}>
<span>
{' '}
Please{' '}
<AuthZTooltip checks={SubscriptionManagePermissions}>
<Button
variant="link"
color="none"
className="upgrade-link"
onClick={handleFailedPayment}
>
pay the bill
</a>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
| Already paid? <RefreshPaymentStatus type="text" />
</span>
</Button>
</AuthZTooltip>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
| Already paid? <RefreshPaymentStatus type="text" />
</span>
) : (
' Please contact your administrator to pay the bill.'
)}
</span>
</div>
)}
</div>

View File

@@ -0,0 +1,113 @@
import {
SubscriptionCreatePermission,
SubscriptionReadPermission,
SubscriptionUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import {
setupAuthzAdmin,
setupAuthzAllow,
setupAuthzDeny,
} from 'lib/authz/utils/authz-test-utils';
import { trialConvertedToSubscriptionResponse } from 'mocks-server/__mockdata__/licenses';
import { server } from 'mocks-server/server';
import { render, screen, waitFor } from 'tests/test-utils';
import BillingContainer from './BillingContainer';
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
describe('BillingContainer - AuthZ', () => {
afterEach(() => {
server.resetHandlers();
});
it('renders usage and enables actions when all subscription permissions are granted', async () => {
server.use(setupAuthzAdmin());
render(<BillingContainer />);
await expect(
screen.findByRole('columnheader', { name: /data ingested/i }),
).resolves.toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId('header-billing-button')).toBeEnabled();
});
expect(screen.queryByText(/not authorized/i)).not.toBeInTheDocument();
});
it('blocks the usage section when subscription read is denied', async () => {
server.use(setupAuthzDeny(SubscriptionReadPermission));
render(<BillingContainer />);
await expect(
screen.findByText(/not authorized/i),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('header-billing-button')).toBeInTheDocument();
expect(
screen.queryByRole('columnheader', { name: /data ingested/i }),
).not.toBeInTheDocument();
});
it('disables upgrade when subscription create is denied', async () => {
server.use(setupAuthzAllow(SubscriptionReadPermission));
render(<BillingContainer />);
await waitFor(() => {
expect(screen.getByTestId('header-billing-button')).toBeDisabled();
});
expect(screen.getByTestId('upgrade-plan-button')).toBeDisabled();
});
it('disables manage billing when subscription update is denied', async () => {
server.use(
setupAuthzAllow(SubscriptionReadPermission, SubscriptionCreatePermission),
);
render(
<BillingContainer />,
{},
{
appContextOverrides: {
trialInfo: trialConvertedToSubscriptionResponse.data,
},
},
);
await waitFor(() => {
expect(screen.getByTestId('header-billing-button')).toBeDisabled();
});
expect(screen.queryByTestId('upgrade-plan-button')).not.toBeInTheDocument();
});
it('disables manage billing when subscription list is denied', async () => {
server.use(
setupAuthzAllow(
SubscriptionReadPermission,
SubscriptionCreatePermission,
SubscriptionUpdatePermission,
),
);
render(
<BillingContainer />,
{},
{
appContextOverrides: {
trialInfo: trialConvertedToSubscriptionResponse.data,
},
},
);
await waitFor(() => {
expect(screen.getByTestId('header-billing-button')).toBeDisabled();
});
});
});

View File

@@ -128,7 +128,7 @@
}
.upgradePlanBenefits {
margin: 0 var(--spacing-4);
margin: 0;
border: 1px solid var(--l1-border);
border-radius: 5px;
padding: 0 var(--padding-12);

View File

@@ -1,9 +1,11 @@
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { billingSuccessResponse } from 'mocks-server/__mockdata__/billing';
import {
licensesSuccessResponse,
notOfTrailResponse,
trialConvertedToSubscriptionResponse,
} from 'mocks-server/__mockdata__/licenses';
import { server } from 'mocks-server/server';
import { act, render, screen, getAppContextMock } from 'tests/test-utils';
import APIError from 'types/api/error';
import {
@@ -15,11 +17,6 @@ import { getFormattedDate } from 'utils/timeUtils';
import BillingContainer from './BillingContainer';
jest.mock('hooks/useActiveLicenseKey/useActiveLicenseKey', () => ({
__esModule: true,
default: jest.fn(() => ({ licenseKey: 'test-key', isLoading: false })),
}));
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
@@ -31,14 +28,22 @@ window.ResizeObserver =
describe('BillingContainer', () => {
jest.setTimeout(30000);
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
server.resetHandlers();
});
it('Component should render', async () => {
render(<BillingContainer />);
const dataInjection = screen.getByRole('columnheader', {
const dataInjection = await screen.findByRole('columnheader', {
name: /data ingested/i,
});
expect(dataInjection).toBeInTheDocument();
const pricePerUnit = screen.getByRole('columnheader', {
const pricePerUnit = await screen.findByRole('columnheader', {
name: /price per unit/i,
});
expect(pricePerUnit).toBeInTheDocument();

View File

@@ -3,7 +3,7 @@ import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery } from 'react-query';
import { useMutation } from 'react-query';
import { CircleCheck, Landmark, MonitorDown } from '@signozhq/icons';
import {
Card,
@@ -15,25 +15,35 @@ import {
TableColumnsType as ColumnsType,
} from 'antd';
import { Badge } from '@signozhq/ui/badge';
import getUsage, {
BreakdownEntry,
UsageResponsePayloadProps,
} from 'api/billing/getUsage';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
import manageCreditCardApi from 'api/v1/portal/create';
import type {
CreateSubscription201,
GetSubscription200,
SubscriptiontypesGettableSubscriptionUsageDTO,
SubscriptiontypesSubscriptionUsageBreakdownDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
createSubscription,
updateSubscription,
useGetSubscription,
} from 'api/generated/services/subscriptions';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import Spinner from 'components/Spinner';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import useAxiosError from 'hooks/useAxiosError';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import { isEmpty, pick } from 'lodash-es';
import useActiveLicenseKey from 'hooks/useActiveLicenseKey/useActiveLicenseKey';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import {
SubscriptionCreatePermission,
SubscriptionManagePermissions,
SubscriptionReadPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { useAppContext } from 'providers/App/App';
import { ErrorResponse, SuccessResponse, SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import { getBaseUrl } from 'utils/basePath';
import { getFormattedDate, getRemainingDays } from 'utils/timeUtils';
@@ -135,7 +145,7 @@ export default function BillingContainer(): JSX.Element {
const [isFreeTrial, setIsFreeTrial] = useState(false);
const [data, setData] = useState<DataType[]>([]);
const [apiResponse, setApiResponse] = useState<
Partial<UsageResponsePayloadProps>
Partial<SubscriptiontypesGettableSubscriptionUsageDTO>
>({});
const {
@@ -146,7 +156,8 @@ export default function BillingContainer(): JSX.Element {
activeLicense,
activeLicenseFetchError,
} = useAppContext();
const { licenseKey } = useActiveLicenseKey();
const { allowed: canReadSubscription, error: subscriptionAuthZError } =
useAuthZ([SubscriptionReadPermission]);
const { notifications } = useNotifications();
const handleError = useAxiosError();
@@ -154,33 +165,34 @@ export default function BillingContainer(): JSX.Element {
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const processUsageData = useCallback(
(data: SuccessResponse<UsageResponsePayloadProps> | ErrorResponse): void => {
if (isEmpty(data?.payload)) {
(response: GetSubscription200): void => {
const usage = response?.data;
if (isEmpty(usage)) {
return;
}
const {
details: { breakdown = [], billTotal },
billingPeriodStart,
billingPeriodEnd,
} = (data as SuccessResponse<UsageResponsePayloadProps>).payload;
const breakdown = usage.details?.breakdown ?? [];
const billTotal = usage.details?.billTotal ?? 0;
const billingPeriodStart = usage.billingPeriodStart ?? 0;
const billingPeriodEnd = usage.billingPeriodEnd ?? 0;
const formattedUsageData: DataType[] = [];
if (breakdown && Array.isArray(breakdown)) {
for (let index = 0; index < breakdown.length; index += 1) {
const element: BreakdownEntry = breakdown[index];
element?.tiers?.forEach((tier, i: number) => {
breakdown.forEach(
(
element: SubscriptiontypesSubscriptionUsageBreakdownDTO,
index: number,
) => {
element?.tiers?.forEach((tier, tierIndex: number) => {
formattedUsageData.push({
key: `${index}${i}`,
name: i === 0 ? element?.type : '',
key: `${index}${tierIndex}`,
name: tierIndex === 0 ? (element?.type ?? '') : '',
unit: element?.unit ?? '',
dataIngested: `${tier.quantity} ${element?.unit}`,
pricePerUnit: String(tier.unitPrice),
cost: `$ ${tier.tierCost}`,
});
});
}
}
},
);
setData(formattedUsageData);
@@ -196,7 +208,7 @@ export default function BillingContainer(): JSX.Element {
setBillAmount(billTotal);
}
setApiResponse(data?.payload || {});
setApiResponse(usage);
},
[trialInfo?.onTrial],
);
@@ -208,11 +220,12 @@ export default function BillingContainer(): JSX.Element {
isLoading,
isFetching: isFetchingBillingData,
data: billingData,
} = useQuery([REACT_QUERY_KEY.GET_BILLING_USAGE, user?.id], {
queryFn: () => getUsage(licenseKey || ''),
onError: handleError,
enabled: !!licenseKey,
onSuccess: processUsageData,
} = useGetSubscription({
query: {
enabled: canReadSubscription || !!subscriptionAuthZError,
onError: handleError,
onSuccess: processUsageData,
},
});
useEffect(() => {
@@ -284,9 +297,7 @@ export default function BillingContainer(): JSX.Element {
/>
);
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -303,7 +314,7 @@ export default function BillingContainer(): JSX.Element {
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
updateCreditCardApi,
createSubscription,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
@@ -313,7 +324,7 @@ export default function BillingContainer(): JSX.Element {
);
const { mutate: manageCreditCard, isLoading: isLoadingManageBilling } =
useMutation(manageCreditCardApi, {
useMutation(updateSubscription, {
onSuccess: (data) => {
handleBillingOnSuccess(data);
},
@@ -348,15 +359,21 @@ export default function BillingContainer(): JSX.Element {
updateCreditCard,
]);
const billingActionPermissions = trialInfo?.trialConvertedToSubscription
? SubscriptionManagePermissions
: [SubscriptionCreatePermission];
const subscriptionPastDueMessage = (): JSX.Element => (
<Typography>
{`We were not able to process payments for your account. Please update your card details `}
<Typography.Link
onClick={handleBilling}
style={{ cursor: 'pointer', color: 'var(--bg-cherry-500)' }}
>
{t('here')}
</Typography.Link>
<AuthZTooltip checks={billingActionPermissions}>
<Typography.Link
onClick={handleBilling}
style={{ cursor: 'pointer', color: 'var(--bg-cherry-500)' }}
>
{t('here')}
</Typography.Link>
</AuthZTooltip>
{` if your payment information has changed. Email us at `}
<Typography.Text color="muted">cloud-support@signoz.io</Typography.Text>
{` otherwise. Be sure to provide this information immediately to avoid interruption to your service.`}
@@ -423,13 +440,14 @@ export default function BillingContainer(): JSX.Element {
{isFreeTrial ? <Badge color="success"> Free Trial </Badge> : ''}
</p>
{!isLoading && !isFetchingBillingData && !showGracePeriodMessage ? (
{billingData && !isFetchingBillingData && !showGracePeriodMessage ? (
<p className={styles.pageInfoSubtitle}>
{daysRemaining} {daysRemainingStr}
</p>
) : null}
</Flex>
<Button
<AuthZButton
checks={billingActionPermissions}
testId="header-billing-button"
variant="solid"
color="secondary"
@@ -443,7 +461,7 @@ export default function BillingContainer(): JSX.Element {
{trialInfo?.trialConvertedToSubscription
? t('manage_billing')
: t('upgrade_plan')}
</Button>
</AuthZButton>
</Flex>
{trialInfo?.onTrial && trialInfo?.trialConvertedToSubscription && (
@@ -495,66 +513,73 @@ export default function BillingContainer(): JSX.Element {
))}
</Card>
<div className={styles.billingGraphSection}>
{!isLoading && !isFetchingBillingData ? (
<BillingUsageGraph data={apiResponse} billAmount={billAmount} />
) : (
<Card className={styles.emptyGraphCard} bordered={false}>
<Spinner size="large" tip="Loading..." height="35vh" />
</Card>
)}
{!isLoading && !isFetchingBillingData && (
<div className={styles.billingGraphFooter}>
<Button
variant="outlined"
color="secondary"
size="md"
onClick={handleCsvDownload}
prefix={<MonitorDown size={14} />}
testId="download-csv-button"
className={styles.billingFooterBtn}
>
Download CSV
</Button>
<RefreshPaymentStatus type="button" className={styles.billingFooterBtn} />
<AuthZGuardContent checks={[SubscriptionReadPermission]}>
<>
<div className={styles.billingGraphSection}>
{!isLoading && !isFetchingBillingData ? (
<BillingUsageGraph data={apiResponse} billAmount={billAmount} />
) : (
<Card className={styles.emptyGraphCard} bordered={false}>
<Spinner size="large" tip="Loading..." height="35vh" />
</Card>
)}
{!isLoading && !isFetchingBillingData && (
<div className={styles.billingGraphFooter}>
<Button
variant="outlined"
color="secondary"
size="md"
onClick={handleCsvDownload}
prefix={<MonitorDown size={14} />}
testId="download-csv-button"
className={styles.billingFooterBtn}
>
Download CSV
</Button>
<RefreshPaymentStatus
type="button"
className={styles.billingFooterBtn}
/>
</div>
)}
</div>
)}
</div>
{!isLoading && !isFetchingBillingData && (
<Callout type="info" size="small" className={styles.billingUpdateNote}>
Billing metrics are updated once every 24 hours.
</Callout>
)}
{!isLoading && !isFetchingBillingData && (
<Callout type="info" size="small" className={styles.billingUpdateNote}>
Billing metrics are updated once every 24 hours.
</Callout>
)}
<div className={styles.billingDetails}>
{!isLoading && !isFetchingBillingData && (
<Table
columns={columns}
dataSource={data}
pagination={false}
bordered={false}
components={{
header: {
cell: ({
style,
...props
}: React.ThHTMLAttributes<HTMLTableCellElement>): JSX.Element => {
const { background: _, boxShadow: __, ...safeStyle } = style ?? {};
return (
<th
{...props}
style={safeStyle}
className={`${props.className ?? ''} ${styles.billingDetailsHeaderCell}`}
/>
);
},
},
}}
/>
)}
<div className={styles.billingDetails}>
{!isLoading && !isFetchingBillingData && (
<Table
columns={columns}
dataSource={data}
pagination={false}
bordered={false}
components={{
header: {
cell: ({
style,
...props
}: React.ThHTMLAttributes<HTMLTableCellElement>): JSX.Element => {
const { background: _, boxShadow: __, ...safeStyle } = style ?? {};
return (
<th
{...props}
style={safeStyle}
className={`${props.className ?? ''} ${styles.billingDetailsHeaderCell}`}
/>
);
},
},
}}
/>
)}
{(isLoading || isFetchingBillingData) && renderTableSkeleton()}
</div>
{(isLoading || isFetchingBillingData) && renderTableSkeleton()}
</div>
</>
</AuthZGuardContent>
{isCloudUserVal && activeLicense?.state === LicenseState.ACTIVATED && (
<CancelSubscriptionBanner />
@@ -597,7 +622,8 @@ export default function BillingContainer(): JSX.Element {
</Typography.Text>
</Col>
<Col span={4} style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
<AuthZButton
checks={[SubscriptionCreatePermission]}
testId="upgrade-plan-button"
variant="solid"
color="primary"
@@ -606,7 +632,7 @@ export default function BillingContainer(): JSX.Element {
onClick={handleBilling}
>
{t('upgrade_plan')}
</Button>
</AuthZButton>
</Col>
</Row>
</div>

View File

@@ -12,7 +12,7 @@ import {
} from 'lib/uPlotV2/components/types';
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import type uPlot from 'uplot';
import type { UsageResponsePayloadProps } from 'api/billing/getUsage';
import type { SubscriptiontypesGettableSubscriptionUsageDTO } from 'api/generated/services/sigNoz.schemas';
import { BillingBarChartTooltip } from './BillingBarChartTooltip';
import { prepareBillingBarConfig } from './prepareBillingBarConfig';
@@ -25,7 +25,7 @@ import {
import styles from './BillingUsageGraph.module.scss';
interface BillingUsageGraphProps {
data: Partial<UsageResponsePayloadProps>;
data: Partial<SubscriptiontypesGettableSubscriptionUsageDTO>;
billAmount: number;
}
@@ -55,7 +55,7 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
const currentDay = breakdown.dayWiseBreakdown.breakdown[0];
const nextDay = {
...currentDay,
timestamp: currentDay.timestamp + 86400,
timestamp: (currentDay.timestamp ?? 0) + 86400,
count: 0,
size: 0,
quantity: 0,
@@ -94,7 +94,9 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
const { startTime, endTime } = useMemo(
() =>
calculateStartEndTime(normalizedData as Partial<UsageResponsePayloadProps>),
calculateStartEndTime(
normalizedData as Partial<SubscriptiontypesGettableSubscriptionUsageDTO>,
),
[normalizedData],
);

View File

@@ -1,4 +1,4 @@
import { UsageResponsePayloadProps } from 'api/billing/getUsage';
import { SubscriptiontypesGettableSubscriptionUsageDTO } from 'api/generated/services/sigNoz.schemas';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
@@ -117,7 +117,9 @@ export function csvFileName(csvData: QuantityData[]): string {
return `billing_usage_(${startDate}-${endDate}).csv`;
}
export function prepareCsvData(data: Partial<UsageResponsePayloadProps>): {
export function prepareCsvData(
data: Partial<SubscriptiontypesGettableSubscriptionUsageDTO>,
): {
csvData: string;
fileName: string;
} {
@@ -135,12 +137,14 @@ export function prepareCsvData(data: Partial<UsageResponsePayloadProps>): {
}
export function calculateStartEndTime(
data: Partial<UsageResponsePayloadProps>,
data: Partial<SubscriptiontypesGettableSubscriptionUsageDTO>,
): { startTime: number | undefined; endTime: number | undefined } {
const timestamps: number[] = [];
data?.details?.breakdown?.forEach((breakdown) => {
breakdown?.dayWiseBreakdown?.breakdown?.forEach((entry) => {
timestamps.push(entry.timestamp);
if (typeof entry.timestamp === 'number') {
timestamps.push(entry.timestamp);
}
});
});

View File

@@ -1,3 +1,9 @@
import { SubscriptionDeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import {
setupAuthzAdmin,
setupAuthzDeny,
} from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import CancelSubscriptionBanner from './CancelSubscriptionBanner';
@@ -36,10 +42,24 @@ function mockMailto(): {
}
describe('CancelSubscriptionBanner', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
server.resetHandlers();
jest.restoreAllMocks();
});
it('disables Cancel Subscription when subscription delete is denied', async () => {
server.use(setupAuthzDeny(SubscriptionDeletePermission));
render(<CancelSubscriptionBanner />);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeDisabled();
});
});
it('renders banner with title and subtitle', () => {
render(<CancelSubscriptionBanner />);
expect(
@@ -56,9 +76,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(
@@ -76,9 +97,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
const confirmButton = screen.getByTestId('cancel-subscription-confirm-btn');
expect(confirmButton).toBeDisabled();
@@ -95,9 +117,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
const input = screen.getByTestId('cancel-confirm-input');
await user.type(input, 'cancel');
@@ -107,9 +130,10 @@ describe('CancelSubscriptionBanner', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
expect(screen.getByTestId('cancel-confirm-input')).toHaveValue('');
});
@@ -119,9 +143,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
@@ -151,9 +176,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
@@ -172,9 +198,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
@@ -192,9 +219,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));

View File

@@ -11,6 +11,8 @@ import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import logEvent from 'api/common/logEvent';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { SubscriptionDeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { pick } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { useCopyToClipboard } from 'react-use';
@@ -178,15 +180,17 @@ function CancelSubscriptionBanner(): JSX.Element {
immediately and removed from our servers.
</span>
</div>
<Button
<AuthZButton
checks={[SubscriptionDeletePermission]}
variant="solid"
color="secondary"
prefix={<X size={12} />}
onClick={handleOpenCancelDialog}
className={styles.cancelButton}
testId="cancel-subscription-btn"
>
Cancel Subscription
</Button>
</AuthZButton>
</div>
<DialogWrapper
open={dialogView !== null}

View File

@@ -329,16 +329,17 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
const result = transformTransactionGroupsToResourcePermissions([]);
expect(result).toHaveLength(8);
expect(result).toHaveLength(9);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'role',
'serviceaccount',
'license',
'logs',
'traces',
'metrics',
'meter-metrics',
'metrics',
'role',
'serviceaccount',
'subscription',
'traces',
]);
});
@@ -419,16 +420,17 @@ describe('createEmptyRolePermissions', () => {
it('creates permissions for all resources in RESOURCE_ORDER', () => {
const result = createEmptyRolePermissions();
expect(result).toHaveLength(8);
expect(result).toHaveLength(9);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'role',
'serviceaccount',
'license',
'logs',
'traces',
'metrics',
'meter-metrics',
'metrics',
'role',
'serviceaccount',
'subscription',
'traces',
]);
});

View File

@@ -6,6 +6,7 @@ import {
Gauge,
Key,
Logs,
Receipt,
Shield,
} from '@signozhq/icons';
@@ -69,6 +70,13 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
selectorPlaceholder: 'Type license ID, separate multiple with comma or space',
docsAnchor: 'license',
},
subscription: {
label: 'Subscription',
description: 'The workspace subscription, its usage and billing details.',
icon: Receipt,
selectorPlaceholder: 'Type * to cover the workspace subscription',
docsAnchor: 'subscription',
},
logs: {
label: 'Logs',
description: 'Log data collected across the workspace.',
@@ -107,7 +115,11 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
},
};
export const RESOURCE_ORDER = Object.keys(RESOURCE_PANELS) as AuthZResource[];
export const RESOURCE_ORDER = (
Object.keys(RESOURCE_PANELS) as AuthZResource[]
).sort((left, right) =>
RESOURCE_PANELS[left].label.localeCompare(RESOURCE_PANELS[right].label),
);
export function getResourcePanel(resource: AuthZResource): ResourcePanelConfig {
const panel = RESOURCE_PANELS[resource];

View File

@@ -13,6 +13,11 @@ export default {
type: 'metaresource',
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
},
{
kind: 'subscription',
type: 'metaresource',
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
},
{
kind: 'role',
type: 'role',

View File

@@ -4,3 +4,5 @@ import type { BrandedPermission } from '../types';
// Resource-level — require a specific license id
export const buildLicenseReadPermission = (id: string): BrandedPermission =>
buildPermission('read', `license:${id}`);
export const buildLicenseUpdatePermission = (id: string): BrandedPermission =>
buildPermission('update', `license:${id}`);

View File

@@ -0,0 +1,26 @@
import { buildPermission } from '../utils';
export const SubscriptionReadPermission = buildPermission(
'read',
'subscription:*',
);
export const SubscriptionCreatePermission = buildPermission(
'create',
'subscription:*',
);
export const SubscriptionUpdatePermission = buildPermission(
'update',
'subscription:*',
);
export const SubscriptionListPermission = buildPermission(
'list',
'subscription:*',
);
export const SubscriptionDeletePermission = buildPermission(
'delete',
'subscription:*',
);
export const SubscriptionManagePermissions = [
SubscriptionListPermission,
SubscriptionUpdatePermission,
];

View File

@@ -139,7 +139,7 @@ export const handlers = [
res(ctx.status(200), ctx.json(licensesSuccessResponse)),
),
rest.get('http://localhost/api/v1/billing', (req, res, ctx) =>
rest.get('http://localhost/api/v1/subscriptions', (req, res, ctx) =>
res(ctx.status(200), ctx.json(billingSuccessResponse)),
),

View File

@@ -58,14 +58,15 @@ function SettingsPage(): JSX.Element {
if (trialInfo?.workSpaceBlock && !isFetchingActiveLicense) {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled: !!(
isAdmin &&
(item.key === ROUTES.BILLING ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.MY_SETTINGS ||
item.key === ROUTES.SHORTCUTS)
),
isEnabled:
item.key === ROUTES.BILLING ||
!!(
isAdmin &&
(item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.MY_SETTINGS ||
item.key === ROUTES.SHORTCUTS)
),
}));
return updatedItems;
@@ -76,6 +77,7 @@ function SettingsPage(): JSX.Element {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.BILLING ||
item.key === ROUTES.ROLES_SETTINGS ||
item.key === ROUTES.ROLE_CREATE ||
item.key === ROUTES.ROLE_DETAILS ||
@@ -89,7 +91,6 @@ function SettingsPage(): JSX.Element {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.BILLING ||
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.INGESTION_SETTINGS ||
item.key === ROUTES.ORG_SETTINGS ||
@@ -127,6 +128,7 @@ function SettingsPage(): JSX.Element {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.BILLING ||
item.key === ROUTES.ROLES_SETTINGS ||
item.key === ROUTES.ROLE_CREATE ||
item.key === ROUTES.ROLE_DETAILS ||
@@ -140,7 +142,6 @@ function SettingsPage(): JSX.Element {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.BILLING ||
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||

View File

@@ -73,17 +73,13 @@ describe('SettingsPage nav sections', () => {
});
});
it.each(['workspace', 'account', 'roles', 'service-accounts'])(
it.each(['workspace', 'account', 'roles', 'service-accounts', 'billing'])(
'renders "%s" element',
(id) => {
expect(screen.getByTestId(id)).toBeInTheDocument();
},
);
it.each(['billing'])('does not render "%s" element', (id) => {
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
});
it('renders "mcp-server" element', () => {
expect(screen.getByTestId('mcp-server')).toBeInTheDocument();
});

View File

@@ -33,14 +33,20 @@ export const getRoutes = (
const isAdmin = userRole === USER_ROLES.ADMIN;
const isEditor = userRole === USER_ROLES.EDITOR;
if (isWorkspaceBlocked && isAdmin) {
settings.push(
...organizationSettings(t),
...membersSettings(t),
...mySettings(t),
...billingSettings(t),
...keyboardShortcuts(t),
);
if (isWorkspaceBlocked) {
if (isAdmin) {
settings.push(
...organizationSettings(t),
...membersSettings(t),
...mySettings(t),
);
}
settings.push(...billingSettings(t));
if (isAdmin) {
settings.push(...keyboardShortcuts(t));
}
return settings;
}
@@ -73,7 +79,7 @@ export const getRoutes = (
settings.push(...membersSettings(t));
}
if ((isCloudUser || isEnterpriseSelfHostedUser) && isAdmin) {
if (isCloudUser || isEnterpriseSelfHostedUser) {
settings.push(...billingSettings(t));
}

View File

@@ -4,9 +4,12 @@ import { useHistory, useLocation } from 'react-router-dom';
import { Button, Card, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
import { createSubscription } from 'api/generated/services/subscriptions';
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
import { FeatureKeys } from 'constants/features';
import { useNotifications } from 'hooks/useNotifications';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import {
ArrowUpRight,
Book,
@@ -18,8 +21,6 @@ import {
X,
} from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
@@ -116,9 +117,7 @@ export default function Support(): JSX.Element {
const showAddCreditCardModal =
!isPremiumChatSupportEnabled && !trialInfo?.trialConvertedToSubscription;
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -136,7 +135,7 @@ export default function Support(): JSX.Element {
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
updateCreditCardApi,
createSubscription,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
@@ -246,18 +245,23 @@ export default function Support(): JSX.Element {
>
Cancel
</Button>,
<Button
<AuthZTooltip
key="submit"
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn periscope-btn primary"
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
Add Credit Card
</Button>,
<Button
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn periscope-btn primary"
>
Add Credit Card
</Button>
</AuthZTooltip>,
]}
>
<Typography.Text className="add-credit-card-text">

View File

@@ -1,7 +1,11 @@
import {
setupAuthzAdmin,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
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, waitFor } from 'tests/test-utils';
import WorkspaceLocked from '.';
@@ -30,40 +34,37 @@ describe('WorkspaceLocked', () => {
expect(contactUsBtn).toBeInTheDocument();
});
it('Render for Admin', async () => {
it('enables the upgrade action when subscription create is granted', async () => {
server.use(
rest.get(apiURL, (req, res, ctx) =>
res(ctx.status(200), ctx.json(licensesSuccessWorkspaceLockedResponse)),
),
setupAuthzAdmin(),
);
render(<WorkspaceLocked />);
const contactAdminMessage = await screen.queryByText(
/contact your admin to proceed with the upgrade./i,
);
expect(contactAdminMessage).not.toBeInTheDocument();
const updateCreditCardBtn = await screen.findByRole('button', {
name: /continue my journey/i,
});
expect(updateCreditCardBtn).toBeInTheDocument();
await waitFor(() => {
expect(updateCreditCardBtn).toBeEnabled();
});
});
it('Render for non Admin', async () => {
it('disables the upgrade action when subscription create is denied', async () => {
server.use(
rest.get(apiURL, (req, res, ctx) =>
res(ctx.status(200), ctx.json(licensesSuccessWorkspaceLockedResponse)),
),
setupAuthzDenyAll(),
);
render(<WorkspaceLocked />, {}, { role: 'VIEWER' });
const updateCreditCardBtn = await screen.queryByRole('button', {
name: /Continue My Journey/i,
const updateCreditCardBtn = await screen.findByRole('button', {
name: /continue my journey/i,
});
await waitFor(() => {
expect(updateCreditCardBtn).toBeDisabled();
});
expect(updateCreditCardBtn).not.toBeInTheDocument();
const contactAdminMessage = await screen.findByText(
/contact your admin to proceed with the upgrade./i,
);
expect(contactAdminMessage).toBeInTheDocument();
});
});

View File

@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next';
import { useMutation } from 'react-query';
import type { TabsProps } from 'antd';
import {
Alert,
Button,
Col,
Collapse,
@@ -18,11 +17,13 @@ import {
} from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
import { createSubscription } from 'api/generated/services/subscriptions';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import ROUTES from 'constants/routes';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import history from 'lib/history';
import { CircleArrowRight } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
@@ -44,9 +45,7 @@ import {
import './WorkspaceLocked.styles.scss';
export default function WorkspaceBlocked(): JSX.Element {
const { user, isFetchingActiveLicense, trialInfo, activeLicense } =
useAppContext();
const isAdmin = user.role === 'ADMIN';
const { isFetchingActiveLicense, trialInfo, activeLicense } = useAppContext();
const { notifications } = useNotifications();
const { safeNavigate } = useSafeNavigate();
@@ -89,7 +88,7 @@ export default function WorkspaceBlocked(): JSX.Element {
]);
const { mutate: updateCreditCard, isLoading } = useMutation(
updateCreditCardApi,
createSubscription,
{
onSuccess: (data) => {
if (data.data?.redirectURL) {
@@ -184,8 +183,11 @@ export default function WorkspaceBlocked(): JSX.Element {
/>
</Space>
</Col>
{isAdmin && (
<Col span={24}>
<Col span={24}>
<AuthZTooltip
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
<Button
type="primary"
shape="round"
@@ -195,8 +197,8 @@ export default function WorkspaceBlocked(): JSX.Element {
>
{t('continueToUpgrade')}
</Button>
</Col>
)}
</AuthZTooltip>
</Col>
</Row>
</Col>
</Row>
@@ -220,9 +222,9 @@ export default function WorkspaceBlocked(): JSX.Element {
>
{renderCustomerStories((index) => index % 2 !== 0)}
</Col>
{isAdmin && (
<Col span={24}>
<Flex justify="center">
<Col span={24}>
<Flex justify="center">
<AuthZTooltip checks={[SubscriptionCreatePermission]} withPortal={false}>
<Button
type="primary"
shape="round"
@@ -232,9 +234,9 @@ export default function WorkspaceBlocked(): JSX.Element {
>
{t('continueToUpgrade')}
</Button>
</Flex>
</Col>
)}
</AuthZTooltip>
</Flex>
</Col>
</Row>
),
},
@@ -260,7 +262,7 @@ export default function WorkspaceBlocked(): JSX.Element {
defaultActiveKey={['signoz-cloud-vs-community']}
onChange={handleCollapseChange}
/>
{isAdmin && (
<AuthZTooltip checks={[SubscriptionCreatePermission]} withPortal={false}>
<Button
type="primary"
shape="round"
@@ -270,7 +272,7 @@ export default function WorkspaceBlocked(): JSX.Element {
>
{t('continueToUpgrade')}
</Button>
)}
</AuthZTooltip>
</Space>
</Col>
</Row>
@@ -288,21 +290,19 @@ export default function WorkspaceBlocked(): JSX.Element {
{t('trialPlanExpired')}
</span>
<span className="workspace-locked__modal__header__actions">
{isAdmin && (
<Flex gap={8} justify="center" align="center">
<Button
className="workspace-locked__modal__header__actions__billing"
type="link"
size="small"
role="button"
onClick={(e): void => handleViewBilling(e)}
>
View Billing
</Button>
<Flex gap={8} justify="center" align="center">
<Button
className="workspace-locked__modal__header__actions__billing"
type="link"
size="small"
role="button"
onClick={(e): void => handleViewBilling(e)}
>
View Billing
</Button>
<RefreshPaymentStatus />
</Flex>
)}
<RefreshPaymentStatus withPortal={false} />
</Flex>
<Button
type="default"
@@ -346,7 +346,7 @@ export default function WorkspaceBlocked(): JSX.Element {
</Space>
</Col>
</Row>
{!isAdmin && (
<Flex gap={8} vertical justify="center" align="center">
<Row
justify="center"
align="middle"
@@ -354,22 +354,10 @@ export default function WorkspaceBlocked(): JSX.Element {
gutter={[8, 8]}
>
<Col>
<Alert
message="Contact your admin to proceed with the upgrade."
type="info"
/>
</Col>
</Row>
)}
{isAdmin && (
<Flex gap={8} vertical justify="center" align="center">
<Row
justify="center"
align="middle"
className="workspace-locked__modal__cta"
gutter={[8, 8]}
>
<Col>
<AuthZTooltip
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
<Button
type="primary"
shape="round"
@@ -379,21 +367,21 @@ export default function WorkspaceBlocked(): JSX.Element {
>
Continue my Journey
</Button>
</Col>
<Col>
<Button
type="default"
shape="round"
size="middle"
className="periscope-btn"
onClick={handleExtendTrial}
>
{t('needMoreTime')}
</Button>
</Col>
</Row>
</Flex>
)}
</AuthZTooltip>
</Col>
<Col>
<Button
type="default"
shape="round"
size="middle"
className="periscope-btn"
onClick={handleExtendTrial}
>
{t('needMoreTime')}
</Button>
</Col>
</Row>
</Flex>
<div className="workspace-locked__tabs">
<Tabs

View File

@@ -1,12 +1,14 @@
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation } from 'react-query';
import { Alert, Button, Col, Flex, Modal, Row, Skeleton, Space } from 'antd';
import { Button, Col, Flex, Modal, Row, Skeleton, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import manageCreditCardApi from 'api/v1/portal/create';
import { updateSubscription } from 'api/generated/services/subscriptions';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import ROUTES from 'constants/routes';
import { useNotifications } from 'hooks/useNotifications';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionManagePermissions } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import APIError from 'types/api/error';
@@ -18,15 +20,13 @@ import featureGraphicCorrelationUrl from '@/assets/Images/feature-graphic-correl
import './WorkspaceSuspended.styles.scss';
function WorkspaceSuspended(): JSX.Element {
const { user } = useAppContext();
const isAdmin = user.role === 'ADMIN';
const { notifications } = useNotifications();
const { activeLicense, isFetchingActiveLicense } = useAppContext();
const { t } = useTranslation(['failedPayment']);
const { mutate: manageCreditCard, isLoading } = useMutation(
manageCreditCardApi,
updateSubscription,
{
onSuccess: (data) => {
if (data.data?.redirectURL) {
@@ -111,29 +111,17 @@ function WorkspaceSuspended(): JSX.Element {
</Space>
</Col>
</Row>
{!isAdmin && (
<Row
justify="center"
align="middle"
className="workspace-suspended__modal__cta"
gutter={[16, 16]}
>
<Col>
<Alert
message="Contact your admin to proceed with the upgrade."
type="info"
/>
</Col>
</Row>
)}
{isAdmin && (
<Row
justify="center"
align="middle"
className="workspace-suspended__modal__cta"
gutter={[8, 8]}
>
<Flex gap={8} justify="center" align="center">
<Row
justify="center"
align="middle"
className="workspace-suspended__modal__cta"
gutter={[8, 8]}
>
<Flex gap={8} justify="center" align="center">
<AuthZTooltip
checks={SubscriptionManagePermissions}
withPortal={false}
>
<Button
type="primary"
shape="round"
@@ -143,10 +131,10 @@ function WorkspaceSuspended(): JSX.Element {
>
{t('continueMyJourney')}
</Button>
<RefreshPaymentStatus />
</Flex>
</Row>
)}
</AuthZTooltip>
<RefreshPaymentStatus withPortal={false} />
</Flex>
</Row>
<div className="workspace-suspended__creative">
<img src={featureGraphicCorrelationUrl} alt="correlation-graphic" />
</div>

View File

@@ -1,12 +0,0 @@
export interface CheckoutSuccessPayloadProps {
redirectURL: string;
}
export interface CheckoutRequestPayloadProps {
url: string;
}
export interface PayloadProps {
data: CheckoutSuccessPayloadProps;
status: string;
}

View File

@@ -112,7 +112,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
ROLE_EDIT: ['ADMIN', 'EDITOR', 'VIEWER'],
MEMBERS_SETTINGS: ['ADMIN'],
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
BILLING: ['ADMIN'],
BILLING: ['ADMIN', 'EDITOR', 'VIEWER'],
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
LOGS_SAVE_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],
@@ -186,4 +186,5 @@ export const routeWithInitialAuthZSupport = {
WORKSPACE_LOCKED: true,
WORKSPACE_SUSPENDED: true,
WORKSPACE_ACCESS_RESTRICTED: true,
BILLING: true,
} as const satisfies Partial<Record<keyof typeof ROUTES, true>>;

View File

@@ -71,7 +71,7 @@ var (
ResourceMetaResourceFactorPassword = NewResourceMetaResource(KindFactorPassword)
ResourceMetaResourceFactorAPIKey = NewResourceMetaResource(KindFactorAPIKey, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete)
ResourceMetaResourceLicense = NewResourceMetaResource(KindLicense, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete)
ResourceMetaResourceSubscription = NewResourceMetaResource(KindSubscription)
ResourceMetaResourceSubscription = NewResourceMetaResource(KindSubscription, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete)
ResourceMetaResourceDeploymentHost = NewResourceMetaResource(KindDeploymentHost, VerbList, VerbUpdate)
ResourceTelemetryResourceLogs = NewResourceTelemetryResource(KindLogs)
ResourceTelemetryResourceTraces = NewResourceTelemetryResource(KindTraces)