Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Closed
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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