Web: make SelectResource.tsx component leaner (#51698)

* Web: Move resource specs into resource directory

* Web: move code out into separate files
This commit is contained in:
Lisa Kim
2025-02-01 00:18:19 +00:00
committed by GitHub
parent 4d4a406f5a
commit 2004893be0
13 changed files with 660 additions and 537 deletions
@@ -25,12 +25,10 @@ import cfg from 'teleport/config';
import { Discover, DiscoverComponent } from 'teleport/Discover/Discover';
import { ResourceViewConfig } from 'teleport/Discover/flow';
import {
APPLICATIONS,
DATABASES,
DATABASES_UNGUIDED,
DATABASES_UNGUIDED_DOC,
} from 'teleport/Discover/SelectResource/databases';
import {
APPLICATIONS,
KUBERNETES,
SERVERS,
} from 'teleport/Discover/SelectResource/resources';
@@ -30,7 +30,7 @@ import {
IntegrationStatusCode,
} from 'teleport/services/integrations';
import { DATABASES } from '../SelectResource/databases';
import { DATABASES } from '../SelectResource/resources';
import { ResourceKind } from '../Shared';
import { TeleportProvider } from './fixtures';
@@ -37,12 +37,10 @@ import * as userUserContext from 'teleport/User/UserContext';
import { ResourceKind } from '../Shared';
import { resourceKindToPreferredResource } from '../Shared/ResourceKind';
import {
filterResources,
SelectResource,
sortResources,
} from './SelectResource';
import { SelectResource } from './SelectResource';
import { ResourceSpec } from './types';
import { filterBySupportedPlatformsAndAuthTypes } from './utils/filters';
import { sortResourcesByPreferences } from './utils/sort';
const setUp = () => {
jest
@@ -85,7 +83,7 @@ const onboardDiscoverNoResources: OnboardDiscover = {
hasVisited: false,
};
test('sortResources without preferred resources, sorts resources alphabetically with guided resources first', () => {
test('sortResourcesByPreferences without preferred resources, sorts resources alphabetically with guided resources first', () => {
setUp();
const mockIn: ResourceSpec[] = [
// unguided
@@ -99,7 +97,7 @@ test('sortResources without preferred resources, sorts resources alphabetically
makeResourceSpec({ name: 'costco' }),
];
const actual = sortResources(
const actual = sortResourcesByPreferences(
mockIn,
makeDefaultUserPreferences(),
onboardDiscoverWithResources
@@ -358,7 +356,7 @@ describe('preferred resources', () => {
test.each(testCases)('$name', testCase => {
const preferences = makeDefaultUserPreferences();
preferences.onboard.preferredResources = testCase.preferred;
const actual = sortResources(
const actual = sortResourcesByPreferences(
kindBasedList,
preferences,
onboardDiscoverWithResources
@@ -563,7 +561,7 @@ describe('marketing params', () => {
test.each(testCases)('$name', testCase => {
const preferences = makeDefaultUserPreferences();
preferences.onboard = testCase.preferred;
const actual = sortResources(
const actual = sortResourcesByPreferences(
kindBasedList,
preferences,
onboardDiscoverWithResources
@@ -707,7 +705,7 @@ describe('os sorted resources', () => {
test.each(testCases)('$name', testCase => {
OS.mockReturnValue(testCase.userAgent);
const actual = sortResources(
const actual = sortResourcesByPreferences(
osBasedList,
makeDefaultUserPreferences(),
onboardDiscoverWithResources
@@ -726,7 +724,7 @@ describe('os sorted resources', () => {
];
OS.mockReturnValue(UserAgent.macOS);
const actual = sortResources(
const actual = sortResourcesByPreferences(
mockIn,
makeDefaultUserPreferences(),
onboardDiscoverWithResources
@@ -773,7 +771,7 @@ describe('os sorted resources', () => {
},
};
const actual = sortResources(
const actual = sortResourcesByPreferences(
oneOfEachList,
preferences,
onboardDiscoverWithResources
@@ -853,7 +851,7 @@ describe('sorting Connect My Computer', () => {
it('puts the Connect My Computer resource as the first resource if the user has no preferences', () => {
OS.mockReturnValue(UserAgent.macOS);
const actual = sortResources(
const actual = sortResourcesByPreferences(
oneOfEachList,
makeDefaultUserPreferences(),
onboardDiscoverNoResources
@@ -892,7 +890,7 @@ describe('sorting Connect My Computer', () => {
},
};
const actual = sortResources(
const actual = sortResourcesByPreferences(
oneOfEachList,
preferences,
onboardDiscoverNoResources
@@ -935,7 +933,7 @@ describe('sorting Connect My Computer', () => {
platform: Platform.Linux,
});
const actual = sortResources(
const actual = sortResourcesByPreferences(
[
unguidedA,
guidedServerForMatchingPlatformB,
@@ -988,7 +986,7 @@ describe('sorting Connect My Computer', () => {
},
};
const actual = sortResources(
const actual = sortResourcesByPreferences(
[
unguidedA,
guidedServerForMatchingPlatformB,
@@ -1014,7 +1012,7 @@ describe('sorting Connect My Computer', () => {
it('puts the Connect My Computer resource as the last guided resource if the user has resources', () => {
OS.mockReturnValue(UserAgent.macOS);
const actual = sortResources(
const actual = sortResourcesByPreferences(
oneOfEachList,
makeDefaultUserPreferences(),
onboardDiscoverWithResources
@@ -1053,7 +1051,7 @@ describe('sorting Connect My Computer', () => {
},
};
const actual = sortResources(
const actual = sortResourcesByPreferences(
oneOfEachList,
preferences,
onboardDiscoverWithResources
@@ -1099,7 +1097,7 @@ describe('sorting Connect My Computer', () => {
},
};
const actual = sortResources(
const actual = sortResourcesByPreferences(
[...oneOfEachList, databaseForAnotherPlatform],
preferences,
onboardDiscoverNoResources
@@ -1195,12 +1193,11 @@ describe('filterResources', () => {
supportedPlatforms: [Platform.macOS],
});
const result = filterResources(Platform.macOS, 'local', [
winAndLinux,
win,
macosAndLinux,
macos,
]);
const result = filterBySupportedPlatformsAndAuthTypes(
Platform.macOS,
'local',
[winAndLinux, win, macosAndLinux, macos]
);
expect(result).toContain(macosAndLinux);
expect(result).toContain(macos);
@@ -1209,24 +1206,28 @@ describe('filterResources', () => {
});
it('does not filter out resources with supportedPlatforms and supportedAuthTypes that are missing or empty', () => {
const result = filterResources(Platform.macOS, 'local', [
makeResourceSpec({
name: 'Empty supportedPlatforms',
supportedPlatforms: [],
}),
makeResourceSpec({
name: 'Missing supportedPlatforms',
supportedPlatforms: undefined,
}),
makeResourceSpec({
name: 'Empty supportedAuthTypes',
supportedAuthTypes: [],
}),
makeResourceSpec({
name: 'Missing supportedAuthTypes',
supportedAuthTypes: undefined,
}),
]);
const result = filterBySupportedPlatformsAndAuthTypes(
Platform.macOS,
'local',
[
makeResourceSpec({
name: 'Empty supportedPlatforms',
supportedPlatforms: [],
}),
makeResourceSpec({
name: 'Missing supportedPlatforms',
supportedPlatforms: undefined,
}),
makeResourceSpec({
name: 'Empty supportedAuthTypes',
supportedAuthTypes: [],
}),
makeResourceSpec({
name: 'Missing supportedAuthTypes',
supportedAuthTypes: undefined,
}),
]
);
expect(result).toHaveLength(4);
});
@@ -1249,12 +1250,11 @@ describe('filterResources', () => {
supportedAuthTypes: ['local'],
});
const result = filterResources(Platform.macOS, 'local', [
ssoAndPasswordless,
sso,
localAndPasswordless,
local,
]);
const result = filterBySupportedPlatformsAndAuthTypes(
Platform.macOS,
'local',
[ssoAndPasswordless, sso, localAndPasswordless, local]
);
expect(result).toContain(localAndPasswordless);
expect(result).toContain(local);
@@ -16,49 +16,29 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
useEffect,
useMemo,
useState,
type ComponentPropsWithoutRef,
} from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useHistory, useLocation } from 'react-router';
import styled from 'styled-components';
import { Alert, Box, Flex, Link, P3, Text } from 'design';
import * as Icons from 'design/Icon';
import { NewTab } from 'design/Icon';
import { getPlatform, Platform } from 'design/platform';
import { Resource } from 'gen-proto-ts/teleport/userpreferences/v1/onboard_pb';
import { UserPreferences } from 'gen-proto-ts/teleport/userpreferences/v1/userpreferences_pb';
import { getPlatform } from 'design/platform';
import AddApp from 'teleport/Apps/AddApp';
import { FeatureHeader, FeatureHeaderTitle } from 'teleport/components/Layout';
import { ToolTipNoPermBadge } from 'teleport/components/ToolTipNoPermBadge';
import cfg from 'teleport/config';
import {
BASE_RESOURCES,
getResourcePretitle,
} from 'teleport/Discover/SelectResource/resources';
import {
HeaderSubtitle,
PermissionsErrorMessage,
ResourceKind,
} from 'teleport/Discover/Shared';
import { resourceKindToPreferredResource } from 'teleport/Discover/Shared/ResourceKind';
import { BASE_RESOURCES } from 'teleport/Discover/SelectResource/resources';
import { HeaderSubtitle } from 'teleport/Discover/Shared';
import { storageService } from 'teleport/services/storageService';
import { Acl, AuthType, OnboardDiscover } from 'teleport/services/user';
import { useUser } from 'teleport/User/UserContext';
import useTeleport from 'teleport/useTeleport';
import { getMarketingTermMatches } from './getMarketingTermMatches';
import { DiscoverIcon } from './icons';
import { SAML_APPLICATIONS } from './resourcesE';
import {
PrioritizedResources,
SearchResource,
type ResourceSpec,
} from './types';
import { SAML_APPLICATIONS } from './resources';
import { Tile } from './Tile';
import { SearchResource, type ResourceSpec } from './types';
import { addHasAccessField } from './utils/checkAccess';
import { filterBySupportedPlatformsAndAuthTypes } from './utils/filters';
import { sortResourcesByKind, sortResourcesByPreferences } from './utils/sort';
interface SelectResourceProps {
onSelect: (resource: ResourceSpec) => void;
@@ -89,11 +69,11 @@ export function SelectResource({ onSelect }: SelectResourceProps) {
const platform = getPlatform();
const defaultResources: ResourceSpec[] = useMemo(
() =>
sortResources(
sortResourcesByPreferences(
// Apply access check to each resource.
addHasAccessField(
acl,
filterResources(
filterBySupportedPlatformsAndAuthTypes(
platform,
authType,
getDefaultResources(cfg.isEnterprise)
@@ -193,98 +173,15 @@ export function SelectResource({ onSelect }: SelectResourceProps) {
{resources && resources.length > 0 && (
<>
<Grid role="grid">
{resources.map((r, index) => {
const title = r.name;
const pretitle = getResourcePretitle(r);
const select = () => {
if (!r.hasAccess) {
return;
}
setShowApp(true);
onSelect(r);
};
let resourceCardProps: ComponentPropsWithoutRef<
'button' | typeof Link
>;
if (r.kind === ResourceKind.Application && r.isDialog) {
resourceCardProps = {
onClick: select,
onKeyUp: (e: KeyboardEvent) => e.key === 'Enter' && select(),
role: 'button',
};
} else if (r.unguidedLink) {
resourceCardProps = {
as: Link,
href: r.hasAccess ? r.unguidedLink : null,
target: '_blank',
style: { textDecoration: 'none' },
role: 'link',
};
} else {
resourceCardProps = {
onClick: () => r.hasAccess && onSelect(r),
onKeyUp: (e: KeyboardEvent) => {
if (e.key === 'Enter' && r.hasAccess) {
onSelect(r);
}
},
role: 'button',
};
}
// There can be three types of click behavior with the resource cards:
// 1) If the resource has no interactive UI flow ("unguided"),
// clicking on the card will take a user to our docs page
// on a new tab.
// 2) If the resource is guided, we start the "flow" by
// taking user to the next step.
// 3) If the resource is kind 'Application', it will render the legacy
// popup modal where it shows user to add app manually or automatically.
return (
<ResourceCard
data-testid={r.kind}
key={`${index}${pretitle}${title}`}
hasAccess={r.hasAccess}
aria-label={`${pretitle} ${title}`}
{...resourceCardProps}
>
{!r.unguidedLink && r.hasAccess && (
<BadgeGuided>Guided</BadgeGuided>
)}
{!r.hasAccess && (
<ToolTipNoPermBadge
children={<PermissionsErrorMessage resource={r} />}
/>
)}
<Flex px={2} alignItems="center" height="48px">
<Flex mr={3} justifyContent="center" width="24px">
<DiscoverIcon name={r.icon} />
</Flex>
<Box>
{pretitle && (
<Text typography="body3" color="text.slightlyMuted">
{pretitle}
</Text>
)}
{r.unguidedLink ? (
<Text bold color="text.main">
{title}
</Text>
) : (
<Text bold>{title}</Text>
)}
</Box>
</Flex>
{r.unguidedLink && r.hasAccess ? (
<NewTabInCorner color="text.muted" size={18} />
) : null}
</ResourceCard>
);
})}
{resources.map((r, index) => (
<Tile
// TODO(kimlisa): replace with r.id in upcoming PR
key={`${index}${r.name}${r.kind}`}
resourceSpec={r}
onChangeShowApp={setShowApp}
onSelectResource={onSelect}
/>
))}
</Grid>
<P3 mt={6}>
Looking for something else?{' '}
@@ -338,301 +235,6 @@ const ClearSearch = ({ onClick }: { onClick(): void }) => {
);
};
function checkHasAccess(acl: Acl, resourceKind: ResourceKind) {
const basePerm = acl.tokens.create;
if (!basePerm) {
return false;
}
switch (resourceKind) {
case ResourceKind.Application:
return acl.appServers.read && acl.appServers.list;
case ResourceKind.Database:
return acl.dbServers.read && acl.dbServers.list;
case ResourceKind.Desktop:
return acl.desktops.read && acl.desktops.list;
case ResourceKind.Kubernetes:
return acl.kubeServers.read && acl.kubeServers.list;
case ResourceKind.Server:
return acl.nodes.list;
case ResourceKind.SamlApplication:
return acl.samlIdpServiceProvider.create;
case ResourceKind.ConnectMyComputer:
// This is probably already true since without this permission the user wouldn't be able to
// add any other resource, but let's just leave it for completeness sake.
return acl.tokens.create;
default:
return false;
}
}
function sortResourcesByKind(
resourceKind: SearchResource,
resources: ResourceSpec[]
) {
let sorted: ResourceSpec[] = [];
switch (resourceKind) {
case SearchResource.SERVER:
sorted = [
...resources.filter(r => r.kind === ResourceKind.Server),
...resources.filter(r => r.kind !== ResourceKind.Server),
];
break;
case SearchResource.APPLICATION:
sorted = [
...resources.filter(r => r.kind === ResourceKind.Application),
...resources.filter(r => r.kind !== ResourceKind.Application),
];
break;
case SearchResource.DATABASE:
sorted = [
...resources.filter(r => r.kind === ResourceKind.Database),
...resources.filter(r => r.kind !== ResourceKind.Database),
];
break;
case SearchResource.DESKTOP:
sorted = [
...resources.filter(r => r.kind === ResourceKind.Desktop),
...resources.filter(r => r.kind !== ResourceKind.Desktop),
];
break;
case SearchResource.KUBERNETES:
sorted = [
...resources.filter(r => r.kind === ResourceKind.Kubernetes),
...resources.filter(r => r.kind !== ResourceKind.Kubernetes),
];
break;
}
return sorted;
}
const aBeforeB = -1;
const aAfterB = 1;
const aEqualsB = 0;
/**
* Evaluates the predicate and prioritizes the element matching the predicate over the element that
* doesn't.
*
* @example
* comparePredicate({color: 'green'}, {color: 'red'}, (el) => el.color === 'green') // => -1 (a before b)
* comparePredicate({color: 'red'}, {color: 'green'}, (el) => el.color === 'green') // => 1 (a after b)
* comparePredicate({color: 'blue'}, {color: 'pink'}, (el) => el.color === 'green') // => 0 (both are equal)
*/
function comparePredicate<ElementType>(
a: ElementType,
b: ElementType,
predicate: (resource: ElementType) => boolean
): -1 | 0 | 1 {
const aMatches = predicate(a);
const bMatches = predicate(b);
if (aMatches && !bMatches) {
return aBeforeB;
}
if (bMatches && !aMatches) {
return aAfterB;
}
return aEqualsB;
}
export function sortResources(
resources: ResourceSpec[],
preferences: UserPreferences,
onboardDiscover: OnboardDiscover | undefined
) {
const { preferredResources, hasPreferredResources } =
getPrioritizedResources(preferences);
const platform = getPlatform();
const sortedResources = [...resources];
const accessible = sortedResources.filter(r => r.hasAccess);
const restricted = sortedResources.filter(r => !r.hasAccess);
const hasNoResources = onboardDiscover && !onboardDiscover.hasResource;
const prefersServers =
hasPreferredResources &&
preferredResources.includes(
resourceKindToPreferredResource(ResourceKind.Server)
);
const prefersServersOrNoPreferences =
prefersServers || !hasPreferredResources;
const shouldShowConnectMyComputerFirst =
hasNoResources &&
prefersServersOrNoPreferences &&
isConnectMyComputerAvailable(accessible);
// Sort accessible resources by:
// 1. os
// 2. preferred
// 3. guided
// 4. alphabetically
//
// When available on the given platform, Connect My Computer is put either as the first resource
// if the user has no resources, otherwise it's at the end of the guided group.
accessible.sort((a, b) => {
const compareAB = (predicate: (r: ResourceSpec) => boolean) =>
comparePredicate(a, b, predicate);
const areBothGuided = !a.unguidedLink && !b.unguidedLink;
// Special cases for Connect My Computer.
// Show Connect My Computer tile as the first resource.
if (shouldShowConnectMyComputerFirst) {
const prioritizeConnectMyComputer = compareAB(
r => r.kind === ResourceKind.ConnectMyComputer
);
if (prioritizeConnectMyComputer) {
return prioritizeConnectMyComputer;
}
// Within the guided group, deprioritize server tiles of the current user platform if Connect
// My Computer is available.
//
// If the user has no resources available in the cluster, we want to nudge them towards
// Connect My Computer rather than, say, standalone macOS setup.
//
// Only do this if the user doesn't explicitly prefer servers. If they prefer servers, we
// want the servers for their platform to be displayed in their usual place so that the user
// doesn't miss that Teleport supports them.
if (!prefersServers && areBothGuided) {
const deprioritizeServerForUserPlatform = compareAB(
r => !(r.kind == ResourceKind.Server && r.platform === platform)
);
if (deprioritizeServerForUserPlatform) {
return deprioritizeServerForUserPlatform;
}
}
} else if (areBothGuided) {
// Show Connect My Computer tile as the last guided resource if the user already added some
// resources or they prefer other kinds of resources than servers.
const deprioritizeConnectMyComputer = compareAB(
r => r.kind !== ResourceKind.ConnectMyComputer
);
if (deprioritizeConnectMyComputer) {
return deprioritizeConnectMyComputer;
}
}
// Display platform resources first
const prioritizeUserPlatform = compareAB(r => r.platform === platform);
if (prioritizeUserPlatform) {
return prioritizeUserPlatform;
}
// Display preferred resources second
if (hasPreferredResources) {
const prioritizePreferredResource = compareAB(r =>
preferredResources.includes(resourceKindToPreferredResource(r.kind))
);
if (prioritizePreferredResource) {
return prioritizePreferredResource;
}
}
// Display guided resources third
const prioritizeGuided = compareAB(r => !r.unguidedLink);
if (prioritizeGuided) {
return prioritizeGuided;
}
// Alpha
return a.name.localeCompare(b.name);
});
// Sort restricted resources alphabetically
restricted.sort((a, b) => {
return a.name.localeCompare(b.name);
});
// Sort resources that user has access to the
// top of the list, so it is more visible to
// the user.
return [...accessible, ...restricted];
}
function isConnectMyComputerAvailable(
accessibleResources: ResourceSpec[]
): boolean {
return !!accessibleResources.find(
resource => resource.kind === ResourceKind.ConnectMyComputer
);
}
/**
* Returns prioritized resources based on user preferences cluster state
*
* @remarks
* A user can have preferredResources set via onboarding either from the survey (preferredResources)
* or various query parameters (marketingParams). We sort the list by the marketingParams if available.
* If not, we sort by preferred resource type if available.
* We do not search.
*
* @param preferences - Cluster state user preferences
* @returns PrioritizedResources which is both the resource to prioritize and a boolean value of the value
*
*/
function getPrioritizedResources(
preferences: UserPreferences
): PrioritizedResources {
const marketingParams = preferences.onboard.marketingParams;
if (marketingParams) {
const marketingPriorities = getMarketingTermMatches(marketingParams);
if (marketingPriorities.length > 0) {
return {
hasPreferredResources: true,
preferredResources: marketingPriorities,
};
}
}
const preferredResources = preferences.onboard.preferredResources || [];
// hasPreferredResources will be false if all resources are selected
const maxResources = Object.keys(Resource).length / 2 - 1;
const selectedAll = preferredResources.length === maxResources;
return {
preferredResources: preferredResources,
hasPreferredResources: preferredResources.length > 0 && !selectedAll,
};
}
export function filterResources(
platform: Platform,
authType: AuthType,
resources: ResourceSpec[]
) {
return resources.filter(resource => {
const resourceSupportsPlatform =
!resource.supportedPlatforms?.length ||
resource.supportedPlatforms.includes(platform);
const resourceSupportsAuthType =
!resource.supportedAuthTypes?.length ||
resource.supportedAuthTypes.includes(authType);
return resourceSupportsPlatform && resourceSupportsAuthType;
});
}
function addHasAccessField(
acl: Acl,
resources: ResourceSpec[]
): ResourceSpec[] {
return resources.map(r => {
const hasAccess = checkHasAccess(acl, r.kind);
switch (r.kind) {
case ResourceKind.Database:
return { ...r, dbMeta: { ...r.dbMeta }, hasAccess };
default:
return { ...r, hasAccess };
}
});
}
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, 320px);
@@ -640,58 +242,6 @@ const Grid = styled.div`
row-gap: 15px;
`;
const NewTabInCorner = styled(NewTab)`
position: absolute;
top: ${props => props.theme.space[3]}px;
right: ${props => props.theme.space[3]}px;
transition: color 0.3s;
`;
const ResourceCard = styled.button<{ hasAccess?: boolean }>`
position: relative;
text-align: left;
background: ${props => props.theme.colors.spotBackground[0]};
transition: all 0.3s;
border: none;
border-radius: 8px;
padding: 12px;
color: ${props => props.theme.colors.text.main};
line-height: inherit;
font-size: inherit;
font-family: inherit;
cursor: pointer;
opacity: ${props => (props.hasAccess ? '1' : '0.45')};
&:focus-visible {
outline: none;
box-shadow: 0 0 0 3px ${props => props.theme.colors.brand};
}
&:hover,
&:focus-visible {
background: ${props => props.theme.colors.spotBackground[1]};
${NewTabInCorner} {
color: ${props => props.theme.colors.text.slightlyMuted};
}
}
`;
const BadgeGuided = styled.div`
position: absolute;
background: ${props => props.theme.colors.brand};
color: ${props => props.theme.colors.text.primaryInverse};
padding: 0px 6px;
border-top-right-radius: 8px;
border-bottom-left-radius: 8px;
top: 0px;
right: 0px;
font-size: 10px;
line-height: 24px;
`;
const InputWrapper = styled.div`
border-radius: 200px;
height: 40px;
@@ -0,0 +1,183 @@
/**
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { type ComponentPropsWithoutRef } from 'react';
import styled from 'styled-components';
import { Box, Flex, Link, Text } from 'design';
import { NewTab } from 'design/Icon';
import { ToolTipNoPermBadge } from 'teleport/components/ToolTipNoPermBadge';
import {
PermissionsErrorMessage,
ResourceKind,
} from 'teleport/Discover/Shared';
import { getResourcePretitle } from '.';
import { DiscoverIcon } from './icons';
import { type ResourceSpec } from './types';
export function Tile({
resourceSpec,
onChangeShowApp,
onSelectResource,
}: {
resourceSpec: ResourceSpec;
onChangeShowApp(b: boolean): void;
onSelectResource(r: ResourceSpec): void;
}) {
const title = resourceSpec.name;
const pretitle = getResourcePretitle(resourceSpec);
const select = () => {
if (!resourceSpec.hasAccess) {
return;
}
onChangeShowApp(true);
onSelectResource(resourceSpec);
};
let resourceCardProps: ComponentPropsWithoutRef<'button' | typeof Link>;
if (resourceSpec.kind === ResourceKind.Application && resourceSpec.isDialog) {
resourceCardProps = {
onClick: select,
onKeyUp: (e: KeyboardEvent) => e.key === 'Enter' && select(),
role: 'button',
};
} else if (resourceSpec.unguidedLink) {
resourceCardProps = {
as: Link,
href: resourceSpec.hasAccess ? resourceSpec.unguidedLink : null,
target: '_blank',
style: { textDecoration: 'none' },
role: 'link',
};
} else {
resourceCardProps = {
onClick: () => resourceSpec.hasAccess && onSelectResource(resourceSpec),
onKeyUp: (e: KeyboardEvent) => {
if (e.key === 'Enter' && resourceSpec.hasAccess) {
onSelectResource(resourceSpec);
}
},
role: 'button',
};
}
// There can be three types of click behavior with the resource cards:
// 1) If the resource has no interactive UI flow ("unguided"),
// clicking on the card will take a user to our docs page
// on a new tab.
// 2) If the resource is guided, we start the "flow" by
// taking user to the next step.
// 3) If the resource is kind 'Application', it will render the legacy
// popup modal where it shows user to add app manually or automatically.
return (
<ResourceCard
data-testid={resourceSpec.kind}
hasAccess={resourceSpec.hasAccess}
aria-label={`${pretitle} ${title}`}
{...resourceCardProps}
>
{!resourceSpec.unguidedLink && resourceSpec.hasAccess && (
<BadgeGuided>Guided</BadgeGuided>
)}
{!resourceSpec.hasAccess && (
<ToolTipNoPermBadge>
<PermissionsErrorMessage resource={resourceSpec} />
</ToolTipNoPermBadge>
)}
<Flex px={2} alignItems="center" height="48px">
<Flex mr={3} justifyContent="center" width="24px">
<DiscoverIcon name={resourceSpec.icon} />
</Flex>
<Box>
{pretitle && (
<Text typography="body3" color="text.slightlyMuted">
{pretitle}
</Text>
)}
{resourceSpec.unguidedLink ? (
<Text bold color="text.main">
{title}
</Text>
) : (
<Text bold>{title}</Text>
)}
</Box>
</Flex>
{resourceSpec.unguidedLink && resourceSpec.hasAccess ? (
<NewTabInCorner color="text.muted" size={18} />
) : null}
</ResourceCard>
);
}
const NewTabInCorner = styled(NewTab)`
position: absolute;
top: ${props => props.theme.space[3]}px;
right: ${props => props.theme.space[3]}px;
transition: color 0.3s;
`;
const ResourceCard = styled.button<{ hasAccess?: boolean }>`
position: relative;
text-align: left;
background: ${props => props.theme.colors.spotBackground[0]};
transition: all 0.3s;
border: none;
border-radius: 8px;
padding: 12px;
color: ${props => props.theme.colors.text.main};
line-height: inherit;
font-size: inherit;
font-family: inherit;
cursor: pointer;
opacity: ${props => (props.hasAccess ? '1' : '0.45')};
&:focus-visible {
outline: none;
box-shadow: 0 0 0 3px ${props => props.theme.colors.brand};
}
&:hover,
&:focus-visible {
background: ${props => props.theme.colors.spotBackground[1]};
${NewTabInCorner} {
color: ${props => props.theme.colors.text.slightlyMuted};
}
}
`;
const BadgeGuided = styled.div`
position: absolute;
background: ${props => props.theme.colors.brand};
color: ${props => props.theme.colors.text.primaryInverse};
padding: 0px 6px;
border-top-right-radius: 8px;
border-bottom-left-radius: 8px;
top: 0px;
right: 0px;
font-size: 10px;
line-height: 24px;
`;
@@ -17,6 +17,9 @@
*/
export { SelectResource } from './SelectResource';
export { getResourcePretitle } from './resources';
export { getDatabaseProtocol, getDefaultDatabasePort } from './databases';
export {
getResourcePretitle,
getDatabaseProtocol,
getDefaultDatabasePort,
} from './resources';
export * from './types';
@@ -21,8 +21,8 @@ import { DbProtocol } from 'shared/services/databases';
import { DiscoverEventResource } from 'teleport/services/userEvent';
import { ResourceKind } from '../Shared/ResourceKind';
import { DatabaseEngine, DatabaseLocation, ResourceSpec } from './types';
import { ResourceKind } from '../../Shared/ResourceKind';
import { DatabaseEngine, DatabaseLocation, ResourceSpec } from '../types';
const baseDatabaseKeywords = ['db', 'database', 'databases'];
const awsKeywords = [...baseDatabaseKeywords, 'aws', 'amazon web services'];
@@ -0,0 +1,21 @@
/**
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export * from './databases';
export * from './resources';
export * from './resourcesE';
@@ -24,19 +24,19 @@ import {
DiscoverEventResource,
} from 'teleport/services/userEvent';
import { ResourceKind } from '../Shared/ResourceKind';
import {
DATABASES,
DATABASES_UNGUIDED,
DATABASES_UNGUIDED_DOC,
} from './databases';
import { ResourceKind } from '../../Shared/ResourceKind';
import {
DatabaseEngine,
DatabaseLocation,
KubeLocation,
ResourceSpec,
ServerLocation,
} from './types';
} from '../types';
import {
DATABASES,
DATABASES_UNGUIDED,
DATABASES_UNGUIDED_DOC,
} from './databases';
const baseServerKeywords = ['server', 'node', 'ssh'];
const awsKeywords = ['aws', 'amazon', 'amazon web services'];
@@ -19,8 +19,8 @@
import { SamlServiceProviderPreset } from 'teleport/services/samlidp/types';
import { DiscoverEventResource } from 'teleport/services/userEvent';
import { ResourceKind } from '../Shared';
import { ResourceSpec } from './types';
import { ResourceKind } from '../../Shared';
import { ResourceSpec } from '../types';
export const SAML_APPLICATIONS: ResourceSpec[] = [
{
@@ -0,0 +1,65 @@
/**
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Acl } from 'teleport/services/user';
import { ResourceKind } from '../../Shared';
import { ResourceSpec } from '../types';
function checkHasAccess(acl: Acl, resourceKind: ResourceKind) {
const basePerm = acl.tokens.create;
if (!basePerm) {
return false;
}
switch (resourceKind) {
case ResourceKind.Application:
return acl.appServers.read && acl.appServers.list;
case ResourceKind.Database:
return acl.dbServers.read && acl.dbServers.list;
case ResourceKind.Desktop:
return acl.desktops.read && acl.desktops.list;
case ResourceKind.Kubernetes:
return acl.kubeServers.read && acl.kubeServers.list;
case ResourceKind.Server:
return acl.nodes.list;
case ResourceKind.SamlApplication:
return acl.samlIdpServiceProvider.create;
case ResourceKind.ConnectMyComputer:
// This is probably already true since without this permission the user wouldn't be able to
// add any other resource, but let's just leave it for completeness sake.
return acl.tokens.create;
default:
return false;
}
}
export function addHasAccessField(
acl: Acl,
resources: ResourceSpec[]
): ResourceSpec[] {
return resources.map(r => {
const hasAccess = checkHasAccess(acl, r.kind);
switch (r.kind) {
case ResourceKind.Database:
return { ...r, dbMeta: { ...r.dbMeta }, hasAccess };
default:
return { ...r, hasAccess };
}
});
}
@@ -0,0 +1,41 @@
/**
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Platform } from 'design/platform';
import { AuthType } from 'teleport/services/user';
import { type ResourceSpec } from '../types';
export function filterBySupportedPlatformsAndAuthTypes(
platform: Platform,
authType: AuthType,
resources: ResourceSpec[]
) {
return resources.filter(resource => {
const resourceSupportsPlatform =
!resource.supportedPlatforms?.length ||
resource.supportedPlatforms.includes(platform);
const resourceSupportsAuthType =
!resource.supportedAuthTypes?.length ||
resource.supportedAuthTypes.includes(authType);
return resourceSupportsPlatform && resourceSupportsAuthType;
});
}
@@ -0,0 +1,262 @@
/**
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { getPlatform } from 'design/platform';
import { Resource } from 'gen-proto-ts/teleport/userpreferences/v1/onboard_pb';
import { UserPreferences } from 'gen-proto-ts/teleport/userpreferences/v1/userpreferences_pb';
import { OnboardDiscover } from 'teleport/services/user';
import { ResourceKind } from '../../Shared';
import { resourceKindToPreferredResource } from '../../Shared/ResourceKind';
import { getMarketingTermMatches } from '../getMarketingTermMatches';
import { PrioritizedResources, ResourceSpec, SearchResource } from '../types';
function isConnectMyComputerAvailable(
accessibleResources: ResourceSpec[]
): boolean {
return !!accessibleResources.find(
resource => resource.kind === ResourceKind.ConnectMyComputer
);
}
export function sortResourcesByPreferences(
resources: ResourceSpec[],
preferences: UserPreferences,
onboardDiscover: OnboardDiscover | undefined
) {
const { preferredResources, hasPreferredResources } =
getPrioritizedResources(preferences);
const platform = getPlatform();
const sortedResources = [...resources];
const accessible = sortedResources.filter(r => r.hasAccess);
const restricted = sortedResources.filter(r => !r.hasAccess);
const hasNoResources = onboardDiscover && !onboardDiscover.hasResource;
const prefersServers =
hasPreferredResources &&
preferredResources.includes(
resourceKindToPreferredResource(ResourceKind.Server)
);
const prefersServersOrNoPreferences =
prefersServers || !hasPreferredResources;
const shouldShowConnectMyComputerFirst =
hasNoResources &&
prefersServersOrNoPreferences &&
isConnectMyComputerAvailable(accessible);
// Sort accessible resources by:
// 1. os
// 2. preferred
// 3. guided
// 4. alphabetically
//
// When available on the given platform, Connect My Computer is put either as the first resource
// if the user has no resources, otherwise it's at the end of the guided group.
accessible.sort((a, b) => {
const compareAB = (predicate: (r: ResourceSpec) => boolean) =>
comparePredicate(a, b, predicate);
const areBothGuided = !a.unguidedLink && !b.unguidedLink;
// Special cases for Connect My Computer.
// Show Connect My Computer tile as the first resource.
if (shouldShowConnectMyComputerFirst) {
const prioritizeConnectMyComputer = compareAB(
r => r.kind === ResourceKind.ConnectMyComputer
);
if (prioritizeConnectMyComputer) {
return prioritizeConnectMyComputer;
}
// Within the guided group, deprioritize server tiles of the current user platform if Connect
// My Computer is available.
//
// If the user has no resources available in the cluster, we want to nudge them towards
// Connect My Computer rather than, say, standalone macOS setup.
//
// Only do this if the user doesn't explicitly prefer servers. If they prefer servers, we
// want the servers for their platform to be displayed in their usual place so that the user
// doesn't miss that Teleport supports them.
if (!prefersServers && areBothGuided) {
const deprioritizeServerForUserPlatform = compareAB(
r => !(r.kind == ResourceKind.Server && r.platform === platform)
);
if (deprioritizeServerForUserPlatform) {
return deprioritizeServerForUserPlatform;
}
}
} else if (areBothGuided) {
// Show Connect My Computer tile as the last guided resource if the user already added some
// resources or they prefer other kinds of resources than servers.
const deprioritizeConnectMyComputer = compareAB(
r => r.kind !== ResourceKind.ConnectMyComputer
);
if (deprioritizeConnectMyComputer) {
return deprioritizeConnectMyComputer;
}
}
// Display platform resources first
const prioritizeUserPlatform = compareAB(r => r.platform === platform);
if (prioritizeUserPlatform) {
return prioritizeUserPlatform;
}
// Display preferred resources second
if (hasPreferredResources) {
const prioritizePreferredResource = compareAB(r =>
preferredResources.includes(resourceKindToPreferredResource(r.kind))
);
if (prioritizePreferredResource) {
return prioritizePreferredResource;
}
}
// Display guided resources third
const prioritizeGuided = compareAB(r => !r.unguidedLink);
if (prioritizeGuided) {
return prioritizeGuided;
}
// Alpha
return a.name.localeCompare(b.name);
});
// Sort restricted resources alphabetically
restricted.sort((a, b) => {
return a.name.localeCompare(b.name);
});
// Sort resources that user has access to the
// top of the list, so it is more visible to
// the user.
return [...accessible, ...restricted];
}
/**
* Returns prioritized resources based on user preferences cluster state
*
* @remarks
* A user can have preferredResources set via onboarding either from the survey (preferredResources)
* or various query parameters (marketingParams). We sort the list by the marketingParams if available.
* If not, we sort by preferred resource type if available.
* We do not search.
*
* @param preferences - Cluster state user preferences
* @returns PrioritizedResources which is both the resource to prioritize and a boolean value of the value
*
*/
function getPrioritizedResources(
preferences: UserPreferences
): PrioritizedResources {
const marketingParams = preferences.onboard.marketingParams;
if (marketingParams) {
const marketingPriorities = getMarketingTermMatches(marketingParams);
if (marketingPriorities.length > 0) {
return {
hasPreferredResources: true,
preferredResources: marketingPriorities,
};
}
}
const preferredResources = preferences.onboard.preferredResources || [];
// hasPreferredResources will be false if all resources are selected
const maxResources = Object.keys(Resource).length / 2 - 1;
const selectedAll = preferredResources.length === maxResources;
return {
preferredResources: preferredResources,
hasPreferredResources: preferredResources.length > 0 && !selectedAll,
};
}
const aBeforeB = -1;
const aAfterB = 1;
const aEqualsB = 0;
/**
* Evaluates the predicate and prioritizes the element matching the predicate over the element that
* doesn't.
*
* @example
* comparePredicate({color: 'green'}, {color: 'red'}, (el) => el.color === 'green') // => -1 (a before b)
* comparePredicate({color: 'red'}, {color: 'green'}, (el) => el.color === 'green') // => 1 (a after b)
* comparePredicate({color: 'blue'}, {color: 'pink'}, (el) => el.color === 'green') // => 0 (both are equal)
*/
function comparePredicate<ElementType>(
a: ElementType,
b: ElementType,
predicate: (resource: ElementType) => boolean
): -1 | 0 | 1 {
const aMatches = predicate(a);
const bMatches = predicate(b);
if (aMatches && !bMatches) {
return aBeforeB;
}
if (bMatches && !aMatches) {
return aAfterB;
}
return aEqualsB;
}
export function sortResourcesByKind(
resourceKind: SearchResource,
resources: ResourceSpec[]
) {
let sorted: ResourceSpec[] = [];
switch (resourceKind) {
case SearchResource.SERVER:
sorted = [
...resources.filter(r => r.kind === ResourceKind.Server),
...resources.filter(r => r.kind !== ResourceKind.Server),
];
break;
case SearchResource.APPLICATION:
sorted = [
...resources.filter(r => r.kind === ResourceKind.Application),
...resources.filter(r => r.kind !== ResourceKind.Application),
];
break;
case SearchResource.DATABASE:
sorted = [
...resources.filter(r => r.kind === ResourceKind.Database),
...resources.filter(r => r.kind !== ResourceKind.Database),
];
break;
case SearchResource.DESKTOP:
sorted = [
...resources.filter(r => r.kind === ResourceKind.Desktop),
...resources.filter(r => r.kind !== ResourceKind.Desktop),
];
break;
case SearchResource.KUBERNETES:
sorted = [
...resources.filter(r => r.kind === ResourceKind.Kubernetes),
...resources.filter(r => r.kind !== ResourceKind.Kubernetes),
];
break;
}
return sorted;
}