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;
+};