Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 40 additions & 6 deletions web/packages/teleport/src/Main/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -156,7 +156,7 @@ export function Main(props: MainProps) {
<ContentMinWidth>
<Suspense fallback={null}>
<TopBar />
<FeatureRoutes />
<FeatureRoutes lockedFeatures={ctx.lockedFeatures} />
</Suspense>
</ContentMinWidth>
</HorizontalSplit>
Expand All @@ -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(
<Route title={title} key={index} path={path} exact={exact}>
<CatchError>
<Suspense fallback={null}>
<Component />
</Suspense>
</CatchError>
</Route>
);

// 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(
<Route title={title} key={index} path={path} exact={exact}>
<CatchError>
Expand All @@ -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 <Switch>{routes}</Switch>;
}
Expand Down
56 changes: 40 additions & 16 deletions web/packages/teleport/src/Navigation/NavigationItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
<Router history={history}>
<NavigationItem
feature={new MockFeature()}
size={NavigationItemSize.Large}
transitionDelay={100}
visible={true}
/>
</Router>
<TeleportContextProvider ctx={ctx}>
<Router history={history}>
<NavigationItem
feature={new MockFeature()}
size={NavigationItemSize.Large}
transitionDelay={100}
visible={true}
/>
</Router>
</TeleportContextProvider>
);

expect(screen.getByText('Some Feature').closest('a')).toHaveAttribute(
Expand All @@ -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(
<Router history={history}>
<NavigationItem
feature={new MockFeature()}
size={NavigationItemSize.Large}
transitionDelay={100}
visible={true}
/>
</Router>
<TeleportContextProvider ctx={ctx}>
<Router history={history}>
<NavigationItem
feature={new MockFeature()}
size={NavigationItemSize.Large}
transitionDelay={100}
visible={true}
/>
</Router>
</TeleportContextProvider>
);

expect(screen.getByText('Some Feature').closest('a')).toHaveAttribute(
Expand Down
32 changes: 27 additions & 5 deletions web/packages/teleport/src/Navigation/NavigationItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<HTMLDivElement>) => {
Expand Down Expand Up @@ -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 (
<Link
{...linkProps}
onKeyDown={handleKeyDown}
tabIndex={props.visible ? 0 : -1}
to={navigationItem.getLink(clusterId)}
exact={navigationItem.exact}
to={navigationItemVersion.getLink(clusterId)}
exact={navigationItemVersion.exact}
>
<LinkContent size={props.size}>
{getIcon(props.feature, props.size)}
{navigationItem.title}
{navigationItemVersion.title}
</LinkContent>
</Link>
);
Expand Down
2 changes: 1 addition & 1 deletion web/packages/teleport/src/Sessions/useSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,6 @@ export default function useSessions(ctx: Ctx, clusterId: string) {
return {
attempt,
sessions,
showActiveSessionsCTA: ctx.ctas.activeSessions,
showActiveSessionsCTA: ctx.lockedFeatures.activeSessions,
};
}
2 changes: 1 addition & 1 deletion web/packages/teleport/src/Support/Support.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
);
Expand Down
17 changes: 10 additions & 7 deletions web/packages/teleport/src/teleportContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 22 additions & 2 deletions web/packages/teleport/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,15 @@ export interface Context {
getFeatureFlags(): FeatureFlags;
}

interface TeleportFeatureNavigationItem {
export interface TeleportFeatureNavigationItem {
title: string;
icon: React.ReactNode;
exact?: boolean;
getLink?(clusterId: string): string;
isExternalLink?: boolean;
}

interface TeleportFeatureRoute {
export interface TeleportFeatureRoute {
title: string;
path: string;
exact?: boolean;
Expand All @@ -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 = {
Expand Down Expand Up @@ -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;
};