Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
-- AlterTable
ALTER TABLE "Org" ADD COLUMN "trialUsedAt" TIMESTAMP(3);

-- CreateTable
CREATE TABLE "License" (
"id" TEXT NOT NULL,
Expand Down
4 changes: 0 additions & 4 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -293,10 +293,6 @@ model Org {
chats Chat[]
license License?
/// Set the first time this instance is seen to be on a trial subscription.
/// Never cleared. Used to gate the "Start trial" CTA in the UI.
trialUsedAt DateTime?
}

model License {
Expand Down
19 changes: 13 additions & 6 deletions packages/shared/src/entitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,12 @@ const getValidOfflineLicense = (): getValidOfflineLicense | null => {
return payload;
}

// If the license hasn't successfully synced with Lighthouse for this long,
// the locally-cached state is no longer trusted. This guards against an
// operator blocking egress to prevent the license row from hearing about
// a canceled or past-due subscription. 7 days absorbs week-long transient
// outages (weekends, firewall rollouts) without punishing legitimate
// customers.
// If the license hasn't successfully synced with Lighthouse for this long,
// the locally-cached state is no longer trusted. This guards against an
// operator blocking egress to prevent the license row from hearing about
// a canceled or past-due subscription. 7 days absorbs week-long transient
// outages (weekends, firewall rollouts) without punishing legitimate
// customers.
export const STALE_ONLINE_LICENSE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;

// Surface a UI warning (banner + "refreshed" timestamp color) when the
Expand All @@ -116,6 +116,13 @@ const getValidOnlineLicense = (_license: License | null): License | null => {
return null;
}

export const isValidLicenseActive = (_license: License | null): boolean => {
return (
getValidOfflineLicense() !== null ||
getValidOnlineLicense(_license) !== null
);
}

export const isAnonymousAccessAvailable = (_license: License | null): boolean => {
const offlineKey = getValidOfflineLicense();
if (offlineKey) {
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
hasEntitlement as _hasEntitlement,
getEntitlements as _getEntitlements,
isAnonymousAccessAvailable as _isAnonymousAccessAvailable,
isValidLicenseActive as _isValidLicenseActive,
getSeatCap,
getOfflineLicenseMetadata,
STALE_ONLINE_LICENSE_THRESHOLD_MS,
Expand Down
1 change: 0 additions & 1 deletion packages/web/src/__mocks__/prisma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export const MOCK_ORG: Org = {
memberApprovalRequired: false,
inviteLinkEnabled: false,
inviteLinkId: null,
trialUsedAt: null,
}

export const MOCK_API_KEY: ApiKey = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,13 @@ import { getConnectionStats } from "@/actions";
import { getOrgAccountRequests } from "@/features/userManagement/actions";
import { isServiceError } from "@/lib/utils";
import { ServiceErrorException } from "@/lib/serviceError";
import { __unsafePrisma } from "@/prisma";
import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
import { OrgRole } from "@prisma/client";
import { SidebarBase } from "@/app/(app)/@sidebar/components/sidebarBase";
import { Nav } from "./nav";
import { ChatHistory } from "./chatHistory";
import { withAuth } from "@/middleware/withAuth";
import { getAuthContext, withAuth } from "@/middleware/withAuth";
import { sew } from "@/middleware/sew";
import { isValidLicenseActive } from "@/lib/entitlements";

const SIDEBAR_CHAT_LIMIT = 30;

Expand All @@ -27,15 +26,13 @@ export async function DefaultSidebar() {
throw new ServiceErrorException(chatHistory);
}

const licenseActive = await isValidLicenseActive();

const authContext = await getAuthContext();
const isOwner = !isServiceError(authContext) && authContext.role === OrgRole.OWNER;

const isSettingsNotificationVisible = await (async () => {
if (!session) {
return false;
}
const membership = await __unsafePrisma.userToOrg.findUnique({
where: { orgId_userId: { orgId: SINGLE_TENANT_ORG_ID, userId: session.user.id } },
select: { role: true },
});
if (membership?.role !== OrgRole.OWNER) {
if (!isOwner) {
return false;
}
const connectionStats = await getConnectionStats();
Expand All @@ -49,6 +46,8 @@ export async function DefaultSidebar() {
<SidebarBase
session={session}
collapsible="icon"
isValidLicenseActive={licenseActive}
isOwner={isOwner}
headerContent={
<Nav
isSettingsNotificationVisible={isSettingsNotificationVisible}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { getSidebarNavGroups } from "@/app/(app)/settings/layout";
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";

export async function SettingsSidebar() {
const session = await auth();
Expand All @@ -14,10 +17,17 @@ export async function SettingsSidebar() {
throw new ServiceErrorException(sidebarNavGroups);
}

const licenseActive = await isValidLicenseActive();

const authContext = await getAuthContext();
const isOwner = !isServiceError(authContext) && authContext.role === OrgRole.OWNER;

return (
<SidebarBase
session={session}
collapsible="none"
isValidLicenseActive={licenseActive}
isOwner={isOwner}
headerContent={<SettingsSidebarHeader />}
>
<Nav groups={sidebarNavGroups} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { useKeymapType } from "@/hooks/useKeymapType";
import { KeymapType } from "@/lib/types";
import { cn } from "@/lib/utils";
import {
ArrowLeftToLineIcon, ArrowRightToLineIcon, ChevronsUpDown, CodeIcon,
ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowUpCircle, ChevronsUpDown, CodeIcon,
Laptop, LogIn, LogOut, Moon, SettingsIcon, Sun, UserIcon
} from "lucide-react";
import { Session } from "next-auth";
Expand All @@ -44,15 +44,18 @@ import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Separator } from "@/components/ui/separator";
import { WhatsNewSidebarButton } from "./whatsNewSidebarButton";
import { UpsellBadge } from "./upsellBadge";

interface SidebarBaseProps {
session: Session | null;
collapsible?: "icon" | "offcanvas" | "none";
headerContent: ReactNode;
children: ReactNode;
isValidLicenseActive: boolean;
isOwner: boolean;
}

export function SidebarBase({ session, collapsible = "icon", headerContent, children }: SidebarBaseProps) {
export function SidebarBase({ session, collapsible = "icon", headerContent, children, isValidLicenseActive, isOwner }: SidebarBaseProps) {
const [isScrolled, setIsScrolled] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -86,6 +89,7 @@ export function SidebarBase({ session, collapsible = "icon", headerContent, chil
{children}
</SidebarContent>
<SidebarFooter className="border-t border-sidebar-border">
{!isValidLicenseActive && isOwner && <UpsellBadge />}
{collapsible !== "none" && <CollapseSidebarButton />}
<WhatsNewSidebarButton />
{session ? (
Expand Down
40 changes: 40 additions & 0 deletions packages/web/src/app/(app)/@sidebar/components/upsellBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
'use client';

import { ArrowUpCircle } from "lucide-react";
import { useState } from "react";
import { UpsellDialog } from "./upsellDialog";
import { useOffers } from "@/ee/features/lighthouse/useOffers";
import { Skeleton } from "@/components/ui/skeleton";

export const UpsellBadge = () => {
const [isDialogOpen, setIsDialogOpen] = useState(false);

const { data: offers, isPending, isError } = useOffers();

if (isPending) {
return (
<Skeleton className="h-6 w-28 mt-1" />
)
}

if (isError) {
return null;
}

const label = offers.trial.eligible ? "Try Enterprise" : "Free plan";
return (
<>
<div className="group-data-[state=collapsed]:hidden px-2 pt-1">
<button
type="button"
onClick={() => setIsDialogOpen(true)}
className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground text-nowrap transition-colors hover:border-foreground hover:text-foreground"
>
<ArrowUpCircle className="h-3.5 w-3.5" />
{label}
</button>
</div>
<UpsellDialog open={isDialogOpen} onOpenChange={setIsDialogOpen} offers={offers}/>
</>
);
}
Loading
Loading