From 44d65fb872d5aa12aa81c483428b57560800880d Mon Sep 17 00:00:00 2001 From: aliang <1098486429@qq.com> Date: Tue, 5 Nov 2024 01:17:49 +0700 Subject: [PATCH] feat(ui): display alerts for invalid licenses (#3351) * feat(ui): display alerts for invalid licenses * update * [autofix.ci] apply automated fixes * update: seats exceeded * update: license banner * [autofix.ci] apply automated fixes * Update ee/tabby-ui/lib/hooks/use-license.ts * Apply suggestions from code review --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Meng Zhang --- .../(dashboard)/components/main-content.tsx | 13 +- .../app/(dashboard)/components/sidebar.tsx | 13 +- ee/tabby-ui/app/(dashboard)/layout.tsx | 13 +- .../providers/components/nav-bar.tsx | 12 +- .../subscription/components/subscription.tsx | 34 +++++- ee/tabby-ui/components/header.tsx | 2 +- ee/tabby-ui/components/license-banner.tsx | 115 ++++++++++++++++++ ee/tabby-ui/components/license-guard.tsx | 105 ++++++++++++---- ee/tabby-ui/components/providers.tsx | 11 +- ee/tabby-ui/lib/hooks/use-license.ts | 56 +++++++++ 10 files changed, 327 insertions(+), 47 deletions(-) create mode 100644 ee/tabby-ui/components/license-banner.tsx diff --git a/ee/tabby-ui/app/(dashboard)/components/main-content.tsx b/ee/tabby-ui/app/(dashboard)/components/main-content.tsx index ab1588b3f96b..df24707bb3f2 100644 --- a/ee/tabby-ui/app/(dashboard)/components/main-content.tsx +++ b/ee/tabby-ui/app/(dashboard)/components/main-content.tsx @@ -3,6 +3,7 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { BANNER_HEIGHT, useShowDemoBanner } from '@/components/demo-banner' import { Header } from '@/components/header' +import { useShowLicenseBanner } from '@/components/license-banner' export default function MainContent({ children @@ -10,10 +11,16 @@ export default function MainContent({ children: React.ReactNode }) { const [isShowDemoBanner] = useShowDemoBanner() + const [isShowLicenseBanner] = useShowLicenseBanner() + const style = + isShowDemoBanner || isShowLicenseBanner + ? { + height: `calc(100vh - ${ + isShowDemoBanner ? BANNER_HEIGHT : '0rem' + } - ${isShowLicenseBanner ? BANNER_HEIGHT : '0rem'})` + } + : { height: '100vh' } - const style = isShowDemoBanner - ? { height: `calc(100vh - ${BANNER_HEIGHT})` } - : { height: '100vh' } return ( <> {/* Wraps right hand side into ScrollArea, making scroll bar consistent across all browsers */} diff --git a/ee/tabby-ui/app/(dashboard)/components/sidebar.tsx b/ee/tabby-ui/app/(dashboard)/components/sidebar.tsx index 9319cb906765..e44e83bcee60 100644 --- a/ee/tabby-ui/app/(dashboard)/components/sidebar.tsx +++ b/ee/tabby-ui/app/(dashboard)/components/sidebar.tsx @@ -25,6 +25,7 @@ import { } from '@/components/ui/icons' import { ScrollArea } from '@/components/ui/scroll-area' import { BANNER_HEIGHT, useShowDemoBanner } from '@/components/demo-banner' +import { useShowLicenseBanner } from '@/components/license-banner' import LoadingWrapper from '@/components/loading-wrapper' export interface SidebarProps { @@ -124,10 +125,16 @@ const menus: Menu[] = [ export default function Sidebar({ children, className }: SidebarProps) { const [{ data, fetching: fetchingMe }] = useMe() - const [isShowDemoBanner] = useShowDemoBanner() const isAdmin = data?.me.isAdmin - const style = isShowDemoBanner - ? { height: `calc(100vh - ${BANNER_HEIGHT})` } + const [isShowDemoBanner] = useShowDemoBanner() + const [isShowLicenseBanner] = useShowLicenseBanner() + const showBanner = isShowDemoBanner || isShowLicenseBanner + const style = showBanner + ? { + height: `calc(100vh - ${isShowDemoBanner ? BANNER_HEIGHT : '0rem'} - ${ + isShowLicenseBanner ? BANNER_HEIGHT : '0rem' + })` + } : { height: '100vh' } return ( diff --git a/ee/tabby-ui/app/(dashboard)/layout.tsx b/ee/tabby-ui/app/(dashboard)/layout.tsx index 391b2af67fc0..af0feb7b041c 100644 --- a/ee/tabby-ui/app/(dashboard)/layout.tsx +++ b/ee/tabby-ui/app/(dashboard)/layout.tsx @@ -1,5 +1,7 @@ import { Metadata } from 'next' +import { LicenseBanner } from '@/components/license-banner' + import MainContent from './components/main-content' import Sidebar from './components/sidebar' @@ -16,9 +18,12 @@ export default function RootLayout({ children: React.ReactNode }) { return ( -
- - {children} -
+ <> + +
+ + {children} +
+ ) } diff --git a/ee/tabby-ui/app/(dashboard)/settings/(integrations)/providers/components/nav-bar.tsx b/ee/tabby-ui/app/(dashboard)/settings/(integrations)/providers/components/nav-bar.tsx index f73175885d48..1ee26ebdcc07 100644 --- a/ee/tabby-ui/app/(dashboard)/settings/(integrations)/providers/components/nav-bar.tsx +++ b/ee/tabby-ui/app/(dashboard)/settings/(integrations)/providers/components/nav-bar.tsx @@ -7,7 +7,8 @@ import { cva } from 'class-variance-authority' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' -import { BANNER_HEIGHT, useShowDemoBanner } from '@/components/demo-banner' +import { useShowDemoBanner } from '@/components/demo-banner' +import { useShowLicenseBanner } from '@/components/license-banner' import { PROVIDER_KIND_METAS } from '../constants' @@ -33,9 +34,12 @@ interface SidebarButtonProps { export default function NavBar({ className }: { className?: string }) { const [isShowDemoBanner] = useShowDemoBanner() - - const style = isShowDemoBanner - ? { height: `calc(100vh - ${BANNER_HEIGHT} - 4rem)` } + const [isShowLicenseBanner] = useShowLicenseBanner() + const showBanner = isShowDemoBanner || isShowLicenseBanner + const bannerHeight = + isShowDemoBanner && isShowLicenseBanner ? '7rem' : '3.5rem' + const style = showBanner + ? { height: `calc(100vh - ${bannerHeight} - 4rem)` } : { height: 'calc(100vh - 4rem)' } return ( diff --git a/ee/tabby-ui/app/(dashboard)/settings/subscription/components/subscription.tsx b/ee/tabby-ui/app/(dashboard)/settings/subscription/components/subscription.tsx index 692da3443184..2c476544a9e4 100644 --- a/ee/tabby-ui/app/(dashboard)/settings/subscription/components/subscription.tsx +++ b/ee/tabby-ui/app/(dashboard)/settings/subscription/components/subscription.tsx @@ -4,7 +4,10 @@ import { capitalize } from 'lodash-es' import moment from 'moment' import { LicenseInfo, LicenseType } from '@/lib/gql/generates/graphql' -import { useLicense } from '@/lib/hooks/use-license' +import { useLicense, useLicenseValidity } from '@/lib/hooks/use-license' +import useRouterStuff from '@/lib/hooks/use-router-stuff' +import { Badge } from '@/components/ui/badge' +import { IconAlertTriangle } from '@/components/ui/icons' import { Skeleton } from '@/components/ui/skeleton' import LoadingWrapper from '@/components/loading-wrapper' import { SubHeader } from '@/components/sub-header' @@ -13,9 +16,17 @@ import { LicenseForm } from './license-form' import { LicenseTable } from './license-table' export default function Subscription() { + const { updateUrlComponents } = useRouterStuff() const [{ data, fetching }, reexecuteQuery] = useLicense() const license = data?.license const onUploadLicenseSuccess = () => { + // FIXME for testing + updateUrlComponents({ + searchParams: { + del: ['licenseExpired', 'seatsExceeded'] + } + }) + reexecuteQuery() } const canReset = !!license?.type && license.type !== LicenseType.Community @@ -50,6 +61,7 @@ export default function Subscription() { } function License({ license }: { license: LicenseInfo }) { + const { isExpired, isSeatsExceeded } = useLicenseValidity() const expiresAt = license.expiresAt ? moment(license.expiresAt).format('MM/DD/YYYY') : '–' @@ -60,11 +72,27 @@ function License({ license }: { license: LicenseInfo }) {
Expires at
-
{expiresAt}
+
+ {expiresAt} + {isExpired && ( + + + Expired + + )} +
Assigned / Total Seats
-
{seatsText}
+
+ {seatsText} + {isSeatsExceeded && ( + + + Seats exceeded + + )} +
Current plan
diff --git a/ee/tabby-ui/components/header.tsx b/ee/tabby-ui/components/header.tsx index b89f1fdfae38..1b6fcfd88c39 100644 --- a/ee/tabby-ui/components/header.tsx +++ b/ee/tabby-ui/components/header.tsx @@ -22,7 +22,7 @@ export function Header() { return (
-
+
{newVersionAvailable && ( > +} + +const ShowLicenseBannerContext = + React.createContext( + {} as ShowLicenseBannerContextValue + ) + +export const ShowLicenseBannerProvider = ({ + children +}: { + children: React.ReactNode +}) => { + const { isExpired, isSeatsExceeded, isLicenseOK } = useLicenseValidity() + + const [isShowLicenseBanner, setIsShowLicenseBanner] = React.useState(false) + + React.useEffect(() => { + const isInIframe = window.self !== window.top + if (isInIframe) return + + if (isExpired || isSeatsExceeded) { + setIsShowLicenseBanner(true) + } else if (isLicenseOK) { + setIsShowLicenseBanner(false) + } + }, [isLicenseOK, isExpired, isSeatsExceeded]) + + return ( + + {children} + + ) +} + +export function useShowLicenseBanner(): [ + boolean, + React.Dispatch> +] { + const { isShowLicenseBanner, setIsShowLicenseBanner } = React.useContext( + ShowLicenseBannerContext + ) + return [isShowLicenseBanner, setIsShowLicenseBanner] +} + +export function LicenseBanner() { + const [isShowLicenseBanner, setIsShowLicenseBanner] = useShowLicenseBanner() + const { isExpired, isSeatsExceeded } = useLicenseValidity() + const pathname = usePathname() + const style = isShowLicenseBanner ? { height: BANNER_HEIGHT } : { height: 0 } + + const tips = useMemo(() => { + if (isExpired) { + return 'Your subscription is expired.' + } + + if (isSeatsExceeded) { + return 'You have more active users than seats included in your subscription.' + } + + return 'No valid license configured' + }, [isExpired, isSeatsExceeded]) + + return ( +
+
+ + {tips} +
+ +
+ {pathname !== '/settings/subscription' && ( + + See more + + )} + + setIsShowLicenseBanner(false)} + /> +
+
+ ) +} diff --git a/ee/tabby-ui/components/license-guard.tsx b/ee/tabby-ui/components/license-guard.tsx index 5da745e80c59..a41b8a99f170 100644 --- a/ee/tabby-ui/components/license-guard.tsx +++ b/ee/tabby-ui/components/license-guard.tsx @@ -2,12 +2,12 @@ import * as React from 'react' import Link from 'next/link' import { capitalize } from 'lodash-es' +import { GetLicenseInfoQuery, LicenseType } from '@/lib/gql/generates/graphql' import { - GetLicenseInfoQuery, - LicenseStatus, - LicenseType -} from '@/lib/gql/generates/graphql' -import { useLicenseInfo } from '@/lib/hooks/use-license' + useLicenseInfo, + useLicenseValidity, + UseLicenseValidityResponse +} from '@/lib/hooks/use-license' import { cn } from '@/lib/utils' import { buttonVariants } from '@/components/ui/button' import { @@ -17,6 +17,9 @@ import { } from '@/components/ui/hover-card' interface LicenseGuardProps { + /** + * requiredLicenses + */ licenses: LicenseType[] children: (params: { hasValidLicense: boolean @@ -27,35 +30,21 @@ interface LicenseGuardProps { const LicenseGuard: React.FC = ({ licenses, children }) => { const [open, setOpen] = React.useState(false) const license = useLicenseInfo() - const hasValidLicense = - !!license && - license.status === LicenseStatus.Ok && - licenses.includes(license.type) + + const licenseValidity = useLicenseValidity({ licenses }) + const { isLicenseOK, hasSufficientLicense } = licenseValidity + + const hasValidLicense = hasSufficientLicense && isLicenseOK const onOpenChange = (v: boolean) => { if (hasValidLicense) return setOpen(v) } - let licenseString = capitalize(licenses[0]) - let licenseText = licenseString - if (licenses.length == 2) { - licenseText = `${capitalize(licenses[0])} or ${capitalize(licenses[1])}` - } - return ( -
- This feature is only available on Tabby's{' '} - {licenseText} plan. Upgrade to - use this feature. -
-
- - Upgrade to {licenseString} - -
+
= ({ licenses, children }) => { LicenseGuard.displayName = 'LicenseGuard' export { LicenseGuard } + +function LicenseTips({ + hasSufficientLicense, + isExpired, + isSeatsExceeded, + licenses +}: UseLicenseValidityResponse & { + licenses: LicenseType[] +}) { + const licenseString = capitalize(licenses[0]) + let insufficientLicenseText = licenseString + if (licenses.length == 2) { + insufficientLicenseText = `${capitalize(licenses[0])} or ${capitalize( + licenses[1] + )}` + } + + // for expired sufficient license + if (hasSufficientLicense && isExpired) { + return ( + <> +
+ Your license has expired. Please update your license to use this + feature. +
+
+ + Update license + +
+ + ) + } + + // for seatsExceeded sufficient license + if (hasSufficientLicense && isSeatsExceeded) { + return ( + <> +
+ Your seat count has exceeded the limit. Please upgrade your license to + continue using this feature. +
+
+ + Upgrade license + +
+ + ) + } + + return ( + <> +
+ This feature is only available on Tabby's{' '} + {insufficientLicenseText} plan. + Upgrade to use this feature. +
+
+ + Upgrade to {licenseString} + +
+ + ) +} diff --git a/ee/tabby-ui/components/providers.tsx b/ee/tabby-ui/components/providers.tsx index cbf9633badc1..d401fedcd3a0 100644 --- a/ee/tabby-ui/components/providers.tsx +++ b/ee/tabby-ui/components/providers.tsx @@ -12,6 +12,7 @@ import { client } from '@/lib/tabby/gql' import { TooltipProvider } from '@/components/ui/tooltip' import { ShowDemoBannerProvider } from '@/components/demo-banner' +import { ShowLicenseBannerProvider } from './license-banner' import { TopbarProgressProvider } from './topbar-progress-indicator' const publicPaths = ['/chat'] @@ -35,10 +36,12 @@ export function Providers({ children, ...props }: ThemeProviderProps) { - - {!isPublicPath && } - {children} - + + + {!isPublicPath && } + {children} + + diff --git a/ee/tabby-ui/lib/hooks/use-license.ts b/ee/tabby-ui/lib/hooks/use-license.ts index 03c333fa284b..4a0b0f979e93 100644 --- a/ee/tabby-ui/lib/hooks/use-license.ts +++ b/ee/tabby-ui/lib/hooks/use-license.ts @@ -1,6 +1,8 @@ +import { useSearchParams } from 'next/navigation' import { useQuery } from 'urql' import { graphql } from '../gql/generates' +import { LicenseStatus, LicenseType } from '../gql/generates/graphql' const getLicenseInfo = graphql(/* GraphQL */ ` query GetLicenseInfo { @@ -24,4 +26,58 @@ const useLicenseInfo = () => { return data?.license } +type UseLicenseValidityOptions = { + /** + * requiredLicenses + */ + licenses?: LicenseType[] + /** + * Indicates whether the operation permissions for the child component + * are related to the seat count. + * + * If `true`, the component will check if the seat count exceeds the limit + * before allowing the operation. + */ + // isSeatCountRelated?: boolean +} + +export type UseLicenseValidityResponse = ReturnType + +/** + * check the validity of the current license + */ +export const useLicenseValidity = (options?: UseLicenseValidityOptions) => { + const [{ data }] = useLicense() + const licenseInfo = data?.license + const searchParams = useSearchParams() + + const hasLicense = !!licenseInfo + + // Determine if the current license has sufficient level + const hasSufficientLicense = + !!licenseInfo && + (!options?.licenses?.length || options.licenses.includes(licenseInfo.type)) + + const isLicenseOK = licenseInfo?.status === LicenseStatus.Ok + + // Determine if the current license is expired + const isExpired = licenseInfo?.status === LicenseStatus.Expired + + // Determine if the seat count is exceeded + const isSeatsExceeded = licenseInfo?.status === LicenseStatus?.SeatsExceeded + + // Testing parameters from searchParams + const isTestExpired = searchParams.get('licenseError') === 'expired' + const isTestSeatsExceeded = searchParams.get('licenseError') === 'seatsExceed' + + return { + hasLicense, + // FIXME introduced testing searchParams + isLicenseOK: isLicenseOK && !(isTestExpired || isTestSeatsExceeded), + isExpired: isExpired || isTestExpired, + isSeatsExceeded: isSeatsExceeded || isTestSeatsExceeded, + hasSufficientLicense + } +} + export { getLicenseInfo, useLicense, useLicenseInfo }