From ed888176c3d70871e1880f333ee3fc92a53e019a Mon Sep 17 00:00:00 2001 From: Lisa Kim Date: Fri, 31 Jan 2025 16:18:19 -0800 Subject: [PATCH 1/4] 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; +} From c25f4fd163febc63c64e79fb86c008d3fe0dc8aa Mon Sep 17 00:00:00 2001 From: Lisa Kim Date: Tue, 4 Feb 2025 15:11:02 -0800 Subject: [PATCH 2/4] Web: define discover resource guide ids and add discover user preference field (#51672) * Add discover resource preferences to user preferences * Define hard coded guide id consts * Set guide ids for resources * Add test * Address CRs * Define a new type just for SelectResource.tsx which requires id field --- .../v1/discover_resource_preferences.pb.go | 153 ++++++++++++ .../userpreferences/v1/userpreferences.pb.go | 222 ++++++++++-------- .../v1/discover_resource_preferences.proto | 25 ++ .../userpreferences/v1/userpreferences.proto | 3 + .../v1/discover_resource_preferences_pb.ts | 89 +++++++ .../userpreferences/v1/userpreferences_pb.ts | 16 +- lib/services/local/userpreferences_test.go | 26 ++ lib/web/userpreferences.go | 49 +++- .../SelectResource/SelectResource.test.tsx | 25 +- .../SelectResource/SelectResource.tsx | 15 +- .../src/Discover/SelectResource/Tile.tsx | 6 +- .../SelectResource/resources/databases.tsx | 41 +++- .../SelectResource/resources/resources.tsx | 62 ++++- .../SelectResource/resources/resourcesE.tsx | 10 +- .../SelectResource/utils/checkAccess.ts | 6 +- .../Discover/SelectResource/utils/filters.ts | 4 +- .../src/Discover/SelectResource/utils/sort.ts | 13 +- .../userPreferences/discoverPreference.ts | 90 +++++++ .../userPreferences/userPreferences.ts | 2 + 19 files changed, 697 insertions(+), 160 deletions(-) create mode 100644 api/gen/proto/go/userpreferences/v1/discover_resource_preferences.pb.go create mode 100644 api/proto/teleport/userpreferences/v1/discover_resource_preferences.proto create mode 100644 gen/proto/ts/teleport/userpreferences/v1/discover_resource_preferences_pb.ts create mode 100644 web/packages/teleport/src/services/userPreferences/discoverPreference.ts diff --git a/api/gen/proto/go/userpreferences/v1/discover_resource_preferences.pb.go b/api/gen/proto/go/userpreferences/v1/discover_resource_preferences.pb.go new file mode 100644 index 0000000000000..89c5b10a540ea --- /dev/null +++ b/api/gen/proto/go/userpreferences/v1/discover_resource_preferences.pb.go @@ -0,0 +1,153 @@ +// Copyright 2025 Gravitational, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.35.1 +// protoc (unknown) +// source: teleport/userpreferences/v1/discover_resource_preferences.proto + +package userpreferencesv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// DiscoverResourcePreferences holds preferences related to discovering resource. +type DiscoverResourcePreferences struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // pinned_guides is a list of ids of pinned guides. + PinnedGuides []string `protobuf:"bytes,1,rep,name=pinned_guides,json=pinnedGuides,proto3" json:"pinned_guides,omitempty"` +} + +func (x *DiscoverResourcePreferences) Reset() { + *x = DiscoverResourcePreferences{} + mi := &file_teleport_userpreferences_v1_discover_resource_preferences_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DiscoverResourcePreferences) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DiscoverResourcePreferences) ProtoMessage() {} + +func (x *DiscoverResourcePreferences) ProtoReflect() protoreflect.Message { + mi := &file_teleport_userpreferences_v1_discover_resource_preferences_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DiscoverResourcePreferences.ProtoReflect.Descriptor instead. +func (*DiscoverResourcePreferences) Descriptor() ([]byte, []int) { + return file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDescGZIP(), []int{0} +} + +func (x *DiscoverResourcePreferences) GetPinnedGuides() []string { + if x != nil { + return x.PinnedGuides + } + return nil +} + +var File_teleport_userpreferences_v1_discover_resource_preferences_proto protoreflect.FileDescriptor + +var file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDesc = []byte{ + 0x0a, 0x3f, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, + 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, 0x76, 0x31, 0x2f, 0x64, 0x69, + 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, + 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x12, 0x1b, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, + 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x22, 0x42, + 0x0a, 0x1b, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x23, 0x0a, + 0x0d, 0x70, 0x69, 0x6e, 0x6e, 0x65, 0x64, 0x5f, 0x67, 0x75, 0x69, 0x64, 0x65, 0x73, 0x18, 0x01, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x69, 0x6e, 0x6e, 0x65, 0x64, 0x47, 0x75, 0x69, 0x64, + 0x65, 0x73, 0x42, 0x59, 0x5a, 0x57, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, + 0x2f, 0x67, 0x72, 0x61, 0x76, 0x69, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x2f, 0x74, + 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x67, 0x65, 0x6e, 0x2f, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x6f, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, + 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, 0x76, 0x31, 0x3b, 0x75, 0x73, 0x65, 0x72, + 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x76, 0x31, 0x62, 0x06, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDescOnce sync.Once + file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDescData = file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDesc +) + +func file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDescGZIP() []byte { + file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDescOnce.Do(func() { + file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDescData = protoimpl.X.CompressGZIP(file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDescData) + }) + return file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDescData +} + +var file_teleport_userpreferences_v1_discover_resource_preferences_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_teleport_userpreferences_v1_discover_resource_preferences_proto_goTypes = []any{ + (*DiscoverResourcePreferences)(nil), // 0: teleport.userpreferences.v1.DiscoverResourcePreferences +} +var file_teleport_userpreferences_v1_discover_resource_preferences_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_teleport_userpreferences_v1_discover_resource_preferences_proto_init() } +func file_teleport_userpreferences_v1_discover_resource_preferences_proto_init() { + if File_teleport_userpreferences_v1_discover_resource_preferences_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDesc, + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_teleport_userpreferences_v1_discover_resource_preferences_proto_goTypes, + DependencyIndexes: file_teleport_userpreferences_v1_discover_resource_preferences_proto_depIdxs, + MessageInfos: file_teleport_userpreferences_v1_discover_resource_preferences_proto_msgTypes, + }.Build() + File_teleport_userpreferences_v1_discover_resource_preferences_proto = out.File + file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDesc = nil + file_teleport_userpreferences_v1_discover_resource_preferences_proto_goTypes = nil + file_teleport_userpreferences_v1_discover_resource_preferences_proto_depIdxs = nil +} diff --git a/api/gen/proto/go/userpreferences/v1/userpreferences.pb.go b/api/gen/proto/go/userpreferences/v1/userpreferences.pb.go index 62eaf64e6e225..50a98abc28119 100644 --- a/api/gen/proto/go/userpreferences/v1/userpreferences.pb.go +++ b/api/gen/proto/go/userpreferences/v1/userpreferences.pb.go @@ -53,6 +53,8 @@ type UserPreferences struct { AccessGraph *AccessGraphUserPreferences `protobuf:"bytes,6,opt,name=access_graph,json=accessGraph,proto3" json:"access_graph,omitempty"` // side_nav_drawer_mode is the sidenav drawer behavior preference in the frontend. SideNavDrawerMode SideNavDrawerMode `protobuf:"varint,7,opt,name=side_nav_drawer_mode,json=sideNavDrawerMode,proto3,enum=teleport.userpreferences.v1.SideNavDrawerMode" json:"side_nav_drawer_mode,omitempty"` + // discover_resource_preferences are user preferences saved for the discover resource web UI. + DiscoverResourcePreferences *DiscoverResourcePreferences `protobuf:"bytes,8,opt,name=discover_resource_preferences,json=discoverResourcePreferences,proto3" json:"discover_resource_preferences,omitempty"` } func (x *UserPreferences) Reset() { @@ -127,6 +129,13 @@ func (x *UserPreferences) GetSideNavDrawerMode() SideNavDrawerMode { return SideNavDrawerMode_SIDE_NAV_DRAWER_MODE_UNSPECIFIED } +func (x *UserPreferences) GetDiscoverResourcePreferences() *DiscoverResourcePreferences { + if x != nil { + return x.DiscoverResourcePreferences + } + return nil +} + // GetUserPreferencesRequest is a request to get the user preferences. type GetUserPreferencesRequest struct { state protoimpl.MessageState @@ -274,97 +283,109 @@ var file_teleport_userpreferences_v1_userpreferences_proto_rawDesc = []byte{ 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x29, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x75, + 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x3f, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, 0x76, - 0x31, 0x2f, 0x6f, 0x6e, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, - 0x35, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, - 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x69, 0x64, - 0x65, 0x6e, 0x61, 0x76, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x27, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, - 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, - 0x2f, 0x76, 0x31, 0x2f, 0x74, 0x68, 0x65, 0x6d, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, - 0x3e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, - 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, 0x76, 0x31, 0x2f, 0x75, 0x6e, 0x69, - 0x66, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x72, - 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, - 0xc6, 0x04, 0x0a, 0x0f, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, - 0x63, 0x65, 0x73, 0x12, 0x38, 0x0a, 0x05, 0x74, 0x68, 0x65, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, - 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, - 0x2e, 0x54, 0x68, 0x65, 0x6d, 0x65, 0x52, 0x05, 0x74, 0x68, 0x65, 0x6d, 0x65, 0x12, 0x4d, 0x0a, - 0x07, 0x6f, 0x6e, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x33, - 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, - 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x6e, 0x62, - 0x6f, 0x61, 0x72, 0x64, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, - 0x63, 0x65, 0x73, 0x52, 0x07, 0x6f, 0x6e, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x12, 0x64, 0x0a, 0x13, - 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, - 0x63, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x33, 0x2e, 0x74, 0x65, 0x6c, 0x65, + 0x31, 0x2f, 0x64, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x29, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, + 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, + 0x76, 0x31, 0x2f, 0x6f, 0x6e, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x1a, 0x35, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, + 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x69, + 0x64, 0x65, 0x6e, 0x61, 0x76, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, + 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x27, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, + 0x74, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, + 0x73, 0x2f, 0x76, 0x31, 0x2f, 0x74, 0x68, 0x65, 0x6d, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x1a, 0x3e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, + 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, 0x76, 0x31, 0x2f, 0x75, 0x6e, + 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, + 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x22, 0xc4, 0x05, 0x0a, 0x0f, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, + 0x6e, 0x63, 0x65, 0x73, 0x12, 0x38, 0x0a, 0x05, 0x74, 0x68, 0x65, 0x6d, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, + 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, + 0x31, 0x2e, 0x54, 0x68, 0x65, 0x6d, 0x65, 0x52, 0x05, 0x74, 0x68, 0x65, 0x6d, 0x65, 0x12, 0x4d, + 0x0a, 0x07, 0x6f, 0x6e, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x33, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x70, + 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x6e, + 0x62, 0x6f, 0x61, 0x72, 0x64, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, + 0x6e, 0x63, 0x65, 0x73, 0x52, 0x07, 0x6f, 0x6e, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x12, 0x64, 0x0a, + 0x13, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, + 0x6e, 0x63, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x33, 0x2e, 0x74, 0x65, 0x6c, + 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, + 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, + 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, + 0x12, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, + 0x63, 0x65, 0x73, 0x12, 0x79, 0x0a, 0x1c, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, + 0x63, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x37, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, - 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x55, - 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x12, - 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, - 0x65, 0x73, 0x12, 0x79, 0x0a, 0x1c, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, - 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x37, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, + 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x52, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, + 0x65, 0x73, 0x52, 0x1a, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x5a, + 0x0a, 0x0c, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x67, 0x72, 0x61, 0x70, 0x68, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x37, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, + 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, + 0x76, 0x31, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x47, 0x72, 0x61, 0x70, 0x68, 0x55, 0x73, + 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x0b, 0x61, + 0x63, 0x63, 0x65, 0x73, 0x73, 0x47, 0x72, 0x61, 0x70, 0x68, 0x12, 0x5f, 0x0a, 0x14, 0x73, 0x69, + 0x64, 0x65, 0x5f, 0x6e, 0x61, 0x76, 0x5f, 0x64, 0x72, 0x61, 0x77, 0x65, 0x72, 0x5f, 0x6d, 0x6f, + 0x64, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2e, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, - 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, - 0x73, 0x52, 0x1a, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x5a, 0x0a, - 0x0c, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x67, 0x72, 0x61, 0x70, 0x68, 0x18, 0x06, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x37, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, - 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, - 0x31, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x47, 0x72, 0x61, 0x70, 0x68, 0x55, 0x73, 0x65, - 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x0b, 0x61, 0x63, - 0x63, 0x65, 0x73, 0x73, 0x47, 0x72, 0x61, 0x70, 0x68, 0x12, 0x5f, 0x0a, 0x14, 0x73, 0x69, 0x64, - 0x65, 0x5f, 0x6e, 0x61, 0x76, 0x5f, 0x64, 0x72, 0x61, 0x77, 0x65, 0x72, 0x5f, 0x6d, 0x6f, 0x64, - 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2e, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, + 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x69, 0x64, 0x65, 0x4e, 0x61, 0x76, 0x44, 0x72, + 0x61, 0x77, 0x65, 0x72, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x11, 0x73, 0x69, 0x64, 0x65, 0x4e, 0x61, + 0x76, 0x44, 0x72, 0x61, 0x77, 0x65, 0x72, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x7c, 0x0a, 0x1d, 0x64, + 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x5f, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x38, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, + 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, + 0x2e, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x1b, 0x64, 0x69, + 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, + 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, 0x52, + 0x06, 0x61, 0x73, 0x73, 0x69, 0x73, 0x74, 0x22, 0x2b, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x55, 0x73, + 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, + 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x6c, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x50, + 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x4e, 0x0a, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, + 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, - 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x69, 0x64, 0x65, 0x4e, 0x61, 0x76, 0x44, 0x72, 0x61, - 0x77, 0x65, 0x72, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x11, 0x73, 0x69, 0x64, 0x65, 0x4e, 0x61, 0x76, - 0x44, 0x72, 0x61, 0x77, 0x65, 0x72, 0x4d, 0x6f, 0x64, 0x65, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, - 0x52, 0x06, 0x61, 0x73, 0x73, 0x69, 0x73, 0x74, 0x22, 0x2b, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x55, - 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, 0x52, 0x08, 0x75, 0x73, 0x65, - 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x6c, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, - 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x4e, 0x0a, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, - 0x65, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, - 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, - 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, - 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, - 0x63, 0x65, 0x73, 0x22, 0x7e, 0x0a, 0x1c, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x55, 0x73, 0x65, - 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x4e, 0x0a, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, - 0x65, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, - 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, - 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, - 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, - 0x63, 0x65, 0x73, 0x4a, 0x04, 0x08, 0x02, 0x10, 0x03, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, - 0x61, 0x6d, 0x65, 0x32, 0x8c, 0x02, 0x0a, 0x16, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, - 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x85, - 0x01, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, - 0x65, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x36, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, - 0x2e, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, - 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, - 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x37, 0x2e, - 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, - 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x55, - 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x15, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, - 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x12, - 0x39, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x70, - 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, - 0x73, 0x65, 0x72, 0x74, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, - 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, - 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, - 0x74, 0x79, 0x42, 0x59, 0x5a, 0x57, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, - 0x2f, 0x67, 0x72, 0x61, 0x76, 0x69, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x2f, 0x74, - 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x67, 0x65, 0x6e, 0x2f, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x6f, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, - 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, 0x76, 0x31, 0x3b, 0x75, 0x73, 0x65, 0x72, - 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x76, 0x31, 0x62, 0x06, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, + 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, + 0x65, 0x73, 0x22, 0x7e, 0x0a, 0x1c, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x55, 0x73, 0x65, 0x72, + 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x4e, 0x0a, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, + 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, + 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, + 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, + 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, + 0x65, 0x73, 0x4a, 0x04, 0x08, 0x02, 0x10, 0x03, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, + 0x6d, 0x65, 0x32, 0x8c, 0x02, 0x0a, 0x16, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, + 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x85, 0x01, + 0x0a, 0x12, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, + 0x6e, 0x63, 0x65, 0x73, 0x12, 0x36, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, + 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, + 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, + 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x37, 0x2e, 0x74, + 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, + 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x55, 0x73, + 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x15, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x55, + 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x39, + 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, + 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x73, + 0x65, 0x72, 0x74, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, + 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, + 0x79, 0x42, 0x59, 0x5a, 0x57, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, + 0x67, 0x72, 0x61, 0x76, 0x69, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x2f, 0x74, 0x65, + 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x6f, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, + 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, 0x76, 0x31, 0x3b, 0x75, 0x73, 0x65, 0x72, 0x70, + 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -391,7 +412,8 @@ var file_teleport_userpreferences_v1_userpreferences_proto_goTypes = []any{ (*UnifiedResourcePreferences)(nil), // 7: teleport.userpreferences.v1.UnifiedResourcePreferences (*AccessGraphUserPreferences)(nil), // 8: teleport.userpreferences.v1.AccessGraphUserPreferences (SideNavDrawerMode)(0), // 9: teleport.userpreferences.v1.SideNavDrawerMode - (*emptypb.Empty)(nil), // 10: google.protobuf.Empty + (*DiscoverResourcePreferences)(nil), // 10: teleport.userpreferences.v1.DiscoverResourcePreferences + (*emptypb.Empty)(nil), // 11: google.protobuf.Empty } var file_teleport_userpreferences_v1_userpreferences_proto_depIdxs = []int32{ 4, // 0: teleport.userpreferences.v1.UserPreferences.theme:type_name -> teleport.userpreferences.v1.Theme @@ -400,17 +422,18 @@ var file_teleport_userpreferences_v1_userpreferences_proto_depIdxs = []int32{ 7, // 3: teleport.userpreferences.v1.UserPreferences.unified_resource_preferences:type_name -> teleport.userpreferences.v1.UnifiedResourcePreferences 8, // 4: teleport.userpreferences.v1.UserPreferences.access_graph:type_name -> teleport.userpreferences.v1.AccessGraphUserPreferences 9, // 5: teleport.userpreferences.v1.UserPreferences.side_nav_drawer_mode:type_name -> teleport.userpreferences.v1.SideNavDrawerMode - 0, // 6: teleport.userpreferences.v1.GetUserPreferencesResponse.preferences:type_name -> teleport.userpreferences.v1.UserPreferences - 0, // 7: teleport.userpreferences.v1.UpsertUserPreferencesRequest.preferences:type_name -> teleport.userpreferences.v1.UserPreferences - 1, // 8: teleport.userpreferences.v1.UserPreferencesService.GetUserPreferences:input_type -> teleport.userpreferences.v1.GetUserPreferencesRequest - 3, // 9: teleport.userpreferences.v1.UserPreferencesService.UpsertUserPreferences:input_type -> teleport.userpreferences.v1.UpsertUserPreferencesRequest - 2, // 10: teleport.userpreferences.v1.UserPreferencesService.GetUserPreferences:output_type -> teleport.userpreferences.v1.GetUserPreferencesResponse - 10, // 11: teleport.userpreferences.v1.UserPreferencesService.UpsertUserPreferences:output_type -> google.protobuf.Empty - 10, // [10:12] is the sub-list for method output_type - 8, // [8:10] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name + 10, // 6: teleport.userpreferences.v1.UserPreferences.discover_resource_preferences:type_name -> teleport.userpreferences.v1.DiscoverResourcePreferences + 0, // 7: teleport.userpreferences.v1.GetUserPreferencesResponse.preferences:type_name -> teleport.userpreferences.v1.UserPreferences + 0, // 8: teleport.userpreferences.v1.UpsertUserPreferencesRequest.preferences:type_name -> teleport.userpreferences.v1.UserPreferences + 1, // 9: teleport.userpreferences.v1.UserPreferencesService.GetUserPreferences:input_type -> teleport.userpreferences.v1.GetUserPreferencesRequest + 3, // 10: teleport.userpreferences.v1.UserPreferencesService.UpsertUserPreferences:input_type -> teleport.userpreferences.v1.UpsertUserPreferencesRequest + 2, // 11: teleport.userpreferences.v1.UserPreferencesService.GetUserPreferences:output_type -> teleport.userpreferences.v1.GetUserPreferencesResponse + 11, // 12: teleport.userpreferences.v1.UserPreferencesService.UpsertUserPreferences:output_type -> google.protobuf.Empty + 11, // [11:13] is the sub-list for method output_type + 9, // [9:11] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name } func init() { file_teleport_userpreferences_v1_userpreferences_proto_init() } @@ -420,6 +443,7 @@ func file_teleport_userpreferences_v1_userpreferences_proto_init() { } file_teleport_userpreferences_v1_access_graph_proto_init() file_teleport_userpreferences_v1_cluster_preferences_proto_init() + file_teleport_userpreferences_v1_discover_resource_preferences_proto_init() file_teleport_userpreferences_v1_onboard_proto_init() file_teleport_userpreferences_v1_sidenav_preferences_proto_init() file_teleport_userpreferences_v1_theme_proto_init() diff --git a/api/proto/teleport/userpreferences/v1/discover_resource_preferences.proto b/api/proto/teleport/userpreferences/v1/discover_resource_preferences.proto new file mode 100644 index 0000000000000..c779e9331c40d --- /dev/null +++ b/api/proto/teleport/userpreferences/v1/discover_resource_preferences.proto @@ -0,0 +1,25 @@ +// Copyright 2025 Gravitational, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package teleport.userpreferences.v1; + +option go_package = "github.com/gravitational/teleport/api/gen/proto/go/userpreferences/v1;userpreferencesv1"; + +// DiscoverResourcePreferences holds preferences related to discovering resource. +message DiscoverResourcePreferences { + // pinned_guides is a list of ids of pinned guides. + repeated string pinned_guides = 1; +} diff --git a/api/proto/teleport/userpreferences/v1/userpreferences.proto b/api/proto/teleport/userpreferences/v1/userpreferences.proto index 25d7fe4884379..b2926d07f1098 100644 --- a/api/proto/teleport/userpreferences/v1/userpreferences.proto +++ b/api/proto/teleport/userpreferences/v1/userpreferences.proto @@ -19,6 +19,7 @@ package teleport.userpreferences.v1; import "google/protobuf/empty.proto"; import "teleport/userpreferences/v1/access_graph.proto"; import "teleport/userpreferences/v1/cluster_preferences.proto"; +import "teleport/userpreferences/v1/discover_resource_preferences.proto"; import "teleport/userpreferences/v1/onboard.proto"; import "teleport/userpreferences/v1/sidenav_preferences.proto"; import "teleport/userpreferences/v1/theme.proto"; @@ -43,6 +44,8 @@ message UserPreferences { AccessGraphUserPreferences access_graph = 6; // side_nav_drawer_mode is the sidenav drawer behavior preference in the frontend. SideNavDrawerMode side_nav_drawer_mode = 7; + // discover_resource_preferences are user preferences saved for the discover resource web UI. + DiscoverResourcePreferences discover_resource_preferences = 8; } // GetUserPreferencesRequest is a request to get the user preferences. diff --git a/gen/proto/ts/teleport/userpreferences/v1/discover_resource_preferences_pb.ts b/gen/proto/ts/teleport/userpreferences/v1/discover_resource_preferences_pb.ts new file mode 100644 index 0000000000000..d15b19ae60601 --- /dev/null +++ b/gen/proto/ts/teleport/userpreferences/v1/discover_resource_preferences_pb.ts @@ -0,0 +1,89 @@ +/* eslint-disable */ +// @generated by protobuf-ts 2.9.3 with parameter eslint_disable,add_pb_suffix,server_grpc1,ts_nocheck +// @generated from protobuf file "teleport/userpreferences/v1/discover_resource_preferences.proto" (package "teleport.userpreferences.v1", syntax proto3) +// tslint:disable +// @ts-nocheck +// +// Copyright 2025 Gravitational, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import type { BinaryWriteOptions } from "@protobuf-ts/runtime"; +import type { IBinaryWriter } from "@protobuf-ts/runtime"; +import { WireType } from "@protobuf-ts/runtime"; +import type { BinaryReadOptions } from "@protobuf-ts/runtime"; +import type { IBinaryReader } from "@protobuf-ts/runtime"; +import { UnknownFieldHandler } from "@protobuf-ts/runtime"; +import type { PartialMessage } from "@protobuf-ts/runtime"; +import { reflectionMergePartial } from "@protobuf-ts/runtime"; +import { MessageType } from "@protobuf-ts/runtime"; +/** + * DiscoverResourcePreferences holds preferences related to discovering resource. + * + * @generated from protobuf message teleport.userpreferences.v1.DiscoverResourcePreferences + */ +export interface DiscoverResourcePreferences { + /** + * pinned_guides is a list of ids of pinned guides. + * + * @generated from protobuf field: repeated string pinned_guides = 1; + */ + pinnedGuides: string[]; +} +// @generated message type with reflection information, may provide speed optimized methods +class DiscoverResourcePreferences$Type extends MessageType { + constructor() { + super("teleport.userpreferences.v1.DiscoverResourcePreferences", [ + { no: 1, name: "pinned_guides", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ } + ]); + } + create(value?: PartialMessage): DiscoverResourcePreferences { + const message = globalThis.Object.create((this.messagePrototype!)); + message.pinnedGuides = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: DiscoverResourcePreferences): DiscoverResourcePreferences { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* repeated string pinned_guides */ 1: + message.pinnedGuides.push(reader.string()); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: DiscoverResourcePreferences, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* repeated string pinned_guides = 1; */ + for (let i = 0; i < message.pinnedGuides.length; i++) + writer.tag(1, WireType.LengthDelimited).string(message.pinnedGuides[i]); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message teleport.userpreferences.v1.DiscoverResourcePreferences + */ +export const DiscoverResourcePreferences = new DiscoverResourcePreferences$Type(); diff --git a/gen/proto/ts/teleport/userpreferences/v1/userpreferences_pb.ts b/gen/proto/ts/teleport/userpreferences/v1/userpreferences_pb.ts index 2c768f07491f2..c350516bf9e86 100644 --- a/gen/proto/ts/teleport/userpreferences/v1/userpreferences_pb.ts +++ b/gen/proto/ts/teleport/userpreferences/v1/userpreferences_pb.ts @@ -29,6 +29,7 @@ import { UnknownFieldHandler } from "@protobuf-ts/runtime"; import type { PartialMessage } from "@protobuf-ts/runtime"; import { reflectionMergePartial } from "@protobuf-ts/runtime"; import { MessageType } from "@protobuf-ts/runtime"; +import { DiscoverResourcePreferences } from "./discover_resource_preferences_pb"; import { SideNavDrawerMode } from "./sidenav_preferences_pb"; import { AccessGraphUserPreferences } from "./access_graph_pb"; import { UnifiedResourcePreferences } from "./unified_resource_preferences_pb"; @@ -77,6 +78,12 @@ export interface UserPreferences { * @generated from protobuf field: teleport.userpreferences.v1.SideNavDrawerMode side_nav_drawer_mode = 7; */ sideNavDrawerMode: SideNavDrawerMode; + /** + * discover_resource_preferences are user preferences saved for the discover resource web UI. + * + * @generated from protobuf field: teleport.userpreferences.v1.DiscoverResourcePreferences discover_resource_preferences = 8; + */ + discoverResourcePreferences?: DiscoverResourcePreferences; } /** * GetUserPreferencesRequest is a request to get the user preferences. @@ -120,7 +127,8 @@ class UserPreferences$Type extends MessageType { { no: 4, name: "cluster_preferences", kind: "message", T: () => ClusterUserPreferences }, { no: 5, name: "unified_resource_preferences", kind: "message", T: () => UnifiedResourcePreferences }, { no: 6, name: "access_graph", kind: "message", T: () => AccessGraphUserPreferences }, - { no: 7, name: "side_nav_drawer_mode", kind: "enum", T: () => ["teleport.userpreferences.v1.SideNavDrawerMode", SideNavDrawerMode, "SIDE_NAV_DRAWER_MODE_"] } + { no: 7, name: "side_nav_drawer_mode", kind: "enum", T: () => ["teleport.userpreferences.v1.SideNavDrawerMode", SideNavDrawerMode, "SIDE_NAV_DRAWER_MODE_"] }, + { no: 8, name: "discover_resource_preferences", kind: "message", T: () => DiscoverResourcePreferences } ]); } create(value?: PartialMessage): UserPreferences { @@ -154,6 +162,9 @@ class UserPreferences$Type extends MessageType { case /* teleport.userpreferences.v1.SideNavDrawerMode side_nav_drawer_mode */ 7: message.sideNavDrawerMode = reader.int32(); break; + case /* teleport.userpreferences.v1.DiscoverResourcePreferences discover_resource_preferences */ 8: + message.discoverResourcePreferences = DiscoverResourcePreferences.internalBinaryRead(reader, reader.uint32(), options, message.discoverResourcePreferences); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -184,6 +195,9 @@ class UserPreferences$Type extends MessageType { /* teleport.userpreferences.v1.SideNavDrawerMode side_nav_drawer_mode = 7; */ if (message.sideNavDrawerMode !== 0) writer.tag(7, WireType.Varint).int32(message.sideNavDrawerMode); + /* teleport.userpreferences.v1.DiscoverResourcePreferences discover_resource_preferences = 8; */ + if (message.discoverResourcePreferences) + DiscoverResourcePreferences.internalBinaryWrite(message.discoverResourcePreferences, writer.tag(8, WireType.LengthDelimited).fork(), options).join(); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); diff --git a/lib/services/local/userpreferences_test.go b/lib/services/local/userpreferences_test.go index 8abe30a1ec6ee..5309cfba4c1de 100644 --- a/lib/services/local/userpreferences_test.go +++ b/lib/services/local/userpreferences_test.go @@ -219,6 +219,26 @@ func TestUserPreferencesCRUD(t *testing.T) { SideNavDrawerMode: userpreferencesv1.SideNavDrawerMode_SIDE_NAV_DRAWER_MODE_STICKY, }, }, + { + name: "update the discover resource guide preference only", + req: &userpreferencesv1.UpsertUserPreferencesRequest{ + Preferences: &userpreferencesv1.UserPreferences{ + DiscoverResourcePreferences: &userpreferencesv1.DiscoverResourcePreferences{ + PinnedGuides: []string{"guide-1", "guide-2"}, + }, + }, + }, + expected: &userpreferencesv1.UserPreferences{ + Onboard: defaultPref.Onboard, + Theme: defaultPref.Theme, + UnifiedResourcePreferences: defaultPref.UnifiedResourcePreferences, + ClusterPreferences: defaultPref.ClusterPreferences, + SideNavDrawerMode: defaultPref.SideNavDrawerMode, + DiscoverResourcePreferences: &userpreferencesv1.DiscoverResourcePreferences{ + PinnedGuides: []string{"guide-1", "guide-2"}, + }, + }, + }, { name: "update all the settings at once", req: &userpreferencesv1.UpsertUserPreferencesRequest{ @@ -245,6 +265,9 @@ func TestUserPreferencesCRUD(t *testing.T) { ResourceIds: []string{"node1", "node2"}, }, }, + DiscoverResourcePreferences: &userpreferencesv1.DiscoverResourcePreferences{ + PinnedGuides: []string{"guide-3", "guide-4"}, + }, }, }, expected: &userpreferencesv1.UserPreferences{ @@ -269,6 +292,9 @@ func TestUserPreferencesCRUD(t *testing.T) { ResourceIds: []string{"node1", "node2"}, }, }, + DiscoverResourcePreferences: &userpreferencesv1.DiscoverResourcePreferences{ + PinnedGuides: []string{"guide-3", "guide-4"}, + }, SideNavDrawerMode: userpreferencesv1.SideNavDrawerMode_SIDE_NAV_DRAWER_MODE_STICKY, }, }, diff --git a/lib/web/userpreferences.go b/lib/web/userpreferences.go index e6476022eccc5..f11da4c7f6273 100644 --- a/lib/web/userpreferences.go +++ b/lib/web/userpreferences.go @@ -64,15 +64,23 @@ type AccessGraphPreferencesResponse struct { HasBeenRedirected bool `json:"hasBeenRedirected"` } +// DiscoverResourcePreferencesResponse is the JSON response for discover resource preference +// as part of the user preference request. +type DiscoverResourcePreferencesResponse struct { + // PinnedGuides is a list of ids of pinned guides. + PinnedGuides []string `json:"pinnedGuides"` +} + // UserPreferencesResponse is the JSON response for the user preferences. type UserPreferencesResponse struct { - Assist AssistUserPreferencesResponse `json:"assist"` - Theme userpreferencesv1.Theme `json:"theme"` - UnifiedResourcePreferences UnifiedResourcePreferencesResponse `json:"unifiedResourcePreferences"` - Onboard OnboardUserPreferencesResponse `json:"onboard"` - ClusterPreferences ClusterUserPreferencesResponse `json:"clusterPreferences,omitempty"` - AccessGraph AccessGraphPreferencesResponse `json:"accessGraph,omitempty"` - SideNavDrawerMode userpreferencesv1.SideNavDrawerMode `json:"sideNavDrawerMode"` + Assist AssistUserPreferencesResponse `json:"assist"` + Theme userpreferencesv1.Theme `json:"theme"` + UnifiedResourcePreferences UnifiedResourcePreferencesResponse `json:"unifiedResourcePreferences"` + Onboard OnboardUserPreferencesResponse `json:"onboard"` + ClusterPreferences ClusterUserPreferencesResponse `json:"clusterPreferences,omitempty"` + DiscoverResourcePreferences DiscoverResourcePreferencesResponse `json:"discoverResourcePreferences"` + AccessGraph AccessGraphPreferencesResponse `json:"accessGraph,omitempty"` + SideNavDrawerMode userpreferencesv1.SideNavDrawerMode `json:"sideNavDrawerMode"` } func (h *Handler) getUserClusterPreferences(_ http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) { @@ -154,6 +162,9 @@ func makePreferenceRequest(req UserPreferencesResponse) *userpreferencesv1.Upser HasBeenRedirected: req.AccessGraph.HasBeenRedirected, }, SideNavDrawerMode: req.SideNavDrawerMode, + DiscoverResourcePreferences: &userpreferencesv1.DiscoverResourcePreferences{ + PinnedGuides: req.DiscoverResourcePreferences.PinnedGuides, + }, }, } } @@ -182,12 +193,13 @@ func (h *Handler) updateUserPreferences(_ http.ResponseWriter, r *http.Request, // userPreferencesResponse creates a JSON response for the user preferences. func userPreferencesResponse(resp *userpreferencesv1.UserPreferences) *UserPreferencesResponse { jsonResp := &UserPreferencesResponse{ - Theme: resp.Theme, - Onboard: onboardUserPreferencesResponse(resp.Onboard), - ClusterPreferences: clusterPreferencesResponse(resp.ClusterPreferences), - UnifiedResourcePreferences: unifiedResourcePreferencesResponse(resp.UnifiedResourcePreferences), - AccessGraph: accessGraphPreferencesResponse(resp.AccessGraph), - SideNavDrawerMode: resp.SideNavDrawerMode, + Theme: resp.Theme, + Onboard: onboardUserPreferencesResponse(resp.Onboard), + ClusterPreferences: clusterPreferencesResponse(resp.ClusterPreferences), + UnifiedResourcePreferences: unifiedResourcePreferencesResponse(resp.UnifiedResourcePreferences), + AccessGraph: accessGraphPreferencesResponse(resp.AccessGraph), + SideNavDrawerMode: resp.SideNavDrawerMode, + DiscoverResourcePreferences: discoverResourcePreferenceResponse(resp.DiscoverResourcePreferences), } return jsonResp @@ -243,3 +255,14 @@ func accessGraphPreferencesResponse(resp *userpreferencesv1.AccessGraphUserPrefe HasBeenRedirected: resp.HasBeenRedirected, } } + +// discoverResourcePreferenceResponse creates a JSON response for the discover resource preferences. +func discoverResourcePreferenceResponse(resp *userpreferencesv1.DiscoverResourcePreferences) DiscoverResourcePreferencesResponse { + if resp == nil || resp.GetPinnedGuides() == nil { + return DiscoverResourcePreferencesResponse{} + } + + return DiscoverResourcePreferencesResponse{ + PinnedGuides: resp.GetPinnedGuides(), + } +} diff --git a/web/packages/teleport/src/Discover/SelectResource/SelectResource.test.tsx b/web/packages/teleport/src/Discover/SelectResource/SelectResource.test.tsx index 85fae029a64b8..f353d4391c8c7 100644 --- a/web/packages/teleport/src/Discover/SelectResource/SelectResource.test.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/SelectResource.test.tsx @@ -37,8 +37,8 @@ import * as userUserContext from 'teleport/User/UserContext'; import { ResourceKind } from '../Shared'; import { resourceKindToPreferredResource } from '../Shared/ResourceKind'; +import { SelectResourceSpec } from './resources'; import { SelectResource } from './SelectResource'; -import { ResourceSpec } from './types'; import { filterBySupportedPlatformsAndAuthTypes } from './utils/filters'; import { sortResourcesByPreferences } from './utils/sort'; @@ -49,10 +49,11 @@ const setUp = () => { }; const makeResourceSpec = ( - overrides: Partial = {} -): ResourceSpec => { + overrides: Partial = {} +): SelectResourceSpec => { return Object.assign( { + id: '', name: '', kind: ResourceKind.Application, icon: '', @@ -85,7 +86,7 @@ const onboardDiscoverNoResources: OnboardDiscover = { test('sortResourcesByPreferences without preferred resources, sorts resources alphabetically with guided resources first', () => { setUp(); - const mockIn: ResourceSpec[] = [ + const mockIn: SelectResourceSpec[] = [ // unguided makeResourceSpec({ name: 'jenkins', unguidedLink: 'test.com' }), makeResourceSpec({ name: 'grafana', unguidedLink: 'test.com' }), @@ -152,7 +153,7 @@ const z_Discovery_NoAccess = makeResourceSpec({ hasAccess: false, }); -const NoAccessList: ResourceSpec[] = [ +const NoAccessList: SelectResourceSpec[] = [ t_Application_NoAccess, u_Database_NoAccess, v_Desktop_NoAccess, @@ -209,7 +210,7 @@ const l_Saml = makeResourceSpec({ kind: ResourceKind.SamlApplication, }); -const kindBasedList: ResourceSpec[] = [ +const kindBasedList: SelectResourceSpec[] = [ c_Application, a_Database, t_Application_NoAccess, @@ -239,7 +240,7 @@ describe('preferred resources', () => { const testCases: { name: string; preferred: Resource[]; - expected: ResourceSpec[]; + expected: SelectResourceSpec[]; }[] = [ { name: 'preferred server/ssh', @@ -374,7 +375,7 @@ describe('marketing params', () => { const testCases: { name: string; preferred: OnboardUserPreferences; - expected: ResourceSpec[]; + expected: SelectResourceSpec[]; }[] = [ { name: 'marketing params instead of preferred resources', @@ -571,7 +572,7 @@ describe('marketing params', () => { }); }); -const osBasedList: ResourceSpec[] = [ +const osBasedList: SelectResourceSpec[] = [ makeResourceSpec({ name: 'Aaaa' }), makeResourceSpec({ name: 'no-linux-1', @@ -599,7 +600,7 @@ describe('os sorted resources', () => { const testCases: { name: string; userAgent: UserAgent; - expected: ResourceSpec[]; + expected: SelectResourceSpec[]; }[] = [ { name: 'running mac', @@ -714,7 +715,7 @@ describe('os sorted resources', () => { }); test('does not prioritize os if the user does not have access', () => { - const mockIn: ResourceSpec[] = [ + const mockIn: SelectResourceSpec[] = [ makeResourceSpec({ name: 'macOs', platform: Platform.macOS, @@ -739,7 +740,7 @@ describe('os sorted resources', () => { ]); }); - const oneOfEachList: ResourceSpec[] = [ + const oneOfEachList: SelectResourceSpec[] = [ makeResourceSpec({ name: 'no access but super matches', hasAccess: false, diff --git a/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx b/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx index 257b2c2d189a0..0ce653b3e3526 100644 --- a/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx @@ -27,7 +27,10 @@ import { getPlatform } from 'design/platform'; import AddApp from 'teleport/Apps/AddApp'; import { FeatureHeader, FeatureHeaderTitle } from 'teleport/components/Layout'; import cfg from 'teleport/config'; -import { BASE_RESOURCES } from 'teleport/Discover/SelectResource/resources'; +import { + BASE_RESOURCES, + SelectResourceSpec, +} from 'teleport/Discover/SelectResource/resources'; import { HeaderSubtitle } from 'teleport/Discover/Shared'; import { storageService } from 'teleport/services/storageService'; import { useUser } from 'teleport/User/UserContext'; @@ -35,13 +38,13 @@ import useTeleport from 'teleport/useTeleport'; import { SAML_APPLICATIONS } from './resources'; import { Tile } from './Tile'; -import { SearchResource, type ResourceSpec } from './types'; +import { SearchResource } from './types'; import { addHasAccessField } from './utils/checkAccess'; import { filterBySupportedPlatformsAndAuthTypes } from './utils/filters'; import { sortResourcesByKind, sortResourcesByPreferences } from './utils/sort'; interface SelectResourceProps { - onSelect: (resource: ResourceSpec) => void; + onSelect: (resource: SelectResourceSpec) => void; } type UrlLocationState = { @@ -51,7 +54,7 @@ type UrlLocationState = { function getDefaultResources( includeEnterpriseResources: boolean -): ResourceSpec[] { +): SelectResourceSpec[] { const RESOURCES = includeEnterpriseResources ? [...BASE_RESOURCES, ...SAML_APPLICATIONS] : BASE_RESOURCES; @@ -67,7 +70,7 @@ export function SelectResource({ onSelect }: SelectResourceProps) { const [search, setSearch] = useState(''); const { acl, authType } = ctx.storeUser.state; const platform = getPlatform(); - const defaultResources: ResourceSpec[] = useMemo( + const defaultResources: SelectResourceSpec[] = useMemo( () => sortResourcesByPreferences( // Apply access check to each resource. @@ -93,7 +96,7 @@ export function SelectResource({ onSelect }: SelectResourceProps) { const [showApp, setShowApp] = useState(false); - function onSearch(s: string, customList?: ResourceSpec[]) { + function onSearch(s: string, customList?: SelectResourceSpec[]) { const list = customList || defaultResources; const search = s.split(' ').map(s => s.toLowerCase()); const found = list.filter(r => diff --git a/web/packages/teleport/src/Discover/SelectResource/Tile.tsx b/web/packages/teleport/src/Discover/SelectResource/Tile.tsx index e86d681b73394..8a5e68fa8a1f1 100644 --- a/web/packages/teleport/src/Discover/SelectResource/Tile.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/Tile.tsx @@ -30,16 +30,16 @@ import { import { getResourcePretitle } from '.'; import { DiscoverIcon } from './icons'; -import { type ResourceSpec } from './types'; +import { SelectResourceSpec } from './resources'; export function Tile({ resourceSpec, onChangeShowApp, onSelectResource, }: { - resourceSpec: ResourceSpec; + resourceSpec: SelectResourceSpec; onChangeShowApp(b: boolean): void; - onSelectResource(r: ResourceSpec): void; + onSelectResource(r: SelectResourceSpec): void; }) { const title = resourceSpec.name; const pretitle = getResourcePretitle(resourceSpec); diff --git a/web/packages/teleport/src/Discover/SelectResource/resources/databases.tsx b/web/packages/teleport/src/Discover/SelectResource/resources/databases.tsx index d30c280b4c0fb..081152bb72e40 100644 --- a/web/packages/teleport/src/Discover/SelectResource/resources/databases.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/resources/databases.tsx @@ -20,9 +20,11 @@ import { Platform } from 'design/platform'; import { DbProtocol } from 'shared/services/databases'; import { DiscoverEventResource } from 'teleport/services/userEvent'; +import { DiscoverGuideId } from 'teleport/services/userPreferences/discoverPreference'; import { ResourceKind } from '../../Shared/ResourceKind'; -import { DatabaseEngine, DatabaseLocation, ResourceSpec } from '../types'; +import { DatabaseEngine, DatabaseLocation } from '../types'; +import { SelectResourceSpec } from './resources'; const baseDatabaseKeywords = ['db', 'database', 'databases']; const awsKeywords = [...baseDatabaseKeywords, 'aws', 'amazon web services']; @@ -36,8 +38,9 @@ const azureKeywords = [...baseDatabaseKeywords, 'microsoft azure']; // DATABASES_UNGUIDED_DOC are documentations that is not specific // to one type of database. -export const DATABASES_UNGUIDED_DOC: ResourceSpec[] = [ +export const DATABASES_UNGUIDED_DOC: SelectResourceSpec[] = [ { + id: DiscoverGuideId.DatabaseAwsRdsProxyPostgres, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Doc }, name: 'RDS Proxy PostgreSQL', keywords: [...awsKeywords, 'rds', 'proxy', 'postgresql'], @@ -49,6 +52,7 @@ export const DATABASES_UNGUIDED_DOC: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseDocRdsProxy, }, { + id: DiscoverGuideId.DatabaseAwsRdsProxySqlServer, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Doc }, name: 'RDS Proxy SQL Server', keywords: [...awsKeywords, 'rds', 'proxy', 'sql server', 'sqlserver'], @@ -60,6 +64,7 @@ export const DATABASES_UNGUIDED_DOC: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseDocRdsProxy, }, { + id: DiscoverGuideId.DatabaseAwsRdsProxyMariaMySql, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Doc }, name: 'RDS Proxy MariaDB/MySQL', keywords: [...awsKeywords, 'rds', 'proxy', 'mariadb', 'mysql'], @@ -71,6 +76,7 @@ export const DATABASES_UNGUIDED_DOC: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseDocRdsProxy, }, { + id: DiscoverGuideId.DatabaseHighAvailability, dbMeta: { location: DatabaseLocation.TODO, engine: DatabaseEngine.Doc }, name: 'High Availability', keywords: [...baseDatabaseKeywords, 'high availability', 'ha'], @@ -81,6 +87,7 @@ export const DATABASES_UNGUIDED_DOC: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseDocHighAvailability, }, { + id: DiscoverGuideId.DatabaseDynamicRegistration, dbMeta: { location: DatabaseLocation.TODO, engine: DatabaseEngine.Doc }, name: 'Dynamic Registration', keywords: [...baseDatabaseKeywords, 'dynamic registration'], @@ -92,8 +99,9 @@ export const DATABASES_UNGUIDED_DOC: ResourceSpec[] = [ }, ]; -export const DATABASES_UNGUIDED: ResourceSpec[] = [ +export const DATABASES_UNGUIDED: SelectResourceSpec[] = [ { + id: DiscoverGuideId.DatabaseAwsDynamoDb, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.DynamoDb }, name: 'DynamoDB', keywords: [...awsKeywords, 'dynamodb'], @@ -104,6 +112,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseDynamoDb, }, { + id: DiscoverGuideId.DatabaseAwsElastiCacheMemoryDb, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Redis }, name: 'ElastiCache & MemoryDB', keywords: [...awsKeywords, 'elasticache', 'memorydb', 'redis'], @@ -114,6 +123,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseRedisElasticache, }, { + id: DiscoverGuideId.DatabaseAwsCassandraKeyspaces, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Cassandra, @@ -127,6 +137,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseCassandraKeyspaces, }, { + id: DiscoverGuideId.DatabaseAwsPostgresRedshift, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Redshift }, name: 'Redshift PostgreSQL', keywords: [...awsKeywords, 'redshift', 'postgresql'], @@ -137,6 +148,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabasePostgresRedshift, }, { + id: DiscoverGuideId.DatabaseAwsRedshiftServerless, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Redshift }, name: 'Redshift Serverless', keywords: [...awsKeywords, 'redshift', 'serverless', 'postgresql'], @@ -147,6 +159,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabasePostgresRedshiftServerless, }, { + id: DiscoverGuideId.DatabaseAzureRedis, dbMeta: { location: DatabaseLocation.Azure, engine: DatabaseEngine.Redis }, name: 'Cache for Redis', keywords: [...azureKeywords, 'cache', 'redis'], @@ -157,6 +170,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseRedisAzureCache, }, { + id: DiscoverGuideId.DatabaseAzurePostgres, dbMeta: { location: DatabaseLocation.Azure, engine: DatabaseEngine.Postgres, @@ -170,6 +184,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabasePostgresAzure, }, { + id: DiscoverGuideId.DatabaseAzureMysql, dbMeta: { location: DatabaseLocation.Azure, engine: DatabaseEngine.MySql }, name: 'MySQL', keywords: [...azureKeywords, 'mysql'], @@ -180,6 +195,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseMysqlAzure, }, { + id: DiscoverGuideId.DatabaseAzureSqlServerAd, dbMeta: { location: DatabaseLocation.Azure, engine: DatabaseEngine.SqlServer, @@ -201,6 +217,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ platform: Platform.Windows, }, { + id: DiscoverGuideId.DatabaseAwsSqlServerAd, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.SqlServer, @@ -224,6 +241,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ platform: Platform.Windows, }, { + id: DiscoverGuideId.DatabaseGcpMysqlCloudSql, dbMeta: { location: DatabaseLocation.Gcp, engine: DatabaseEngine.MySql }, name: 'Cloud SQL MySQL', keywords: [...gcpKeywords, 'mysql'], @@ -234,6 +252,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseMysqlGcp, }, { + id: DiscoverGuideId.DatabaseGcpPostgresCloudSql, dbMeta: { location: DatabaseLocation.Gcp, engine: DatabaseEngine.Postgres }, name: 'Cloud SQL PostgreSQL', keywords: [...gcpKeywords, 'postgresql'], @@ -244,6 +263,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabasePostgresGcp, }, { + id: DiscoverGuideId.DatabaseMongoAtlas, dbMeta: { location: DatabaseLocation.TODO, engine: DatabaseEngine.MongoDb, @@ -257,6 +277,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseMongodbAtlas, }, { + id: DiscoverGuideId.DatabaseCassandraScyllaDb, dbMeta: { location: DatabaseLocation.SelfHosted, engine: DatabaseEngine.Cassandra, @@ -270,6 +291,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseCassandraSelfHosted, }, { + id: DiscoverGuideId.DatabaseCockroachDb, dbMeta: { location: DatabaseLocation.SelfHosted, engine: DatabaseEngine.CockroachDb, @@ -283,6 +305,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseCockroachDbSelfHosted, }, { + id: DiscoverGuideId.DatabaseElasticSearch, dbMeta: { location: DatabaseLocation.SelfHosted, engine: DatabaseEngine.ElasticSearch, @@ -296,6 +319,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseElasticSearchSelfHosted, }, { + id: DiscoverGuideId.DatabaseMongoDb, dbMeta: { location: DatabaseLocation.SelfHosted, engine: DatabaseEngine.MongoDb, @@ -309,6 +333,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseMongodbSelfHosted, }, { + id: DiscoverGuideId.DatabaseRedis, dbMeta: { location: DatabaseLocation.SelfHosted, engine: DatabaseEngine.Redis, @@ -322,6 +347,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseRedisSelfHosted, }, { + id: DiscoverGuideId.DatabaseRedisCluster, dbMeta: { location: DatabaseLocation.SelfHosted, engine: DatabaseEngine.Redis, @@ -335,6 +361,7 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseRedisClusterSelfHosted, }, { + id: DiscoverGuideId.DatabaseSnowflake, dbMeta: { location: DatabaseLocation.TODO, engine: DatabaseEngine.Snowflake, @@ -349,8 +376,9 @@ export const DATABASES_UNGUIDED: ResourceSpec[] = [ }, ]; -export const DATABASES: ResourceSpec[] = [ +export const DATABASES: SelectResourceSpec[] = [ { + id: DiscoverGuideId.DatabaseAwsRdsPostgres, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Postgres, @@ -362,6 +390,7 @@ export const DATABASES: ResourceSpec[] = [ event: DiscoverEventResource.DatabasePostgresRds, }, { + id: DiscoverGuideId.DatabaseAwsRdsAuroraPostgres, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.AuroraPostgres, @@ -373,6 +402,7 @@ export const DATABASES: ResourceSpec[] = [ event: DiscoverEventResource.DatabasePostgresRds, }, { + id: DiscoverGuideId.DatabaseAwsRdsMysqlMariaDb, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.MySql }, name: 'RDS MySQL/MariaDB', keywords: [...awsKeywords, 'rds mysql mariadb'], @@ -381,6 +411,7 @@ export const DATABASES: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseMysqlRds, }, { + id: DiscoverGuideId.DatabaseAwsRdsAuroraMysql, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.AuroraMysql, @@ -392,6 +423,7 @@ export const DATABASES: ResourceSpec[] = [ event: DiscoverEventResource.DatabaseMysqlRds, }, { + id: DiscoverGuideId.DatabasePostgres, dbMeta: { location: DatabaseLocation.SelfHosted, engine: DatabaseEngine.Postgres, @@ -403,6 +435,7 @@ export const DATABASES: ResourceSpec[] = [ event: DiscoverEventResource.DatabasePostgresSelfHosted, }, { + id: DiscoverGuideId.DatabaseMysql, dbMeta: { location: DatabaseLocation.SelfHosted, engine: DatabaseEngine.MySql, diff --git a/web/packages/teleport/src/Discover/SelectResource/resources/resources.tsx b/web/packages/teleport/src/Discover/SelectResource/resources/resources.tsx index 56cfb9c66e80b..c46a8b58bbdcb 100644 --- a/web/packages/teleport/src/Discover/SelectResource/resources/resources.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/resources/resources.tsx @@ -23,6 +23,7 @@ import { DiscoverDiscoveryConfigMethod, DiscoverEventResource, } from 'teleport/services/userEvent'; +import { DiscoverGuideId } from 'teleport/services/userPreferences/discoverPreference'; import { ResourceKind } from '../../Shared/ResourceKind'; import { @@ -38,12 +39,21 @@ import { DATABASES_UNGUIDED_DOC, } from './databases'; +export type SelectResourceSpec = ResourceSpec & { + id: DiscoverGuideId; + /** + * true if user pinned this guide + */ + pinned?: boolean; +}; + const baseServerKeywords = ['server', 'node', 'ssh']; const awsKeywords = ['aws', 'amazon', 'amazon web services']; const kubeKeywords = ['kubernetes', 'k8s', 'kubes', 'cluster']; -export const SERVERS: ResourceSpec[] = [ +export const SERVERS: SelectResourceSpec[] = [ { + id: DiscoverGuideId.ServerLinuxUbuntu, name: 'Ubuntu 18.04+', kind: ResourceKind.Server, keywords: [...baseServerKeywords, 'ubuntu', 'linux'], @@ -52,6 +62,7 @@ export const SERVERS: ResourceSpec[] = [ platform: Platform.Linux, }, { + id: DiscoverGuideId.ServerLinuxDebian, name: 'Debian 11+', kind: ResourceKind.Server, keywords: [...baseServerKeywords, 'debian', 'linux'], @@ -60,6 +71,7 @@ export const SERVERS: ResourceSpec[] = [ platform: Platform.Linux, }, { + id: DiscoverGuideId.ServerLinuxRhelCentos, name: 'RHEL 8+/CentOS Stream 9+', kind: ResourceKind.Server, keywords: [...baseServerKeywords, 'rhel', 'redhat', 'centos', 'linux'], @@ -68,6 +80,7 @@ export const SERVERS: ResourceSpec[] = [ platform: Platform.Linux, }, { + id: DiscoverGuideId.ServerLinuxAmazon, name: 'Amazon Linux 2/2023', kind: ResourceKind.Server, keywords: [...baseServerKeywords, 'amazon', 'linux'], @@ -76,6 +89,7 @@ export const SERVERS: ResourceSpec[] = [ platform: Platform.Linux, }, { + id: DiscoverGuideId.ServerMac, name: 'macOS', kind: ResourceKind.Server, keywords: [ @@ -92,6 +106,7 @@ export const SERVERS: ResourceSpec[] = [ platform: Platform.macOS, }, { + id: DiscoverGuideId.ServerAwsEc2Ssm, name: 'EC2 Auto Enrollment via SSM', kind: ResourceKind.Server, keywords: [ @@ -111,6 +126,7 @@ export const SERVERS: ResourceSpec[] = [ }, }, { + id: DiscoverGuideId.ConnectMyComputer, name: 'Connect My Computer', kind: ResourceKind.ConnectMyComputer, keywords: [ @@ -127,9 +143,10 @@ export const SERVERS: ResourceSpec[] = [ }, ]; -export const APPLICATIONS: ResourceSpec[] = [ +export const APPLICATIONS: SelectResourceSpec[] = [ { - name: 'Application', + id: DiscoverGuideId.ApplicationWebHttpProxy, + name: 'Web Application', kind: ResourceKind.Application, keywords: ['application'], icon: 'application', @@ -137,6 +154,7 @@ export const APPLICATIONS: ResourceSpec[] = [ event: DiscoverEventResource.ApplicationHttp, }, { + id: DiscoverGuideId.ApplicationAwsCliConsole, name: 'AWS CLI/Console Access', kind: ResourceKind.Application, keywords: [...awsKeywords, 'application', 'cli', 'console access'], @@ -146,8 +164,9 @@ export const APPLICATIONS: ResourceSpec[] = [ }, ]; -export const WINDOWS_DESKTOPS: ResourceSpec[] = [ +export const WINDOWS_DESKTOPS: SelectResourceSpec[] = [ { + id: DiscoverGuideId.WindowsDesktopsActiveDirectory, name: 'Active Directory Users', kind: ResourceKind.Desktop, keywords: ['windows', 'desktop', 'microsoft active directory', 'ad'], @@ -157,6 +176,7 @@ export const WINDOWS_DESKTOPS: ResourceSpec[] = [ 'https://goteleport.com/docs/enroll-resources/desktop-access/active-directory/', }, { + id: DiscoverGuideId.WindowsDesktopsLocal, name: 'Local Users', kind: ResourceKind.Desktop, keywords: ['windows', 'desktop', 'non-ad', 'local'], @@ -167,8 +187,9 @@ export const WINDOWS_DESKTOPS: ResourceSpec[] = [ }, ]; -export const KUBERNETES: ResourceSpec[] = [ +export const KUBERNETES: SelectResourceSpec[] = [ { + id: DiscoverGuideId.Kubernetes, name: 'Kubernetes', kind: ResourceKind.Kubernetes, keywords: [...kubeKeywords], @@ -177,6 +198,7 @@ export const KUBERNETES: ResourceSpec[] = [ kubeMeta: { location: KubeLocation.SelfHosted }, }, { + id: DiscoverGuideId.KubernetesAwsEks, name: 'EKS', kind: ResourceKind.Kubernetes, keywords: [...awsKeywords, ...kubeKeywords, 'eks', 'elastic', 'service'], @@ -186,7 +208,7 @@ export const KUBERNETES: ResourceSpec[] = [ }, ]; -export const BASE_RESOURCES: ResourceSpec[] = [ +export const BASE_RESOURCES: SelectResourceSpec[] = [ ...APPLICATIONS, ...KUBERNETES, ...WINDOWS_DESKTOPS, @@ -196,7 +218,7 @@ export const BASE_RESOURCES: ResourceSpec[] = [ ...DATABASES_UNGUIDED_DOC, ]; -export function getResourcePretitle(r: ResourceSpec) { +export function getResourcePretitle(r: SelectResourceSpec) { if (!r) { return ''; } @@ -220,6 +242,15 @@ export function getResourcePretitle(r: ResourceSpec) { if (r.dbMeta.engine === DatabaseEngine.Doc) { return 'Database'; } + if ( + r.id === DiscoverGuideId.DatabaseSnowflake || + r.id === DiscoverGuideId.DatabaseMongoAtlas + ) { + return 'Database as a Service'; + } + if (r.id === DiscoverGuideId.DatabaseDynamicRegistration) { + return 'Self-Hosted'; + } } break; case ResourceKind.Desktop: @@ -240,9 +271,24 @@ export function getResourcePretitle(r: ResourceSpec) { if (r.nodeMeta?.location === ServerLocation.Aws) { return 'Amazon Web Services (AWS)'; } - return 'Server'; + return 'SSH'; case ResourceKind.SamlApplication: + if ( + r.id === DiscoverGuideId.ApplicationSamlGeneric || + r.id === DiscoverGuideId.ApplicationSamlGrafana + ) { + return 'Teleport as IDP'; + } return 'SAML Application'; + case ResourceKind.ConnectMyComputer: + return 'SSH'; + case ResourceKind.Application: + if (r.id === DiscoverGuideId.ApplicationAwsCliConsole) { + return 'Amazon Web Services (AWS)'; + } + if (r.id === DiscoverGuideId.ApplicationWebHttpProxy) { + return 'HTTP Proxy'; + } } return ''; diff --git a/web/packages/teleport/src/Discover/SelectResource/resources/resourcesE.tsx b/web/packages/teleport/src/Discover/SelectResource/resources/resourcesE.tsx index b6056f4cf344c..a6a69f5d04b7a 100644 --- a/web/packages/teleport/src/Discover/SelectResource/resources/resourcesE.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/resources/resourcesE.tsx @@ -18,12 +18,14 @@ import { SamlServiceProviderPreset } from 'teleport/services/samlidp/types'; import { DiscoverEventResource } from 'teleport/services/userEvent'; +import { DiscoverGuideId } from 'teleport/services/userPreferences/discoverPreference'; import { ResourceKind } from '../../Shared'; -import { ResourceSpec } from '../types'; +import { SelectResourceSpec } from './resources'; -export const SAML_APPLICATIONS: ResourceSpec[] = [ +export const SAML_APPLICATIONS: SelectResourceSpec[] = [ { + id: DiscoverGuideId.ApplicationSamlGeneric, name: 'SAML Application (Generic)', kind: ResourceKind.SamlApplication, samlMeta: { preset: SamlServiceProviderPreset.Unspecified }, @@ -32,7 +34,8 @@ export const SAML_APPLICATIONS: ResourceSpec[] = [ event: DiscoverEventResource.SamlApplication, }, { - name: 'Grafana', + id: DiscoverGuideId.ApplicationSamlGrafana, + name: 'Grafana SAML', kind: ResourceKind.SamlApplication, samlMeta: { preset: SamlServiceProviderPreset.Grafana }, keywords: ['saml', 'sso', 'application', 'idp', 'grafana'], @@ -40,6 +43,7 @@ export const SAML_APPLICATIONS: ResourceSpec[] = [ event: DiscoverEventResource.SamlApplication, }, { + id: DiscoverGuideId.ApplicationSamlWorkforceIdentityFederation, name: 'Workforce Identity Federation', kind: ResourceKind.SamlApplication, samlMeta: { preset: SamlServiceProviderPreset.GcpWorkforce }, diff --git a/web/packages/teleport/src/Discover/SelectResource/utils/checkAccess.ts b/web/packages/teleport/src/Discover/SelectResource/utils/checkAccess.ts index 7292e28413c52..01fadfed5ec48 100644 --- a/web/packages/teleport/src/Discover/SelectResource/utils/checkAccess.ts +++ b/web/packages/teleport/src/Discover/SelectResource/utils/checkAccess.ts @@ -19,7 +19,7 @@ import { Acl } from 'teleport/services/user'; import { ResourceKind } from '../../Shared'; -import { ResourceSpec } from '../types'; +import { SelectResourceSpec } from '../resources'; function checkHasAccess(acl: Acl, resourceKind: ResourceKind) { const basePerm = acl.tokens.create; @@ -51,8 +51,8 @@ function checkHasAccess(acl: Acl, resourceKind: ResourceKind) { export function addHasAccessField( acl: Acl, - resources: ResourceSpec[] -): ResourceSpec[] { + resources: SelectResourceSpec[] +): SelectResourceSpec[] { return resources.map(r => { const hasAccess = checkHasAccess(acl, r.kind); switch (r.kind) { diff --git a/web/packages/teleport/src/Discover/SelectResource/utils/filters.ts b/web/packages/teleport/src/Discover/SelectResource/utils/filters.ts index 325a85c97d94a..a2eebc5d74b44 100644 --- a/web/packages/teleport/src/Discover/SelectResource/utils/filters.ts +++ b/web/packages/teleport/src/Discover/SelectResource/utils/filters.ts @@ -20,12 +20,12 @@ import { Platform } from 'design/platform'; import { AuthType } from 'teleport/services/user'; -import { type ResourceSpec } from '../types'; +import { SelectResourceSpec } from '../resources'; export function filterBySupportedPlatformsAndAuthTypes( platform: Platform, authType: AuthType, - resources: ResourceSpec[] + resources: SelectResourceSpec[] ) { return resources.filter(resource => { const resourceSupportsPlatform = diff --git a/web/packages/teleport/src/Discover/SelectResource/utils/sort.ts b/web/packages/teleport/src/Discover/SelectResource/utils/sort.ts index c43049f632418..fbf5884fd165b 100644 --- a/web/packages/teleport/src/Discover/SelectResource/utils/sort.ts +++ b/web/packages/teleport/src/Discover/SelectResource/utils/sort.ts @@ -25,10 +25,11 @@ 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'; +import { SelectResourceSpec } from '../resources'; +import { PrioritizedResources, SearchResource } from '../types'; function isConnectMyComputerAvailable( - accessibleResources: ResourceSpec[] + accessibleResources: SelectResourceSpec[] ): boolean { return !!accessibleResources.find( resource => resource.kind === ResourceKind.ConnectMyComputer @@ -36,7 +37,7 @@ function isConnectMyComputerAvailable( } export function sortResourcesByPreferences( - resources: ResourceSpec[], + resources: SelectResourceSpec[], preferences: UserPreferences, onboardDiscover: OnboardDiscover | undefined ) { @@ -70,7 +71,7 @@ export function sortResourcesByPreferences( // 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) => + const compareAB = (predicate: (r: SelectResourceSpec) => boolean) => comparePredicate(a, b, predicate); const areBothGuided = !a.unguidedLink && !b.unguidedLink; @@ -223,9 +224,9 @@ function comparePredicate( export function sortResourcesByKind( resourceKind: SearchResource, - resources: ResourceSpec[] + resources: SelectResourceSpec[] ) { - let sorted: ResourceSpec[] = []; + let sorted: SelectResourceSpec[] = []; switch (resourceKind) { case SearchResource.SERVER: sorted = [ diff --git a/web/packages/teleport/src/services/userPreferences/discoverPreference.ts b/web/packages/teleport/src/services/userPreferences/discoverPreference.ts new file mode 100644 index 0000000000000..05c1b2ce273c2 --- /dev/null +++ b/web/packages/teleport/src/services/userPreferences/discoverPreference.ts @@ -0,0 +1,90 @@ +/** + * 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 . + */ + +/** + * Used to uniquely identify resource guides. These UID's will + * be stored in the backend as user preference to preserve + * which resource guides user wanted to "pin". + * + * There is no specific format to use, just ensure that enum values + * are unique. + * + * Existing enum values must not be modified. + */ +export enum DiscoverGuideId { + // Servers: + ServerLinuxUbuntu = 'server-linux-ubuntu', + ServerLinuxDebian = 'server-linux-debian', + ServerLinuxRhelCentos = 'server-linux-rhel-centos', + ServerLinuxAmazon = 'server-linux-amazon', + ServerMac = 'server-mac', + ServerAwsEc2Ssm = 'server-aws-ec2-ssm', + ConnectMyComputer = 'connect-my-computer', + + // Applications: + ApplicationWebHttpProxy = 'application-web-http-proxy', + ApplicationAwsCliConsole = 'application-aws-cli-console', + ApplicationSamlGeneric = 'application-saml-generic', + ApplicationSamlGrafana = 'application-saml-grafana', + ApplicationSamlWorkforceIdentityFederation = 'application-saml-workforce-identity-federation', + + // Windows Desktops: + WindowsDesktopsActiveDirectory = 'windows-desktops-active-directory', + WindowsDesktopsLocal = 'windows-desktops-local', + + // Kubernetes: + Kubernetes = 'kubernetes', + KubernetesAwsEks = 'kubernetes-aws-eks', + + // Databases: + DatabaseAwsDynamoDb = 'database-aws-dynamo-db', + DatabaseAwsElastiCacheMemoryDb = 'database-aws-elasticache-memorydb', + DatabaseAwsCassandraKeyspaces = 'database-aws-cassandra-keyspaces', + DatabaseAwsRedshiftServerless = 'database-aws-redshift-serverless', + DatabaseAwsSqlServerAd = 'database-aws-sql-server-ad', + DatabaseAwsPostgresRedshift = 'database-aws-postgres-redshift', + DatabaseAwsRdsPostgres = 'database-aws-rds-postgres', + DatabaseAwsRdsProxyPostgres = 'database-aws-rds-proxy-postgres', + DatabaseAwsRdsAuroraPostgres = 'database-aws-rds-aurora-postgres', + DatabaseAwsRdsProxySqlServer = 'database-aws-rds-proxy-sql-server', + DatabaseAwsRdsProxyMariaMySql = 'database-aws-rds-proxy-maria-mysql', + DatabaseAwsRdsAuroraMysql = 'database-aws-rds-aurora-mysql', + DatabaseAwsRdsMysqlMariaDb = 'database-aws-rds-mysql-mariadb', + + DatabaseHighAvailability = 'database-high-availability', + DatabaseDynamicRegistration = 'database-dynamic-registration', + + DatabaseAzureRedis = 'database-azure-redis', + DatabaseAzurePostgres = 'database-azure-postgres', + DatabaseAzureMysql = 'database-azure-mysql', + DatabaseAzureSqlServerAd = 'database-azure-sql-server-ad', + + DatabaseGcpMysqlCloudSql = 'database-gcp-mysql-cloud-sql', + DatabaseGcpPostgresCloudSql = 'database-gcp-postgres-cloud-sql', + + DatabaseMongoAtlas = 'database-mongo-atlas', + DatabaseCassandraScyllaDb = 'database-cassandra-scylladb', + DatabaseCockroachDb = 'database-cockroachdb', + DatabaseElasticSearch = 'database-elasticsearch', + DatabaseMongoDb = 'database-mongodb', + DatabaseRedis = 'database-redis', + DatabaseRedisCluster = 'database-redis-cluster', + DatabaseSnowflake = 'database-snowflake', + DatabasePostgres = 'database-postgres', + DatabaseMysql = 'database-mysql', +} diff --git a/web/packages/teleport/src/services/userPreferences/userPreferences.ts b/web/packages/teleport/src/services/userPreferences/userPreferences.ts index b8a05ddb3ea76..18b7a500f68fe 100644 --- a/web/packages/teleport/src/services/userPreferences/userPreferences.ts +++ b/web/packages/teleport/src/services/userPreferences/userPreferences.ts @@ -17,6 +17,7 @@ */ import { ClusterUserPreferences } from 'gen-proto-ts/teleport/userpreferences/v1/cluster_preferences_pb'; +import { DiscoverResourcePreferences } from 'gen-proto-ts/teleport/userpreferences/v1/discover_resource_preferences_pb'; import { OnboardUserPreferences } from 'gen-proto-ts/teleport/userpreferences/v1/onboard_pb'; import { SideNavDrawerMode } from 'gen-proto-ts/teleport/userpreferences/v1/sidenav_preferences_pb'; import { Theme } from 'gen-proto-ts/teleport/userpreferences/v1/theme_pb'; @@ -43,6 +44,7 @@ export interface BackendUserPreferences { onboard?: OnboardUserPreferences; clusterPreferences?: BackendClusterUserPreferences; unifiedResourcePreferences?: UnifiedResourcePreferences; + discoverResourcePreferences?: DiscoverResourcePreferences; } export async function getUserPreferences(): Promise { From 347c0cb50a945f19341056d4bb5010de95c230bd Mon Sep 17 00:00:00 2001 From: Lisa Kim Date: Wed, 12 Feb 2025 00:43:43 -0800 Subject: [PATCH 3/4] Wrap discover guide preference "pinned" field inside a proto message (#52017) This allows us to use "nil" value to mean no discover guide preferences set (which is used to set default values in the web UI). --- .../v1/discover_resource_preferences.pb.go | 110 +++++++++++++----- .../v1/discover_resource_preferences.proto | 12 +- .../v1/discover_resource_preferences_pb.ts | 79 +++++++++++-- lib/services/local/userpreferences_test.go | 16 ++- lib/web/userpreferences.go | 23 +++- 5 files changed, 192 insertions(+), 48 deletions(-) diff --git a/api/gen/proto/go/userpreferences/v1/discover_resource_preferences.pb.go b/api/gen/proto/go/userpreferences/v1/discover_resource_preferences.pb.go index 89c5b10a540ea..e41df80ac9a86 100644 --- a/api/gen/proto/go/userpreferences/v1/discover_resource_preferences.pb.go +++ b/api/gen/proto/go/userpreferences/v1/discover_resource_preferences.pb.go @@ -34,19 +34,66 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// DiscoverGuide defines preferences related to discover guides. +type DiscoverGuide struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // pinned is a list of ids of pinned guides. + Pinned []string `protobuf:"bytes,1,rep,name=pinned,proto3" json:"pinned,omitempty"` +} + +func (x *DiscoverGuide) Reset() { + *x = DiscoverGuide{} + mi := &file_teleport_userpreferences_v1_discover_resource_preferences_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DiscoverGuide) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DiscoverGuide) ProtoMessage() {} + +func (x *DiscoverGuide) ProtoReflect() protoreflect.Message { + mi := &file_teleport_userpreferences_v1_discover_resource_preferences_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DiscoverGuide.ProtoReflect.Descriptor instead. +func (*DiscoverGuide) Descriptor() ([]byte, []int) { + return file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDescGZIP(), []int{0} +} + +func (x *DiscoverGuide) GetPinned() []string { + if x != nil { + return x.Pinned + } + return nil +} + // DiscoverResourcePreferences holds preferences related to discovering resource. type DiscoverResourcePreferences struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - // pinned_guides is a list of ids of pinned guides. - PinnedGuides []string `protobuf:"bytes,1,rep,name=pinned_guides,json=pinnedGuides,proto3" json:"pinned_guides,omitempty"` + // discover_guide defines preferences related to discover guides. + DiscoverGuide *DiscoverGuide `protobuf:"bytes,2,opt,name=discover_guide,json=discoverGuide,proto3" json:"discover_guide,omitempty"` } func (x *DiscoverResourcePreferences) Reset() { *x = DiscoverResourcePreferences{} - mi := &file_teleport_userpreferences_v1_discover_resource_preferences_proto_msgTypes[0] + mi := &file_teleport_userpreferences_v1_discover_resource_preferences_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -58,7 +105,7 @@ func (x *DiscoverResourcePreferences) String() string { func (*DiscoverResourcePreferences) ProtoMessage() {} func (x *DiscoverResourcePreferences) ProtoReflect() protoreflect.Message { - mi := &file_teleport_userpreferences_v1_discover_resource_preferences_proto_msgTypes[0] + mi := &file_teleport_userpreferences_v1_discover_resource_preferences_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -71,12 +118,12 @@ func (x *DiscoverResourcePreferences) ProtoReflect() protoreflect.Message { // Deprecated: Use DiscoverResourcePreferences.ProtoReflect.Descriptor instead. func (*DiscoverResourcePreferences) Descriptor() ([]byte, []int) { - return file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDescGZIP(), []int{0} + return file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDescGZIP(), []int{1} } -func (x *DiscoverResourcePreferences) GetPinnedGuides() []string { +func (x *DiscoverResourcePreferences) GetDiscoverGuide() *DiscoverGuide { if x != nil { - return x.PinnedGuides + return x.DiscoverGuide } return nil } @@ -89,18 +136,25 @@ var file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDesc 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x1b, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, - 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x22, 0x42, - 0x0a, 0x1b, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x23, 0x0a, - 0x0d, 0x70, 0x69, 0x6e, 0x6e, 0x65, 0x64, 0x5f, 0x67, 0x75, 0x69, 0x64, 0x65, 0x73, 0x18, 0x01, - 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x69, 0x6e, 0x6e, 0x65, 0x64, 0x47, 0x75, 0x69, 0x64, - 0x65, 0x73, 0x42, 0x59, 0x5a, 0x57, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, - 0x2f, 0x67, 0x72, 0x61, 0x76, 0x69, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x2f, 0x74, - 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x67, 0x65, 0x6e, 0x2f, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x6f, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, - 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, 0x76, 0x31, 0x3b, 0x75, 0x73, 0x65, 0x72, - 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x76, 0x31, 0x62, 0x06, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x22, 0x27, + 0x0a, 0x0d, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x47, 0x75, 0x69, 0x64, 0x65, 0x12, + 0x16, 0x0a, 0x06, 0x70, 0x69, 0x6e, 0x6e, 0x65, 0x64, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, + 0x06, 0x70, 0x69, 0x6e, 0x6e, 0x65, 0x64, 0x22, 0x85, 0x01, 0x0a, 0x1b, 0x44, 0x69, 0x73, 0x63, + 0x6f, 0x76, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, + 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x51, 0x0a, 0x0e, 0x64, 0x69, 0x73, 0x63, 0x6f, + 0x76, 0x65, 0x72, 0x5f, 0x67, 0x75, 0x69, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x2a, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x70, + 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, + 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x47, 0x75, 0x69, 0x64, 0x65, 0x52, 0x0d, 0x64, 0x69, 0x73, + 0x63, 0x6f, 0x76, 0x65, 0x72, 0x47, 0x75, 0x69, 0x64, 0x65, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, + 0x52, 0x0d, 0x70, 0x69, 0x6e, 0x6e, 0x65, 0x64, 0x5f, 0x67, 0x75, 0x69, 0x64, 0x65, 0x73, 0x42, + 0x59, 0x5a, 0x57, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, + 0x61, 0x76, 0x69, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x2f, 0x74, 0x65, 0x6c, 0x65, + 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x2f, 0x67, 0x6f, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, + 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, 0x76, 0x31, 0x3b, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, + 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, } var ( @@ -115,16 +169,18 @@ func file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDes return file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDescData } -var file_teleport_userpreferences_v1_discover_resource_preferences_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_teleport_userpreferences_v1_discover_resource_preferences_proto_msgTypes = make([]protoimpl.MessageInfo, 2) var file_teleport_userpreferences_v1_discover_resource_preferences_proto_goTypes = []any{ - (*DiscoverResourcePreferences)(nil), // 0: teleport.userpreferences.v1.DiscoverResourcePreferences + (*DiscoverGuide)(nil), // 0: teleport.userpreferences.v1.DiscoverGuide + (*DiscoverResourcePreferences)(nil), // 1: teleport.userpreferences.v1.DiscoverResourcePreferences } var file_teleport_userpreferences_v1_discover_resource_preferences_proto_depIdxs = []int32{ - 0, // [0:0] is the sub-list for method output_type - 0, // [0:0] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name + 0, // 0: teleport.userpreferences.v1.DiscoverResourcePreferences.discover_guide:type_name -> teleport.userpreferences.v1.DiscoverGuide + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name } func init() { file_teleport_userpreferences_v1_discover_resource_preferences_proto_init() } @@ -138,7 +194,7 @@ func file_teleport_userpreferences_v1_discover_resource_preferences_proto_init() GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_teleport_userpreferences_v1_discover_resource_preferences_proto_rawDesc, NumEnums: 0, - NumMessages: 1, + NumMessages: 2, NumExtensions: 0, NumServices: 0, }, diff --git a/api/proto/teleport/userpreferences/v1/discover_resource_preferences.proto b/api/proto/teleport/userpreferences/v1/discover_resource_preferences.proto index c779e9331c40d..97431bcea1307 100644 --- a/api/proto/teleport/userpreferences/v1/discover_resource_preferences.proto +++ b/api/proto/teleport/userpreferences/v1/discover_resource_preferences.proto @@ -18,8 +18,16 @@ package teleport.userpreferences.v1; option go_package = "github.com/gravitational/teleport/api/gen/proto/go/userpreferences/v1;userpreferencesv1"; +// DiscoverGuide defines preferences related to discover guides. +message DiscoverGuide { + // pinned is a list of ids of pinned guides. + repeated string pinned = 1; +} + // DiscoverResourcePreferences holds preferences related to discovering resource. message DiscoverResourcePreferences { - // pinned_guides is a list of ids of pinned guides. - repeated string pinned_guides = 1; + reserved 1; + reserved "pinned_guides"; + // discover_guide defines preferences related to discover guides. + DiscoverGuide discover_guide = 2; } diff --git a/gen/proto/ts/teleport/userpreferences/v1/discover_resource_preferences_pb.ts b/gen/proto/ts/teleport/userpreferences/v1/discover_resource_preferences_pb.ts index d15b19ae60601..763ba7ee433a1 100644 --- a/gen/proto/ts/teleport/userpreferences/v1/discover_resource_preferences_pb.ts +++ b/gen/proto/ts/teleport/userpreferences/v1/discover_resource_preferences_pb.ts @@ -27,6 +27,19 @@ import { UnknownFieldHandler } from "@protobuf-ts/runtime"; import type { PartialMessage } from "@protobuf-ts/runtime"; import { reflectionMergePartial } from "@protobuf-ts/runtime"; import { MessageType } from "@protobuf-ts/runtime"; +/** + * DiscoverGuide defines preferences related to discover guides. + * + * @generated from protobuf message teleport.userpreferences.v1.DiscoverGuide + */ +export interface DiscoverGuide { + /** + * pinned is a list of ids of pinned guides. + * + * @generated from protobuf field: repeated string pinned = 1; + */ + pinned: string[]; +} /** * DiscoverResourcePreferences holds preferences related to discovering resource. * @@ -34,22 +47,68 @@ import { MessageType } from "@protobuf-ts/runtime"; */ export interface DiscoverResourcePreferences { /** - * pinned_guides is a list of ids of pinned guides. + * discover_guide defines preferences related to discover guides. * - * @generated from protobuf field: repeated string pinned_guides = 1; + * @generated from protobuf field: teleport.userpreferences.v1.DiscoverGuide discover_guide = 2; */ - pinnedGuides: string[]; + discoverGuide?: DiscoverGuide; } // @generated message type with reflection information, may provide speed optimized methods +class DiscoverGuide$Type extends MessageType { + constructor() { + super("teleport.userpreferences.v1.DiscoverGuide", [ + { no: 1, name: "pinned", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ } + ]); + } + create(value?: PartialMessage): DiscoverGuide { + const message = globalThis.Object.create((this.messagePrototype!)); + message.pinned = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: DiscoverGuide): DiscoverGuide { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* repeated string pinned */ 1: + message.pinned.push(reader.string()); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: DiscoverGuide, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* repeated string pinned = 1; */ + for (let i = 0; i < message.pinned.length; i++) + writer.tag(1, WireType.LengthDelimited).string(message.pinned[i]); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message teleport.userpreferences.v1.DiscoverGuide + */ +export const DiscoverGuide = new DiscoverGuide$Type(); +// @generated message type with reflection information, may provide speed optimized methods class DiscoverResourcePreferences$Type extends MessageType { constructor() { super("teleport.userpreferences.v1.DiscoverResourcePreferences", [ - { no: 1, name: "pinned_guides", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ } + { no: 2, name: "discover_guide", kind: "message", T: () => DiscoverGuide } ]); } create(value?: PartialMessage): DiscoverResourcePreferences { const message = globalThis.Object.create((this.messagePrototype!)); - message.pinnedGuides = []; if (value !== undefined) reflectionMergePartial(this, message, value); return message; @@ -59,8 +118,8 @@ class DiscoverResourcePreferences$Type extends MessageType Date: Thu, 13 Feb 2025 22:01:14 -0800 Subject: [PATCH 4/4] Web: Add filters and pinning support for Enroll Resources page (#51893) * Minor changes - Stop propagation and default behavior when clicking on pin button - Add small or large icon option for discover icons * Add pinning guide support * Fix lint --- .../src/DataTable/InputSearch/InputSearch.tsx | 7 +- .../UnifiedResources/UnifiedResources.tsx | 2 +- .../UnifiedResources/shared/PinButton.tsx | 16 +- .../SetupConnect/SetupConnect.story.tsx | 2 + .../teleport/src/Discover/Discover.test.tsx | 125 ++++--- .../SelectResource/SelectResource.story.tsx | 2 + .../SelectResource/SelectResource.test.tsx | 298 +++++++---------- .../SelectResource/SelectResource.tsx | 311 +++++++++++------- .../src/Discover/SelectResource/Tile.tsx | 198 ++++++++--- .../src/Discover/SelectResource/icons.tsx | 9 +- .../SelectResource/resources/databases.tsx | 67 ++-- .../SelectResource/resources/keywords.ts | 35 ++ .../SelectResource/resources/resources.tsx | 24 +- .../SelectResource/resources/resourcesE.tsx | 2 +- .../src/Discover/SelectResource/testUtils.ts | 159 +++++++++ .../SelectResource/utils/filters.test.ts | 156 +++++++++ .../Discover/SelectResource/utils/filters.ts | 112 +++++++ .../src/Discover/SelectResource/utils/pins.ts | 57 ++++ .../teleport/src/Discover/testUtils.ts | 34 ++ .../teleport/src/User/UserContext.tsx | 20 ++ .../User/testHelpers/makeTestUserContext.ts | 1 + .../userPreferences/userPreferences.ts | 1 + 22 files changed, 1200 insertions(+), 438 deletions(-) create mode 100644 web/packages/teleport/src/Discover/SelectResource/resources/keywords.ts create mode 100644 web/packages/teleport/src/Discover/SelectResource/testUtils.ts create mode 100644 web/packages/teleport/src/Discover/SelectResource/utils/filters.test.ts create mode 100644 web/packages/teleport/src/Discover/SelectResource/utils/pins.ts create mode 100644 web/packages/teleport/src/Discover/testUtils.ts diff --git a/web/packages/design/src/DataTable/InputSearch/InputSearch.tsx b/web/packages/design/src/DataTable/InputSearch/InputSearch.tsx index 422e3501848c2..4708f19cc9969 100644 --- a/web/packages/design/src/DataTable/InputSearch/InputSearch.tsx +++ b/web/packages/design/src/DataTable/InputSearch/InputSearch.tsx @@ -35,6 +35,8 @@ export default function InputSearch({ setSearchValue, children, bigInputSize = false, + autoFocus = false, + placeholder = 'Search...', }: Props) { function submitSearch(e: FormEvent) { e.preventDefault(); // prevent form default @@ -50,10 +52,11 @@ export default function InputSearch({
{children} @@ -68,6 +71,8 @@ type Props = { setSearchValue: (searchValue: string) => void; children?: JSX.Element; bigInputSize?: boolean; + autoFocus?: boolean; + placeholder?: string; }; const ChildWrapper = styled.div` diff --git a/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx b/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx index 01ffdc258fcb2..e73ae78263457 100644 --- a/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx +++ b/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx @@ -473,7 +473,7 @@ export function UnifiedResources(props: UnifiedResourcesProps) { bg="levels.sunken" details={updatePinnedResourcesAttempt.statusText} > - Could not update pinned resources: + Could not update pinned resources )} {unifiedResourcePreferencesAttempt?.status === 'error' && ( diff --git a/web/packages/shared/components/UnifiedResources/shared/PinButton.tsx b/web/packages/shared/components/UnifiedResources/shared/PinButton.tsx index ecaadd42b21f8..a6ea24f67556a 100644 --- a/web/packages/shared/components/UnifiedResources/shared/PinButton.tsx +++ b/web/packages/shared/components/UnifiedResources/shared/PinButton.tsx @@ -23,8 +23,10 @@ import { PushPin, PushPinFilled } from 'design/Icon'; import { HoverTooltip } from 'design/Tooltip'; import { PinningSupport } from '../types'; -import { PINNING_NOT_SUPPORTED_MESSAGE } from '../UnifiedResources'; +// TODO(kimlisa): move this out of the UnifiedResources directory, +// since it is also used outside of UnifiedResources +// (eg: Discover/SelectResource.tsx) export function PinButton({ pinned, pinningSupport, @@ -55,10 +57,18 @@ export function PinButton({ return ( { + // This ButtonIcon can be used within another element that also has a + // onClick handler (stops propagating click event) or within an + // anchor element (prevents browser default to go the link). + e.stopPropagation(); + e.preventDefault(); + setPinned(); + }} className={className} css={` visibility: ${shouldShowButton ? 'visible' : 'hidden'}; @@ -83,7 +93,7 @@ function getTipContent( ): string { switch (pinningSupport) { case PinningSupport.NotSupported: - return PINNING_NOT_SUPPORTED_MESSAGE; + return 'To enable pinning support, upgrade to 17.3 or newer.'; case PinningSupport.Supported: return pinned ? 'Unpin' : 'Pin'; default: diff --git a/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.story.tsx b/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.story.tsx index 12abddcc32b6f..c7dd146c15c6c 100644 --- a/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.story.tsx +++ b/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.story.tsx @@ -144,6 +144,7 @@ const Provider = ({ children }) => { const updatePreferences = () => Promise.resolve(); const getClusterPinnedResources = () => Promise.resolve([]); const updateClusterPinnedResources = () => Promise.resolve(); + const updateDiscoverResourcePreferences = () => Promise.resolve(); return ( @@ -153,6 +154,7 @@ const Provider = ({ children }) => { updatePreferences, getClusterPinnedResources, updateClusterPinnedResources, + updateDiscoverResourcePreferences, }} > {children} diff --git a/web/packages/teleport/src/Discover/Discover.test.tsx b/web/packages/teleport/src/Discover/Discover.test.tsx index aeef68ce57940..289dbaf9172e0 100644 --- a/web/packages/teleport/src/Discover/Discover.test.tsx +++ b/web/packages/teleport/src/Discover/Discover.test.tsx @@ -42,6 +42,7 @@ import { makeTestUserContext } from 'teleport/User/testHelpers/makeTestUserConte import { mockUserContextProviderWith } from 'teleport/User/testHelpers/mockUserContextWith'; import { ResourceKind } from './Shared'; +import { getGuideTileId } from './testUtils'; import { DiscoverUpdateProps, useDiscover } from './useDiscover'; beforeEach(() => { @@ -88,18 +89,24 @@ test('displays all resources by default', () => { expect( screen - .getAllByTestId(ResourceKind.Server) - .concat(screen.getAllByTestId(ResourceKind.ConnectMyComputer)) + .getAllByTestId(getGuideTileId({ kind: ResourceKind.Server })) + .concat( + screen.getAllByTestId( + getGuideTileId({ kind: ResourceKind.ConnectMyComputer }) + ) + ) ).toHaveLength(SERVERS.length); - expect(screen.getAllByTestId(ResourceKind.Database)).toHaveLength( + expect( + screen.getAllByTestId(getGuideTileId({ kind: ResourceKind.Database })) + ).toHaveLength( DATABASES.length + DATABASES_UNGUIDED.length + DATABASES_UNGUIDED_DOC.length ); - expect(screen.getAllByTestId(ResourceKind.Application)).toHaveLength( - APPLICATIONS.length - ); - expect(screen.getAllByTestId(ResourceKind.Kubernetes)).toHaveLength( - KUBERNETES.length - ); + expect( + screen.getAllByTestId(getGuideTileId({ kind: ResourceKind.Application })) + ).toHaveLength(APPLICATIONS.length); + expect( + screen.getAllByTestId(getGuideTileId({ kind: ResourceKind.Kubernetes })) + ).toHaveLength(KUBERNETES.length); }); test('location state applies filter/search', () => { @@ -109,11 +116,17 @@ test('location state applies filter/search', () => { }); expect( - screen.queryByTestId(ResourceKind.Application) + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Application })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Server })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Database })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Kubernetes })) ).not.toBeInTheDocument(); - expect(screen.queryByTestId(ResourceKind.Server)).not.toBeInTheDocument(); - expect(screen.queryByTestId(ResourceKind.Database)).not.toBeInTheDocument(); - expect(screen.queryByTestId(ResourceKind.Kubernetes)).not.toBeInTheDocument(); }); describe('location state', () => { @@ -122,79 +135,109 @@ describe('location state', () => { expect( screen - .getAllByTestId(ResourceKind.Server) - .concat(screen.getAllByTestId(ResourceKind.ConnectMyComputer)) + .getAllByTestId(getGuideTileId({ kind: ResourceKind.Server })) + .concat( + screen.getAllByTestId( + getGuideTileId({ kind: ResourceKind.ConnectMyComputer }) + ) + ) ).toHaveLength(SERVERS.length); // we assert three databases for servers because the naming convention includes "server" - expect(screen.queryAllByTestId(ResourceKind.Database)).toHaveLength(4); + expect( + screen.queryAllByTestId(getGuideTileId({ kind: ResourceKind.Database })) + ).toHaveLength(4); - expect(screen.queryByTestId(ResourceKind.Desktop)).not.toBeInTheDocument(); expect( - screen.queryByTestId(ResourceKind.Application) + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Desktop })) ).not.toBeInTheDocument(); expect( - screen.queryByTestId(ResourceKind.Kubernetes) + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Application })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Kubernetes })) ).not.toBeInTheDocument(); }); test('displays desktops when the location state is desktop', () => { create({ initialEntry: 'desktop' }); - expect(screen.queryByTestId(ResourceKind.Server)).not.toBeInTheDocument(); - expect(screen.queryByTestId(ResourceKind.Database)).not.toBeInTheDocument(); expect( - screen.queryByTestId(ResourceKind.Application) + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Server })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Database })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Application })) ).not.toBeInTheDocument(); expect( - screen.queryByTestId(ResourceKind.Kubernetes) + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Kubernetes })) ).not.toBeInTheDocument(); }); test('displays apps when the location state is application', () => { create({ initialEntry: 'application' }); - expect(screen.getAllByTestId(ResourceKind.Application)).toHaveLength( - APPLICATIONS.length - ); + expect( + screen.getAllByTestId(getGuideTileId({ kind: ResourceKind.Application })) + ).toHaveLength(APPLICATIONS.length); - expect(screen.queryByTestId(ResourceKind.Server)).not.toBeInTheDocument(); - expect(screen.queryByTestId(ResourceKind.Desktop)).not.toBeInTheDocument(); - expect(screen.queryByTestId(ResourceKind.Database)).not.toBeInTheDocument(); expect( - screen.queryByTestId(ResourceKind.Kubernetes) + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Server })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Desktop })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Database })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Kubernetes })) ).not.toBeInTheDocument(); }); test('displays databases when the location state is database', () => { create({ initialEntry: 'database' }); - expect(screen.getAllByTestId(ResourceKind.Database)).toHaveLength( + expect( + screen.getAllByTestId(getGuideTileId({ kind: ResourceKind.Database })) + ).toHaveLength( DATABASES.length + DATABASES_UNGUIDED.length + DATABASES_UNGUIDED_DOC.length ); - expect(screen.queryByTestId(ResourceKind.Server)).not.toBeInTheDocument(); - expect(screen.queryByTestId(ResourceKind.Desktop)).not.toBeInTheDocument(); expect( - screen.queryByTestId(ResourceKind.Application) + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Server })) ).not.toBeInTheDocument(); expect( - screen.queryByTestId(ResourceKind.Kubernetes) + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Desktop })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Application })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Kubernetes })) ).not.toBeInTheDocument(); }); test('displays kube resources when the location state is kubernetes', () => { create({ initialEntry: 'kubernetes' }); - expect(screen.getAllByTestId(ResourceKind.Kubernetes)).toHaveLength( - KUBERNETES.length - ); + expect( + screen.getAllByTestId(getGuideTileId({ kind: ResourceKind.Kubernetes })) + ).toHaveLength(KUBERNETES.length); - expect(screen.queryByTestId(ResourceKind.Server)).not.toBeInTheDocument(); - expect(screen.queryByTestId(ResourceKind.Desktop)).not.toBeInTheDocument(); - expect(screen.queryByTestId(ResourceKind.Database)).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Server })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Desktop })) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(getGuideTileId({ kind: ResourceKind.Database })) + ).not.toBeInTheDocument(); expect( screen.queryByTestId(ResourceKind.Application) ).not.toBeInTheDocument(); diff --git a/web/packages/teleport/src/Discover/SelectResource/SelectResource.story.tsx b/web/packages/teleport/src/Discover/SelectResource/SelectResource.story.tsx index 517792f6090d5..4fa1af39d53fb 100644 --- a/web/packages/teleport/src/Discover/SelectResource/SelectResource.story.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/SelectResource.story.tsx @@ -96,6 +96,7 @@ const Provider = ({ const updatePreferences = () => Promise.resolve(); const getClusterPinnedResources = () => Promise.resolve([]); const updateClusterPinnedResources = () => Promise.resolve(); + const updateDiscoverResourcePreferences = () => Promise.resolve(); const preferences: UserPreferences = makeDefaultUserPreferences(); preferences.onboard.preferredResources = resources; @@ -109,6 +110,7 @@ const Provider = ({ updatePreferences, getClusterPinnedResources, updateClusterPinnedResources, + updateDiscoverResourcePreferences, }} > {children} diff --git a/web/packages/teleport/src/Discover/SelectResource/SelectResource.test.tsx b/web/packages/teleport/src/Discover/SelectResource/SelectResource.test.tsx index f353d4391c8c7..2e27f87ca912d 100644 --- a/web/packages/teleport/src/Discover/SelectResource/SelectResource.test.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/SelectResource.test.tsx @@ -16,10 +16,11 @@ * along with this program. If not, see . */ +import { within } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import { Platform, UserAgent } from 'design/platform'; -import { render, screen, waitFor } from 'design/utils/testing'; +import { render, screen, userEvent, waitFor } from 'design/utils/testing'; import { OnboardUserPreferences, Resource, @@ -32,14 +33,35 @@ import { noAccess, } from 'teleport/mocks/contexts'; import { OnboardDiscover } from 'teleport/services/user'; +import * as service from 'teleport/services/userPreferences/userPreferences'; import { makeDefaultUserPreferences } from 'teleport/services/userPreferences/userPreferences'; import * as userUserContext from 'teleport/User/UserContext'; +import { UserContextProvider } from 'teleport/User/UserContext'; import { ResourceKind } from '../Shared'; import { resourceKindToPreferredResource } from '../Shared/ResourceKind'; +import { getGuideTileId } from '../testUtils'; import { SelectResourceSpec } from './resources'; import { SelectResource } from './SelectResource'; +import { + a_DatabaseAws, + c_ApplicationGcp, + d_Saml, + e_KubernetesSelfHosted_unguided, + f_Server, + g_Application, + h_Server, + i_Desktop, + j_Kubernetes, + k_Database, + kindBasedList, + l_DesktopAzure, + l_Saml, + makeResourceSpec, + NoAccessList, +} from './testUtils'; import { filterBySupportedPlatformsAndAuthTypes } from './utils/filters'; +import { defaultPins } from './utils/pins'; import { sortResourcesByPreferences } from './utils/sort'; const setUp = () => { @@ -48,23 +70,6 @@ const setUp = () => { .mockReturnValue(UserAgent.macOS); }; -const makeResourceSpec = ( - overrides: Partial = {} -): SelectResourceSpec => { - return Object.assign( - { - id: '', - name: '', - kind: ResourceKind.Application, - icon: '', - event: null, - keywords: [], - hasAccess: true, - }, - overrides - ); -}; - /** * If the user has resources, Connect My Computer is not prioritized when sorting resources. */ @@ -84,6 +89,10 @@ const onboardDiscoverNoResources: OnboardDiscover = { hasVisited: false, }; +beforeEach(() => { + jest.restoreAllMocks(); +}); + test('sortResourcesByPreferences without preferred resources, sorts resources alphabetically with guided resources first', () => { setUp(); const mockIn: SelectResourceSpec[] = [ @@ -117,121 +126,6 @@ test('sortResourcesByPreferences without preferred resources, sorts resources al ]); }); -const t_Application_NoAccess = makeResourceSpec({ - name: 'tango', - kind: ResourceKind.Application, - hasAccess: false, -}); -const u_Database_NoAccess = makeResourceSpec({ - name: 'uniform', - kind: ResourceKind.Database, - hasAccess: false, -}); -const v_Desktop_NoAccess = makeResourceSpec({ - name: 'victor', - kind: ResourceKind.Desktop, - hasAccess: false, -}); -const w_Kubernetes_NoAccess = makeResourceSpec({ - name: 'whiskey', - kind: ResourceKind.Kubernetes, - hasAccess: false, -}); -const x_Server_NoAccess = makeResourceSpec({ - name: 'xray', - kind: ResourceKind.Server, - hasAccess: false, -}); -const y_Saml_NoAccess = makeResourceSpec({ - name: 'yankee', - kind: ResourceKind.SamlApplication, - hasAccess: false, -}); -const z_Discovery_NoAccess = makeResourceSpec({ - name: 'zulu', - kind: ResourceKind.Discovery, - hasAccess: false, -}); - -const NoAccessList: SelectResourceSpec[] = [ - t_Application_NoAccess, - u_Database_NoAccess, - v_Desktop_NoAccess, - w_Kubernetes_NoAccess, - x_Server_NoAccess, - y_Saml_NoAccess, - z_Discovery_NoAccess, -]; - -const c_Application = makeResourceSpec({ - name: 'charlie', - kind: ResourceKind.Application, -}); -const a_Database = makeResourceSpec({ - name: 'alpha', - kind: ResourceKind.Database, -}); -const l_Desktop = makeResourceSpec({ - name: 'linux', - kind: ResourceKind.Desktop, -}); -const e_Kubernetes_unguided = makeResourceSpec({ - name: 'echo', - kind: ResourceKind.Kubernetes, - unguidedLink: 'test.com', -}); -const f_Server = makeResourceSpec({ - name: 'foxtrot', - kind: ResourceKind.Server, -}); -const d_Saml = makeResourceSpec({ - name: 'delta', - kind: ResourceKind.SamlApplication, -}); -const g_Application = makeResourceSpec({ - name: 'golf', - kind: ResourceKind.Application, -}); -const k_Database = makeResourceSpec({ - name: 'kilo', - kind: ResourceKind.Database, -}); -const i_Desktop = makeResourceSpec({ - name: 'india', - kind: ResourceKind.Desktop, -}); -const j_Kubernetes = makeResourceSpec({ - name: 'juliette', - kind: ResourceKind.Kubernetes, -}); -const h_Server = makeResourceSpec({ name: 'hotel', kind: ResourceKind.Server }); -const l_Saml = makeResourceSpec({ - name: 'lima', - kind: ResourceKind.SamlApplication, -}); - -const kindBasedList: SelectResourceSpec[] = [ - c_Application, - a_Database, - t_Application_NoAccess, - l_Desktop, - e_Kubernetes_unguided, - u_Database_NoAccess, - f_Server, - w_Kubernetes_NoAccess, - d_Saml, - v_Desktop_NoAccess, - g_Application, - x_Server_NoAccess, - k_Database, - i_Desktop, - z_Discovery_NoAccess, - j_Kubernetes, - h_Server, - y_Saml_NoAccess, - l_Saml, -]; - describe('preferred resources', () => { beforeEach(() => { setUp(); @@ -250,16 +144,16 @@ describe('preferred resources', () => { f_Server, h_Server, // alpha; guided before unguided - a_Database, - c_Application, + a_DatabaseAws, + c_ApplicationGcp, d_Saml, g_Application, i_Desktop, j_Kubernetes, k_Database, l_Saml, - l_Desktop, - e_Kubernetes_unguided, + l_DesktopAzure, + e_KubernetesSelfHosted_unguided, // no access is last ...NoAccessList, ], @@ -269,10 +163,10 @@ describe('preferred resources', () => { preferred: [Resource.DATABASES], expected: [ // preferred first - a_Database, + a_DatabaseAws, k_Database, // alpha; guided before unguided - c_Application, + c_ApplicationGcp, d_Saml, f_Server, g_Application, @@ -280,8 +174,8 @@ describe('preferred resources', () => { i_Desktop, j_Kubernetes, l_Saml, - l_Desktop, - e_Kubernetes_unguided, + l_DesktopAzure, + e_KubernetesSelfHosted_unguided, // no access is last ...NoAccessList, ], @@ -292,10 +186,10 @@ describe('preferred resources', () => { expected: [ // preferred first i_Desktop, - l_Desktop, + l_DesktopAzure, // alpha; guided before unguided - a_Database, - c_Application, + a_DatabaseAws, + c_ApplicationGcp, d_Saml, f_Server, g_Application, @@ -303,7 +197,7 @@ describe('preferred resources', () => { j_Kubernetes, k_Database, l_Saml, - e_Kubernetes_unguided, + e_KubernetesSelfHosted_unguided, // no access is last ...NoAccessList, ], @@ -313,10 +207,10 @@ describe('preferred resources', () => { preferred: [Resource.WEB_APPLICATIONS], expected: [ // preferred first - c_Application, + c_ApplicationGcp, g_Application, // alpha; guided before unguided - a_Database, + a_DatabaseAws, d_Saml, f_Server, h_Server, @@ -324,8 +218,8 @@ describe('preferred resources', () => { j_Kubernetes, k_Database, l_Saml, - l_Desktop, - e_Kubernetes_unguided, + l_DesktopAzure, + e_KubernetesSelfHosted_unguided, // no access is last ...NoAccessList, ], @@ -336,10 +230,10 @@ describe('preferred resources', () => { expected: [ // preferred first; guided before unguided j_Kubernetes, - e_Kubernetes_unguided, + e_KubernetesSelfHosted_unguided, // alpha - a_Database, - c_Application, + a_DatabaseAws, + c_ApplicationGcp, d_Saml, f_Server, g_Application, @@ -347,7 +241,7 @@ describe('preferred resources', () => { i_Desktop, k_Database, l_Saml, - l_Desktop, + l_DesktopAzure, // no access is last ...NoAccessList, ], @@ -391,10 +285,10 @@ describe('marketing params', () => { expected: [ // marketing params first; no preferred priority, guided before unguided j_Kubernetes, - e_Kubernetes_unguided, + e_KubernetesSelfHosted_unguided, // alpha - a_Database, - c_Application, + a_DatabaseAws, + c_ApplicationGcp, d_Saml, f_Server, g_Application, @@ -402,7 +296,7 @@ describe('marketing params', () => { i_Desktop, k_Database, l_Saml, - l_Desktop, + l_DesktopAzure, // no access is last ...NoAccessList, ], @@ -423,16 +317,16 @@ describe('marketing params', () => { f_Server, h_Server, // alpha; guided before unguided - a_Database, - c_Application, + a_DatabaseAws, + c_ApplicationGcp, d_Saml, g_Application, i_Desktop, j_Kubernetes, k_Database, l_Saml, - l_Desktop, - e_Kubernetes_unguided, + l_DesktopAzure, + e_KubernetesSelfHosted_unguided, // no access is last ...NoAccessList, ], @@ -450,10 +344,10 @@ describe('marketing params', () => { }, expected: [ // preferred first - a_Database, + a_DatabaseAws, k_Database, // alpha; guided before unguided - c_Application, + c_ApplicationGcp, d_Saml, f_Server, g_Application, @@ -461,8 +355,8 @@ describe('marketing params', () => { i_Desktop, j_Kubernetes, l_Saml, - l_Desktop, - e_Kubernetes_unguided, + l_DesktopAzure, + e_KubernetesSelfHosted_unguided, // no access is last ...NoAccessList, ], @@ -481,10 +375,10 @@ describe('marketing params', () => { expected: [ // preferred first i_Desktop, - l_Desktop, + l_DesktopAzure, // alpha; guided before unguided - a_Database, - c_Application, + a_DatabaseAws, + c_ApplicationGcp, d_Saml, f_Server, g_Application, @@ -492,7 +386,7 @@ describe('marketing params', () => { j_Kubernetes, k_Database, l_Saml, - e_Kubernetes_unguided, + e_KubernetesSelfHosted_unguided, // no access is last ...NoAccessList, ], @@ -510,10 +404,10 @@ describe('marketing params', () => { }, expected: [ // preferred first - c_Application, + c_ApplicationGcp, g_Application, // alpha; guided before unguided - a_Database, + a_DatabaseAws, d_Saml, f_Server, h_Server, @@ -521,8 +415,8 @@ describe('marketing params', () => { j_Kubernetes, k_Database, l_Saml, - l_Desktop, - e_Kubernetes_unguided, + l_DesktopAzure, + e_KubernetesSelfHosted_unguided, // no access is last ...NoAccessList, ], @@ -541,10 +435,10 @@ describe('marketing params', () => { expected: [ // preferred first; guided before unguided j_Kubernetes, - e_Kubernetes_unguided, + e_KubernetesSelfHosted_unguided, // alpha - a_Database, - c_Application, + a_DatabaseAws, + c_ApplicationGcp, d_Saml, f_Server, g_Application, @@ -552,7 +446,7 @@ describe('marketing params', () => { i_Desktop, k_Database, l_Saml, - l_Desktop, + l_DesktopAzure, // no access is last ...NoAccessList, ], @@ -1131,6 +1025,7 @@ test('displays an info banner if lacking "all" permissions to add resources', as updatePreferences: () => null, updateClusterPinnedResources: () => null, getClusterPinnedResources: () => null, + updateDiscoverResourcePreferences: () => null, }); const ctx = createTeleportContext(); @@ -1151,12 +1046,59 @@ test('displays an info banner if lacking "all" permissions to add resources', as }); }); +test('add and remove pin, and rendering of default pins', async () => { + jest + .spyOn(window.navigator, 'userAgent', 'get') + .mockReturnValue(UserAgent.macOS); + + const prefs = makeDefaultUserPreferences(); + jest.spyOn(service, 'getUserPreferences').mockResolvedValue(prefs); + jest.spyOn(service, 'updateUserPreferences').mockResolvedValue(prefs); + + render( + + + + {}} /> + + + + ); + + await screen.findAllByTestId(/large-tile-/); + + // Default pins on initial render with no preferences set. + let pinnedGuides = screen.queryAllByTestId(/large-tile-/); + expect(pinnedGuides).toHaveLength(defaultPins.length); + + // Add pin. + let snowflakeGuide = screen.getByTestId( + getGuideTileId({ kind: ResourceKind.Database, title: 'snowflake' }) + ); + await userEvent.click(within(snowflakeGuide).getByTestId(/pin-button/i)); + pinnedGuides = screen.queryAllByTestId(/large-tile-/); + expect(pinnedGuides).toHaveLength(defaultPins.length + 1); + + // Remove pin. + snowflakeGuide = screen.getByTestId( + getGuideTileId({ + kind: ResourceKind.Database, + title: 'snowflake', + size: 'large', + }) + ); + await userEvent.click(within(snowflakeGuide).getByTestId(/pin-button/i)); + pinnedGuides = screen.queryAllByTestId(/large-tile-/); + expect(pinnedGuides).toHaveLength(defaultPins.length); +}); + test('does not display erorr banner if user has "some" permissions to add', async () => { jest.spyOn(userUserContext, 'useUser').mockReturnValue({ preferences: makeDefaultUserPreferences(), updatePreferences: () => null, updateClusterPinnedResources: () => null, getClusterPinnedResources: () => null, + updateDiscoverResourcePreferences: () => null, }); const ctx = createTeleportContext(); @@ -1175,7 +1117,7 @@ test('does not display erorr banner if user has "some" permissions to add', asyn ).not.toBeInTheDocument(); }); -describe('filterResources', () => { +describe('filterBySupportedPlatformsAndAuthTypes', () => { it('filters out resources based on supportedPlatforms', () => { const winAndLinux = makeResourceSpec({ name: 'Filtered out with many supported platforms', diff --git a/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx b/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx index 0ce653b3e3526..afd9cd6facaf8 100644 --- a/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx @@ -20,27 +20,42 @@ 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 { Alert, Box, Flex, H3, Link, P3, Text } from 'design'; +import { Danger } from 'design/Alert'; +import InputSearch from 'design/DataTable/InputSearch'; +import { Magnifier } from 'design/Icon'; import { getPlatform } from 'design/platform'; +import { MultiselectMenu } from 'shared/components/Controls/MultiselectMenu'; +import { PinningSupport } from 'shared/components/UnifiedResources'; +import { useAsync } from 'shared/hooks/useAsync'; import AddApp from 'teleport/Apps/AddApp'; -import { FeatureHeader, FeatureHeaderTitle } from 'teleport/components/Layout'; +import { FeatureHeaderTitle } from 'teleport/components/Layout'; import cfg from 'teleport/config'; -import { - BASE_RESOURCES, - SelectResourceSpec, -} from 'teleport/Discover/SelectResource/resources'; -import { HeaderSubtitle } from 'teleport/Discover/Shared'; +import { BASE_RESOURCES } from 'teleport/Discover/SelectResource/resources/resources'; import { storageService } from 'teleport/services/storageService'; +import { DiscoverGuideId } from 'teleport/services/userPreferences/discoverPreference'; import { useUser } from 'teleport/User/UserContext'; import useTeleport from 'teleport/useTeleport'; -import { SAML_APPLICATIONS } from './resources'; +import { TextIcon } from '../Shared'; +import { SelectResourceSpec } from './resources'; +import { SAML_APPLICATIONS } from './resources/resourcesE'; import { Tile } from './Tile'; import { SearchResource } from './types'; import { addHasAccessField } from './utils/checkAccess'; -import { filterBySupportedPlatformsAndAuthTypes } from './utils/filters'; +import { + filterBySupportedPlatformsAndAuthTypes, + filterResources, + Filters, + hostingPlatformOptions, + resourceTypeOptions, +} from './utils/filters'; +import { + DiscoverResourcePreference, + getDefaultPins, + getPins, +} from './utils/pins'; import { sortResourcesByKind, sortResourcesByPreferences } from './utils/sort'; interface SelectResourceProps { @@ -65,30 +80,58 @@ export function SelectResource({ onSelect }: SelectResourceProps) { const ctx = useTeleport(); const location = useLocation(); const history = useHistory(); - const { preferences } = useUser(); + const { preferences, updateDiscoverResourcePreferences } = useUser(); + + const [filters, setFilters] = useState({ + resourceTypes: [], + hostingPlatforms: [], + }); + const [updateDiscoverPreferenceAttempt, updateDiscoverPreference] = useAsync( + async (newPref: DiscoverResourcePreference) => { + await updateDiscoverResourcePreferences(newPref); + } + ); const [search, setSearch] = useState(''); const { acl, authType } = ctx.storeUser.state; const platform = getPlatform(); - const defaultResources: SelectResourceSpec[] = useMemo( - () => - sortResourcesByPreferences( - // Apply access check to each resource. - addHasAccessField( - acl, - filterBySupportedPlatformsAndAuthTypes( - platform, - authType, - getDefaultResources(cfg.isEnterprise) - ) - ), - preferences, - storageService.getOnboardDiscover() - ), - [acl, authType, platform, preferences] - ); + + /** + * defaultResources does initial processing of all resource guides that will + * be used as base for dynamic filtering and determining default pins: + * - sets the "hasAccess" field (checks user perms) + * - sets the "pinned" field (checks user discover resource preference) + * - filters out guides that are not supported by users + * platform and auth settings (eg: "Connect My Computer" guide + * has limited support for platforms and auth settings) + * - sorts resources where preferred resources are at top of the list + * (certain cloud editions renders a questionaire for new users asking + * for their interest in resources) + */ + const defaultResources: SelectResourceSpec[] = useMemo(() => { + const withHasAccessFieldResources = addHasAccessField( + acl, + filterBySupportedPlatformsAndAuthTypes( + platform, + authType, + getDefaultResources(cfg.isEnterprise) + ) + ); + + return sortResourcesByPreferences( + withHasAccessFieldResources, + preferences, + storageService.getOnboardDiscover() + ); + }, [acl, authType, platform, preferences]); + const [resources, setResources] = useState(defaultResources); + const filteredResources = useMemo( + () => filterResources(resources, filters), + [filters, resources] + ); + // a user must be able to create tokens AND have access to create at least one // type of resource in order to be considered eligible to "add resources" const canAddResources = @@ -98,6 +141,12 @@ export function SelectResource({ onSelect }: SelectResourceProps) { function onSearch(s: string, customList?: SelectResourceSpec[]) { const list = customList || defaultResources; + if (s == '') { + history.replace({ state: {} }); // Clear any loc state. + setResources(list); + setSearch(s); + return; + } const search = s.split(' ').map(s => s.toLowerCase()); const found = list.filter(r => search.every(s => r.keywords.some(k => k.toLowerCase().includes(s))) @@ -107,11 +156,6 @@ export function SelectResource({ onSelect }: SelectResourceProps) { setSearch(s); } - function onClearSearch() { - history.replace({ state: {} }); // Clear any loc state. - onSearch(''); - } - useEffect(() => { // A user can come to this screen by clicking on // a `add ` button. @@ -146,6 +190,47 @@ export function SelectResource({ onSelect }: SelectResourceProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + async function updatePinnedGuides(guideId: DiscoverGuideId) { + const { discoverResourcePreferences } = preferences; + + let previousPins = discoverResourcePreferences?.discoverGuide?.pinned || []; + + if (!discoverResourcePreferences?.discoverGuide) { + previousPins = getDefaultPins(defaultResources); + } + + // Toggles pins. + let latestPins: string[]; + if (previousPins.includes(guideId)) { + latestPins = previousPins.filter(p => p !== guideId); + } else { + latestPins = [...previousPins, guideId]; + } + + const newPreferences: DiscoverResourcePreference = { + discoverResourcePreferences: { + discoverGuide: { pinned: latestPins }, + }, + }; + + updateDiscoverPreference(newPreferences); + } + + // TODO(kimlisa): DELETE IN 19.0 - only remove the check for "NotSupported". + let pinningSupport = preferences.discoverResourcePreferences + ? PinningSupport.Supported + : PinningSupport.NotSupported; + + if (updateDiscoverPreferenceAttempt.status === 'processing') { + pinningSupport = PinningSupport.Disabled; + } + + const pins = getPins(preferences); + let pinnedGuides: SelectResourceSpec[] = []; + if (pins.length > 0) { + pinnedGuides = filteredResources.filter(r => pins.includes(r.id)); + } + return ( {!canAddResources && ( @@ -154,39 +239,88 @@ export function SelectResource({ onSelect }: SelectResourceProps) { for additional permissions. )} - - Select Resource To Add - - - Teleport can integrate into most, if not all, of your infrastructure. - Search for what resource you want to add. - - - - + Could not update pinned resources + + )} + + Enroll a New Resource + + Teleport can integrate with most, if not all, of your infrastructure. + Search below for resources you want to add. + + + + + onSearch(e.target.value)} - max={100} /> - - {search && } - - {resources && resources.length > 0 && ( + + + + setFilters({ ...filters, resourceTypes })} + selected={filters.resourceTypes || []} + label="Resource Type" + tooltip="Filter by resource type" + /> + + setFilters({ ...filters, hostingPlatforms }) + } + selected={filters.hostingPlatforms || []} + label="Hosting Platform" + tooltip="Filter by hosting platform" + /> + + {!filteredResources.length && ( + + + No results found + + )} + {pinnedGuides.length > 0 && ( + +

Pinned

+ + {pinnedGuides.map(r => ( + + ))} + +
+ )} + {filteredResources.length > 0 && ( <> + {pinnedGuides.length > 0 &&

All Resources

} - {resources.map((r, index) => ( + {filteredResources.map(r => ( ))} - + Looking for something else?{' '} { - return ( - - props.theme.colors.error.main}; - `} - > - - - Clear search - - ); -}; - -const Grid = styled.div` +const Grid = styled.div<{ pinnedSection?: boolean }>` display: grid; - grid-template-columns: repeat(auto-fill, 320px); + grid-template-columns: repeat( + auto-fill, + ${p => (p.pinnedSection ? '250px' : '320px')} + ); column-gap: 10px; row-gap: 15px; `; - -const InputWrapper = styled.div` - border-radius: 200px; - height: 40px; - border: 1px solid ${props => props.theme.colors.spotBackground[2]}; - transition: all 0.1s; - - &:hover, - &:focus-within, - &:active { - background: ${props => props.theme.colors.spotBackground[0]}; - } -`; - -const StyledInput = styled.input` - border: none; - outline: none; - box-sizing: border-box; - height: 100%; - width: 100%; - transition: all 0.2s; - color: ${props => props.theme.colors.text.main}; - background: transparent; - margin-right: ${props => props.theme.space[3]}px; - margin-bottom: ${props => props.theme.space[2]}px; - padding: ${props => props.theme.space[3]}px; -`; diff --git a/web/packages/teleport/src/Discover/SelectResource/Tile.tsx b/web/packages/teleport/src/Discover/SelectResource/Tile.tsx index 8a5e68fa8a1f1..15bcac0f4ce7a 100644 --- a/web/packages/teleport/src/Discover/SelectResource/Tile.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/Tile.tsx @@ -16,11 +16,14 @@ * along with this program. If not, see . */ -import { type ComponentPropsWithoutRef } from 'react'; +import { useState, type ComponentPropsWithoutRef } from 'react'; import styled from 'styled-components'; import { Box, Flex, Link, Text } from 'design'; import { NewTab } from 'design/Icon'; +import { Theme } from 'design/theme'; +import { PinningSupport } from 'shared/components/UnifiedResources'; +import { PinButton } from 'shared/components/UnifiedResources/shared/PinButton'; import { ToolTipNoPermBadge } from 'teleport/components/ToolTipNoPermBadge'; import { @@ -32,15 +35,31 @@ import { getResourcePretitle } from '.'; import { DiscoverIcon } from './icons'; import { SelectResourceSpec } from './resources'; +export type Size = 'regular' | 'large'; + export function Tile({ resourceSpec, + size = 'regular', + isPinned = false, onChangeShowApp, onSelectResource, + onChangePin, + pinningSupport, }: { + /** + * if true, renders a larger tile with larger icon to + * help differentiate pinned tiles from regular tiles. + */ + size?: Size; + isPinned: boolean; resourceSpec: SelectResourceSpec; - onChangeShowApp(b: boolean): void; - onSelectResource(r: SelectResourceSpec): void; + onChangeShowApp(showApp: boolean): void; + onSelectResource(selectedResourceSpec: SelectResourceSpec): void; + pinningSupport: PinningSupport; + onChangePin(guideId: string): void; }) { + const [pinHovered, setPinHovered] = useState(false); + const title = resourceSpec.name; const pretitle = getResourcePretitle(resourceSpec); const select = () => { @@ -80,6 +99,8 @@ export function Tile({ }; } + const wantLargeTile = size === 'large'; + // 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 @@ -90,61 +111,126 @@ export function Tile({ // popup modal where it shows user to add app manually or automatically. return ( setPinHovered(true)} + onMouseLeave={() => setPinHovered(false)} {...resourceCardProps} > - {!resourceSpec.unguidedLink && resourceSpec.hasAccess && ( - Guided - )} - {!resourceSpec.hasAccess && ( - - - - )} - - - + + {!resourceSpec.unguidedLink && resourceSpec.hasAccess && ( + Guided + )} + {!resourceSpec.hasAccess && ( + + + + )} + + + + + {resourceSpec.unguidedLink ? ( + + {title} + {resourceSpec.hasAccess && ( + + )} + + ) : ( + {title} + )} + {pretitle && ( + + {pretitle} + + )} + + + + onChangePin(resourceSpec.id)} + pinningSupport={pinningSupport} + /> + - - {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; +const NewTabIcon = styled(NewTab)` transition: color 0.3s; `; -const ResourceCard = styled.button<{ hasAccess?: boolean }>` +/** + * ResourceCard cannot be a button, even though it's used like a button + * since "PinButton.tsx" is rendered as its children. Otherwise it causes + * an error where "button cannot be nested within a button". + */ +const ResourceCard = styled.div` position: relative; + + border-radius: ${props => props.theme.radii[3]}px; + transition: all 150ms; + + &:hover { + background-color: ${props => props.theme.colors.levels.surface}; + + // We use a pseudo element for the shadow with position: absolute in order + // to prevent the shadow from increasing the size of the layout and causing + // scrollbar flicker. + &:after { + box-shadow: ${props => props.theme.boxShadow[3]}; + border-radius: ${props => props.theme.radii[3]}px; + content: ''; + position: absolute; + top: 0; + left: 0; + z-index: -1; + width: 100%; + height: 100%; + } + } +`; + +const InnerCard = styled.div<{ hasAccess?: boolean; wantLargeTile?: boolean }>` + align-items: flex-start; + display: inline-block; + box-sizing: border-box; + margin: 0; + appearance: auto; text-align: left; - background: ${props => props.theme.colors.spotBackground[0]}; - transition: all 0.3s; - border: none; - border-radius: 8px; + height: ${p => (p.wantLargeTile ? '154px' : 'auto')}; + + width: 100%; + border: ${props => props.theme.borders[2]} + ${props => props.theme.colors.spotBackground[0]}; + border-radius: ${props => props.theme.radii[3]}px; + background-color: ${props => getBackgroundColor(props)}; + padding: 12px; color: ${props => props.theme.colors.text.main}; line-height: inherit; @@ -161,14 +247,26 @@ const ResourceCard = styled.button<{ hasAccess?: boolean }>` &:hover, &:focus-visible { - background: ${props => props.theme.colors.spotBackground[1]}; + // Make the border invisible instead of removing it, + // this is to prevent things from shifting due to the size change. + border: ${props => props.theme.borders[2]} rgba(0, 0, 0, 0); - ${NewTabInCorner} { + ${NewTabIcon} { color: ${props => props.theme.colors.text.slightlyMuted}; } } `; +export const getBackgroundColor = (props: { + pinned?: boolean; + theme: Theme; +}) => { + if (props.pinned) { + return props.theme.colors.interactive.tonal.primary[1]; + } + return 'transparent'; +}; + const BadgeGuided = styled.div` position: absolute; background: ${props => props.theme.colors.brand}; @@ -179,5 +277,11 @@ const BadgeGuided = styled.div` top: 0px; right: 0px; font-size: 10px; - line-height: 24px; + line-height: 18px; +`; + +const StyledText = styled(Text)<{ wantLargeTile?: boolean }>` + white-space: ${p => (p.wantLargeTile ? 'nowrap' : 'normal')}; + width: ${p => (p.wantLargeTile ? '155px' : 'auto')}; + font-weight: bold; `; diff --git a/web/packages/teleport/src/Discover/SelectResource/icons.tsx b/web/packages/teleport/src/Discover/SelectResource/icons.tsx index 2b73cadc9180b..07ae623e4b14e 100644 --- a/web/packages/teleport/src/Discover/SelectResource/icons.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/icons.tsx @@ -20,8 +20,11 @@ import { ResourceIcon, ResourceIconName } from 'design/ResourceIcon'; interface DiscoverIconProps { name: ResourceIconName; + size?: 'large' | 'small'; } -export const DiscoverIcon = ({ name }: DiscoverIconProps) => ( - -); +export const DiscoverIcon = ({ name, size = 'small' }: DiscoverIconProps) => { + const width = size === 'small' ? '24px' : '72'; + const height = size === 'small' ? '24px' : '72'; + return ; +}; diff --git a/web/packages/teleport/src/Discover/SelectResource/resources/databases.tsx b/web/packages/teleport/src/Discover/SelectResource/resources/databases.tsx index 081152bb72e40..2a7e074615d37 100644 --- a/web/packages/teleport/src/Discover/SelectResource/resources/databases.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/resources/databases.tsx @@ -22,19 +22,16 @@ import { DbProtocol } from 'shared/services/databases'; import { DiscoverEventResource } from 'teleport/services/userEvent'; import { DiscoverGuideId } from 'teleport/services/userPreferences/discoverPreference'; +import { SelectResourceSpec } from '.'; import { ResourceKind } from '../../Shared/ResourceKind'; import { DatabaseEngine, DatabaseLocation } from '../types'; -import { SelectResourceSpec } from './resources'; - -const baseDatabaseKeywords = ['db', 'database', 'databases']; -const awsKeywords = [...baseDatabaseKeywords, 'aws', 'amazon web services']; -const gcpKeywords = [...baseDatabaseKeywords, 'gcp', 'google cloud platform']; -const selfhostedKeywords = [ - ...baseDatabaseKeywords, - 'self hosted', - 'self-hosted', -]; -const azureKeywords = [...baseDatabaseKeywords, 'microsoft azure']; +import { + awsDatabaseKeywords, + azureKeywords, + baseDatabaseKeywords, + gcpKeywords, + selfHostedDatabaseKeywords, +} from './keywords'; // DATABASES_UNGUIDED_DOC are documentations that is not specific // to one type of database. @@ -43,7 +40,7 @@ export const DATABASES_UNGUIDED_DOC: SelectResourceSpec[] = [ id: DiscoverGuideId.DatabaseAwsRdsProxyPostgres, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Doc }, name: 'RDS Proxy PostgreSQL', - keywords: [...awsKeywords, 'rds', 'proxy', 'postgresql'], + keywords: [...awsDatabaseKeywords, 'rds', 'proxy', 'postgresql'], kind: ResourceKind.Database, icon: 'aws', unguidedLink: @@ -55,7 +52,13 @@ export const DATABASES_UNGUIDED_DOC: SelectResourceSpec[] = [ id: DiscoverGuideId.DatabaseAwsRdsProxySqlServer, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Doc }, name: 'RDS Proxy SQL Server', - keywords: [...awsKeywords, 'rds', 'proxy', 'sql server', 'sqlserver'], + keywords: [ + ...awsDatabaseKeywords, + 'rds', + 'proxy', + 'sql server', + 'sqlserver', + ], kind: ResourceKind.Database, icon: 'aws', unguidedLink: @@ -67,7 +70,7 @@ export const DATABASES_UNGUIDED_DOC: SelectResourceSpec[] = [ id: DiscoverGuideId.DatabaseAwsRdsProxyMariaMySql, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Doc }, name: 'RDS Proxy MariaDB/MySQL', - keywords: [...awsKeywords, 'rds', 'proxy', 'mariadb', 'mysql'], + keywords: [...awsDatabaseKeywords, 'rds', 'proxy', 'mariadb', 'mysql'], kind: ResourceKind.Database, icon: 'aws', unguidedLink: @@ -104,7 +107,7 @@ export const DATABASES_UNGUIDED: SelectResourceSpec[] = [ id: DiscoverGuideId.DatabaseAwsDynamoDb, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.DynamoDb }, name: 'DynamoDB', - keywords: [...awsKeywords, 'dynamodb'], + keywords: [...awsDatabaseKeywords, 'dynamodb'], kind: ResourceKind.Database, icon: 'dynamo', unguidedLink: @@ -115,7 +118,7 @@ export const DATABASES_UNGUIDED: SelectResourceSpec[] = [ id: DiscoverGuideId.DatabaseAwsElastiCacheMemoryDb, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Redis }, name: 'ElastiCache & MemoryDB', - keywords: [...awsKeywords, 'elasticache', 'memorydb', 'redis'], + keywords: [...awsDatabaseKeywords, 'elasticache', 'memorydb', 'redis'], kind: ResourceKind.Database, icon: 'aws', unguidedLink: @@ -129,7 +132,7 @@ export const DATABASES_UNGUIDED: SelectResourceSpec[] = [ engine: DatabaseEngine.Cassandra, }, name: 'Keyspaces (Apache Cassandra)', - keywords: [...awsKeywords, 'keyspaces', 'apache', 'cassandra'], + keywords: [...awsDatabaseKeywords, 'keyspaces', 'apache', 'cassandra'], kind: ResourceKind.Database, icon: 'aws', unguidedLink: @@ -140,7 +143,7 @@ export const DATABASES_UNGUIDED: SelectResourceSpec[] = [ id: DiscoverGuideId.DatabaseAwsPostgresRedshift, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Redshift }, name: 'Redshift PostgreSQL', - keywords: [...awsKeywords, 'redshift', 'postgresql'], + keywords: [...awsDatabaseKeywords, 'redshift', 'postgresql'], kind: ResourceKind.Database, icon: 'redshift', unguidedLink: @@ -151,7 +154,7 @@ export const DATABASES_UNGUIDED: SelectResourceSpec[] = [ id: DiscoverGuideId.DatabaseAwsRedshiftServerless, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.Redshift }, name: 'Redshift Serverless', - keywords: [...awsKeywords, 'redshift', 'serverless', 'postgresql'], + keywords: [...awsDatabaseKeywords, 'redshift', 'serverless', 'postgresql'], kind: ResourceKind.Database, icon: 'redshift', unguidedLink: @@ -224,7 +227,7 @@ export const DATABASES_UNGUIDED: SelectResourceSpec[] = [ }, name: 'RDS SQL Server', keywords: [ - ...awsKeywords, + ...awsDatabaseKeywords, 'rds', 'microsoft', 'active directory', @@ -283,7 +286,7 @@ export const DATABASES_UNGUIDED: SelectResourceSpec[] = [ engine: DatabaseEngine.Cassandra, }, name: 'Cassandra & ScyllaDB', - keywords: [...selfhostedKeywords, 'cassandra scylladb'], + keywords: [...selfHostedDatabaseKeywords, 'cassandra scylladb'], kind: ResourceKind.Database, icon: 'selfhosted', unguidedLink: @@ -297,7 +300,7 @@ export const DATABASES_UNGUIDED: SelectResourceSpec[] = [ engine: DatabaseEngine.CockroachDb, }, name: 'CockroachDB', - keywords: [...selfhostedKeywords, 'cockroachdb'], + keywords: [...selfHostedDatabaseKeywords, 'cockroachdb'], kind: ResourceKind.Database, icon: 'cockroach', unguidedLink: @@ -311,7 +314,7 @@ export const DATABASES_UNGUIDED: SelectResourceSpec[] = [ engine: DatabaseEngine.ElasticSearch, }, name: 'Elasticsearch', - keywords: [...selfhostedKeywords, 'elasticsearch', 'es'], + keywords: [...selfHostedDatabaseKeywords, 'elasticsearch', 'es'], kind: ResourceKind.Database, icon: 'selfhosted', unguidedLink: @@ -325,7 +328,7 @@ export const DATABASES_UNGUIDED: SelectResourceSpec[] = [ engine: DatabaseEngine.MongoDb, }, name: 'MongoDB', - keywords: [...selfhostedKeywords, 'mongodb'], + keywords: [...selfHostedDatabaseKeywords, 'mongodb'], kind: ResourceKind.Database, icon: 'mongo', unguidedLink: @@ -339,7 +342,7 @@ export const DATABASES_UNGUIDED: SelectResourceSpec[] = [ engine: DatabaseEngine.Redis, }, name: 'Redis', - keywords: [...selfhostedKeywords, 'redis'], + keywords: [...selfHostedDatabaseKeywords, 'redis'], kind: ResourceKind.Database, icon: 'selfhosted', unguidedLink: @@ -353,7 +356,7 @@ export const DATABASES_UNGUIDED: SelectResourceSpec[] = [ engine: DatabaseEngine.Redis, }, name: 'Redis Cluster', - keywords: [...selfhostedKeywords, 'redis cluster'], + keywords: [...selfHostedDatabaseKeywords, 'redis cluster'], kind: ResourceKind.Database, icon: 'selfhosted', unguidedLink: @@ -384,7 +387,7 @@ export const DATABASES: SelectResourceSpec[] = [ engine: DatabaseEngine.Postgres, }, name: 'RDS PostgreSQL', - keywords: [...awsKeywords, 'rds postgresql'], + keywords: [...awsDatabaseKeywords, 'rds postgresql'], kind: ResourceKind.Database, icon: 'aws', event: DiscoverEventResource.DatabasePostgresRds, @@ -396,7 +399,7 @@ export const DATABASES: SelectResourceSpec[] = [ engine: DatabaseEngine.AuroraPostgres, }, name: 'RDS Aurora PostgreSQL', - keywords: [...awsKeywords, 'rds aurora postgresql'], + keywords: [...awsDatabaseKeywords, 'rds aurora postgresql'], kind: ResourceKind.Database, icon: 'aws', event: DiscoverEventResource.DatabasePostgresRds, @@ -405,7 +408,7 @@ export const DATABASES: SelectResourceSpec[] = [ id: DiscoverGuideId.DatabaseAwsRdsMysqlMariaDb, dbMeta: { location: DatabaseLocation.Aws, engine: DatabaseEngine.MySql }, name: 'RDS MySQL/MariaDB', - keywords: [...awsKeywords, 'rds mysql mariadb'], + keywords: [...awsDatabaseKeywords, 'rds mysql mariadb'], kind: ResourceKind.Database, icon: 'aws', event: DiscoverEventResource.DatabaseMysqlRds, @@ -417,7 +420,7 @@ export const DATABASES: SelectResourceSpec[] = [ engine: DatabaseEngine.AuroraMysql, }, name: 'RDS Aurora MySQL', - keywords: [...awsKeywords, 'rds aurora mysql'], + keywords: [...awsDatabaseKeywords, 'rds aurora mysql'], kind: ResourceKind.Database, icon: 'aws', event: DiscoverEventResource.DatabaseMysqlRds, @@ -429,7 +432,7 @@ export const DATABASES: SelectResourceSpec[] = [ engine: DatabaseEngine.Postgres, }, name: 'PostgreSQL', - keywords: [...selfhostedKeywords, 'postgresql'], + keywords: [...selfHostedDatabaseKeywords, 'postgresql'], kind: ResourceKind.Database, icon: 'postgres', event: DiscoverEventResource.DatabasePostgresSelfHosted, @@ -441,7 +444,7 @@ export const DATABASES: SelectResourceSpec[] = [ engine: DatabaseEngine.MySql, }, name: 'MySQL/MariaDB', - keywords: [...selfhostedKeywords, 'mysql mariadb'], + keywords: [...selfHostedDatabaseKeywords, 'mysql mariadb'], kind: ResourceKind.Database, icon: 'selfhosted', event: DiscoverEventResource.DatabaseMysqlSelfHosted, diff --git a/web/packages/teleport/src/Discover/SelectResource/resources/keywords.ts b/web/packages/teleport/src/Discover/SelectResource/resources/keywords.ts new file mode 100644 index 0000000000000..489ba11058154 --- /dev/null +++ b/web/packages/teleport/src/Discover/SelectResource/resources/keywords.ts @@ -0,0 +1,35 @@ +/** + * Teleport + * Copyright (C) 2023 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 const baseServerKeywords = ['server', 'node', 'ssh']; +export const awsKeywords = ['aws', 'amazon', 'amazon web services']; +export const kubeKeywords = ['kubernetes', 'k8s', 'kubes', 'cluster']; +export const selfHostedKeywords = ['self hosted', 'self-hosted']; + +export const baseDatabaseKeywords = ['db', 'database', 'databases']; +export const awsDatabaseKeywords = [...baseDatabaseKeywords, ...awsKeywords]; +export const gcpKeywords = [ + ...baseDatabaseKeywords, + 'gcp', + 'google cloud platform', +]; +export const selfHostedDatabaseKeywords = [ + ...baseDatabaseKeywords, + ...selfHostedKeywords, +]; +export const azureKeywords = [...baseDatabaseKeywords, 'microsoft azure']; diff --git a/web/packages/teleport/src/Discover/SelectResource/resources/resources.tsx b/web/packages/teleport/src/Discover/SelectResource/resources/resources.tsx index c46a8b58bbdcb..5f2e5c29cb3b5 100644 --- a/web/packages/teleport/src/Discover/SelectResource/resources/resources.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/resources/resources.tsx @@ -38,18 +38,12 @@ import { DATABASES_UNGUIDED, DATABASES_UNGUIDED_DOC, } from './databases'; - -export type SelectResourceSpec = ResourceSpec & { - id: DiscoverGuideId; - /** - * true if user pinned this guide - */ - pinned?: boolean; -}; - -const baseServerKeywords = ['server', 'node', 'ssh']; -const awsKeywords = ['aws', 'amazon', 'amazon web services']; -const kubeKeywords = ['kubernetes', 'k8s', 'kubes', 'cluster']; +import { + awsKeywords, + baseServerKeywords, + kubeKeywords, + selfHostedKeywords, +} from './keywords'; export const SERVERS: SelectResourceSpec[] = [ { @@ -192,7 +186,7 @@ export const KUBERNETES: SelectResourceSpec[] = [ id: DiscoverGuideId.Kubernetes, name: 'Kubernetes', kind: ResourceKind.Kubernetes, - keywords: [...kubeKeywords], + keywords: [...kubeKeywords, ...selfHostedKeywords], icon: 'kube', event: DiscoverEventResource.Kubernetes, kubeMeta: { location: KubeLocation.SelfHosted }, @@ -293,3 +287,7 @@ export function getResourcePretitle(r: SelectResourceSpec) { return ''; } + +export type SelectResourceSpec = ResourceSpec & { + id: DiscoverGuideId; +}; diff --git a/web/packages/teleport/src/Discover/SelectResource/resources/resourcesE.tsx b/web/packages/teleport/src/Discover/SelectResource/resources/resourcesE.tsx index a6a69f5d04b7a..8ec54b0a13238 100644 --- a/web/packages/teleport/src/Discover/SelectResource/resources/resourcesE.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/resources/resourcesE.tsx @@ -20,8 +20,8 @@ import { SamlServiceProviderPreset } from 'teleport/services/samlidp/types'; import { DiscoverEventResource } from 'teleport/services/userEvent'; import { DiscoverGuideId } from 'teleport/services/userPreferences/discoverPreference'; +import { SelectResourceSpec } from '.'; import { ResourceKind } from '../../Shared'; -import { SelectResourceSpec } from './resources'; export const SAML_APPLICATIONS: SelectResourceSpec[] = [ { diff --git a/web/packages/teleport/src/Discover/SelectResource/testUtils.ts b/web/packages/teleport/src/Discover/SelectResource/testUtils.ts new file mode 100644 index 0000000000000..87241db882ea0 --- /dev/null +++ b/web/packages/teleport/src/Discover/SelectResource/testUtils.ts @@ -0,0 +1,159 @@ +/** + * 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 { ResourceKind } from '../Shared'; +import { SelectResourceSpec } from './resources'; + +export const makeResourceSpec = ( + overrides: Partial = {} +): SelectResourceSpec => { + return Object.assign( + { + id: '', + name: '', + kind: ResourceKind.Application, + icon: '', + event: null, + keywords: [], + hasAccess: true, + }, + overrides + ); +}; + +export const t_Application_NoAccess = makeResourceSpec({ + name: 'tango', + kind: ResourceKind.Application, + hasAccess: false, +}); +export const u_Database_NoAccess = makeResourceSpec({ + name: 'uniform', + kind: ResourceKind.Database, + hasAccess: false, +}); +export const v_Desktop_NoAccess = makeResourceSpec({ + name: 'victor', + kind: ResourceKind.Desktop, + hasAccess: false, +}); +export const w_Kubernetes_NoAccess = makeResourceSpec({ + name: 'whiskey', + kind: ResourceKind.Kubernetes, + hasAccess: false, +}); +export const x_Server_NoAccess = makeResourceSpec({ + name: 'xray', + kind: ResourceKind.Server, + hasAccess: false, +}); +export const y_Saml_NoAccess = makeResourceSpec({ + name: 'yankee', + kind: ResourceKind.SamlApplication, + hasAccess: false, +}); +export const z_Discovery_NoAccess = makeResourceSpec({ + name: 'zulu', + kind: ResourceKind.Discovery, + hasAccess: false, +}); + +export const NoAccessList: SelectResourceSpec[] = [ + t_Application_NoAccess, + u_Database_NoAccess, + v_Desktop_NoAccess, + w_Kubernetes_NoAccess, + x_Server_NoAccess, + y_Saml_NoAccess, + z_Discovery_NoAccess, +]; + +export const c_ApplicationGcp = makeResourceSpec({ + name: 'charlie', + kind: ResourceKind.Application, + keywords: ['gcp'], +}); +export const a_DatabaseAws = makeResourceSpec({ + name: 'alpha', + kind: ResourceKind.Database, + keywords: ['aws'], +}); +export const l_DesktopAzure = makeResourceSpec({ + name: 'linux', + kind: ResourceKind.Desktop, + keywords: ['azure'], +}); +export const e_KubernetesSelfHosted_unguided = makeResourceSpec({ + name: 'echo', + kind: ResourceKind.Kubernetes, + unguidedLink: 'test.com', + keywords: ['self-hosted'], +}); +export const f_Server = makeResourceSpec({ + name: 'foxtrot', + kind: ResourceKind.Server, +}); +export const d_Saml = makeResourceSpec({ + name: 'delta', + kind: ResourceKind.SamlApplication, +}); +export const g_Application = makeResourceSpec({ + name: 'golf', + kind: ResourceKind.Application, +}); +export const k_Database = makeResourceSpec({ + name: 'kilo', + kind: ResourceKind.Database, +}); +export const i_Desktop = makeResourceSpec({ + name: 'india', + kind: ResourceKind.Desktop, +}); +export const j_Kubernetes = makeResourceSpec({ + name: 'juliette', + kind: ResourceKind.Kubernetes, +}); +export const h_Server = makeResourceSpec({ + name: 'hotel', + kind: ResourceKind.Server, +}); +export const l_Saml = makeResourceSpec({ + name: 'lima', + kind: ResourceKind.SamlApplication, +}); + +export const kindBasedList: SelectResourceSpec[] = [ + c_ApplicationGcp, + a_DatabaseAws, + t_Application_NoAccess, + l_DesktopAzure, + e_KubernetesSelfHosted_unguided, + u_Database_NoAccess, + f_Server, + w_Kubernetes_NoAccess, + d_Saml, + v_Desktop_NoAccess, + g_Application, + x_Server_NoAccess, + k_Database, + i_Desktop, + z_Discovery_NoAccess, + j_Kubernetes, + h_Server, + y_Saml_NoAccess, + l_Saml, +]; diff --git a/web/packages/teleport/src/Discover/SelectResource/utils/filters.test.ts b/web/packages/teleport/src/Discover/SelectResource/utils/filters.test.ts new file mode 100644 index 0000000000000..ccc0d53ffdf6c --- /dev/null +++ b/web/packages/teleport/src/Discover/SelectResource/utils/filters.test.ts @@ -0,0 +1,156 @@ +/** + * 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 { SelectResourceSpec } from '../resources'; +import { + a_DatabaseAws, + c_ApplicationGcp, + e_KubernetesSelfHosted_unguided, + f_Server, + l_DesktopAzure, + t_Application_NoAccess, +} from '../testUtils'; +import { filterResources, Filters } from './filters'; + +const resources: SelectResourceSpec[] = [ + c_ApplicationGcp, + t_Application_NoAccess, + a_DatabaseAws, + l_DesktopAzure, + e_KubernetesSelfHosted_unguided, + f_Server, +]; + +describe('filters by resource types', () => { + const testCases: { + name: string; + filter: Filters; + expected: SelectResourceSpec[]; + }[] = [ + { + name: 'no filter', + filter: { resourceTypes: [], hostingPlatforms: [] }, + expected: resources, + }, + { + name: 'filter by application', + filter: { resourceTypes: ['app'], hostingPlatforms: [] }, + expected: [c_ApplicationGcp, t_Application_NoAccess], + }, + { + name: 'filter by database', + filter: { resourceTypes: ['db'], hostingPlatforms: [] }, + expected: [a_DatabaseAws], + }, + { + name: 'filter by desktop', + filter: { resourceTypes: ['desktops'], hostingPlatforms: [] }, + expected: [l_DesktopAzure], + }, + { + name: 'filter by kuberenetes', + filter: { resourceTypes: ['kube'], hostingPlatforms: [] }, + expected: [e_KubernetesSelfHosted_unguided], + }, + { + name: 'filter by server', + filter: { resourceTypes: ['server'], hostingPlatforms: [] }, + expected: [f_Server], + }, + { + name: 'filter by server and app', + filter: { resourceTypes: ['app', 'server'], hostingPlatforms: [] }, + expected: [c_ApplicationGcp, t_Application_NoAccess, f_Server], + }, + ]; + test.each(testCases)('$name', tc => { + expect(filterResources(resources, tc.filter)).toEqual(tc.expected); + }); +}); + +describe('filters by hosting platform', () => { + const testCases: { + name: string; + filter: Filters; + expected: SelectResourceSpec[]; + }[] = [ + { + name: 'no filter', + filter: { resourceTypes: [], hostingPlatforms: [] }, + expected: resources, + }, + { + name: 'filter by aws', + filter: { resourceTypes: [], hostingPlatforms: ['aws'] }, + expected: [a_DatabaseAws], + }, + { + name: 'filter by azure', + filter: { resourceTypes: [], hostingPlatforms: ['azure'] }, + expected: [l_DesktopAzure], + }, + { + name: 'filter by gcp', + filter: { resourceTypes: [], hostingPlatforms: ['gcp'] }, + expected: [c_ApplicationGcp], + }, + { + name: 'filter by self-hosted', + filter: { resourceTypes: [], hostingPlatforms: ['self-hosted'] }, + expected: [e_KubernetesSelfHosted_unguided], + }, + { + name: 'filter by aws and azure', + filter: { resourceTypes: [], hostingPlatforms: ['aws', 'azure'] }, + expected: [a_DatabaseAws, l_DesktopAzure], + }, + ]; + test.each(testCases)('$name', tc => { + expect(filterResources(resources, tc.filter)).toEqual(tc.expected); + }); +}); + +describe('filters by resource types and hosting platform', () => { + const testCases: { + name: string; + filter: Filters; + expected: SelectResourceSpec[]; + }[] = [ + { + name: 'no results found', + filter: { resourceTypes: ['app'], hostingPlatforms: ['aws'] }, + expected: [], + }, + { + name: 'filter by app and gcp', + filter: { resourceTypes: ['app'], hostingPlatforms: ['gcp'] }, + expected: [c_ApplicationGcp], + }, + { + name: 'filter by app, kube and self-hosted', + filter: { + resourceTypes: ['app', 'kube'], + hostingPlatforms: ['self-hosted'], + }, + expected: [e_KubernetesSelfHosted_unguided], + }, + ]; + test.each(testCases)('$name', tc => { + expect(filterResources(resources, tc.filter)).toEqual(tc.expected); + }); +}); diff --git a/web/packages/teleport/src/Discover/SelectResource/utils/filters.ts b/web/packages/teleport/src/Discover/SelectResource/utils/filters.ts index a2eebc5d74b44..6fd058a48fada 100644 --- a/web/packages/teleport/src/Discover/SelectResource/utils/filters.ts +++ b/web/packages/teleport/src/Discover/SelectResource/utils/filters.ts @@ -18,6 +18,7 @@ import { Platform } from 'design/platform'; +import { ResourceKind } from 'teleport/Discover/Shared'; import { AuthType } from 'teleport/services/user'; import { SelectResourceSpec } from '../resources'; @@ -39,3 +40,114 @@ export function filterBySupportedPlatformsAndAuthTypes( return resourceSupportsPlatform && resourceSupportsAuthType; }); } + +export const resourceTypeOptions = [ + { value: 'app', label: 'Applications' }, + { value: 'db', label: 'Database' }, + { value: 'desktops', label: 'Desktops' }, + { value: 'kube', label: 'Kubernetes' }, + { value: 'server', label: 'SSH' }, +] as const satisfies { value: string; label: string }[]; + +type ResourceType = Extract< + (typeof resourceTypeOptions)[number], + { value: string } +>['value']; + +export const hostingPlatformOptions = [ + { value: 'aws', label: 'Amazon Web Services (AWS)' }, + { value: 'azure', label: 'Microsoft Azure' }, + { value: 'gcp', label: 'Google Cloud Services (GCP)' }, + { value: 'self-hosted', label: 'Self-Hosted' }, +] as const satisfies { value: string; label: string }[]; + +type HostingPlatform = Extract< + (typeof hostingPlatformOptions)[number], + { value: string } +>['value']; + +export type Filters = { + resourceTypes?: ResourceType[]; + hostingPlatforms?: HostingPlatform[]; +}; + +export function filterResources( + resources: SelectResourceSpec[], + filters: Filters +) { + if ( + !resources.length && + !filters.resourceTypes && + !filters.hostingPlatforms + ) { + return resources; + } + + let filtered = [...resources]; + if (filters.resourceTypes.length) { + const resourceTypes = filters.resourceTypes; + filtered = filtered.filter(r => { + if ( + resourceTypes.includes('app') && + (r.kind === ResourceKind.Application || + r.kind === ResourceKind.SamlApplication) + ) { + return true; + } + if (resourceTypes.includes('db') && r.kind === ResourceKind.Database) { + return true; + } + if ( + resourceTypes.includes('desktops') && + r.kind === ResourceKind.Desktop + ) { + return true; + } + if ( + resourceTypes.includes('kube') && + r.kind === ResourceKind.Kubernetes + ) { + return true; + } + if ( + resourceTypes.includes('server') && + (r.kind === ResourceKind.Server || + r.kind === ResourceKind.ConnectMyComputer) + ) { + return true; + } + }); + } + + if (filters.hostingPlatforms.length) { + const hostingPlatforms = filters.hostingPlatforms; + filtered = filtered.filter(r => { + if ( + hostingPlatforms.includes('aws') && + r.keywords.some(k => k.toLowerCase().includes('aws')) + ) { + return true; + } + if ( + hostingPlatforms.includes('azure') && + r.keywords.some(k => k.toLowerCase().includes('azure')) + ) { + return true; + } + if ( + hostingPlatforms.includes('gcp') && + r.keywords.some(k => k.toLowerCase().includes('gcp')) + ) { + return true; + } + if ( + hostingPlatforms.includes('self-hosted') && + r.keywords.some(k => k.toLowerCase().includes('self-hosted')) + ) { + return true; + } + }); + } + + return filtered; +} diff --git a/web/packages/teleport/src/Discover/SelectResource/utils/pins.ts b/web/packages/teleport/src/Discover/SelectResource/utils/pins.ts new file mode 100644 index 0000000000000..a41f45372d3c4 --- /dev/null +++ b/web/packages/teleport/src/Discover/SelectResource/utils/pins.ts @@ -0,0 +1,57 @@ +/** + * 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 { UserPreferences } from 'gen-proto-ts/teleport/userpreferences/v1/userpreferences_pb'; + +import { DiscoverGuideId } from 'teleport/services/userPreferences/discoverPreference'; + +import { SelectResourceSpec } from '../resources'; + +export const defaultPins = [ + DiscoverGuideId.ConnectMyComputer, + DiscoverGuideId.Kubernetes, + DiscoverGuideId.ApplicationWebHttpProxy, + // TODO(kimlisa): add linux server, once they are all consolidated into one +]; + +/** + * Only returns the defaults of resources that is available to the user. + */ +export function getDefaultPins(availableResources: SelectResourceSpec[]) { + return availableResources + .filter(r => defaultPins.includes(r.id)) + .map(r => r.id); +} + +/** + * Returns pins from preference if any or default pins. + */ +export function getPins(preferences: DiscoverResourcePreference) { + if (!preferences.discoverResourcePreferences) { + return []; + } + + if (!preferences.discoverResourcePreferences.discoverGuide) { + return defaultPins; + } + + return preferences.discoverResourcePreferences.discoverGuide.pinned || []; +} + +export type DiscoverResourcePreference = Partial< + Pick +>; diff --git a/web/packages/teleport/src/Discover/testUtils.ts b/web/packages/teleport/src/Discover/testUtils.ts new file mode 100644 index 0000000000000..ae22fa46aaacb --- /dev/null +++ b/web/packages/teleport/src/Discover/testUtils.ts @@ -0,0 +1,34 @@ +/** + * 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 { Size } from './SelectResource/Tile'; +import { ResourceKind } from './Shared'; + +export function getGuideTileId({ + kind, + title, + size = 'regular', +}: { + kind: ResourceKind; + title?: string; + size?: Size; +}) { + const base = `${size}-tile-${kind}`; + + return new RegExp(title ? `${base}-${title}` : base, 'i'); +} diff --git a/web/packages/teleport/src/User/UserContext.tsx b/web/packages/teleport/src/User/UserContext.tsx index 3dba8e941de8b..38cf0836226b6 100644 --- a/web/packages/teleport/src/User/UserContext.tsx +++ b/web/packages/teleport/src/User/UserContext.tsx @@ -32,6 +32,7 @@ import { UserPreferences } from 'gen-proto-ts/teleport/userpreferences/v1/userpr import useAttempt from 'shared/hooks/useAttemptNext'; import cfg from 'teleport/config'; +import { DiscoverResourcePreference } from 'teleport/Discover/SelectResource/utils/pins'; import { StyledIndicator } from 'teleport/Main'; import { KeysEnum, storageService } from 'teleport/services/storageService'; import * as service from 'teleport/services/userPreferences'; @@ -39,6 +40,9 @@ import { makeDefaultUserPreferences } from 'teleport/services/userPreferences/us export interface UserContextValue { preferences: UserPreferences; + updateDiscoverResourcePreferences: ( + preferences: Partial + ) => Promise; updatePreferences: (preferences: Partial) => Promise; updateClusterPinnedResources: ( clusterId: string, @@ -93,6 +97,20 @@ export function UserContextProvider(props: PropsWithChildren) { }); }; + const updateDiscoverResourcePreferences = async ( + discoverResourcePreferences: Partial + ) => { + const nextPreferences: UserPreferences = { + ...preferences, + ...discoverResourcePreferences, + }; + + return service.updateUserPreferences(nextPreferences).then(() => { + setPreferences(nextPreferences); + storageService.setUserPreferences(nextPreferences); + }); + }; + async function loadUserPreferences() { const storedPreferences = storageService.getUserPreferences(); @@ -132,6 +150,7 @@ export function UserContextProvider(props: PropsWithChildren) { ...newPreferences.accessGraph, }, } as UserPreferences; + setPreferences(nextPreferences); storageService.setUserPreferences(nextPreferences); @@ -171,6 +190,7 @@ export function UserContextProvider(props: PropsWithChildren) { updatePreferences, getClusterPinnedResources, updateClusterPinnedResources, + updateDiscoverResourcePreferences, }} > {props.children} diff --git a/web/packages/teleport/src/User/testHelpers/makeTestUserContext.ts b/web/packages/teleport/src/User/testHelpers/makeTestUserContext.ts index 8b81a3e095c7e..fc60daed886bf 100644 --- a/web/packages/teleport/src/User/testHelpers/makeTestUserContext.ts +++ b/web/packages/teleport/src/User/testHelpers/makeTestUserContext.ts @@ -38,6 +38,7 @@ export const makeTestUserContext = ( updatePreferences: () => Promise.resolve(), updateClusterPinnedResources: () => Promise.resolve(), getClusterPinnedResources: () => Promise.resolve(), + updateDiscoverResourcePreferences: () => Promise.resolve(), }, overrides ); diff --git a/web/packages/teleport/src/services/userPreferences/userPreferences.ts b/web/packages/teleport/src/services/userPreferences/userPreferences.ts index 18b7a500f68fe..0adfb5eb36f67 100644 --- a/web/packages/teleport/src/services/userPreferences/userPreferences.ts +++ b/web/packages/teleport/src/services/userPreferences/userPreferences.ts @@ -101,6 +101,7 @@ export function makeDefaultUserPreferences(): UserPreferences { }, clusterPreferences: makeDefaultUserClusterPreferences(), sideNavDrawerMode: SideNavDrawerMode.COLLAPSED, + discoverResourcePreferences: {}, }; }