Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- The "Upgrade to Pro" button in the sidebar footer and the per-item "Upgrade" badges in the default and settings sidebars are now hidden for users who are not an OWNER in the org, since those prompts are only meaningful for the billing decision-maker. [#1524](https://github.com/sourcebot-dev/sourcebot/pull/1524)

## [5.1.5] - 2026-07-31

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,13 @@ export async function DefaultSidebar() {
collapsible="icon"
isValidLicenseActive={licenseActive}
isAskGhEnabled={env.EXPERIMENT_ASK_GH_ENABLED === 'true'}
isOwner={isOwner}
headerContent={
<Nav
isSettingsNotificationVisible={isSettingsNotificationVisible}
isSignedIn={!!session}
homeView={homeView}
isOwner={isOwner}
/>
}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, test, vi } from 'vitest';
import { render } from '@testing-library/react';
import { SidebarProvider } from '@/components/ui/sidebar';
import { TooltipProvider } from '@/components/ui/tooltip';
import type { ReactNode } from 'react';
import { Entitlement } from '@sourcebot/shared';

// next/link renders a plain anchor so we can assert on the rendered href
// without a Next.js router context.
vi.mock('next/link', async () => {
const { createElement } = await import('react');
return {
default: ({ href, children }: { href: string; children: ReactNode }) =>
createElement('a', { href }, children),
};
});

// `useEntitlements` is a client hook backed by an entitlements context.
// Stub it so each test can pick the entitlement set it wants.
const mockEntitlements = vi.hoisted(() => ({
current: [] as Entitlement[],
}));

vi.mock('@/features/entitlements/useEntitlements', () => ({
useEntitlements: () => mockEntitlements.current,
}));

vi.mock('next/navigation', () => ({
usePathname: () => '/search',
}));

// Stub the UpgradeBadge so the test doesn't need the lucide-react tree.
vi.mock('@/app/(app)/@sidebar/components/upgradeBadge', () => ({
UpgradeBadge: () => <span data-testid="upgrade-badge">Upgrade</span>,
}));

// useIsMobile reads window.matchMedia in a useEffect, which jsdom does
// not implement. Stub it so the test environment stays stable.
vi.mock('@/hooks/use-mobile', () => ({
useIsMobile: () => false,
}));

// Import after mocks so the test file's hoisted values take effect.
const { Nav } = await import('./nav');

const renderNav = (opts: { isOwner?: boolean; isSignedIn?: boolean }) => {
// No entitlements: every gated nav item in the default sidebar
// ("settings" → audit) should be missing its required entitlement
// and would therefore trigger the badge if the isOwner gate is not
// in place.
mockEntitlements.current = [];
return render(
// SidebarProvider is required because Nav uses SidebarMenuButton
// which calls useSidebar(); the provider's "defaultOpen" is
// arbitrary for these tests because we only assert on badge
// presence, not on interaction state.
<SidebarProvider defaultOpen={true}>
<TooltipProvider>
<Nav
isSettingsNotificationVisible={false}
isSignedIn={opts.isSignedIn ?? true}
homeView="search"
isOwner={opts.isOwner}
/>
</TooltipProvider>
</SidebarProvider>
);
};

const countBadges = (container: HTMLElement) =>
container.querySelectorAll('[data-testid="upgrade-badge"]').length;

describe('Nav upgrade badge gating (issue #1524)', () => {
test('renders the upgrade badge for an OWNER who is missing the required entitlement', () => {
// Without the fix, isOwner is ignored and the badge shows for
// everyone. With the fix, the badge only renders for owners —
// this asserts the positive case (the badge is reachable at all
// when the user IS an owner).
const { container } = renderNav({ isOwner: true });
expect(countBadges(container)).toBeGreaterThan(0);
});

test('does NOT render the upgrade badge for a non-owner (MEMBER) user', () => {
// The same fixture as the positive test, but with isOwner=false.
// The badge must disappear — the entitlement check is unchanged,
// so the only thing that suppresses the badge is the new isOwner
// gate.
const { container } = renderNav({ isOwner: false });
expect(countBadges(container)).toBe(0);
});

test('does NOT render the upgrade badge for an unauthenticated user', () => {
// Unauthenticated visitors hit the sidebar on the landing page
// (no auth context, so isOwner defaults to false). No badge.
const { container } = renderNav({ isOwner: undefined, isSignedIn: false });
expect(countBadges(container)).toBe(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,19 @@ interface NavProps {
isSettingsNotificationVisible?: boolean;
isSignedIn?: boolean;
homeView: HomeView;
/**
* Whether the current user is an OWNER in the org. The per-item
* "Upgrade" badge is only meaningful for owners — a MEMBER cannot
* act on the upgrade flow, so we hide the badge unless this is true.
*/
isOwner?: boolean;
}

export function Nav({
isSettingsNotificationVisible,
isSignedIn,
homeView
homeView,
isOwner = false,
}: NavProps) {
const pathname = usePathname();
const entitlements = useEntitlements();
Expand Down Expand Up @@ -121,6 +128,7 @@ export function Nav({
(item.key === "settings" && isSettingsNotificationVisible);

const showUpgradeBadge =
isOwner &&
(item.requiredEntitlement && !entitlements.includes(item.requiredEntitlement));

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { SidebarBase } from "../sidebarBase";
import { Nav } from "./nav";
import { SettingsSidebarHeader } from "./header";
import { isValidLicenseActive } from "@/lib/entitlements";
import { getAuthContext } from "@/middleware/withAuth";
import { OrgRole } from "@prisma/client";
import { env } from "@sourcebot/shared";

export async function SettingsSidebar() {
Expand All @@ -18,15 +20,22 @@ export async function SettingsSidebar() {

const licenseActive = await isValidLicenseActive();

// The "Upgrade" prompts in the sidebar (UpgradeButton in the footer
// and the per-item UpgradeBadge) are only meaningful for the org's
// owner — a MEMBER cannot act on the upgrade flow. See issue #1524.
const authContext = await getAuthContext();
const isOwner = !isServiceError(authContext) && authContext.role === OrgRole.OWNER;

return (
<SidebarBase
session={session}
collapsible="none"
isValidLicenseActive={licenseActive}
isAskGhEnabled={env.EXPERIMENT_ASK_GH_ENABLED === 'true'}
isOwner={isOwner}
headerContent={<SettingsSidebarHeader />}
>
<Nav groups={sidebarNavGroups} />
<Nav groups={sidebarNavGroups} isOwner={isOwner} />
</SidebarBase>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, test, vi } from 'vitest';
import { render } from '@testing-library/react';
import { SidebarProvider } from '@/components/ui/sidebar';
import { TooltipProvider } from '@/components/ui/tooltip';
import type { ReactNode } from 'react';
import { Entitlement } from '@sourcebot/shared';

vi.mock('next/link', async () => {
const { createElement } = await import('react');
return {
default: ({ href, children }: { href: string; children: ReactNode }) =>
createElement('a', { href }, children),
};
});

const mockEntitlements = vi.hoisted(() => ({
current: [] as Entitlement[],
}));

vi.mock('@/features/entitlements/useEntitlements', () => ({
useEntitlements: () => mockEntitlements.current,
}));

vi.mock('next/navigation', () => ({
usePathname: () => '/settings/security',
}));

vi.mock('@/app/(app)/@sidebar/components/upgradeBadge', () => ({
UpgradeBadge: () => <span data-testid="upgrade-badge">Upgrade</span>,
}));

vi.mock('@/hooks/use-mobile', () => ({
useIsMobile: () => false,
}));

const { Nav } = await import('./nav');

// Two nav items: one gated on an entitlement the test is missing
// ('audit'), one ungated (no `requiredEntitlement`). The settings nav
// real entries (`Security` → `audit`, `License` → none) match this
// shape — see packages/web/src/app/(app)/settings/layout.tsx.
const GROUPS = [
{
label: 'Test group',
items: [
{ href: '/settings/audit', title: 'Audit', icon: 'scroll-text' as const, requiredEntitlement: 'audit' as Entitlement },
{ href: '/settings/license', title: 'License', icon: 'key-round' as const },
],
},
];

const renderNav = (isOwner?: boolean) => {
mockEntitlements.current = [];
return render(
<SidebarProvider defaultOpen={true}>
<TooltipProvider>
<Nav groups={GROUPS} isOwner={isOwner} />
</TooltipProvider>
</SidebarProvider>
);
};

const countBadges = (container: HTMLElement) =>
container.querySelectorAll('[data-testid="upgrade-badge"]').length;

describe('settingsSidebar Nav upgrade badge gating (issue #1524)', () => {
test('renders the upgrade badge for an OWNER missing the required entitlement', () => {
// The ungated "License" item should never show a badge; the
// gated "Audit" item should show exactly one when isOwner=true
// and the user is missing the audit entitlement.
const { container } = renderNav(true);
expect(countBadges(container)).toBe(1);
});

test('does NOT render the upgrade badge for a non-owner (MEMBER) user', () => {
// Same fixture, isOwner=false: the only entitlement-gated item
// is suppressed, so the total drops to 0. The "License" item
// was never gated and remains badge-free, so the count is a
// clean 0 — the regression assertion for the bug.
const { container } = renderNav(false);
expect(countBadges(container)).toBe(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,15 @@ export type NavGroup = {

interface NavProps {
groups: NavGroup[];
/**
* Whether the current user is an OWNER in the org. The per-item
* "Upgrade" badge is only meaningful for owners — a MEMBER cannot
* act on the upgrade flow, so we hide the badge unless this is true.
*/
isOwner?: boolean;
}

export function Nav({ groups }: NavProps) {
export function Nav({ groups, isOwner = false }: NavProps) {
const pathname = usePathname();
const entitlements = useEntitlements();

Expand All @@ -85,6 +91,7 @@ export function Nav({ groups }: NavProps) {
: pathname === item.href;

const showUpgradeBadge =
isOwner &&
(item.requiredEntitlement && !entitlements.includes(item.requiredEntitlement));

const Icon = item.icon ? iconMap[item.icon] : undefined;
Expand Down
Loading