From 1bce4cc998952ca29ede4a875ca794d9e86e13fb Mon Sep 17 00:00:00 2001
From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com>
Date: Fri, 31 Jul 2026 19:02:32 +0530
Subject: [PATCH 1/2] fix(web): hide upgrade prompts from non-owner users in
the sidebar
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The "Upgrade to Pro" button in the sidebar footer and the per-item
"Upgrade" badges in the default and settings sidebars were rendered
to any signed-in user, not just to the org's OWNER. A MEMBER (or a
former owner who lost the role) would see prompts they cannot act on.
The settings pages already gate on OWNER via authenticatedPage, so
the upsell cards inside /settings/security and /settings/audit were
unaffected — only the shared sidebar was missing the check.
Fix: thread an isOwner prop from the sidebar index files through the
Nav and SidebarBase components. defaultSidebar/index.tsx already
computed isOwner for the connection-stats notification dot;
settingsSidebar/index.tsx now does the same. Both UpgradeBadge
rendering in the Nav components and UpgradeButton rendering in
SidebarBase are gated on isOwner.
Issue #1524.
---
CHANGELOG.md | 3 +
.../components/defaultSidebar/index.tsx | 2 +
.../components/defaultSidebar/nav.test.tsx | 98 +++++++++++++++++++
.../components/defaultSidebar/nav.tsx | 10 +-
.../components/settingsSidebar/index.tsx | 11 ++-
.../components/settingsSidebar/nav.tsx | 9 +-
.../(app)/@sidebar/components/sidebarBase.tsx | 11 ++-
7 files changed, 139 insertions(+), 5 deletions(-)
create mode 100644 packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.test.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e358b71e0..6030dafdf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx
index a0ccd6269..724fe59a0 100644
--- a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx
+++ b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx
@@ -59,11 +59,13 @@ export async function DefaultSidebar() {
collapsible="icon"
isValidLicenseActive={licenseActive}
isAskGhEnabled={env.EXPERIMENT_ASK_GH_ENABLED === 'true'}
+ isOwner={isOwner}
headerContent={
}
>
diff --git a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.test.tsx b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.test.tsx
new file mode 100644
index 000000000..f89126301
--- /dev/null
+++ b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.test.tsx
@@ -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: () => Upgrade,
+}));
+
+// 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.
+
+
+
+
+
+ );
+};
+
+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);
+ });
+});
diff --git a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.tsx b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.tsx
index 62d1bbf03..d98c8cb71 100644
--- a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.tsx
+++ b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.tsx
@@ -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();
@@ -121,6 +128,7 @@ export function Nav({
(item.key === "settings" && isSettingsNotificationVisible);
const showUpgradeBadge =
+ isOwner &&
(item.requiredEntitlement && !entitlements.includes(item.requiredEntitlement));
return (
diff --git a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx
index 5fbded676..d6977e77d 100644
--- a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx
+++ b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx
@@ -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() {
@@ -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 (
}
>
-
+
);
}
diff --git a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx
index 9f86c73d0..c34f8c02e 100644
--- a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx
+++ b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx
@@ -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();
@@ -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;
diff --git a/packages/web/src/app/(app)/@sidebar/components/sidebarBase.tsx b/packages/web/src/app/(app)/@sidebar/components/sidebarBase.tsx
index 3633f15dc..2618428f9 100644
--- a/packages/web/src/app/(app)/@sidebar/components/sidebarBase.tsx
+++ b/packages/web/src/app/(app)/@sidebar/components/sidebarBase.tsx
@@ -60,9 +60,16 @@ interface SidebarBaseProps {
children: ReactNode;
isValidLicenseActive: boolean;
isAskGhEnabled: boolean;
+ /**
+ * Whether the current user is an OWNER in the org. The "Upgrade" CTA
+ * in the sidebar footer is only meaningful for owners — a MEMBER
+ * (or an unauthenticated visitor) cannot act on it, so we hide it
+ * unless this is true.
+ */
+ isOwner?: boolean;
}
-export function SidebarBase({ session, collapsible = "icon", headerContent, children, isValidLicenseActive, isAskGhEnabled }: SidebarBaseProps) {
+export function SidebarBase({ session, collapsible = "icon", headerContent, children, isValidLicenseActive, isAskGhEnabled, isOwner = false }: SidebarBaseProps) {
const [isScrolled, setIsScrolled] = useState(false);
const contentRef = useRef(null);
const isMobile = useIsMobile();
@@ -114,7 +121,7 @@ export function SidebarBase({ session, collapsible = "icon", headerContent, chil
{children}
- {!isValidLicenseActive && }
+ {isOwner && !isValidLicenseActive && }
{
(collapsible !== "none" && !isMobile) &&
From 680bfb821096533d14b1152f1ba4a5e1d1426bc3 Mon Sep 17 00:00:00 2001
From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com>
Date: Fri, 31 Jul 2026 19:12:29 +0530
Subject: [PATCH 2/2] test(web): cover settingsSidebar nav and SidebarBase
owner-gating
CodeRabbit nitpick: the original test only covered the defaultSidebar
Nav. Extend the suite to cover the other two owner-gated surfaces
that the fix touches:
- settingsSidebar/nav.test.tsx (2 cases): badge renders for an OWNER
missing the entitlement, hidden for a non-owner. Uses two mock nav
groups (one gated, one ungated) so the test also verifies that the
ungated item is not affected.
- sidebarBase.test.tsx (3 cases): UpgradeButton renders for an OWNER
with no valid license, hidden for a non-owner with no valid
license, hidden for an OWNER with a valid license. The last case
locks in the pre-existing !isValidLicenseActive gate so the
isOwner refactor doesn't accidentally drop it.
The SidebarBase test mocks the heavy client-only dependencies
(theme, keymap, PostHog, signOut, dropdown menu primitives) so the
test only exercises the upgrade CTA gate, which is the only behaviour
this PR changes.
8/8 sidebar tests pass; full suite 1005/1005 (5 new tests added since
the original 1000 baseline; the 7 pre-existing OTel-setup failures
in ee/askmcp and ee/permissionSyncStatus are unchanged by this PR).
Refs #1524.
---
.../components/settingsSidebar/nav.test.tsx | 83 +++++++++++
.../@sidebar/components/sidebarBase.test.tsx | 133 ++++++++++++++++++
2 files changed, 216 insertions(+)
create mode 100644 packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.test.tsx
create mode 100644 packages/web/src/app/(app)/@sidebar/components/sidebarBase.test.tsx
diff --git a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.test.tsx b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.test.tsx
new file mode 100644
index 000000000..969f5ad30
--- /dev/null
+++ b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.test.tsx
@@ -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: () => Upgrade,
+}));
+
+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(
+
+
+
+
+
+ );
+};
+
+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);
+ });
+});
diff --git a/packages/web/src/app/(app)/@sidebar/components/sidebarBase.test.tsx b/packages/web/src/app/(app)/@sidebar/components/sidebarBase.test.tsx
new file mode 100644
index 000000000..ee1b43ca7
--- /dev/null
+++ b/packages/web/src/app/(app)/@sidebar/components/sidebarBase.test.tsx
@@ -0,0 +1,133 @@
+import { describe, expect, test, vi } from 'vitest';
+import { render } from '@testing-library/react';
+import { SidebarProvider } from '@/components/ui/sidebar';
+import type { ReactNode } from 'react';
+
+// The SidebarBase component pulls in a lot of client-only dependencies
+// (theme, keymap, PostHog, signOut, lucide icons). Stub the heavy
+// imports so the test only exercises the upgrade CTA gate, which is
+// the only behaviour this PR changes.
+vi.mock('@/components/ui/dropdown-menu', () => ({
+ DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}>,
+ DropdownMenuContent: ({ children }: { children: ReactNode }) => <>{children}>,
+ DropdownMenuGroup: ({ children }: { children: ReactNode }) => <>{children}>,
+ DropdownMenuItem: ({ children }: { children: ReactNode }) => <>{children}>,
+ DropdownMenuPortal: ({ children }: { children: ReactNode }) => <>{children}>,
+ DropdownMenuRadioGroup: ({ children }: { children: ReactNode }) => <>{children}>,
+ DropdownMenuRadioItem: () => null,
+ DropdownMenuSeparator: () => null,
+ DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}>,
+ DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <>{children}>,
+ DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => <>{children}>,
+ DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}>,
+}));
+
+vi.mock('@/app/components/sourcebotLogo', () => ({
+ SourcebotLogo: () => ,
+}));
+
+vi.mock('@/components/userAvatar', () => ({
+ UserAvatar: () => ,
+}));
+
+vi.mock('@/hooks/use-mobile', () => ({
+ useIsMobile: () => false,
+}));
+
+vi.mock('@/hooks/useKeymapType', () => ({
+ useKeymapType: () => ['default', vi.fn()] as const,
+}));
+
+vi.mock('next-themes', () => ({
+ useTheme: () => ({ theme: 'light', setTheme: vi.fn(), resolvedTheme: 'light' }),
+}));
+
+vi.mock('posthog-js', () => ({
+ default: { capture: vi.fn() },
+}));
+
+vi.mock('next-auth/react', () => ({
+ signOut: vi.fn(),
+}));
+
+vi.mock('./whatsNewSidebarButton', () => ({
+ WhatsNewSidebarButton: () => null,
+}));
+
+vi.mock('./bookACallSidebarButton', () => ({
+ BookACallSidebarButton: () => null,
+}));
+
+// The UpgradeButton component itself makes a fetch call to offers.
+// Stub the inner button to just render a recognisable marker so the
+// test can assert on its presence vs absence.
+vi.mock('./upgradeButton', () => ({
+ UpgradeButton: () => ,
+}));
+
+vi.mock('lucide-react', () => ({
+ ArrowLeftToLineIcon: () => null,
+ ArrowRightToLineIcon: () => null,
+ ChevronsUpDown: () => null,
+ CodeIcon: () => null,
+ Laptop: () => null,
+ LogIn: () => null,
+ LogOut: () => null,
+ Menu: () => null,
+ Moon: () => null,
+ SettingsIcon: () => null,
+ Sun: () => null,
+ UserIcon: () => null,
+}));
+
+vi.mock('@/app/components/keyboardShortcutHint', () => ({
+ KeyboardShortcutHint: () => null,
+}));
+
+const { SidebarBase } = await import('./sidebarBase');
+
+const renderSidebarBase = (opts: { isOwner: boolean; isValidLicenseActive: boolean }) => {
+ return render(
+
+ header}
+ >
+
child
+
+
+ );
+};
+
+const hasUpgradeButton = (container: HTMLElement) =>
+ container.querySelectorAll('[data-testid="upgrade-button"]').length > 0;
+
+describe('SidebarBase UpgradeButton gating (issue #1524)', () => {
+ test('renders the UpgradeButton for an OWNER when no license is active', () => {
+ // Owner + invalid license → button shows. This is the existing
+ // happy path the bug is preserving; the assertion makes sure
+ // the gate didn't accidentally hide it for owners too.
+ const { container } = renderSidebarBase({ isOwner: true, isValidLicenseActive: false });
+ expect(hasUpgradeButton(container)).toBe(true);
+ });
+
+ test('does NOT render the UpgradeButton for a non-owner (MEMBER) even when no license is active', () => {
+ // Member + invalid license → button hidden. This is the
+ // regression test for the bug. The "no license" condition is
+ // still true, so the only thing that suppresses the button is
+ // the new isOwner gate.
+ const { container } = renderSidebarBase({ isOwner: false, isValidLicenseActive: false });
+ expect(hasUpgradeButton(container)).toBe(false);
+ });
+
+ test('does NOT render the UpgradeButton for an OWNER when a license is already active', () => {
+ // Owner + valid license → button hidden. This is the existing
+ // gate (was `!isValidLicenseActive`); adding it to the suite
+ // makes sure the isOwner refactor didn't break it.
+ const { container } = renderSidebarBase({ isOwner: true, isValidLicenseActive: true });
+ expect(hasUpgradeButton(container)).toBe(false);
+ });
+});