diff --git a/public/app/core/components/AppChrome/TopBar/InviteUserButton.test.tsx b/public/app/core/components/AppChrome/TopBar/InviteUserButton.test.tsx
index f5e66653ddb..aff058721f5 100644
--- a/public/app/core/components/AppChrome/TopBar/InviteUserButton.test.tsx
+++ b/public/app/core/components/AppChrome/TopBar/InviteUserButton.test.tsx
@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event';
import { config, reportInteraction } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
-import { getExternalUserMngLinkUrl } from 'app/features/users/utils';
+import { getExternalUserMngLinkUrl, getUpgradeUrl } from 'app/features/users/utils';
import { InviteUserButton } from './InviteUserButton';
@@ -12,6 +12,12 @@ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
externalUserMngLinkUrl: 'https://example.com/invite',
+ namespace: 'default', // on-prem by default
+ bootData: {
+ user: {
+ orgName: 'test-org',
+ },
+ },
},
reportInteraction: jest.fn(),
}));
@@ -24,12 +30,18 @@ jest.mock('app/core/services/context_srv', () => ({
jest.mock('app/features/users/utils', () => ({
getExternalUserMngLinkUrl: jest.fn(),
+ getUpgradeUrl: jest.fn(),
+}));
+
+jest.mock('app/api/clients/legacy', () => ({
+ useGetCurrentOrgQuotaQuery: jest.fn(),
}));
const mockContextSrv = jest.mocked(contextSrv);
const mockConfig = jest.mocked(config);
const mockReportInteraction = jest.mocked(reportInteraction);
const mockGetExternalUserMngLinkUrl = jest.mocked(getExternalUserMngLinkUrl);
+const mockGetUpgradeUrl = jest.mocked(getUpgradeUrl);
// Mock window.open
const mockWindowOpen = jest.fn();
@@ -52,10 +64,27 @@ const mockMatchMedia = (matches: boolean) => {
describe('InviteUserButton', () => {
const mockInviteUrl = 'https://example.com/invite?cnt=invite-user-top-bar';
+ const mockUpgradeUrl = 'https://grafana.com/orgs/test-org/my-account/manage-plan?cnt=upgrade-user-top-bar';
+
+ // Import the mocked hook
+ const { useGetCurrentOrgQuotaQuery } = require('app/api/clients/legacy');
+ const mockUseGetCurrentOrgQuotaQuery = jest.mocked(useGetCurrentOrgQuotaQuery);
beforeEach(() => {
jest.clearAllMocks();
mockGetExternalUserMngLinkUrl.mockReturnValue(mockInviteUrl);
+ mockGetUpgradeUrl.mockReturnValue(mockUpgradeUrl);
+
+ // Default mock: no quotas, no error (on-prem scenario)
+ mockUseGetCurrentOrgQuotaQuery.mockReturnValue({
+ data: undefined,
+ error: undefined,
+ isLoading: false,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any);
+
+ // Default to on-prem
+ mockConfig.namespace = 'default';
});
describe('Business Logic - When button should appear', () => {
@@ -128,45 +157,111 @@ describe('InviteUserButton', () => {
});
});
+ describe('Upgrade functionality - Grafana Cloud', () => {
+ beforeEach(() => {
+ mockConfig.externalUserMngLinkUrl = 'https://example.com/invite';
+ mockContextSrv.hasPermission.mockReturnValue(true);
+ mockMatchMedia(true);
+ // Simulate Grafana Cloud
+ mockConfig.namespace = 'stacks-12345';
+ });
+
+ it('should show invite button when quota is not reached', () => {
+ mockUseGetCurrentOrgQuotaQuery.mockReturnValue({
+ data: [{ target: 'org_user', limit: 5, used: 2, org_id: 1 }],
+ error: undefined,
+ isLoading: false,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any);
+
+ render();
+
+ const button = screen.getByRole('button', { name: /invite user/i });
+ expect(button).toHaveTextContent('Invite');
+ });
+
+ it('should show upgrade button when quota is reached', () => {
+ mockUseGetCurrentOrgQuotaQuery.mockReturnValue({
+ data: [{ target: 'org_user', limit: 5, used: 5, org_id: 1 }],
+ error: undefined,
+ isLoading: false,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any);
+
+ render();
+
+ const button = screen.getByRole('button', { name: /upgrade to invite more users/i });
+ expect(button).toHaveTextContent('Upgrade');
+ });
+
+ it('should open upgrade URL when upgrade button is clicked', async () => {
+ mockUseGetCurrentOrgQuotaQuery.mockReturnValue({
+ data: [{ target: 'org_user', limit: 5, used: 5, org_id: 1 }],
+ error: undefined,
+ isLoading: false,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any);
+
+ const user = userEvent.setup();
+
+ render();
+
+ await user.click(screen.getByRole('button', { name: /upgrade to invite more users/i }));
+
+ expect(mockReportInteraction).toHaveBeenCalledWith('upgrade_user_button_clicked', {
+ placement: 'top_bar_right',
+ });
+ expect(mockGetUpgradeUrl).toHaveBeenCalledWith('upgrade-user-top-bar');
+ expect(mockWindowOpen).toHaveBeenCalledWith(mockUpgradeUrl, '_blank');
+ });
+
+ it('should not fetch quotas on on-prem instances', () => {
+ mockConfig.namespace = 'default'; // on-prem
+
+ render();
+
+ // Should skip the query
+ expect(mockUseGetCurrentOrgQuotaQuery).toHaveBeenCalledWith(undefined, {
+ skip: true,
+ });
+ });
+
+ it('should fetch quotas on cloud instances when button will render', () => {
+ mockConfig.namespace = 'stacks-12345'; // cloud
+
+ render();
+
+ // Should not skip the query
+ expect(mockUseGetCurrentOrgQuotaQuery).toHaveBeenCalledWith(undefined, {
+ skip: false,
+ });
+ });
+ });
+
describe('Error Handling - Preventing crashes', () => {
beforeEach(() => {
mockConfig.externalUserMngLinkUrl = 'https://example.com/invite';
mockContextSrv.hasPermission.mockReturnValue(true);
mockMatchMedia(true);
+ mockConfig.namespace = 'default'; // on-prem
});
- it('should handle URL generation errors gracefully', async () => {
- mockGetExternalUserMngLinkUrl.mockImplementation(() => {
- throw new Error('URL generation failed');
- });
+ it('should handle quota API errors gracefully', () => {
+ mockConfig.namespace = 'stacks-12345';
+ mockUseGetCurrentOrgQuotaQuery.mockReturnValue({
+ data: undefined,
+ error: { message: 'API Error' },
+ isLoading: false,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any);
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
- const user = userEvent.setup();
render();
- // Should not crash when URL generation fails
- await user.click(screen.getByRole('button', { name: /invite user/i }));
-
- expect(consoleSpy).toHaveBeenCalledWith('Failed to handle invite user click:', expect.any(Error));
-
- consoleSpy.mockRestore();
- });
-
- it('should handle popup blocking gracefully', async () => {
- mockWindowOpen.mockImplementation(() => {
- throw new Error('Popup blocked');
- });
-
- const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
- const user = userEvent.setup();
-
- render();
-
- // Should not crash when popup is blocked
- await user.click(screen.getByRole('button', { name: /invite user/i }));
-
- expect(consoleSpy).toHaveBeenCalledWith('Failed to handle invite user click:', expect.any(Error));
+ // Should still render the invite button (no quota check)
+ expect(screen.getByRole('button', { name: /invite user/i })).toBeInTheDocument();
+ expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch org quotas:', { message: 'API Error' });
consoleSpy.mockRestore();
});
diff --git a/public/app/core/components/AppChrome/TopBar/InviteUserButton.tsx b/public/app/core/components/AppChrome/TopBar/InviteUserButton.tsx
index 9c86223b026..db6f285feed 100644
--- a/public/app/core/components/AppChrome/TopBar/InviteUserButton.tsx
+++ b/public/app/core/components/AppChrome/TopBar/InviteUserButton.tsx
@@ -1,33 +1,61 @@
import { t } from '@grafana/i18n';
import { ToolbarButton } from '@grafana/ui';
+import { useGetCurrentOrgQuotaQuery } from 'app/api/clients/legacy';
import { useMediaQueryMinWidth } from 'app/core/hooks/useMediaQueryMinWidth';
+import { isOnPrem } from 'app/features/provisioning/utils/isOnPrem';
import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator';
-import { performInviteUserClick, shouldRenderInviteUserButton } from './InviteUserButtonUtils';
+import { performInviteUserClick, performUpgradeClick, shouldRenderInviteUserButton } from './InviteUserButtonUtils';
export function InviteUserButton() {
const isLargeScreen = useMediaQueryMinWidth('lg');
+ const shouldRender = shouldRenderInviteUserButton();
+ const isCloudInstance = !isOnPrem();
+
+ // Only fetch quotas when button will render AND on Grafana Cloud
+ const { data: quotas, error } = useGetCurrentOrgQuotaQuery(undefined, {
+ skip: !shouldRender || !isCloudInstance,
+ });
+
+ // Check if org_user quota is reached
+ const userQuota = quotas?.find((quota) => quota.target === 'org_user');
+ const isQuotaReached = userQuota ? userQuota.used! >= userQuota.limit! : false;
+
+ // Only show upgrade button on Grafana Cloud when quota is reached
+ const shouldShowUpgrade = isCloudInstance && isQuotaReached;
+
+ if (error) {
+ console.error('Failed to fetch org quotas:', error);
+ }
const handleClick = () => {
- try {
+ if (shouldShowUpgrade) {
+ performUpgradeClick('top_bar_right', 'upgrade-user-top-bar');
+ } else {
performInviteUserClick('top_bar_right', 'invite-user-top-bar');
- } catch (error) {
- console.error('Failed to handle invite user click:', error);
}
};
+ const buttonLabel = shouldShowUpgrade
+ ? t('navigation.invite-user.upgrade-tooltip', 'Upgrade to invite more users')
+ : t('navigation.invite-user.invite-tooltip', 'Invite user');
+
return (
- shouldRenderInviteUserButton() && (
+ shouldRender && (
<>
- {isLargeScreen ? t('navigation.invite-user.invite-button', 'Invite') : undefined}
+ {isLargeScreen
+ ? shouldShowUpgrade
+ ? t('navigation.invite-user.upgrade-button', 'Upgrade')
+ : t('navigation.invite-user.invite-button', 'Invite')
+ : undefined}
>
diff --git a/public/app/core/components/AppChrome/TopBar/InviteUserButtonUtils.tsx b/public/app/core/components/AppChrome/TopBar/InviteUserButtonUtils.tsx
index 6e7d90bd879..647c5445ca4 100644
--- a/public/app/core/components/AppChrome/TopBar/InviteUserButtonUtils.tsx
+++ b/public/app/core/components/AppChrome/TopBar/InviteUserButtonUtils.tsx
@@ -1,6 +1,6 @@
import { reportInteraction, config } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
-import { getExternalUserMngLinkUrl } from 'app/features/users/utils';
+import { getExternalUserMngLinkUrl, getUpgradeUrl } from 'app/features/users/utils';
import { AccessControlAction } from 'app/types/accessControl';
export const shouldRenderInviteUserButton = () =>
@@ -14,3 +14,12 @@ export const performInviteUserClick = (placement: string, cnt: string) => {
const url = getExternalUserMngLinkUrl(cnt);
window.open(url.toString(), '_blank');
};
+
+export const performUpgradeClick = (placement: string, cnt: string) => {
+ reportInteraction('upgrade_user_button_clicked', {
+ placement,
+ });
+
+ const url = getUpgradeUrl(cnt);
+ window.open(url.toString(), '_blank');
+};
diff --git a/public/app/features/users/utils.ts b/public/app/features/users/utils.ts
index def3777d476..ff4c6dc60b5 100644
--- a/public/app/features/users/utils.ts
+++ b/public/app/features/users/utils.ts
@@ -19,3 +19,25 @@ export function getExternalUserMngLinkUrl(cnt: string) {
return url.toString();
}
+
+export function getUpgradeUrl(cnt?: string) {
+ const orgName = config.bootData?.user?.orgName;
+
+ let baseUrl: string;
+ if (orgName) {
+ // Use org-specific URL: https://grafana.com/orgs//my-account/manage-plan
+ baseUrl = `https://grafana.com/orgs/${encodeURIComponent(orgName)}/my-account/manage-plan`;
+ } else {
+ // Fallback to generic subscription page
+ baseUrl = 'https://grafana.com/profile/org/subscription';
+ }
+
+ // Add cnt parameter for conversion tracking if provided
+ if (cnt) {
+ const url = new URL(baseUrl);
+ url.searchParams.append('cnt', cnt);
+ return url.toString();
+ }
+
+ return baseUrl;
+}