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 13fa5f3f2fa3d..de46db8257c92 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: {}, }; }