From 46b4369eab8a5dec598e0dba17da4cfa797d9fff Mon Sep 17 00:00:00 2001 From: Grzegorz Zdunek Date: Fri, 7 Jun 2024 18:30:56 +0200 Subject: [PATCH 1/5] Add `AvailableResourceMode` to the updated preferences in the handler --- .../services/userpreferences/userpreferences.go | 16 +--------------- .../userpreferences/userpreferences_test.go | 14 ++++++++------ 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/lib/teleterm/services/userpreferences/userpreferences.go b/lib/teleterm/services/userpreferences/userpreferences.go index a8e1b9d6ae273..471641a0a5938 100644 --- a/lib/teleterm/services/userpreferences/userpreferences.go +++ b/lib/teleterm/services/userpreferences/userpreferences.go @@ -161,18 +161,13 @@ func Update(ctx context.Context, rootClient Client, leafClient Client, newPrefer // and LabelsViewMode fields in UnifiedResourcePreferences. // The fields are updated one by one (instead of passing the entire struct as new preferences) // to prevent potential new fields from being overwritten. -// Supports oldPreferences being nil. func updateUnifiedResourcePreferences(oldPreferences *userpreferencesv1.UnifiedResourcePreferences, newPreferences *userpreferencesv1.UnifiedResourcePreferences) *userpreferencesv1.UnifiedResourcePreferences { updated := oldPreferences - // TODO(gzdunek): DELETE IN 16.0.0. - // We won't have to support old preferences being nil. - if oldPreferences == nil { - updated = &userpreferencesv1.UnifiedResourcePreferences{} - } updated.DefaultTab = newPreferences.DefaultTab updated.ViewMode = newPreferences.ViewMode updated.LabelsViewMode = newPreferences.LabelsViewMode + updated.AvailableResourceMode = newPreferences.AvailableResourceMode return updated } @@ -180,17 +175,8 @@ func updateUnifiedResourcePreferences(oldPreferences *userpreferencesv1.UnifiedR // updateClusterPreferences updates pinned resources in ClusterUserPreferences. // The fields are updated one by one (instead of passing the entire struct as new preferences) // to prevent potential new fields from being overwritten. -// Supports oldPreferences being nil. func updateClusterPreferences(oldPreferences *userpreferencesv1.ClusterUserPreferences, newPreferences *userpreferencesv1.ClusterUserPreferences) *userpreferencesv1.ClusterUserPreferences { updated := oldPreferences - // TODO(gzdunek): DELETE IN 16.0.0. - // We won't have to support old preferences being nil. - if oldPreferences == nil { - updated = &userpreferencesv1.ClusterUserPreferences{} - } - if updated.PinnedResources == nil { - updated.PinnedResources = &userpreferencesv1.PinnedResourcesUserPreferences{} - } updated.PinnedResources.ResourceIds = newPreferences.PinnedResources.ResourceIds diff --git a/lib/teleterm/services/userpreferences/userpreferences_test.go b/lib/teleterm/services/userpreferences/userpreferences_test.go index 230da4a3c3f4c..003a1e26aa9b7 100644 --- a/lib/teleterm/services/userpreferences/userpreferences_test.go +++ b/lib/teleterm/services/userpreferences/userpreferences_test.go @@ -36,9 +36,10 @@ var rootPreferencesMock = &userpreferencesv1.UserPreferences{ }, }, UnifiedResourcePreferences: &userpreferencesv1.UnifiedResourcePreferences{ - DefaultTab: userpreferencesv1.DefaultTab_DEFAULT_TAB_ALL, - ViewMode: userpreferencesv1.ViewMode_VIEW_MODE_CARD, - LabelsViewMode: userpreferencesv1.LabelsViewMode_LABELS_VIEW_MODE_COLLAPSED, + DefaultTab: userpreferencesv1.DefaultTab_DEFAULT_TAB_ALL, + ViewMode: userpreferencesv1.ViewMode_VIEW_MODE_CARD, + LabelsViewMode: userpreferencesv1.LabelsViewMode_LABELS_VIEW_MODE_COLLAPSED, + AvailableResourceMode: userpreferencesv1.AvailableResourceMode_AVAILABLE_RESOURCE_MODE_NONE, }, } @@ -106,9 +107,10 @@ func TestUserPreferencesUpdateForRootAndLeaf(t *testing.T) { }, }, UnifiedResourcePreferences: &userpreferencesv1.UnifiedResourcePreferences{ - DefaultTab: userpreferencesv1.DefaultTab_DEFAULT_TAB_PINNED, - ViewMode: userpreferencesv1.ViewMode_VIEW_MODE_LIST, - LabelsViewMode: userpreferencesv1.LabelsViewMode_LABELS_VIEW_MODE_EXPANDED, + DefaultTab: userpreferencesv1.DefaultTab_DEFAULT_TAB_PINNED, + ViewMode: userpreferencesv1.ViewMode_VIEW_MODE_LIST, + LabelsViewMode: userpreferencesv1.LabelsViewMode_LABELS_VIEW_MODE_EXPANDED, + AvailableResourceMode: userpreferencesv1.AvailableResourceMode_AVAILABLE_RESOURCE_MODE_REQUESTABLE, }, } From 4c7cf20433409ce94f40d625840893f569697dea Mon Sep 17 00:00:00 2001 From: Grzegorz Zdunek Date: Fri, 7 Jun 2024 19:04:05 +0200 Subject: [PATCH 2/5] Improve the preferences parser to handle missing properties, as well as the entire object being undefined --- .../ui/DocumentCluster/useUserPreferences.ts | 9 +++- .../workspacesService.test.ts | 21 +++++++- .../workspacesService/workspacesService.ts | 52 +++++++++++++++---- 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/web/packages/teleterm/src/ui/DocumentCluster/useUserPreferences.ts b/web/packages/teleterm/src/ui/DocumentCluster/useUserPreferences.ts index 014670b492e0e..bce52fb8b65fa 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/useUserPreferences.ts +++ b/web/packages/teleterm/src/ui/DocumentCluster/useUserPreferences.ts @@ -66,7 +66,7 @@ export function useUserPreferences(clusterUri: ClusterUri): { defaultTab: DefaultTab.ALL, viewMode: ViewMode.CARD, labelsViewMode: LabelsViewMode.COLLAPSED, - availableResourceMode: AvailableResourceMode.ACCESSIBLE, + availableResourceMode: AvailableResourceMode.NONE, } ); const [clusterPreferences, setClusterPreferences] = useState< @@ -240,6 +240,11 @@ function mergeWithDefaultUnifiedResourcePreferences( unifiedResourcePreferences.labelsViewMode !== LabelsViewMode.UNSPECIFIED ? unifiedResourcePreferences.labelsViewMode : LabelsViewMode.COLLAPSED, - availableResourceMode: AvailableResourceMode.ACCESSIBLE, + availableResourceMode: + unifiedResourcePreferences && + unifiedResourcePreferences.availableResourceMode !== + AvailableResourceMode.UNSPECIFIED + ? unifiedResourcePreferences.availableResourceMode + : AvailableResourceMode.NONE, }; } diff --git a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts index 9ba45f0d0ef15..a9d82f2ab4396 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts @@ -16,6 +16,13 @@ * along with this program. If not, see . */ +import { + DefaultTab, + ViewMode, + LabelsViewMode, + AvailableResourceMode, +} from 'gen-proto-ts/teleport/userpreferences/v1/unified_resource_preferences_pb'; + import { makeRootCluster } from 'teleterm/services/tshd/testHelpers'; import Logger, { NullService } from 'teleterm/logger'; import { makeDocumentCluster } from 'teleterm/ui/services/workspacesService/documentsService/testHelpers'; @@ -85,7 +92,12 @@ describe('restoring workspace', () => { location: testWorkspace.location, }, connectMyComputer: undefined, - unifiedResourcePreferences: undefined, + unifiedResourcePreferences: { + defaultTab: DefaultTab.ALL, + viewMode: ViewMode.CARD, + labelsViewMode: LabelsViewMode.COLLAPSED, + availableResourceMode: AvailableResourceMode.NONE, + }, }, }); }); @@ -116,7 +128,12 @@ describe('restoring workspace', () => { location: clusterDocument.uri, previous: undefined, connectMyComputer: undefined, - unifiedResourcePreferences: undefined, + unifiedResourcePreferences: { + defaultTab: DefaultTab.ALL, + viewMode: ViewMode.CARD, + labelsViewMode: LabelsViewMode.COLLAPSED, + availableResourceMode: AvailableResourceMode.NONE, + }, }, }); }); diff --git a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts index ce647a436e163..ba0eb74da8b23 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts @@ -25,6 +25,7 @@ import { LabelsViewMode, UnifiedResourcePreferences, ViewMode, + AvailableResourceMode, } from 'gen-proto-ts/teleport/userpreferences/v1/unified_resource_preferences_pb'; import { ModalsService } from 'teleterm/ui/services/modals'; @@ -93,6 +94,9 @@ export interface Workspace { connectMyComputer?: { autoStart: boolean; }; + //TODO(gzdunek): Make this property required. + // This requires updating many of tests + // where we construct the workspace manually. unifiedResourcePreferences?: UnifiedResourcePreferences; previous?: { documents: Document[]; @@ -258,7 +262,7 @@ export class WorkspacesService extends ImmutableStore { getUnifiedResourcePreferences( rootClusterUri: RootClusterUri - ): UnifiedResourcePreferences | undefined { + ): UnifiedResourcePreferences { return this.state.workspaces[rootClusterUri].unifiedResourcePreferences; } @@ -429,10 +433,11 @@ export class WorkspacesService extends ImmutableStore { // TODO(gzdunek): Parse the entire workspace state read from disk like below. private parseUnifiedResourcePreferences( unifiedResourcePreferences: unknown - // TODO(gzdunek): DELETE IN 16.0.0. See comment in useUserPreferences.ts. - ): Partial | undefined { + ): UnifiedResourcePreferences | undefined { try { - return unifiedResourcePreferencesSchema.parse(unifiedResourcePreferences); + return unifiedResourcePreferencesSchema.parse( + unifiedResourcePreferences + ) as UnifiedResourcePreferencesSchemaAsRequired; } catch (e) { this.logger.error('Failed to parse unified resource preferences', e); } @@ -529,6 +534,7 @@ export class WorkspacesService extends ImmutableStore { localClusterUri, location: defaultDocument.uri, documents: [defaultDocument], + unifiedResourcePreferences: getDefaultUnifiedResourcePreferences(), }; } @@ -551,8 +557,36 @@ export class WorkspacesService extends ImmutableStore { } } -const unifiedResourcePreferencesSchema = z.object({ - defaultTab: z.nativeEnum(DefaultTab), - viewMode: z.nativeEnum(ViewMode), - labelsViewMode: z.nativeEnum(LabelsViewMode), -}); +// Best to keep in sync with lib/services/local/userpreferences.go. +export function getDefaultUnifiedResourcePreferences(): UnifiedResourcePreferences { + return { + defaultTab: DefaultTab.ALL, + viewMode: ViewMode.CARD, + labelsViewMode: LabelsViewMode.COLLAPSED, + availableResourceMode: AvailableResourceMode.NONE, + }; +} +const unifiedResourcePreferencesSchema = z + .object({ + defaultTab: z + .nativeEnum(DefaultTab) + .default(getDefaultUnifiedResourcePreferences().defaultTab), + viewMode: z + .nativeEnum(ViewMode) + .default(getDefaultUnifiedResourcePreferences().viewMode), + labelsViewMode: z + .nativeEnum(LabelsViewMode) + .default(getDefaultUnifiedResourcePreferences().labelsViewMode), + availableResourceMode: z + .nativeEnum(AvailableResourceMode) + .default(getDefaultUnifiedResourcePreferences().availableResourceMode), + }) + // Assign the default values if undefined is passed. + .default({}); + +// Because we don't have `strictNullChecks` enabled, zod infers +// all properties as optional. +// With this helper, we can enforce the schema to contain all properties. +type UnifiedResourcePreferencesSchemaAsRequired = Required< + z.infer +>; From a90d2985dee965e1143880bb60972f252a0d766a Mon Sep 17 00:00:00 2001 From: Grzegorz Zdunek Date: Fri, 7 Jun 2024 19:15:52 +0200 Subject: [PATCH 3/5] Add support for resource availability switcher --- .../DocumentCluster/UnifiedResources.test.tsx | 272 ++++++++++++++++++ .../ui/DocumentCluster/UnifiedResources.tsx | 98 +++++-- 2 files changed, 351 insertions(+), 19 deletions(-) create mode 100644 web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx diff --git a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx new file mode 100644 index 0000000000000..eb537ce8335e5 --- /dev/null +++ b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx @@ -0,0 +1,272 @@ +/** + * Teleport + * Copyright (C) 2024 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 { render, screen } from 'design/utils/testing'; +import { mockIntersectionObserver } from 'jsdom-testing-mocks'; +import { act } from '@testing-library/react'; + +import { + AvailableResourceMode, + DefaultTab, + ViewMode, + LabelsViewMode, +} from 'gen-proto-ts/teleport/userpreferences/v1/unified_resource_preferences_pb'; +import { ShowResources } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb'; + +import { UnifiedResources } from 'teleterm/ui/DocumentCluster/UnifiedResources'; +import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; +import { ResourcesContextProvider } from 'teleterm/ui/DocumentCluster/resourcesContext'; +import { ConnectMyComputerContextProvider } from 'teleterm/ui/ConnectMyComputer'; +import { MockWorkspaceContextProvider } from 'teleterm/ui/fixtures/MockWorkspaceContextProvider'; +import { makeDocumentCluster } from 'teleterm/ui/services/workspacesService/documentsService/testHelpers'; +import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; +import { + makeRootCluster, + rootClusterUri, +} from 'teleterm/services/tshd/testHelpers'; +import { getEmptyPendingAccessRequest } from 'teleterm/ui/services/workspacesService/accessRequestsService'; + +import { MockedUnaryCall } from 'teleterm/services/tshd/cloneableClient'; + +const mio = mockIntersectionObserver(); + +const tests = [ + { + name: 'fetches only available resources if cluster does not support access requests', + conditions: { + isClusterSupportingAccessRequests: false, + showResources: ShowResources.REQUESTABLE, + availableResourceModePreference: AvailableResourceMode.ALL, + }, + expect: { + searchAsRoles: false, + includeRequestable: false, + }, + }, + { + name: 'fetches all resources if cluster allows listing all and user preferences says all', + conditions: { + isClusterSupportingAccessRequests: true, + showResources: ShowResources.REQUESTABLE, + availableResourceModePreference: AvailableResourceMode.ALL, + }, + expect: { + searchAsRoles: false, + includeRequestable: true, + }, + }, + { + name: 'fetches all resources if cluster allows listing all and user preferences says none', + conditions: { + isClusterSupportingAccessRequests: true, + showResources: ShowResources.REQUESTABLE, + availableResourceModePreference: AvailableResourceMode.ALL, + }, + expect: { + searchAsRoles: false, + includeRequestable: true, + }, + }, + { + name: 'fetches accessible resources if cluster allows listing all and user preferences says accessible', + conditions: { + isClusterSupportingAccessRequests: true, + showResources: ShowResources.REQUESTABLE, + availableResourceModePreference: AvailableResourceMode.ACCESSIBLE, + }, + expect: { + searchAsRoles: false, + includeRequestable: false, + }, + }, + { + name: 'fetches requestable resources if cluster allows listing all and user preferences says requestable', + conditions: { + isClusterSupportingAccessRequests: true, + showResources: ShowResources.REQUESTABLE, + availableResourceModePreference: AvailableResourceMode.REQUESTABLE, + }, + expect: { + searchAsRoles: true, + includeRequestable: false, + }, + }, + { + name: 'fetches only accessible resources if cluster does not allow listing all', + conditions: { + isClusterSupportingAccessRequests: true, + showResources: ShowResources.ACCESSIBLE_ONLY, + availableResourceModePreference: AvailableResourceMode.UNSPECIFIED, + }, + expect: { + searchAsRoles: false, + includeRequestable: false, + }, + }, + { + name: 'fetches only accessible resources if cluster does not allow listing all and user preferences says accessible', + conditions: { + isClusterSupportingAccessRequests: true, + showResources: ShowResources.ACCESSIBLE_ONLY, + availableResourceModePreference: AvailableResourceMode.ALL, + }, + expect: { + searchAsRoles: false, + includeRequestable: false, + }, + }, + { + name: 'fetches only requestable resources if cluster does not allow listing all and user preferences says requestable', + conditions: { + isClusterSupportingAccessRequests: true, + showResources: ShowResources.ACCESSIBLE_ONLY, + availableResourceModePreference: AvailableResourceMode.REQUESTABLE, + }, + expect: { + searchAsRoles: true, + includeRequestable: false, + }, + }, + { + name: 'fetches only accessible resources if cluster does not allow listing all but user preferences says all', + conditions: { + isClusterSupportingAccessRequests: true, + showResources: ShowResources.ACCESSIBLE_ONLY, + availableResourceModePreference: AvailableResourceMode.ALL, + }, + expect: { + searchAsRoles: false, + includeRequestable: false, + }, + }, + { + name: 'fetches only accessible resources if cluster does not allow listing all but user preferences says none', + conditions: { + isClusterSupportingAccessRequests: true, + showResources: ShowResources.ACCESSIBLE_ONLY, + availableResourceModePreference: AvailableResourceMode.NONE, + }, + expect: { + searchAsRoles: false, + includeRequestable: false, + }, + }, +]; + +test.each(tests)('$name', async testCase => { + const doc = makeDocumentCluster(); + + const appContext = new MockAppContext({ platform: 'darwin' }); + appContext.clustersService.setState(draft => { + draft.clusters.set( + doc.clusterUri, + makeRootCluster({ + uri: doc.clusterUri, + features: { + advancedAccessWorkflows: + testCase.conditions.isClusterSupportingAccessRequests, + isUsageBasedBilling: false, + }, + showResources: testCase.conditions.showResources, + }) + ); + }); + + appContext.workspacesService.setState(draftState => { + const rootClusterUri = doc.clusterUri; + draftState.rootClusterUri = rootClusterUri; + draftState.workspaces[rootClusterUri] = { + localClusterUri: doc.clusterUri, + documents: [doc], + location: doc.uri, + unifiedResourcePreferences: { + defaultTab: DefaultTab.ALL, + viewMode: ViewMode.CARD, + labelsViewMode: LabelsViewMode.COLLAPSED, + availableResourceMode: + testCase.conditions.availableResourceModePreference, + }, + accessRequests: { + pending: getEmptyPendingAccessRequest(), + isBarCollapsed: true, + }, + }; + }); + + jest.spyOn(appContext.tshd, 'getUserPreferences').mockResolvedValue( + new MockedUnaryCall({ + userPreferences: { + unifiedResourcePreferences: { + defaultTab: DefaultTab.ALL, + viewMode: ViewMode.CARD, + labelsViewMode: LabelsViewMode.COLLAPSED, + availableResourceMode: + testCase.conditions.availableResourceModePreference, + }, + }, + }) + ); + + jest + .spyOn(appContext.resourcesService, 'listUnifiedResources') + .mockResolvedValue({ + resources: [], + nextKey: '', + }); + + render( + + + + + + + + + + ); + + act(mio.enterAll); + + await expect( + screen.findByText('Add your first resource to Teleport') + ).resolves.toBeInTheDocument(); + + expect(appContext.resourcesService.listUnifiedResources).toHaveBeenCalledWith( + { + clusterUri: rootClusterUri, + includeRequestable: testCase.expect.includeRequestable, + kinds: [], + limit: 48, + pinnedOnly: false, + query: '', + search: '', + searchAsRoles: testCase.expect.searchAsRoles, + sortBy: { + field: 'name', + isDesc: false, + }, + startKey: '', + }, + new AbortController().signal + ); +}); diff --git a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx index bb7e1b44caefb..754eaf4c572e3 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx @@ -24,6 +24,8 @@ import { UnifiedResourcesQueryParams, SharedUnifiedResource, UnifiedResourcesPinning, + getResourceAvailabilityFilter, + ResourceAvailabilityFilter, } from 'shared/components/UnifiedResources'; import { DbProtocol, @@ -83,6 +85,8 @@ export function UnifiedResources(props: { useUserPreferences(props.clusterUri); const { documentsService, rootClusterUri, accessRequestsService } = useWorkspaceContext(); + const rootCluster = clustersService.findCluster(rootClusterUri); + const addedResources = useStoreSelector( 'workspacesService', useCallback( @@ -101,7 +105,7 @@ export function UnifiedResources(props: { const { unifiedResourcePreferences } = userPreferences; - const mergedParams: UnifiedResourcesQueryParams = useMemo( + const mergedParams = useMemo( () => ({ kinds: props.queryParams.resourceKinds, sort: props.queryParams.sort, @@ -122,13 +126,36 @@ export function UnifiedResources(props: { ] ); + const integratedAccessRequests = useMemo(() => { + // Ideally, we would have a cluster loading status that would tell us, + // whether the cluster data from the auth server has been loaded. + // However, since we don't have that, + // we use the `showResources` status as an indicator. + if (rootCluster.showResources === ShowResources.UNSPECIFIED) { + return { supported: 'unknown' }; + } + if (!rootCluster.features?.advancedAccessWorkflows) { + return { supported: 'no' }; + } + return { + supported: 'yes', + availabilityFilter: getResourceAvailabilityFilter( + userPreferences.unifiedResourcePreferences.availableResourceMode, + rootCluster.showResources === ShowResources.REQUESTABLE + ), + }; + }, [ + rootCluster.features?.advancedAccessWorkflows, + rootCluster.showResources, + userPreferences.unifiedResourcePreferences.availableResourceMode, + ]); + const { canUse: hasPermissionsForConnectMyComputer, agentCompatibility } = useConnectMyComputerContext(); const isRootCluster = props.clusterUri === rootClusterUri; const canAddResources = isRootCluster && loggedInUser?.acl?.tokens.create; let discoverUrl: string; - const rootCluster = clustersService.findCluster(rootClusterUri); if (isRootCluster) { discoverUrl = `https://${rootCluster.proxyHost}/web/discover`; } @@ -162,13 +189,13 @@ export function UnifiedResources(props: { (resource: UnifiedResourceResponse) => { const isResourceAdded = addedResources?.has(resource.resource.uri); - const showRequestableResources = - rootCluster.showResources === ShowResources.REQUESTABLE; - // If we are currently making an access request, all buttons change to - // add to request. const showRequestButton = - showRequestableResources && - (resource.requiresRequest || requestStarted); + integratedAccessRequests.supported === 'yes' && + (integratedAccessRequests.availabilityFilter.mode === 'requestable' || + resource.requiresRequest || + // If we are currently making an access request, all buttons change to + // add to request. + requestStarted); if (showRequestButton) { return ( @@ -184,14 +211,13 @@ export function UnifiedResources(props: { accessRequestsService, addedResources, requestStarted, - rootCluster.showResources, + integratedAccessRequests, ] ); return ( ); } @@ -224,18 +251,28 @@ const Resources = memo( onResourcesRefreshRequest: ResourcesContext['onResourcesRefreshRequest']; discoverUrl: string; getAccessRequestButton?: (resource: UnifiedResourceResponse) => JSX.Element; - showResources: ShowResources; + integratedAccessRequests: IntegratedAccessRequests; }) => { const appContext = useAppContext(); const { fetch, resources, attempt, clear } = useUnifiedResourcesFetch({ fetchFunc: useCallback( async (paginationParams, signal) => { - // Block call if we don't know yet what resources to show. - // We will remount the component and do the call when showResources changes. - if (props.showResources === ShowResources.UNSPECIFIED) { + // Block the call if we don't know yet what resources to show. + // We will remount the component and do the call when integratedAccessRequests changes. + if (props.integratedAccessRequests.supported === 'unknown') { await waitForever(signal); } + + const fetchOnlyRequestable = + props.integratedAccessRequests.supported === 'yes' && + props.integratedAccessRequests.availabilityFilter.mode === + 'requestable'; + const fetchAll = + props.integratedAccessRequests.supported === 'yes' && + (props.integratedAccessRequests.availabilityFilter.mode === + 'none' || + props.integratedAccessRequests.availabilityFilter.mode === 'all'); const response = await retryWithRelogin( appContext, props.clusterUri, @@ -243,7 +280,7 @@ const Resources = memo( appContext.resourcesService.listUnifiedResources( { clusterUri: props.clusterUri, - searchAsRoles: false, + searchAsRoles: fetchOnlyRequestable, sortBy: { isDesc: props.queryParams.sort.dir === 'DESC', field: props.queryParams.sort.fieldName, @@ -254,8 +291,7 @@ const Resources = memo( pinnedOnly: props.queryParams.pinnedOnly, startKey: paginationParams.startKey, limit: paginationParams.limit, - includeRequestable: - props.showResources === ShowResources.REQUESTABLE, + includeRequestable: fetchAll, }, signal ) @@ -275,7 +311,7 @@ const Resources = memo( props.queryParams.sort.dir, props.queryParams.sort.fieldName, props.clusterUri, - props.showResources, + props.integratedAccessRequests, ] ), }); @@ -315,6 +351,11 @@ const Resources = memo( props.updateUserPreferences({ unifiedResourcePreferences }) } pinning={pinning} + availabilityFilter={ + props.integratedAccessRequests.supported === 'yes' + ? props.integratedAccessRequests.availabilityFilter + : undefined + } resources={resources.map(r => { const { resource, ui } = mapToSharedResource(r); return { @@ -512,3 +553,22 @@ function NoResources(props: { ); } + +/** + * Describes availability of integrated access requests + * (requesting resources from the unified resources view). + * + * If `supported` is `'no'` it basically means that the cluster doesn't support + * access requests at all. + */ +type IntegratedAccessRequests = + | { + supported: 'unknown'; + } + | { + supported: 'no'; + } + | { + supported: 'yes'; + availabilityFilter: ResourceAvailabilityFilter; + }; From 09ec659b9746bc4de08f7fe204081b73e803892b Mon Sep 17 00:00:00 2001 From: Grzegorz Zdunek Date: Mon, 10 Jun 2024 08:41:27 +0200 Subject: [PATCH 4/5] Show requestable resources only if the cluster supports access requests --- .../teleterm/src/ui/Search/useSearch.test.tsx | 5 +++-- web/packages/teleterm/src/ui/Search/useSearch.ts | 15 +++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/web/packages/teleterm/src/ui/Search/useSearch.test.tsx b/web/packages/teleterm/src/ui/Search/useSearch.test.tsx index 2bc4cff1e20f9..6030b4aded786 100644 --- a/web/packages/teleterm/src/ui/Search/useSearch.test.tsx +++ b/web/packages/teleterm/src/ui/Search/useSearch.test.tsx @@ -191,7 +191,7 @@ describe('useResourceSearch', () => { search: 'foo', filters: [], limit: 100, - includeRequestable: true, + includeRequestable: false, }); expect(appContext.resourcesService.searchResources).toHaveBeenCalledTimes( 1 @@ -223,7 +223,7 @@ describe('useResourceSearch', () => { search: '', filters: [], limit: 10, - includeRequestable: true, + includeRequestable: false, }); expect(appContext.resourcesService.searchResources).toHaveBeenCalledTimes( 1 @@ -276,6 +276,7 @@ describe('useResourceSearch', () => { const appContext = new MockAppContext(); const rootCluster = makeRootCluster({ showResources: ShowResources.REQUESTABLE, + features: { advancedAccessWorkflows: true, isUsageBasedBilling: false }, }); const leafCluster = makeLeafCluster({ showResources: ShowResources.UNSPECIFIED, diff --git a/web/packages/teleterm/src/ui/Search/useSearch.ts b/web/packages/teleterm/src/ui/Search/useSearch.ts index 86de88e823efb..e656ae8066617 100644 --- a/web/packages/teleterm/src/ui/Search/useSearch.ts +++ b/web/packages/teleterm/src/ui/Search/useSearch.ts @@ -120,17 +120,20 @@ export function useResourceSearch() { : connectedClusters; const promiseResults = await Promise.allSettled( - clustersToSearch.map(cluster => - resourcesService.searchResources({ + clustersToSearch.map(cluster => { + const rootCluster = clustersService.findRootClusterByResource( + cluster.uri + ); + return resourcesService.searchResources({ clusterUri: cluster.uri, search, filters: resourceTypeSearchFilters.map(f => f.resourceType), limit, includeRequestable: - clustersService.findRootClusterByResource(cluster.uri) - ?.showResources === ShowResources.REQUESTABLE, - }) - ) + rootCluster?.showResources === ShowResources.REQUESTABLE && + !!rootCluster?.features?.advancedAccessWorkflows, + }); + }) ); const results: resourcesServiceTypes.SearchResult[] = []; From 80a82aff6981ee0b77915057b985c361bcd35f0c Mon Sep 17 00:00:00 2001 From: Grzegorz Zdunek Date: Mon, 10 Jun 2024 16:54:38 +0200 Subject: [PATCH 5/5] Return an object with `searchAsRoles` and `includeRequestable` --- .../ui/DocumentCluster/UnifiedResources.tsx | 52 ++++++++++++++----- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx index 754eaf4c572e3..0374e4b9f5740 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx @@ -47,7 +47,11 @@ import { DefaultTab } from 'gen-proto-ts/teleport/userpreferences/v1/unified_res import { NodeSubKind } from 'shared/services'; import { waitForever } from 'shared/utils/wait'; -import { UserPreferences } from 'teleterm/services/tshd/types'; +import { + UserPreferences, + ListUnifiedResourcesRequest, +} from 'gen-proto-ts/teleport/lib/teleterm/v1/service_pb'; + import { UnifiedResourceResponse } from 'teleterm/ui/services/resources'; import { useAppContext } from 'teleterm/ui/appContextProvider'; import * as uri from 'teleterm/ui/uri'; @@ -264,15 +268,8 @@ const Resources = memo( await waitForever(signal); } - const fetchOnlyRequestable = - props.integratedAccessRequests.supported === 'yes' && - props.integratedAccessRequests.availabilityFilter.mode === - 'requestable'; - const fetchAll = - props.integratedAccessRequests.supported === 'yes' && - (props.integratedAccessRequests.availabilityFilter.mode === - 'none' || - props.integratedAccessRequests.availabilityFilter.mode === 'all'); + const { searchAsRoles, includeRequestable } = + getRequestableResourcesParams(props.integratedAccessRequests); const response = await retryWithRelogin( appContext, props.clusterUri, @@ -280,7 +277,6 @@ const Resources = memo( appContext.resourcesService.listUnifiedResources( { clusterUri: props.clusterUri, - searchAsRoles: fetchOnlyRequestable, sortBy: { isDesc: props.queryParams.sort.dir === 'DESC', field: props.queryParams.sort.fieldName, @@ -291,7 +287,8 @@ const Resources = memo( pinnedOnly: props.queryParams.pinnedOnly, startKey: paginationParams.startKey, limit: paginationParams.limit, - includeRequestable: fetchAll, + searchAsRoles, + includeRequestable, }, signal ) @@ -572,3 +569,34 @@ type IntegratedAccessRequests = supported: 'yes'; availabilityFilter: ResourceAvailabilityFilter; }; + +/** + * When `includeRequestable` is true, + * all resources (accessible and requestable) are returned. + * When only `searchAsRoles` is true, only requestable resources are returned. + * When both are false, only accessible resources are returned. + */ +function getRequestableResourcesParams( + integratedAccessRequests: IntegratedAccessRequests +): Pick { + if (integratedAccessRequests.supported === 'yes') { + switch (integratedAccessRequests.availabilityFilter.mode) { + case 'all': + case 'none': + return { + searchAsRoles: false, + includeRequestable: true, + }; + case 'requestable': + return { + searchAsRoles: true, + includeRequestable: false, + }; + } + } + + return { + searchAsRoles: false, + includeRequestable: false, + }; +}