diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts index 97d743990..b6da38fc5 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -25,6 +25,9 @@ const ACTIVE_ONLINE_LICENSE_STATUSES: LicenseStatus[] = [ 'past_due', ]; +// @WARNING: when adding a new entitlement to this list, make sure +// lighthouse/lambda/entitlements.ts is also updated && deployed +// prior to rolling a new Sourcebot version. // eslint-disable-next-line @typescript-eslint/no-unused-vars const ALL_ENTITLEMENTS = [ "search-contexts", @@ -37,6 +40,7 @@ const ALL_ENTITLEMENTS = [ "chat-sharing", "org-management", "oauth", + "ask" ] as const; export type Entitlement = (typeof ALL_ENTITLEMENTS)[number]; diff --git a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.tsx b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.tsx index 775db2bac..d20fe1057 100644 --- a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.tsx @@ -11,6 +11,9 @@ import { HomeView } from "@/hooks/useHomeView"; import { NotificationDot } from "../../../components/notificationDot"; import { useMemo } from "react"; import Link from "next/link"; +import { useEntitlements } from "@/features/entitlements/useEntitlements"; +import { Entitlement } from "@sourcebot/shared"; +import { UpgradeBadge } from "../upgradeBadge"; interface NavItem { title: string; @@ -18,6 +21,7 @@ interface NavItem { icon: LucideIcon; key: string; requiresAuth?: boolean; + requiredEntitlement?: Entitlement; } interface NavProps { @@ -26,8 +30,13 @@ interface NavProps { homeView: HomeView; } -export function Nav({ isSettingsNotificationVisible, isSignedIn, homeView }: NavProps) { +export function Nav({ + isSettingsNotificationVisible, + isSignedIn, + homeView +}: NavProps) { const pathname = usePathname(); + const entitlements = useEntitlements(); const baseItems = useMemo((): NavItem[] => { @@ -42,7 +51,8 @@ export function Nav({ isSettingsNotificationVisible, isSignedIn, homeView }: Nav title: "Ask", href: "/chat", icon: MessageCircleIcon, - key: "chat" + key: "chat", + requiredEntitlement: "ask" } return [ @@ -101,6 +111,10 @@ export function Nav({ isSettingsNotificationVisible, isSignedIn, homeView }: Nav {baseItems.filter((item) => !item.requiresAuth || isSignedIn).map((item) => { const showNotification = (item.key === "settings" && isSettingsNotificationVisible); + + const showUpgradeBadge = + (item.requiredEntitlement && !entitlements.includes(item.requiredEntitlement)); + return ( @@ -109,6 +123,7 @@ export function Nav({ isSettingsNotificationVisible, isSignedIn, homeView }: Nav > {item.title} + {showUpgradeBadge && } {showNotification && } diff --git a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx index 862298a61..1c2cd5857 100644 --- a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx @@ -9,6 +9,8 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar"; +import { useEntitlements } from "@/features/entitlements/useEntitlements"; +import { Entitlement } from "@sourcebot/shared"; import { ChartAreaIcon, KeyRoundIcon, @@ -23,6 +25,7 @@ import { } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { UpgradeBadge } from "../upgradeBadge"; const iconMap = { "link": LinkIcon, @@ -44,6 +47,7 @@ export type NavItem = { title: React.ReactNode; icon?: NavIconName; isNotificationDotVisible?: boolean; + requiredEntitlement?: Entitlement; }; export type NavGroup = { @@ -57,6 +61,7 @@ interface NavProps { export function Nav({ groups }: NavProps) { const pathname = usePathname(); + const entitlements = useEntitlements(); return ( <> @@ -69,6 +74,10 @@ export function Nav({ groups }: NavProps) { const isActive = item.hrefRegex ? new RegExp(item.hrefRegex).test(pathname) : pathname === item.href; + + const showUpgradeBadge = + (item.requiredEntitlement && !entitlements.includes(item.requiredEntitlement)); + const Icon = item.icon ? iconMap[item.icon] : undefined; return ( @@ -76,6 +85,7 @@ export function Nav({ groups }: NavProps) { {Icon && } {item.title} + {showUpgradeBadge && } {item.isNotificationDotVisible && } diff --git a/packages/web/src/app/(app)/@sidebar/components/sidebarBase.tsx b/packages/web/src/app/(app)/@sidebar/components/sidebarBase.tsx index 6ad52bb9e..65ee523c4 100644 --- a/packages/web/src/app/(app)/@sidebar/components/sidebarBase.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/sidebarBase.tsx @@ -31,7 +31,7 @@ import { useKeymapType } from "@/hooks/useKeymapType"; import { KeymapType } from "@/lib/types"; import { cn } from "@/lib/utils"; import { - ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowUpCircle, ChevronsUpDown, CodeIcon, + ArrowLeftToLineIcon, ArrowRightToLineIcon, ChevronsUpDown, CodeIcon, Laptop, LogIn, LogOut, Moon, SettingsIcon, Sun, UserIcon } from "lucide-react"; import { Session } from "next-auth"; @@ -44,7 +44,7 @@ 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"; +import { UpgradeButton } from "./upgradeButton"; interface SidebarBaseProps { session: Session | null; @@ -89,7 +89,7 @@ export function SidebarBase({ session, collapsible = "icon", headerContent, chil {children} - {!isValidLicenseActive && isOwner && } + {!isValidLicenseActive && isOwner && } {collapsible !== "none" && } {session ? ( diff --git a/packages/web/src/app/(app)/@sidebar/components/upgradeBadge.tsx b/packages/web/src/app/(app)/@sidebar/components/upgradeBadge.tsx new file mode 100644 index 000000000..7762797ba --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/components/upgradeBadge.tsx @@ -0,0 +1,11 @@ +import { Badge } from "@/components/ui/badge" + +export const UpgradeBadge = () => { + return ( + + Upgrade + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/(app)/@sidebar/components/upsellBadge.tsx b/packages/web/src/app/(app)/@sidebar/components/upgradeButton.tsx similarity index 67% rename from packages/web/src/app/(app)/@sidebar/components/upsellBadge.tsx rename to packages/web/src/app/(app)/@sidebar/components/upgradeButton.tsx index afc1cf681..d0b367d42 100644 --- a/packages/web/src/app/(app)/@sidebar/components/upsellBadge.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/upgradeButton.tsx @@ -2,11 +2,11 @@ import { ArrowUpCircle } from "lucide-react"; import { useState } from "react"; -import { UpsellDialog } from "./upsellDialog"; +import { UpsellDialog } from "@/ee/features/lighthouse/upsellDialog"; import { useOffers } from "@/ee/features/lighthouse/useOffers"; import { Skeleton } from "@/components/ui/skeleton"; -export const UpsellBadge = () => { +export const UpgradeButton = () => { const [isDialogOpen, setIsDialogOpen] = useState(false); const { data: offers, isPending, isError } = useOffers(); @@ -21,20 +21,20 @@ export const UpsellBadge = () => { return null; } - const label = offers.trial.eligible ? "Try Enterprise" : "Free plan"; + const label = offers.trial.eligible ? "Try Enterprise" : "Upgrade to Enterprise"; return ( <>
- + ); } diff --git a/packages/web/src/app/(app)/@sidebar/components/upsellDialog.tsx b/packages/web/src/app/(app)/@sidebar/components/upsellDialog.tsx deleted file mode 100644 index a4710a0c4..000000000 --- a/packages/web/src/app/(app)/@sidebar/components/upsellDialog.tsx +++ /dev/null @@ -1,245 +0,0 @@ -"use client"; - -import { useCallback, useMemo, useState } from "react"; -import { ArrowUpCircle, CircleCheck, CircleX, ExternalLink } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { LoadingButton } from "@/components/ui/loading-button"; -import { Switch } from "@/components/ui/switch"; -import { cn, formatCurrency, isServiceError } from "@/lib/utils"; -import { createCheckoutSession } from "@/ee/features/lighthouse/actions"; -import { useToast } from "@/components/hooks/use-toast"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { OffersResponse } from "@/ee/features/lighthouse/types"; - -interface FeatureLinkProps { - text: string; - href: string; -} - -function FeatureLink({ text, href }: FeatureLinkProps) { - return ( - - - {text} - - - - ); -} - -interface SupportIconProps { - supported: boolean; -} - -function SupportIcon({ supported }: SupportIconProps) { - const Icon = supported ? CircleCheck : CircleX; - return ( - - - - ); -} - -interface UpsellDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - offers: OffersResponse, -} - -type BillingInterval = "year" | "month"; - -function formatPrice(unitAmount: number, currency: string, interval: BillingInterval): string { - const options = { minimumFractionDigits: 0 }; - if (interval === "year") { - return `${formatCurrency(Math.round(unitAmount / 12), currency, options)} per user/month, annually`; - } - return `${formatCurrency(unitAmount, currency, options)} per user/month`; -} - -export function UpsellDialog({ open, onOpenChange, offers }: UpsellDialogProps) { - const [billingInterval, setBillingInterval] = useState("year"); - const [isCheckoutSessionCreating, setIsCheckoutSessionCreating] = useState(false); - const { toast } = useToast(); - - const enterprisePrice = formatPrice( - billingInterval === "year" ? offers.pricing.annual.unitAmount : offers.pricing.monthly.unitAmount, - billingInterval === "year" ? offers.pricing.annual.currency : offers.pricing.monthly.currency, - billingInterval, - ); - - const handlePrimaryAction = useCallback(() => { - setIsCheckoutSessionCreating(true); - createCheckoutSession({ - requestTrial: offers.trial.eligible, - interval: billingInterval, - }) - .then((response) => { - if (isServiceError(response)) { - toast({ - description: `Failed to start checkout: ${response.message}`, - variant: "destructive", - }); - setIsCheckoutSessionCreating(false); - } else { - window.location.assign(response.url); - } - }) - .catch(() => { - toast({ - description: "Failed to start checkout. Please try again.", - variant: "destructive", - }); - setIsCheckoutSessionCreating(false); - }) - }, [billingInterval, offers.trial.eligible, toast]); - - const { title, description, buttonText } = useMemo(() => { - // trial, no cc - if (offers.trial.eligible && !offers.trial.creditCardRequired) { - return { - title: "Try Sourcebot Enterprise free", - description: `Get full access free for ${offers.trial.durationDays} days. No credit card required.`, - buttonText: "Start free trial" - } - } - // trial, cc - else if ( offers.trial.eligible && offers.trial.creditCardRequired) { - return { - title: "Try Sourcebot Enterprise free", - description: `Get full access free for ${offers.trial.durationDays} days. Card required, cancel anytime.`, - buttonText: "Start free trial" - } - } - // no trial - else { - return { - title: "Your workspace is on the free plan", - description: "Upgrade to unlock more features.", - buttonText: "Upgrade" - } - } - }, [offers.trial.creditCardRequired, offers.trial.durationDays, offers.trial.eligible]); - - return ( - - - - - - {title} - - - {description} - - - - - - - - -
Community
-
Free
-
- - Enterprise -
{enterprisePrice}
-
- setBillingInterval(checked ? "year" : "month")} - className="scale-75 origin-left" - /> - - Annual billing - -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - {buttonText} - - -
-
- ); -} diff --git a/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx b/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx index 43ccb1a87..5a8a92abc 100644 --- a/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx +++ b/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx @@ -5,7 +5,6 @@ import { SearchModeSelector } from "@/app/(app)/components/searchModeSelector"; import { Separator } from "@/components/ui/separator"; import { ChatBox } from "@/features/chat/components/chatBox"; import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolbar"; -import { LoginModal } from "@/app/components/loginModal"; import { NotConfiguredErrorBanner } from "@/features/chat/components/notConfiguredErrorBanner"; import { LanguageModelInfo, RepoSearchScope } from "@/features/chat/types"; import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread"; @@ -29,7 +28,7 @@ export const LandingPage = ({ repoId, isAuthenticated, }: LandingPageProps) => { - const { createNewChatThread, isLoading, loginWall } = useCreateNewChatThread({ isAuthenticated }); + const { createNewChatThread, isLoading } = useCreateNewChatThread(); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); const isChatBoxDisabled = languageModels.length === 0; @@ -77,6 +76,8 @@ export const LandingPage = ({ selectedSearchScopes={selectedSearchScopes} searchContexts={[]} isDisabled={isChatBoxDisabled} + isAuthenticated={isAuthenticated} + isLoginWallEnabled={true} />
@@ -103,13 +104,6 @@ export const LandingPage = ({ )}
- - ) } diff --git a/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx b/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx index 574001e5f..cd1d16b2f 100644 --- a/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx +++ b/packages/web/src/app/(app)/chat/[id]/components/chatThreadPanel.tsx @@ -16,6 +16,7 @@ interface ChatThreadPanelProps { messages: SBChatMessage[]; isOwner: boolean; isAuthenticated: boolean; + isLoginWallEnabled: boolean; chatName?: string; } @@ -26,6 +27,7 @@ export const ChatThreadPanel = ({ messages, isOwner, isAuthenticated, + isLoginWallEnabled, chatName, }: ChatThreadPanelProps) => { // @note: we are guaranteed to have a chatId because this component will only be @@ -74,6 +76,7 @@ export const ChatThreadPanel = ({ onSelectedSearchScopesChange={setSelectedSearchScopes} isOwner={isOwner} isAuthenticated={isAuthenticated} + isLoginWallEnabled={isLoginWallEnabled} chatName={chatName} /> diff --git a/packages/web/src/app/(app)/chat/[id]/page.tsx b/packages/web/src/app/(app)/chat/[id]/page.tsx index 610d4eb47..e31e0acf4 100644 --- a/packages/web/src/app/(app)/chat/[id]/page.tsx +++ b/packages/web/src/app/(app)/chat/[id]/page.tsx @@ -160,6 +160,7 @@ export default async function Page(props: PageProps) { messages={messages} isOwner={isOwner} isAuthenticated={!!session} + isLoginWallEnabled={env.EXPERIMENT_ASK_GH_ENABLED === 'true'} chatName={name ?? undefined} /> diff --git a/packages/web/src/app/(app)/chat/chatLandingPage.tsx b/packages/web/src/app/(app)/chat/chatLandingPage.tsx index 18dc52473..5bd84a3d0 100644 --- a/packages/web/src/app/(app)/chat/chatLandingPage.tsx +++ b/packages/web/src/app/(app)/chat/chatLandingPage.tsx @@ -69,6 +69,7 @@ export async function ChatLandingPage() { repos={allRepos} searchContexts={searchContexts} isAuthenticated={!!session} + isLoginWallEnabled={env.EXPERIMENT_ASK_GH_ENABLED === 'true'} /> diff --git a/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx b/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx index 9d6b92381..d33d2e5b4 100644 --- a/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx +++ b/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx @@ -11,13 +11,13 @@ import { useLocalStorage } from "usehooks-ts"; import { SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY } from "@/features/chat/constants"; import { SearchModeSelector } from "../../components/searchModeSelector"; import { NotConfiguredErrorBanner } from "@/features/chat/components/notConfiguredErrorBanner"; -import { LoginModal } from "@/app/components/loginModal"; interface LandingPageChatBox { languageModels: LanguageModelInfo[]; repos: RepositoryQuery[]; searchContexts: SearchContextQuery[]; isAuthenticated: boolean; + isLoginWallEnabled: boolean; } export const LandingPageChatBox = ({ @@ -25,8 +25,9 @@ export const LandingPageChatBox = ({ repos, searchContexts, isAuthenticated, + isLoginWallEnabled, }: LandingPageChatBox) => { - const { createNewChatThread, isLoading, loginWall } = useCreateNewChatThread({ isAuthenticated }); + const { createNewChatThread, isLoading } = useCreateNewChatThread(); const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage(SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY, [], { initializeWithValue: false }); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); const isChatBoxDisabled = languageModels.length === 0; @@ -36,7 +37,7 @@ export const LandingPageChatBox = ({
{ - createNewChatThread(children, selectedSearchScopes); + createNewChatThread(children); }} className="min-h-[50px]" isRedirecting={isLoading} @@ -44,6 +45,8 @@ export const LandingPageChatBox = ({ selectedSearchScopes={selectedSearchScopes} searchContexts={searchContexts} isDisabled={isChatBoxDisabled} + isAuthenticated={isAuthenticated} + isLoginWallEnabled={isLoginWallEnabled} />
@@ -68,13 +71,6 @@ export const LandingPageChatBox = ({ {isChatBoxDisabled && ( )} - -
) } diff --git a/packages/web/src/app/(app)/layout.tsx b/packages/web/src/app/(app)/layout.tsx index 495086c8a..4f66ac142 100644 --- a/packages/web/src/app/(app)/layout.tsx +++ b/packages/web/src/app/(app)/layout.tsx @@ -32,6 +32,7 @@ import { OrgRole } from "@sourcebot/db"; import { ServiceErrorException } from "@/lib/serviceError"; import { ConnectAccountsCard } from "@/ee/features/sso/components/connectAccountsCard"; import { SidebarProvider } from "@/components/ui/sidebar"; +import { CheckoutReturnHandler } from "@/ee/features/lighthouse/checkoutReturnHandler"; interface LayoutProps { children: React.ReactNode; @@ -192,6 +193,7 @@ export default async function Layout(props: LayoutProps) { {env.EXPERIMENT_ASK_GH_ENABLED !== 'true' && } + ) } \ No newline at end of file diff --git a/packages/web/src/app/(app)/settings/layout.tsx b/packages/web/src/app/(app)/settings/layout.tsx index a03ef575a..96f84eeea 100644 --- a/packages/web/src/app/(app)/settings/layout.tsx +++ b/packages/web/src/app/(app)/settings/layout.tsx @@ -112,6 +112,7 @@ export const getSidebarNavGroups = async () => title: "Analytics", href: `/settings/analytics`, icon: "chart-area" as const, + requiredEntitlement: 'analytics' }, { title: "License", diff --git a/packages/web/src/app/(app)/settings/license/checkoutSuccessModal.tsx b/packages/web/src/app/(app)/settings/license/checkoutSuccessModal.tsx deleted file mode 100644 index 4ac1bc851..000000000 --- a/packages/web/src/app/(app)/settings/license/checkoutSuccessModal.tsx +++ /dev/null @@ -1,156 +0,0 @@ -"use client"; - -import { useCallback, useState } from "react"; -import { useRouter } from "next/navigation"; -import { CheckCircle2 } from "lucide-react"; -import confetti from "canvas-confetti"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { LoadingButton } from "@/components/ui/loading-button"; -import { useToast } from "@/components/hooks/use-toast"; -import { activateLicense } from "@/ee/features/lighthouse/actions"; -import { isServiceError } from "@/lib/utils"; - -const CONFETTI_COLORS = [ - "#ff3b3b", // red - "#ffb800", // amber - "#ffe600", // yellow - "#3ecf8e", // green - "#3b82f6", // blue - "#a855f7", // purple - "#ec4899", // pink -]; - -const rainConfetti = () => { - const duration = 1500; - const end = Date.now() + duration; - const frame = () => { - confetti({ - particleCount: 10, - startVelocity: 20, - ticks: 250, - spread: 360, - gravity: 2.5, - colors: CONFETTI_COLORS, - origin: { x: Math.random(), y: -0.1 }, - }); - if (Date.now() < end) { - requestAnimationFrame(frame); - } - }; - frame(); -}; - -interface CheckoutSuccessModalProps { - userEmail?: string | null; -} - -export function CheckoutSuccessModal({ userEmail }: CheckoutSuccessModalProps) { - const [open, setOpen] = useState(true); - const [activationCode, setActivationCode] = useState(""); - const [isActivating, setIsActivating] = useState(false); - const router = useRouter(); - const { toast } = useToast(); - - const dismiss = useCallback(() => { - setOpen(false); - router.replace("/settings/license"); - }, [router]); - - const handleOpenChange = useCallback((next: boolean) => { - if (!next) { - dismiss(); - } - }, [dismiss]); - - const handleActivate = useCallback(() => { - const code = activationCode.trim(); - if (!code) { - return; - } - - setIsActivating(true); - activateLicense(code) - .then((response) => { - if (isServiceError(response)) { - toast({ - description: `Failed to activate license: ${response.message}`, - variant: "destructive", - }); - return; - } - - toast({ - description: "✅ License activated successfully.", - }); - rainConfetti(); - dismiss(); - }) - .finally(() => { - setIsActivating(false); - }); - }, [activationCode, toast, dismiss]); - - return ( - - - -
- -
- One more step - - Check your email for your activation code, then paste it below to activate your license. - -
-
- - setActivationCode(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleActivate(); - } - }} - disabled={isActivating} - className="font-mono" - /> - {userEmail && ( -

- Sent to {userEmail} -

- )} -
- - Activate license - -

- Didn't get it?{" "} - - Email us - -

-
-
- ); -} diff --git a/packages/web/src/app/(app)/settings/license/page.tsx b/packages/web/src/app/(app)/settings/license/page.tsx index 932495348..79bb989be 100644 --- a/packages/web/src/app/(app)/settings/license/page.tsx +++ b/packages/web/src/app/(app)/settings/license/page.tsx @@ -5,7 +5,6 @@ import { Button } from "@/components/ui/button"; import { ExternalLink } from "lucide-react"; import { redirect } from "next/navigation"; import { ActivationCodeCard } from "./activationCodeCard"; -import { CheckoutSuccessModal } from "./checkoutSuccessModal"; import { OnlineLicenseCard } from "./onlineLicenseCard"; import { OfflineLicenseCard } from "./offlineLicenseCard"; import { RecentInvoicesCard } from "./recentInvoicesCard"; @@ -17,7 +16,7 @@ type LicensePageProps = { searchParams?: Promise>; } & Record; -export default authenticatedPage(async ({ prisma, org, user }, props) => { +export default authenticatedPage(async ({ prisma, org }, props) => { const searchParams = await props.searchParams; if (searchParams?.refresh === 'true') { // Side-trips to the Stripe portal (add PM, manage sub) include @@ -47,8 +46,6 @@ export default authenticatedPage(async ({ prisma, org, user }, const invoicesResult = license ? await getAllInvoices() : null; const invoices = invoicesResult && !isServiceError(invoicesResult) ? invoicesResult : []; - const showCheckoutSuccess = searchParams?.checkout === 'success' && !license; - return (
@@ -74,7 +71,6 @@ export default authenticatedPage(async ({ prisma, org, user }, {license && } {license && } {!offlineLicense && !license && } - {showCheckoutSuccess && }
); }, { diff --git a/packages/web/src/app/components/authMethodSelector.tsx b/packages/web/src/app/components/authMethodSelector.tsx index 638e2bef3..f309e7e04 100644 --- a/packages/web/src/app/components/authMethodSelector.tsx +++ b/packages/web/src/app/components/authMethodSelector.tsx @@ -8,11 +8,10 @@ import { CredentialsForm } from "@/app/login/components/credentialsForm"; import { DividerSet } from "@/app/components/dividerSet"; import { ProviderButton } from "@/app/components/providerButton"; import { AuthSecurityNotice } from "@/app/components/authSecurityNotice"; -import type { IdentityProviderMetadata } from "@/lib/identityProviders"; import Link from "next/link"; +import { useIdentityProviders } from "@/features/auth/useIdentityProviders"; interface AuthMethodSelectorProps { - providers: IdentityProviderMetadata[]; callbackUrl?: string; context: "login" | "signup"; onProviderClick?: (providerId: string) => void; @@ -21,13 +20,14 @@ interface AuthMethodSelectorProps { } export const AuthMethodSelector = ({ - providers, callbackUrl, context, onProviderClick, securityNoticeClosable = false, hideSecurityNotice = false }: AuthMethodSelectorProps) => { + const providers = useIdentityProviders(); + const onSignInWithOauth = useCallback((provider: string) => { // Call the optional analytics callback first onProviderClick?.(provider); diff --git a/packages/web/src/app/invite/page.tsx b/packages/web/src/app/invite/page.tsx index 7bd615a61..b8173dee0 100644 --- a/packages/web/src/app/invite/page.tsx +++ b/packages/web/src/app/invite/page.tsx @@ -6,7 +6,6 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { AuthMethodSelector } from "@/app/components/authMethodSelector"; import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; -import { getIdentityProviderMetadata, IdentityProviderMetadata } from "@/lib/identityProviders"; import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard"; interface InvitePageProps { @@ -29,8 +28,7 @@ export default async function InvitePage(props: InvitePageProps) { const session = await auth(); if (!session) { - const providers = await getIdentityProviderMetadata(); - return ; + return ; } const membership = await __unsafePrisma.userToOrg.findUnique({ @@ -56,7 +54,7 @@ export default async function InvitePage(props: InvitePageProps) { ); } -function WelcomeCard({ inviteLinkId, providers }: { inviteLinkId: string; providers: IdentityProviderMetadata[] }) { +function WelcomeCard({ inviteLinkId }: { inviteLinkId: string; }) { return (
@@ -74,7 +72,6 @@ function WelcomeCard({ inviteLinkId, providers }: { inviteLinkId: string; provid
) { const entitlements = await getEntitlements(); + const identityProviders = await getIdentityProviderMetadata(); return ( - - + - - - - {children} - - - - - + + + + + {children} + + + + + + diff --git a/packages/web/src/app/login/components/loginForm.tsx b/packages/web/src/app/login/components/loginForm.tsx index dac25ba9b..a5c4b2c89 100644 --- a/packages/web/src/app/login/components/loginForm.tsx +++ b/packages/web/src/app/login/components/loginForm.tsx @@ -6,18 +6,16 @@ import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { AuthMethodSelector } from "@/app/components/authMethodSelector"; import useCaptureEvent from "@/hooks/useCaptureEvent"; import Link from "next/link"; -import type { IdentityProviderMetadata } from "@/lib/identityProviders"; interface LoginFormProps { callbackUrl?: string; error?: string; - providers: IdentityProviderMetadata[]; context: "login" | "signup"; isAnonymousAccessEnabled?: boolean; hideSecurityNotice?: boolean; } -export const LoginForm = ({ callbackUrl, error, providers, context, isAnonymousAccessEnabled = false, hideSecurityNotice = false }: LoginFormProps) => { +export const LoginForm = ({ callbackUrl, error, context, isAnonymousAccessEnabled = false, hideSecurityNotice = false }: LoginFormProps) => { const captureEvent = useCaptureEvent(); const safeCallbackUrl = useMemo(() => { @@ -87,7 +85,6 @@ export const LoginForm = ({ callbackUrl, error, providers, context, isAnonymousA
)} => sew(() => + withAuth(async ({ role }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + const result = await client.claimActivationCode({ + sessionId, + installId: env.SOURCEBOT_INSTALL_ID, + }); + + if (isServiceError(result)) { + return result; + } + + return { activationCode: result.activationCode }; + }) + ) +); + export const refreshLicense = async (): Promise<{ success: boolean } | ServiceError> => sew(() => withAuth(async ({ org, role, prisma }) => withMinimumOrgRole(role, OrgRole.OWNER, async () => { @@ -94,10 +111,12 @@ export const refreshLicense = async (): Promise<{ success: boolean } | ServiceEr export const createCheckoutSession = async ({ requestTrial = false, - interval = 'month', + interval = 'year', + returnPath: _returnPath = '/settings/license' }: { requestTrial?: boolean; interval?: 'month' | 'year'; + returnPath?: string; }): Promise<{ url: string } | ServiceError> => sew(() => withAuth(async ({ user, org, role, prisma }) => withMinimumOrgRole(role, OrgRole.OWNER, async () => { @@ -115,16 +134,39 @@ export const createCheckoutSession = async ({ }, }); + // Resolve the candidate against AUTH_URL so absolute paths, protocol- + // relative paths (`//evil.com`), and bare relative paths all get + // normalized through the URL parser. Reject anything that lands off- + // origin or carries its own query / fragment — we own those. + let returnPath: string; + try { + const candidate = new URL(_returnPath, env.AUTH_URL); + const authOrigin = new URL(env.AUTH_URL).origin; + if (candidate.origin !== authOrigin) { + throw new Error('returnPath escapes AUTH_URL origin'); + } + if (candidate.search || candidate.hash) { + throw new Error('returnPath must not include query string or fragment'); + } + returnPath = candidate.pathname; + } catch { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.UNEXPECTED_ERROR, + message: "Invalid returnPath.", + } satisfies ServiceError; + } + const result = await client.checkout({ email: user.email, installId: env.SOURCEBOT_INSTALL_ID, quantity: Math.max(memberCount, 1), requestTrial, interval, - successUrl: requestTrial - ? `${env.AUTH_URL}/settings/license?checkout=success&refresh=true` - : `${env.AUTH_URL}/settings/license?checkout=success&refresh=true`, - cancelUrl: `${env.AUTH_URL}/settings/license?refresh=true`, + // `{CHECKOUT_SESSION_ID}` is substituted server-side by Stripe at + // redirect time with the real session ID. + successUrl: `${env.AUTH_URL}${returnPath}?checkout=success&session_id={CHECKOUT_SESSION_ID}`, + cancelUrl: `${env.AUTH_URL}${returnPath}`, }); if (isServiceError(result)) { diff --git a/packages/web/src/ee/features/lighthouse/checkoutReturnHandler.tsx b/packages/web/src/ee/features/lighthouse/checkoutReturnHandler.tsx new file mode 100644 index 000000000..65143b74c --- /dev/null +++ b/packages/web/src/ee/features/lighthouse/checkoutReturnHandler.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { LicenseActivactionDialog } from "./licenseActivactionDialog"; + +interface PostCheckoutHandlerProps { + userEmail?: string | null; +} + +// Layout-mounted handler that drives the post-Stripe activation flow regardless +// of which page the user lands on after checkout. Detects `session_id` in the +// URL (set by Stripe's substitution of `{CHECKOUT_SESSION_ID}` in successUrl), +// and renders the claim + activate modal when present. +export function CheckoutReturnHandler({ userEmail }: PostCheckoutHandlerProps) { + const searchParams = useSearchParams(); + const sessionId = searchParams.get("session_id"); + + if (!sessionId) { + return null; + } + + return ; +} diff --git a/packages/web/src/ee/features/lighthouse/client.ts b/packages/web/src/ee/features/lighthouse/client.ts index a317ffefd..8fbf12cb8 100644 --- a/packages/web/src/ee/features/lighthouse/client.ts +++ b/packages/web/src/ee/features/lighthouse/client.ts @@ -7,6 +7,9 @@ import { CheckoutRequest, CheckoutResponse, checkoutResponseSchema, + ClaimActivationCodeRequest, + ClaimActivationCodeResponse, + claimActivationCodeResponseSchema, InvoicesRequest, InvoicesResponse, invoicesResponseSchema, @@ -36,6 +39,16 @@ export const client = { return parseResponseBody(response, activateResponseSchema); }, + claimActivationCode: async (body: ClaimActivationCodeRequest): Promise => { + const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/claim-activation-code`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + return parseResponseBody(response, claimActivationCodeResponseSchema); + }, + ping: async (body: ServicePingRequest): Promise => { const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/ping`, { method: 'POST', diff --git a/packages/web/src/ee/features/lighthouse/licenseActivactionDialog.tsx b/packages/web/src/ee/features/lighthouse/licenseActivactionDialog.tsx new file mode 100644 index 000000000..5d4e0a21d --- /dev/null +++ b/packages/web/src/ee/features/lighthouse/licenseActivactionDialog.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { CheckCircle2, Loader2 } from "lucide-react"; +import confetti from "canvas-confetti"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { LoadingButton } from "@/components/ui/loading-button"; +import { useToast } from "@/components/hooks/use-toast"; +import { activateLicense } from "@/ee/features/lighthouse/actions"; +import { useClaimActivationCode } from "@/ee/features/lighthouse/useClaimActivationCode"; +import { isServiceError } from "@/lib/utils"; + +const CONFETTI_COLORS = [ + "#ff3b3b", // red + "#ffb800", // amber + "#ffe600", // yellow + "#3ecf8e", // green + "#3b82f6", // blue + "#a855f7", // purple + "#ec4899", // pink +]; + +const rainConfetti = () => { + const duration = 1500; + const end = Date.now() + duration; + const frame = () => { + confetti({ + particleCount: 10, + startVelocity: 20, + ticks: 250, + spread: 360, + gravity: 2.5, + colors: CONFETTI_COLORS, + origin: { x: Math.random(), y: -0.1 }, + }); + if (Date.now() < end) { + requestAnimationFrame(frame); + } + }; + frame(); +}; + +interface CheckoutSuccessModalProps { + userEmail?: string | null; +} + +export function LicenseActivactionDialog({ userEmail }: CheckoutSuccessModalProps) { + const [open, setOpen] = useState(true); + const [activationCode, setActivationCode] = useState(""); + const [isActivating, setIsActivating] = useState(false); + const router = useRouter(); + const pathname = usePathname(); + const { toast } = useToast(); + + const searchParams = useSearchParams(); + const sessionId = searchParams.get("session_id"); + + const { + status: claimStatus, + activationCode: claimedCode, + attempt: claimAttempt, + start: startClaim, + } = useClaimActivationCode(); + + const dismiss = useCallback(() => { + setOpen(false); + // Strip the Stripe query params from whatever page we're currently on, + // so a refresh doesn't re-trigger the claim flow. + router.replace(pathname); + }, [router, pathname]); + + const handleOpenChange = useCallback((next: boolean) => { + if (!next) { + dismiss(); + } + }, [dismiss]); + + const activate = useCallback((code: string) => { + setIsActivating(true); + activateLicense(code) + .then((response) => { + if (isServiceError(response)) { + toast({ + description: `Failed to activate license: ${response.message}`, + variant: "destructive", + }); + return; + } + + toast({ + description: "✅ License activated successfully.", + }); + rainConfetti(); + // Re-fetch the server-rendered layout so PlanContext picks up the + // newly granted entitlements. Without this, callers like ChatBox + // would keep reading the stale `isAskEnabled === false` and never + // resume the pending submission stashed pre-checkout. + router.refresh(); + dismiss(); + }) + .finally(() => { + setIsActivating(false); + }); + }, [toast, dismiss, router]); + + const handleManualActivate = useCallback(() => { + const code = activationCode.trim(); + if (!code) { + return; + } + activate(code); + }, [activationCode, activate]); + + // Kick off auto-claim polling if Stripe redirected us with a session_id. + useEffect(() => { + if (sessionId) { + startClaim(sessionId); + } + }, [sessionId, startClaim]); + + // When the claim succeeds, populate the input (so the user can see what was + // claimed) and chain straight into activation. + useEffect(() => { + if (claimStatus === "success" && claimedCode) { + setActivationCode(claimedCode); + activate(claimedCode); + } + }, [claimStatus, claimedCode, activate]); + + const isPolling = claimStatus === "polling"; + + return ( + + + +
+ +
+ + {isPolling ? "Finalizing your purchase" : "One more step"} + + + {isPolling + ? claimAttempt > 3 + ? "Almost there. This is taking a little longer than usual." + : "Just a moment while we activate your license." + : "Check your email for your activation code, then paste it below to activate your license."} + +
+ + {isPolling ? ( +
+ +
+ ) : ( + <> +
+ + setActivationCode(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleManualActivate(); + } + }} + disabled={isActivating} + className="font-mono" + /> + {userEmail && ( +

+ Sent to {userEmail} +

+ )} +
+ + Activate license + +

+ Didn't get it?{" "} + + Email us + +

+ + )} +
+
+ ); +} diff --git a/packages/web/src/ee/features/lighthouse/types.ts b/packages/web/src/ee/features/lighthouse/types.ts index 02c77c532..9798a22b4 100644 --- a/packages/web/src/ee/features/lighthouse/types.ts +++ b/packages/web/src/ee/features/lighthouse/types.ts @@ -23,6 +23,17 @@ export const activateResponseSchema = z.object({ }); export type ActivateResponse = z.infer; +export const claimActivationCodeRequestSchema = z.object({ + sessionId: z.string(), + installId: z.string(), +}); +export type ClaimActivationCodeRequest = z.infer; + +export const claimActivationCodeResponseSchema = z.object({ + activationCode: z.string(), +}); +export type ClaimActivationCodeResponse = z.infer; + export const servicePingResponseSchema = z.object({ license: z.object({ entitlements: z.string().array(), diff --git a/packages/web/src/ee/features/lighthouse/upsellDialog.tsx b/packages/web/src/ee/features/lighthouse/upsellDialog.tsx new file mode 100644 index 000000000..aa233d775 --- /dev/null +++ b/packages/web/src/ee/features/lighthouse/upsellDialog.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ArrowUpCircle, CircleCheck, CircleX, ExternalLink, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { LoadingButton } from "@/components/ui/loading-button"; +import { Switch } from "@/components/ui/switch"; +import { cn, formatCurrency, isServiceError } from "@/lib/utils"; +import { createCheckoutSession } from "@/ee/features/lighthouse/actions"; +import { useToast } from "@/components/hooks/use-toast"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { OffersResponse } from "@/ee/features/lighthouse/types"; +import { useOffers } from "@/ee/features/lighthouse/useOffers"; + +interface FeatureLinkProps { + text: string; + href: string; +} + +function FeatureLink({ text, href }: FeatureLinkProps) { + return ( + + + {text} + + + + ); +} + +interface SupportIconProps { + supported: boolean; +} + +function SupportIcon({ supported }: SupportIconProps) { + const Icon = supported ? CircleCheck : CircleX; + return ( + + + + ); +} + +interface UpsellDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + returnPath?: string; +} + +type BillingInterval = "year" | "month"; + +function formatPrice(unitAmount: number, currency: string, interval: BillingInterval): string { + const options = { minimumFractionDigits: 0 }; + if (interval === "year") { + return `${formatCurrency(Math.round(unitAmount / 12), currency, options)} per user/month, annually`; + } + return `${formatCurrency(unitAmount, currency, options)} per user/month`; +} + +export function UpsellDialog({ open, onOpenChange, returnPath }: UpsellDialogProps) { + const { data: offers, isPending, isError } = useOffers(); + const { toast } = useToast(); + + // Surface pricing-fetch failures via a toast and dismiss the dialog. Without + // closing it ourselves, the parent's `open` state would keep us mounted but + // we'd have nothing to render — leaving the user stuck with an invisible + // dialog they can't dismiss. + useEffect(() => { + if (open && isError) { + toast({ + description: "Something went wrong loading pricing. Please try again.", + variant: "destructive", + }); + onOpenChange(false); + } + }, [open, isError, toast, onOpenChange]); + + if (isError) { + return null; + } + + return ( + + + {isPending || !offers ? ( +
+ +
+ ) : ( + + )} +
+
+ ); +} + +interface UpsellDialogContentProps { + offers: OffersResponse; + returnPath?: string; +} + +function UpsellDialogContent({ offers, returnPath }: UpsellDialogContentProps) { + const [billingInterval, setBillingInterval] = useState("year"); + const [isCheckoutSessionCreating, setIsCheckoutSessionCreating] = useState(false); + const { toast } = useToast(); + + const enterprisePrice = formatPrice( + billingInterval === "year" ? offers.pricing.annual.unitAmount : offers.pricing.monthly.unitAmount, + billingInterval === "year" ? offers.pricing.annual.currency : offers.pricing.monthly.currency, + billingInterval, + ); + + const handlePrimaryAction = useCallback(() => { + setIsCheckoutSessionCreating(true); + createCheckoutSession({ + requestTrial: offers.trial.eligible, + interval: billingInterval, + returnPath, + }) + .then((response) => { + if (isServiceError(response)) { + toast({ + description: `Failed to start checkout: ${response.message}`, + variant: "destructive", + }); + setIsCheckoutSessionCreating(false); + } else { + window.location.assign(response.url); + } + }) + .catch(() => { + toast({ + description: "Failed to start checkout. Please try again.", + variant: "destructive", + }); + setIsCheckoutSessionCreating(false); + }) + }, [billingInterval, offers.trial.eligible, returnPath, toast]); + + const { title, description, buttonText } = useMemo(() => { + // trial, no cc + if (offers.trial.eligible && !offers.trial.creditCardRequired) { + return { + title: "Try Sourcebot Enterprise free", + description: `Get full access free for ${offers.trial.durationDays} days. No credit card required.`, + buttonText: "Start free trial" + } + } + // trial, cc + else if ( offers.trial.eligible && offers.trial.creditCardRequired) { + return { + title: "Try Sourcebot Enterprise free", + description: `Get full access free for ${offers.trial.durationDays} days. Card required, cancel anytime.`, + buttonText: "Start free trial" + } + } + // no trial + else { + return { + title: "Your workspace is on the free plan", + description: "Upgrade to unlock more features.", + buttonText: "Upgrade" + } + } + }, [offers.trial.creditCardRequired, offers.trial.durationDays, offers.trial.eligible]); + + return ( + <> + + + + {title} + + + {description} + + + + + + + + +
Community
+
Free
+
+ + Enterprise +
{enterprisePrice}
+
+ setBillingInterval(checked ? "year" : "month")} + className="scale-75 origin-left" + /> + + Annual billing + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + {buttonText} + + + + ); +} diff --git a/packages/web/src/ee/features/lighthouse/useClaimActivationCode.ts b/packages/web/src/ee/features/lighthouse/useClaimActivationCode.ts new file mode 100644 index 000000000..a500e0fc8 --- /dev/null +++ b/packages/web/src/ee/features/lighthouse/useClaimActivationCode.ts @@ -0,0 +1,108 @@ +'use client'; + +import { ServiceError } from '@/lib/serviceError'; +import { isServiceError } from '@/lib/utils'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { claimActivationCode } from './actions'; + +// Lighthouse-side error code that means "the subscription.created webhook hasn't +// minted the activation code yet, retry shortly." Any other lighthouse error +// (session not found, install mismatch, already claimed) is terminal. +// See `lighthouse/lambda/serviceError.ts`. +const ACTIVATION_CODE_NOT_READY = 'ACTIVATION_CODE_NOT_READY'; + +const INITIAL_DELAY_MS = 500; +const MAX_DELAY_MS = 3_000; +const MAX_TOTAL_MS = 15_000; + +type ClaimStatus = 'idle' | 'polling' | 'success' | 'error'; + +export interface UseClaimActivationCodeReturn { + status: ClaimStatus; + activationCode?: string; + error?: ServiceError; + attempt: number; + start: (sessionId: string) => void; + cancel: () => void; +} + +export const useClaimActivationCode = (): UseClaimActivationCodeReturn => { + const [status, setStatus] = useState('idle'); + const [activationCode, setActivationCode] = useState(); + const [error, setError] = useState(); + const [attempt, setAttempt] = useState(0); + + const cancelledRef = useRef(false); + + const cancel = useCallback(() => { + cancelledRef.current = true; + }, []); + + const start = useCallback((sessionId: string) => { + cancelledRef.current = false; + const startedAt = Date.now(); + + setStatus('polling'); + setActivationCode(undefined); + setError(undefined); + setAttempt(0); + + const run = async () => { + let delay = INITIAL_DELAY_MS; + let currentAttempt = 0; + + while (!cancelledRef.current) { + currentAttempt++; + setAttempt(currentAttempt); + + if (Date.now() - startedAt >= MAX_TOTAL_MS) { + setError({ + statusCode: 408, + errorCode: 'CLAIM_POLL_TIMEOUT', + message: 'Timed out waiting for the activation code to be issued.', + }); + setStatus('error'); + return; + } + + const result = await claimActivationCode(sessionId); + if (cancelledRef.current) { + return; + } + + if (!isServiceError(result)) { + setActivationCode(result.activationCode); + setStatus('success'); + return; + } + + if (result.errorCode !== ACTIVATION_CODE_NOT_READY) { + setError(result); + setStatus('error'); + return; + } + + await new Promise((resolve) => setTimeout(resolve, delay)); + delay = Math.min(delay * 2, MAX_DELAY_MS); + } + }; + + void run(); + }, []); + + // Cancel any in-flight polling when the component using this hook unmounts. + useEffect(() => { + return () => { + cancelledRef.current = true; + }; + }, []); + + return { + status, + activationCode, + error, + attempt, + start, + cancel, + }; +}; diff --git a/packages/web/src/features/auth/identityProvidersProvider.tsx b/packages/web/src/features/auth/identityProvidersProvider.tsx new file mode 100644 index 000000000..e0b36e0c8 --- /dev/null +++ b/packages/web/src/features/auth/identityProvidersProvider.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { createContext } from "react"; +import type { IdentityProviderMetadata } from "@/lib/identityProviders"; + +export const IdentityProvidersContext = createContext<{ providers: IdentityProviderMetadata[] }>({ providers: [] }); + +interface IdentityProvidersProviderProps { + children: React.ReactNode; + providers: IdentityProviderMetadata[]; +} + +export const IdentityProvidersProvider = ({ children, providers }: IdentityProvidersProviderProps) => { + return ( + + {children} + + ) +}; diff --git a/packages/web/src/features/auth/useIdentityProviders.ts b/packages/web/src/features/auth/useIdentityProviders.ts new file mode 100644 index 000000000..c0a06382a --- /dev/null +++ b/packages/web/src/features/auth/useIdentityProviders.ts @@ -0,0 +1,9 @@ +'use client'; + +import { useContext } from "react"; +import { IdentityProvidersContext } from "./identityProvidersProvider"; + +export const useIdentityProviders = () => { + const { providers } = useContext(IdentityProvidersContext); + return providers; +} diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts index 6cc585353..c23f2404a 100644 --- a/packages/web/src/features/chat/actions.ts +++ b/packages/web/src/features/chat/actions.ts @@ -8,11 +8,9 @@ import { captureEvent } from "@/lib/posthog"; import { notFound, ServiceError } from "@/lib/serviceError"; import { withAuth, withOptionalAuth } from "@/middleware/withAuth"; import { ChatVisibility, Prisma } from "@sourcebot/db"; -import { env } from "@sourcebot/shared"; import { StatusCodes } from "http-status-codes"; import { SBChatMessage } from "./types"; import { generateChatNameFromMessage, getConfiguredLanguageModels, isChatSharedWithUser, isOwnerOfChat } from "./utils.server"; -import { getIdentityProviderMetadata } from "@/lib/identityProviders"; export const createChat = async ({ source }: { source?: string } = {}) => sew(() => withOptionalAuth(async ({ org, user, prisma }) => { @@ -527,15 +525,3 @@ export const submitFeedback = async ({ return { success: true }; }) ) - -// eslint-disable-next-line authz/require-auth-wrapper -- returns identity provider metadata for the login wall, consulted before auth -export const getAskGhLoginWallData = async () => sew(async () => { - const isEnabled = env.EXPERIMENT_ASK_GH_ENABLED === 'true'; - if (!isEnabled) { - return { isEnabled: false as const, providers: [] }; - } - - const providers = await getIdentityProviderMetadata(); - return { isEnabled: true as const, providers }; -}); - diff --git a/packages/web/src/features/chat/components/chatBox/chatBox.tsx b/packages/web/src/features/chat/components/chatBox/chatBox.tsx index f25ed311d..bd26857ad 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBox.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBox.tsx @@ -21,6 +21,12 @@ import { useSuggestionsData } from "./useSuggestionsData"; import { useToast } from "@/components/hooks/use-toast"; import { SearchContextQuery } from "@/lib/types"; import isEqual from "fast-deep-equal/react"; +import { LoginDialog } from "./loginDialog"; +import { usePathname } from "next/navigation"; +import { PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY } from "@/features/chat/constants"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; +import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; +import { UpsellDialog } from "@/ee/features/lighthouse/upsellDialog"; interface ChatBoxProps { onSubmit: (children: Descendant[], editor: CustomEditor) => void; @@ -33,6 +39,8 @@ interface ChatBoxProps { languageModels: LanguageModelInfo[]; selectedSearchScopes: SearchScope[]; searchContexts: SearchContextQuery[]; + isLoginWallEnabled: boolean; + isAuthenticated: boolean; } const ChatBoxComponent = ({ @@ -43,6 +51,8 @@ const ChatBoxComponent = ({ isRedirecting, isGenerating, isDisabled, + isLoginWallEnabled, + isAuthenticated, languageModels, selectedSearchScopes, searchContexts, @@ -50,6 +60,7 @@ const ChatBoxComponent = ({ const suggestionsBoxRef = useRef(null); const [index, setIndex] = useState(0); const editor = useSlate(); + const captureEvent = useCaptureEvent(); const { suggestionQuery, suggestionMode, range } = useSuggestionModeAndQuery(); const { suggestions, isLoading } = useSuggestionsData({ suggestionMode, @@ -73,6 +84,10 @@ const ChatBoxComponent = ({ languageModels, }); const { toast } = useToast(); + const isAskEnabled = useHasEntitlement('ask'); + const [isLoginDialogOpen, setIsLoginDialogOpen] = useState(false); + const [isUpsellDialogOpen, setIsUpsellDialogOpen] = useState(false); + const pathname = usePathname(); // Reset the index when the suggestion mode changes. useEffect(() => { @@ -146,6 +161,18 @@ const ChatBoxComponent = ({ }, [editor.children, isRedirecting, isGenerating, selectedLanguageModel]) + const { + requiresLogin, + requiresUpgrade + } = useMemo(() => ({ + requiresLogin: isLoginWallEnabled && !isAuthenticated, + requiresUpgrade: !isAskEnabled, + }), [ + isAuthenticated, + isLoginWallEnabled, + isAskEnabled + ]) + const onSubmit = useCallback(() => { if (isSubmitDisabled) { if (isSubmitDisabledReason === "no-language-model-selected") { @@ -158,8 +185,71 @@ const ChatBoxComponent = ({ return; } + if (requiresLogin) { + sessionStorage.setItem( + PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY, + JSON.stringify({ pathname, children: editor.children }), + ); + captureEvent('wa_askgh_login_wall_prompted', {}); + setIsLoginDialogOpen(true); + return; + } + + if (requiresUpgrade) { + sessionStorage.setItem( + PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY, + JSON.stringify({ pathname, children: editor.children }), + ); + setIsUpsellDialogOpen(true); + return; + } + _onSubmit(editor.children, editor); - }, [_onSubmit, editor, isSubmitDisabled, isSubmitDisabledReason, toast]); + }, [ + isSubmitDisabled, + requiresLogin, + requiresUpgrade, + _onSubmit, + editor, + isSubmitDisabledReason, + toast, + pathname, + captureEvent + ]); + + useEffect(() => { + if ( + requiresLogin || + requiresUpgrade + ) { + return; + } + + const stored = sessionStorage.getItem(PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY); + if (!stored) { + return; + } + + try { + const { pathname: storedPathname, children } = JSON.parse(stored) as { pathname: string; children: Descendant[] }; + if (storedPathname !== pathname) { + return; + } + + sessionStorage.removeItem(PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY); + _onSubmit(children, editor); + } catch (error) { + console.error('Failed to restore pending chat submission:', error); + sessionStorage.removeItem(PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY); + } + }, [ + pathname, + editor, + _onSubmit, + requiresLogin, + requiresUpgrade, + isSubmitDisabled + ]); const onInsertSuggestion = useCallback((suggestion: Suggestion) => { switch (suggestion.type) { @@ -272,76 +362,97 @@ const ChatBoxComponent = ({ }, [editor, index, range, preferredSuggestionsBoxPlacement]); return ( -
- -
- {isRedirecting ? ( - - ) : - isGenerating ? ( + <> +
+ +
+ {isRedirecting ? ( - ) : ( - - -
{ - // @hack: When submission is disabled, we still want to issue - // a warning to the user as to why the submission is disabled. - // onSubmit on the Button will not be called because of the - // disabled prop, hence the call here. - if (isSubmitDisabled) { - onSubmit(); - } - }} - > - + ) : ( + + +
{ + // @hack: When submission is disabled, we still want to issue + // a warning to the user as to why the submission is disabled. + // onSubmit on the Button will not be called because of the + // disabled prop, hence the call here. + if (isSubmitDisabled) { + onSubmit(); + } + }} > - - -
-
-
- )} + +
+
+
+ )} +
+ {suggestionMode !== "none" && ( + + )}
- {suggestionMode !== "none" && ( - - )} -
+ { + setIsLoginDialogOpen(open); + if (!open) { + sessionStorage.removeItem(PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY); + } + }} + /> + { + setIsUpsellDialogOpen(open); + if (!open) { + sessionStorage.removeItem(PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY); + } + }} + returnPath={pathname} + /> + ) } diff --git a/packages/web/src/app/components/loginModal.tsx b/packages/web/src/features/chat/components/chatBox/loginDialog.tsx similarity index 69% rename from packages/web/src/app/components/loginModal.tsx rename to packages/web/src/features/chat/components/chatBox/loginDialog.tsx index f1fc67ed2..9fc83d257 100644 --- a/packages/web/src/app/components/loginModal.tsx +++ b/packages/web/src/features/chat/components/chatBox/loginDialog.tsx @@ -8,35 +8,32 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { AuthMethodSelector } from "@/app/components/authMethodSelector"; -import type { IdentityProviderMetadata } from "@/lib/identityProviders"; +import { usePathname } from "next/navigation"; -interface LoginModalProps { +interface LoginDialogProps { isOpen: boolean; onOpenChange: (open: boolean) => void; - providers: IdentityProviderMetadata[]; - callbackUrl: string; } -export const LoginModal = ({ +export const LoginDialog = ({ isOpen, onOpenChange, - providers, - callbackUrl, -}: LoginModalProps) => { +}: LoginDialogProps) => { + const pathname = usePathname(); + return ( - Sign up to continue + Sign in to continue Sign into your account to continue.
diff --git a/packages/web/src/features/chat/components/chatThread/chatThread.tsx b/packages/web/src/features/chat/components/chatThread/chatThread.tsx index f60d281b7..9394e62d0 100644 --- a/packages/web/src/features/chat/components/chatThread/chatThread.tsx +++ b/packages/web/src/features/chat/components/chatThread/chatThread.tsx @@ -28,16 +28,11 @@ import { NotConfiguredErrorBanner } from '../notConfiguredErrorBanner'; import useCaptureEvent from '@/hooks/useCaptureEvent'; import { SignInPromptBanner } from './signInPromptBanner'; import { DuplicateChatDialog } from '@/app/(app)/chat/components/duplicateChatDialog'; -import { LoginModal } from '@/app/components/loginModal'; -import type { IdentityProviderMetadata } from '@/lib/identityProviders'; -import { getAskGhLoginWallData } from '../../actions'; type ChatHistoryState = { scrollOffset?: number; } -const PENDING_MESSAGE_STORAGE_KEY = "askgh_chat_pending_message"; - interface ChatThreadProps { id?: string | undefined; initialMessages?: SBChatMessage[]; @@ -48,7 +43,8 @@ interface ChatThreadProps { selectedSearchScopes: SearchScope[]; onSelectedSearchScopesChange: (items: SearchScope[]) => void; isOwner?: boolean; - isAuthenticated?: boolean; + isAuthenticated: boolean; + isLoginWallEnabled: boolean; chatName?: string; } @@ -62,7 +58,8 @@ export const ChatThread = ({ selectedSearchScopes, onSelectedSearchScopesChange, isOwner = true, - isAuthenticated = false, + isAuthenticated, + isLoginWallEnabled, chatName, }: ChatThreadProps) => { const [isErrorBannerVisible, setIsErrorBannerVisible] = useState(false); @@ -72,9 +69,6 @@ export const ChatThread = ({ const router = useRouter(); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); const [isDuplicateDialogOpen, setIsDuplicateDialogOpen] = useState(false); - const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); - const [loginWallProviders, setLoginWallProviders] = useState([]); - const hasRestoredPendingMessage = useRef(false); const captureEvent = useCaptureEvent(); // Initial state is from attachments that exist in in the chat history. @@ -207,38 +201,6 @@ export const ChatThread = ({ hasSubmittedInputMessage.current = true; }, [inputMessage, scrollToBottom, sendMessage]); - // Restore pending message after OAuth redirect (askgh login wall) - useEffect(() => { - if (!isAuthenticated || !isOwner || hasRestoredPendingMessage.current) { - return; - } - - const stored = sessionStorage.getItem(PENDING_MESSAGE_STORAGE_KEY); - if (!stored) { - return; - } - - hasRestoredPendingMessage.current = true; - sessionStorage.removeItem(PENDING_MESSAGE_STORAGE_KEY); - - try { - const { chatId: storedChatId, children } = JSON.parse(stored) as { chatId: string; children: Descendant[] }; - - // Only restore if we're on the same chat that stored the pending message - if (storedChatId !== chatId) { - return; - } - - const text = slateContentToString(children); - const mentions = getAllMentionElements(children); - const message = createUIMessage(text, mentions.map(({ data }) => data), selectedSearchScopes); - sendMessage(message); - scrollToBottom(); - } catch (error) { - console.error('Failed to restore pending message:', error); - } - }, [isAuthenticated, isOwner, chatId, sendMessage, selectedSearchScopes, scrollToBottom]); - // Track scroll position for history state restoration. useEffect(() => { const scrollElement = scrollRef.current; @@ -305,17 +267,6 @@ export const ChatThread = ({ }, [error]); const onSubmit = useCallback(async (children: Descendant[], editor: CustomEditor) => { - if (!isAuthenticated) { - const result = await getAskGhLoginWallData(); - if (!isServiceError(result) && result.isEnabled) { - captureEvent('wa_askgh_login_wall_prompted', {}); - sessionStorage.setItem(PENDING_MESSAGE_STORAGE_KEY, JSON.stringify({ chatId, children })); - setLoginWallProviders(result.providers); - setIsLoginModalOpen(true); - return; - } - } - const text = slateContentToString(children); const mentions = getAllMentionElements(children); @@ -325,7 +276,7 @@ export const ChatThread = ({ scrollToBottom(); resetEditor(editor); - }, [sendMessage, selectedSearchScopes, isAuthenticated, captureEvent, chatId, scrollToBottom]); + }, [sendMessage, selectedSearchScopes, scrollToBottom]); const onDuplicate = useCallback(async (newName: string): Promise => { if (!defaultChatId) { @@ -437,6 +388,8 @@ export const ChatThread = ({ selectedSearchScopes={selectedSearchScopes} searchContexts={searchContexts} isDisabled={languageModels.length === 0} + isAuthenticated={isAuthenticated} + isLoginWallEnabled={isLoginWallEnabled} />
)}
- - ); } diff --git a/packages/web/src/features/chat/constants.ts b/packages/web/src/features/chat/constants.ts index b84e9d922..1038852c6 100644 --- a/packages/web/src/features/chat/constants.ts +++ b/packages/web/src/features/chat/constants.ts @@ -9,3 +9,4 @@ export const ANSWER_TAG = ''; export const SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY = 'selectedSearchScopes'; export const SET_CHAT_STATE_SESSION_STORAGE_KEY = 'setChatState'; +export const PENDING_CHAT_SUBMISSION_SESSION_STORAGE_KEY = 'pendingChatSubmission'; diff --git a/packages/web/src/features/chat/useCreateNewChatThread.ts b/packages/web/src/features/chat/useCreateNewChatThread.ts index 63ead0249..18a5a58b9 100644 --- a/packages/web/src/features/chat/useCreateNewChatThread.ts +++ b/packages/web/src/features/chat/useCreateNewChatThread.ts @@ -1,39 +1,36 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useState } from "react"; import { Descendant } from "slate"; import { createUIMessage, getAllMentionElements } from "./utils"; import { slateContentToString } from "./utils"; import { useToast } from "@/components/hooks/use-toast"; import { useRouter } from "next/navigation"; -import { createChat, getAskGhLoginWallData } from "./actions"; +import { createChat } from "./actions"; import { isServiceError } from "@/lib/utils"; import { createPathWithQueryParams } from "@/lib/utils"; import { SearchScope, SetChatStatePayload } from "./types"; -import { SET_CHAT_STATE_SESSION_STORAGE_KEY } from "./constants"; +import { SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY, SET_CHAT_STATE_SESSION_STORAGE_KEY } from "./constants"; import { useSessionStorage } from "usehooks-ts"; -import type { IdentityProviderMetadata } from "@/lib/identityProviders"; -import useCaptureEvent from "@/hooks/useCaptureEvent"; -const PENDING_NEW_CHAT_KEY = "askgh_pending_new_chat"; - -interface UseCreateNewChatThreadOptions { - isAuthenticated?: boolean; -} - -export const useCreateNewChatThread = ({ isAuthenticated = false }: UseCreateNewChatThreadOptions = {}) => { +export const useCreateNewChatThread = () => { const [isLoading, setIsLoading] = useState(false); const { toast } = useToast(); const router = useRouter(); const [, setChatState] = useSessionStorage(SET_CHAT_STATE_SESSION_STORAGE_KEY, null); - const [loginWallState, setLoginWallState] = useState<{ isOpen: boolean; providers: IdentityProviderMetadata[] }>({ isOpen: false, providers: [] }); - const hasRestoredPendingMessage = useRef(false); - const captureEvent = useCaptureEvent(); - const doCreateChat = useCallback(async (children: Descendant[], selectedSearchScopes: SearchScope[]) => { + const createNewChatThread = useCallback(async (children: Descendant[], overrideSearchScopes?: SearchScope[]) => { const text = slateContentToString(children); const mentions = getAllMentionElements(children); + let storedScopes: SearchScope[] = []; + try { + const stored = window.localStorage.getItem(SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY); + if (stored) storedScopes = JSON.parse(stored) as SearchScope[]; + } catch { /* fall through to [] */ } + + const selectedSearchScopes = overrideSearchScopes ?? storedScopes; + const inputMessage = createUIMessage(text, mentions.map((mention) => mention.data), selectedSearchScopes); setIsLoading(true); @@ -56,52 +53,8 @@ export const useCreateNewChatThread = ({ isAuthenticated = false }: UseCreateNew router.push(url); }, [router, toast, setChatState]); - const createNewChatThread = useCallback(async (children: Descendant[], selectedSearchScopes: SearchScope[]) => { - if (!isAuthenticated) { - const result = await getAskGhLoginWallData(); - if (!isServiceError(result) && result.isEnabled) { - captureEvent('wa_askgh_login_wall_prompted', {}); - sessionStorage.setItem(PENDING_NEW_CHAT_KEY, JSON.stringify({ children, selectedSearchScopes })); - setLoginWallState({ isOpen: true, providers: result.providers }); - return; - } - } - - doCreateChat(children, selectedSearchScopes); - }, [isAuthenticated, captureEvent, doCreateChat]); - - // Restore pending message after OAuth redirect - useEffect(() => { - if (!isAuthenticated || hasRestoredPendingMessage.current) { - return; - } - - const stored = sessionStorage.getItem(PENDING_NEW_CHAT_KEY); - if (!stored) { - return; - } - - hasRestoredPendingMessage.current = true; - sessionStorage.removeItem(PENDING_NEW_CHAT_KEY); - - try { - const { children, selectedSearchScopes } = JSON.parse(stored) as { - children: Descendant[]; - selectedSearchScopes: SearchScope[]; - }; - doCreateChat(children, selectedSearchScopes); - } catch (error) { - console.error('Failed to restore pending message:', error); - } - }, [isAuthenticated, doCreateChat]); - return { createNewChatThread, isLoading, - loginWall: { - isOpen: loginWallState.isOpen, - providers: loginWallState.providers, - onOpenChange: (open: boolean) => setLoginWallState(prev => ({ ...prev, isOpen: open })), - }, }; } diff --git a/packages/web/src/features/entitlements/useEntitlements.ts b/packages/web/src/features/entitlements/useEntitlements.ts new file mode 100644 index 000000000..f30fc6ede --- /dev/null +++ b/packages/web/src/features/entitlements/useEntitlements.ts @@ -0,0 +1,9 @@ +'use client'; + +import { useContext } from "react"; +import { PlanContext } from "./planProvider"; + +export const useEntitlements = () => { + const { entitlements } = useContext(PlanContext); + return entitlements; +} \ No newline at end of file