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; +}