From 2004893be0a8314a4a2b9307a4490dfd7baa48ba Mon Sep 17 00:00:00 2001 From: Lisa Kim Date: Fri, 31 Jan 2025 16:18:19 -0800 Subject: [PATCH] Web: make SelectResource.tsx component leaner (#51698) * Web: Move resource specs into resource directory * Web: move code out into separate files --- .../teleport/src/Discover/Discover.test.tsx | 4 +- .../src/Discover/Fixtures/databases.tsx | 2 +- .../SelectResource/SelectResource.test.tsx | 98 ++-- .../SelectResource/SelectResource.tsx | 492 +----------------- .../src/Discover/SelectResource/Tile.tsx | 183 +++++++ .../src/Discover/SelectResource/index.ts | 7 +- .../{ => resources}/databases.tsx | 4 +- .../SelectResource/resources/index.ts | 21 + .../{ => resources}/resources.tsx | 14 +- .../{ => resources}/resourcesE.tsx | 4 +- .../SelectResource/utils/checkAccess.ts | 65 +++ .../Discover/SelectResource/utils/filters.ts | 41 ++ .../src/Discover/SelectResource/utils/sort.ts | 262 ++++++++++ 13 files changed, 660 insertions(+), 537 deletions(-) create mode 100644 web/packages/teleport/src/Discover/SelectResource/Tile.tsx rename web/packages/teleport/src/Discover/SelectResource/{ => resources}/databases.tsx (99%) create mode 100644 web/packages/teleport/src/Discover/SelectResource/resources/index.ts rename web/packages/teleport/src/Discover/SelectResource/{ => resources}/resources.tsx (98%) rename web/packages/teleport/src/Discover/SelectResource/{ => resources}/resourcesE.tsx (95%) create mode 100644 web/packages/teleport/src/Discover/SelectResource/utils/checkAccess.ts create mode 100644 web/packages/teleport/src/Discover/SelectResource/utils/filters.ts create mode 100644 web/packages/teleport/src/Discover/SelectResource/utils/sort.ts diff --git a/web/packages/teleport/src/Discover/Discover.test.tsx b/web/packages/teleport/src/Discover/Discover.test.tsx index d484dbf0456e2..aeef68ce57940 100644 --- a/web/packages/teleport/src/Discover/Discover.test.tsx +++ b/web/packages/teleport/src/Discover/Discover.test.tsx @@ -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'; diff --git a/web/packages/teleport/src/Discover/Fixtures/databases.tsx b/web/packages/teleport/src/Discover/Fixtures/databases.tsx index d39910f2efc30..a699e37eb0520 100644 --- a/web/packages/teleport/src/Discover/Fixtures/databases.tsx +++ b/web/packages/teleport/src/Discover/Fixtures/databases.tsx @@ -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'; diff --git a/web/packages/teleport/src/Discover/SelectResource/SelectResource.test.tsx b/web/packages/teleport/src/Discover/SelectResource/SelectResource.test.tsx index 496f7481a6e62..85fae029a64b8 100644 --- a/web/packages/teleport/src/Discover/SelectResource/SelectResource.test.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/SelectResource.test.tsx @@ -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); diff --git a/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx b/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx index 34b0d848933b8..257b2c2d189a0 100644 --- a/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx @@ -16,49 +16,29 @@ * along with this program. If not, see . */ -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 && ( <> - {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 ( - - {!r.unguidedLink && r.hasAccess && ( - Guided - )} - {!r.hasAccess && ( - } - /> - )} - - - - - - {pretitle && ( - - {pretitle} - - )} - {r.unguidedLink ? ( - - {title} - - ) : ( - {title} - )} - - - - {r.unguidedLink && r.hasAccess ? ( - - ) : null} - - ); - })} + {resources.map((r, index) => ( + + ))} 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( - 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; diff --git a/web/packages/teleport/src/Discover/SelectResource/Tile.tsx b/web/packages/teleport/src/Discover/SelectResource/Tile.tsx new file mode 100644 index 0000000000000..e86d681b73394 --- /dev/null +++ b/web/packages/teleport/src/Discover/SelectResource/Tile.tsx @@ -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 . + */ + +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 ( + + {!resourceSpec.unguidedLink && resourceSpec.hasAccess && ( + Guided + )} + {!resourceSpec.hasAccess && ( + + + + )} + + + + + + {pretitle && ( + + {pretitle} + + )} + {resourceSpec.unguidedLink ? ( + + {title} + + ) : ( + {title} + )} + + + + {resourceSpec.unguidedLink && resourceSpec.hasAccess ? ( + + ) : null} + + ); +} + +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; +`; diff --git a/web/packages/teleport/src/Discover/SelectResource/index.ts b/web/packages/teleport/src/Discover/SelectResource/index.ts index f253c05ca928d..ab372429f3336 100644 --- a/web/packages/teleport/src/Discover/SelectResource/index.ts +++ b/web/packages/teleport/src/Discover/SelectResource/index.ts @@ -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'; diff --git a/web/packages/teleport/src/Discover/SelectResource/databases.tsx b/web/packages/teleport/src/Discover/SelectResource/resources/databases.tsx similarity index 99% rename from web/packages/teleport/src/Discover/SelectResource/databases.tsx rename to web/packages/teleport/src/Discover/SelectResource/resources/databases.tsx index a9f3d55112619..d30c280b4c0fb 100644 --- a/web/packages/teleport/src/Discover/SelectResource/databases.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/resources/databases.tsx @@ -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']; diff --git a/web/packages/teleport/src/Discover/SelectResource/resources/index.ts b/web/packages/teleport/src/Discover/SelectResource/resources/index.ts new file mode 100644 index 0000000000000..032144296417b --- /dev/null +++ b/web/packages/teleport/src/Discover/SelectResource/resources/index.ts @@ -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 . + */ + +export * from './databases'; +export * from './resources'; +export * from './resourcesE'; diff --git a/web/packages/teleport/src/Discover/SelectResource/resources.tsx b/web/packages/teleport/src/Discover/SelectResource/resources/resources.tsx similarity index 98% rename from web/packages/teleport/src/Discover/SelectResource/resources.tsx rename to web/packages/teleport/src/Discover/SelectResource/resources/resources.tsx index f089fe9dc4db2..56cfb9c66e80b 100644 --- a/web/packages/teleport/src/Discover/SelectResource/resources.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/resources/resources.tsx @@ -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']; diff --git a/web/packages/teleport/src/Discover/SelectResource/resourcesE.tsx b/web/packages/teleport/src/Discover/SelectResource/resources/resourcesE.tsx similarity index 95% rename from web/packages/teleport/src/Discover/SelectResource/resourcesE.tsx rename to web/packages/teleport/src/Discover/SelectResource/resources/resourcesE.tsx index 2cba11ef39d34..b6056f4cf344c 100644 --- a/web/packages/teleport/src/Discover/SelectResource/resourcesE.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/resources/resourcesE.tsx @@ -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[] = [ { diff --git a/web/packages/teleport/src/Discover/SelectResource/utils/checkAccess.ts b/web/packages/teleport/src/Discover/SelectResource/utils/checkAccess.ts new file mode 100644 index 0000000000000..7292e28413c52 --- /dev/null +++ b/web/packages/teleport/src/Discover/SelectResource/utils/checkAccess.ts @@ -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 . + */ + +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 }; + } + }); +} diff --git a/web/packages/teleport/src/Discover/SelectResource/utils/filters.ts b/web/packages/teleport/src/Discover/SelectResource/utils/filters.ts new file mode 100644 index 0000000000000..325a85c97d94a --- /dev/null +++ b/web/packages/teleport/src/Discover/SelectResource/utils/filters.ts @@ -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 . + */ + +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; + }); +} diff --git a/web/packages/teleport/src/Discover/SelectResource/utils/sort.ts b/web/packages/teleport/src/Discover/SelectResource/utils/sort.ts new file mode 100644 index 0000000000000..c43049f632418 --- /dev/null +++ b/web/packages/teleport/src/Discover/SelectResource/utils/sort.ts @@ -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 . + */ + +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( + 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; +}