diff --git a/web/packages/teleport/src/Main/Main.tsx b/web/packages/teleport/src/Main/Main.tsx index 5ad00a3b80de9..7eed581afa489 100644 --- a/web/packages/teleport/src/Main/Main.tsx +++ b/web/packages/teleport/src/Main/Main.tsx @@ -47,7 +47,7 @@ import { MainContainer } from './MainContainer'; import { OnboardDiscover } from './OnboardDiscover'; import type { BannerType } from 'teleport/components/BannerList/BannerList'; -import type { TeleportFeature } from 'teleport/types'; +import type { LockedFeatures, TeleportFeature } from 'teleport/types'; interface MainProps { initialAlerts?: ClusterAlert[]; @@ -156,7 +156,7 @@ export function Main(props: MainProps) { - + @@ -169,13 +169,47 @@ export function Main(props: MainProps) { ); } -function renderRoutes(features: TeleportFeature[]) { +function renderRoutes( + features: TeleportFeature[], + lockedFeatures: LockedFeatures +) { const routes = []; for (const [index, feature] of features.entries()) { + const isParentLocked = + feature.parent && new feature.parent().isLocked?.(lockedFeatures); + + // remove features with parents locked. + // The parent itself will be rendered if it has a lockedRoute, + // but the children shouldn't be. + if (isParentLocked) { + continue; + } + + // add the route of the 'locked' variants of the features + if (feature.isLocked?.(lockedFeatures)) { + if (!feature.lockedRoute) { + throw new Error('a locked feature without a locked route was found'); + } + + const { path, title, exact, component: Component } = feature.lockedRoute; + routes.push( + + + + + + + + ); + + // return early so we don't add the original route + continue; + } + + // add regular feature routes if (feature.route) { const { path, title, exact, component: Component } = feature.route; - routes.push( @@ -191,9 +225,9 @@ function renderRoutes(features: TeleportFeature[]) { return routes; } -function FeatureRoutes() { +function FeatureRoutes({ lockedFeatures }: { lockedFeatures: LockedFeatures }) { const features = useFeatures(); - const routes = renderRoutes(features); + const routes = renderRoutes(features, lockedFeatures); return {routes}; } diff --git a/web/packages/teleport/src/Navigation/NavigationItem.test.tsx b/web/packages/teleport/src/Navigation/NavigationItem.test.tsx index 92e8cb2c14bda..8817399b3c334 100644 --- a/web/packages/teleport/src/Navigation/NavigationItem.test.tsx +++ b/web/packages/teleport/src/Navigation/NavigationItem.test.tsx @@ -22,10 +22,14 @@ import { generatePath, Router } from 'react-router'; import { createMemoryHistory } from 'history'; +import TeleportContextProvider from 'teleport/TeleportContextProvider'; +import TeleportContext from 'teleport/teleportContext'; + import { TeleportFeature } from 'teleport/types'; import { NavigationCategory } from 'teleport/Navigation/categories'; import { NavigationItem } from 'teleport/Navigation/NavigationItem'; import { NavigationItemSize } from 'teleport/Navigation/common'; +import { makeUserContext } from 'teleport/services/user'; class MockFeature implements TeleportFeature { category = NavigationCategory.Resources; @@ -57,15 +61,25 @@ describe('navigation items', () => { initialEntries: ['/web/cluster/root/feature'], }); + const ctx = new TeleportContext(); + ctx.storeUser.state = makeUserContext({ + cluster: { + name: 'test-cluster', + lastConnected: Date.now(), + }, + }); + render( - - - + + + + + ); expect(screen.getByText('Some Feature').closest('a')).toHaveAttribute( @@ -79,15 +93,25 @@ describe('navigation items', () => { initialEntries: ['/web/cluster/root/feature'], }); + const ctx = new TeleportContext(); + ctx.storeUser.state = makeUserContext({ + cluster: { + name: 'test-cluster', + lastConnected: Date.now(), + }, + }); + render( - - - + + + + + ); expect(screen.getByText('Some Feature').closest('a')).toHaveAttribute( diff --git a/web/packages/teleport/src/Navigation/NavigationItem.tsx b/web/packages/teleport/src/Navigation/NavigationItem.tsx index 5b209c499fde3..78aa108340c7f 100644 --- a/web/packages/teleport/src/Navigation/NavigationItem.tsx +++ b/web/packages/teleport/src/Navigation/NavigationItem.tsx @@ -31,7 +31,12 @@ import { import useStickyClusterId from 'teleport/useStickyClusterId'; -import type { TeleportFeature } from 'teleport/types'; +import { useTeleport } from 'teleport'; + +import type { + TeleportFeature, + TeleportFeatureNavigationItem, +} from 'teleport/types'; interface NavigationItemProps { feature: TeleportFeature; @@ -75,9 +80,11 @@ const ExternalLinkIndicator = styled.div` `; export function NavigationItem(props: NavigationItemProps) { + const ctx = useTeleport(); const { clusterId } = useStickyClusterId(); - const { navigationItem, route } = props.feature; + const { navigationItem, route, isLocked, lockedNavigationItem, lockedRoute } = + props.feature; const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { @@ -178,18 +185,33 @@ export function NavigationItem(props: NavigationItemProps) { ); } + let navigationItemVersion: TeleportFeatureNavigationItem; if (route) { + navigationItemVersion = navigationItem; + } + + // use locked item version if feature is locked + if (lockedRoute && isLocked?.(ctx.lockedFeatures)) { + if (!lockedNavigationItem) { + throw new Error( + 'locked feature without an alternative navigation item' + ); + } + navigationItemVersion = lockedNavigationItem; + } + + if (navigationItemVersion) { return ( {getIcon(props.feature, props.size)} - {navigationItem.title} + {navigationItemVersion.title} ); diff --git a/web/packages/teleport/src/Sessions/useSessions.ts b/web/packages/teleport/src/Sessions/useSessions.ts index 2118dbd7d3285..9b212e1cedca5 100644 --- a/web/packages/teleport/src/Sessions/useSessions.ts +++ b/web/packages/teleport/src/Sessions/useSessions.ts @@ -51,6 +51,6 @@ export default function useSessions(ctx: Ctx, clusterId: string) { return { attempt, sessions, - showActiveSessionsCTA: ctx.ctas.activeSessions, + showActiveSessionsCTA: ctx.lockedFeatures.activeSessions, }; } diff --git a/web/packages/teleport/src/Support/Support.tsx b/web/packages/teleport/src/Support/Support.tsx index 9ccbcff6ede77..83aa12afc3754 100644 --- a/web/packages/teleport/src/Support/Support.tsx +++ b/web/packages/teleport/src/Support/Support.tsx @@ -39,7 +39,7 @@ export default function Container({ isEnterprise={cfg.isEnterprise} tunnelPublicAddress={cfg.tunnelPublicAddress} isCloud={cfg.isCloud} - showPremiumSupportCTA={ctx.ctas.premiumSupport} + showPremiumSupportCTA={ctx.lockedFeatures.premiumSupport} children={children} /> ); diff --git a/web/packages/teleport/src/teleportContext.tsx b/web/packages/teleport/src/teleportContext.tsx index f6ce5e620a38f..5ec6ef4c0c2bc 100644 --- a/web/packages/teleport/src/teleportContext.tsx +++ b/web/packages/teleport/src/teleportContext.tsx @@ -63,13 +63,16 @@ class TeleportContext implements types.Context { automaticUpgradesEnabled = false; agentService = agentService; - // No CTA is currently shown - ctas = { - authConnectors: false, - activeSessions: false, - accessRequests: false, - premiumSupport: false, - trustedDevices: false, + // lockedFeatures are the features disabled in the user's cluster. + // Mainly used to hide features and/or show CTAs when the user cluster doesn't support it. + // TODO(mcbattirola): use cluster features instead of only using `isUsageBasedBilling` + // to determine which feature is locked + lockedFeatures: types.LockedFeatures = { + authConnectors: cfg.isUsageBasedBilling, + activeSessions: cfg.isUsageBasedBilling, + accessRequests: cfg.isUsageBasedBilling, + premiumSupport: cfg.isUsageBasedBilling, + trustedDevices: cfg.isUsageBasedBilling, }; // init fetches data required for initial rendering of components. diff --git a/web/packages/teleport/src/types.ts b/web/packages/teleport/src/types.ts index ceafcc80c815c..cfb0acb8752fd 100644 --- a/web/packages/teleport/src/types.ts +++ b/web/packages/teleport/src/types.ts @@ -28,7 +28,7 @@ export interface Context { getFeatureFlags(): FeatureFlags; } -interface TeleportFeatureNavigationItem { +export interface TeleportFeatureNavigationItem { title: string; icon: React.ReactNode; exact?: boolean; @@ -36,7 +36,7 @@ interface TeleportFeatureNavigationItem { isExternalLink?: boolean; } -interface TeleportFeatureRoute { +export interface TeleportFeatureRoute { title: string; path: string; exact?: boolean; @@ -48,9 +48,20 @@ export interface TeleportFeature { category?: NavigationCategory; section?: ManagementSection; hasAccess(flags: FeatureFlags): boolean; + // route defines react router Route fields. + // This field can be left undefined to indicate + // this feature is a parent to children features + // eg: FeatureAccessRequests is parent to sub features + // FeatureNewAccessRequest and FeatureReviewAccessRequests. + // These childrens will be responsible for routing. route?: TeleportFeatureRoute; navigationItem?: TeleportFeatureNavigationItem; topMenuItem?: TeleportFeatureNavigationItem; + // alternative items to display when the user has permissions (RBAC) + // but the cluster lacks the feature: + isLocked?(lockedFeatures: LockedFeatures): boolean; + lockedNavigationItem?: TeleportFeatureNavigationItem; + lockedRoute?: TeleportFeatureRoute; } export type StickyCluster = { @@ -94,3 +105,12 @@ export interface FeatureFlags { enrollIntegrationsOrPlugins: boolean; enrollIntegrations: boolean; } + +// LockedFeatures are used for determining which features are disabled in the user's cluster. +export type LockedFeatures = { + authConnectors: boolean; + activeSessions: boolean; + accessRequests: boolean; + premiumSupport: boolean; + trustedDevices: boolean; +};