From 768b594bd6d0b2b63a906fc559cb6f6fe62b0bce Mon Sep 17 00:00:00 2001 From: Lisa Kim Date: Tue, 8 Oct 2024 08:47:00 -0700 Subject: [PATCH] Changes on request checkout component - wrap validation higher - add a custom row for kube resources - add kube resource unsupported check --- .../RequestCheckout/RequestCheckout.story.tsx | 8 + .../RequestCheckout/RequestCheckout.test.tsx | 3 + .../RequestCheckout/RequestCheckout.tsx | 360 +++++++++++------- 3 files changed, 237 insertions(+), 134 deletions(-) diff --git a/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.story.tsx b/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.story.tsx index 115f18f84c7bf..237cd5def3438 100644 --- a/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.story.tsx +++ b/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.story.tsx @@ -164,6 +164,14 @@ export const Success = () => ( ); const baseProps: RequestCheckoutWithSliderProps = { + fetchKubeNamespaces: async () => [ + { value: 'namespace1', label: 'namespace1' }, + { value: 'namespace2', label: 'namespace2' }, + { value: 'namespace3', label: 'namespace3' }, + { value: 'namespace4', label: 'namespace4' }, + ], + allowedKubeSubresourceKinds: ['*'], + bulkToggleKubeResources: () => null, createAttempt: { status: '' }, fetchResourceRequestRolesAttempt: { status: '' }, isResourceRequest: false, diff --git a/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.test.tsx b/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.test.tsx index 89fca74f4ff92..2574287724a7d 100644 --- a/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.test.tsx +++ b/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.test.tsx @@ -174,4 +174,7 @@ const props: RequestCheckoutWithSliderProps = { dryRunResponse: null, startTime: null, onStartTimeChange: () => null, + fetchKubeNamespaces: () => null, + bulkToggleKubeResources: () => null, + allowedKubeSubresourceKinds: [], }; diff --git a/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.tsx b/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.tsx index 6dc9509864375..c2d61257bf4b6 100644 --- a/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.tsx +++ b/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.tsx @@ -30,19 +30,15 @@ import { Image, Indicator, LabelInput, + Link as ExternalLink, P3, Subtitle2, Text, } from 'design'; -import { - ArrowBack, - ChevronDown, - ChevronRight, - Warning, - Cross, -} from 'design/Icon'; +import { ArrowBack, ChevronDown, ChevronRight, Warning } from 'design/Icon'; import Table, { Cell } from 'design/DataTable'; import { Danger } from 'design/Alert'; +import { KubeResourceKind } from 'teleport/services/kube'; import Validation, { useRule, Validator } from 'shared/components/Validation'; import { Attempt } from 'shared/hooks/useAttemptNext'; @@ -50,21 +46,28 @@ import { pluralize } from 'shared/utils/text'; import { Option } from 'shared/components/Select'; import { FieldCheckbox } from 'shared/components/FieldCheckbox'; +import { RequestableResourceKind } from 'shared/components/AccessRequests/NewRequest/resource'; import { CreateRequest } from '../../Shared/types'; import { AssumeStartTime } from '../../AssumeStartTime/AssumeStartTime'; import { AccessDurationRequest } from '../../AccessDuration'; +import { + excludeKubeClusterWithNamespaces, + getKubeResourceRequestMode, + type KubeNamespaceRequest, +} from '../kube'; import { ReviewerOption } from './types'; import shieldCheck from './shield-check.png'; import { SelectReviewers } from './SelectReviewers'; import { AdditionalOptions } from './AdditionalOptions'; +import { KubeNamespaceSelector } from './KubeNamespaceSelector'; +import { CrossIcon } from './CrossIcon'; import type { TransitionStatus } from 'react-transition-group'; import type { AccessRequest } from 'shared/services/accessRequests'; -import type { ResourceKind } from '../resource'; export function RequestCheckoutWithSlider< T extends PendingListItem = PendingListItem, @@ -156,8 +159,12 @@ export function RequestCheckout({ Header, startTime, onStartTimeChange, + fetchKubeNamespaces, + bulkToggleKubeResources, + allowedKubeSubresourceKinds, }: RequestCheckoutProps) { const [reason, setReason] = useState(''); + function updateReason(reason: string) { setReason(reason); } @@ -176,6 +183,16 @@ export function RequestCheckout({ }); } + const { + canRequestKubeCluster, + canRequestKubeResource, + canRequestKubeNamespace, + disableCheckoutFromKubeRestrictions, + } = getKubeResourceRequestMode( + allowedKubeSubresourceKinds, + !!data.find(d => d.kind === 'kube_cluster') + ); + const isInvalidRoleSelection = resourceRequestRoles.length > 0 && isResourceRequest && @@ -186,7 +203,12 @@ export function RequestCheckout({ createAttempt.status === 'processing' || isInvalidRoleSelection || fetchResourceRequestRolesAttempt.status === 'failed' || - fetchResourceRequestRolesAttempt.status === 'processing'; + fetchResourceRequestRolesAttempt.status === 'processing' || + disableCheckoutFromKubeRestrictions; + + const numResourcesSelected = data.filter(item => + excludeKubeClusterWithNamespaces(item, data) + ); const DefaultHeader = () => { return ( @@ -200,131 +222,176 @@ export function RequestCheckout({ />

- {data.length} {pluralize(data.length, 'Resource')} Selected + {numResourcesSelected.length}{' '} + {pluralize(numResourcesSelected.length, 'Resource')} Selected

); }; - return ( - <> - {fetchResourceRequestRolesAttempt.status === 'failed' && ( - - )} - {fetchStatus === 'loading' && ( - - - - )} - - {fetchStatus === 'loaded' && ( -
- {createAttempt.status === 'success' ? ( - <> - - -

Resources Requested Successfully

- - You've successfully requested {numRequestedResources}{' '} - {pluralize(numRequestedResources, 'resource')} - -
- - - -
- - - ) : ( - <> - {Header?.() || DefaultHeader()} - {createAttempt.status === 'failed' && ( - - )} - ( - {getPrettyResourceKind(item.kind)} - ), - }, - { - key: 'name', - headerText: 'Name', - }, - { - altKey: 'delete-btn', - render: resource => ( - - { - clearAttempt(); - toggleResource(resource); - }} - disabled={createAttempt.status === 'processing'} - css={` - cursor: pointer; - - background-color: ${({ theme }) => - theme.colors.buttons.trashButton.default}; - border-radius: 2px; - - &:hover { - background-color: ${({ theme }) => - theme.colors.buttons.trashButton.hover}; - } - `} - /> - - ), - }, - ]} - emptyText="No resources are selected" - /> - {userGroupFetchAttempt?.status === 'processing' && ( - - + function customRow(item: T) { + if ( + item.kind === 'kube_cluster' && + (canRequestKubeResource || canRequestKubeNamespace) + ) { + return ( + + + + + + {getPrettyResourceKind(item.kind)} + {item.name} - )} - {userGroupFetchAttempt?.status === 'failed' && ( - {userGroupFetchAttempt.statusText} - )} - {userGroupFetchAttempt?.status === 'success' && - appsGrantedByUserGroup.length > 0 && ( - - )} - {isResourceRequest && ( - - )} - - r.name) ?? []} - selectedReviewers={selectedReviewers} - setSelectedReviewers={setSelectedReviewers} - /> - - - {({ validator }) => ( + + + + + + ); + } + } + + return ( + + {({ validator }) => ( + <> + {fetchResourceRequestRolesAttempt.status === 'failed' && ( + + )} + {disableCheckoutFromKubeRestrictions && ( + + You can only request Kubernetes resource kind [ + {allowedKubeSubresourceKinds.join(', ')}], but the web UI does not + support this kind yet. Use the{' '} + + tsh CLI tool + {' '} + to create this particular request. + + )} + {fetchStatus === 'loading' && ( + + + + )} + + {fetchStatus === 'loaded' && ( +
+ {createAttempt.status === 'success' ? ( + <> + + +

Resources Requested Successfully

+ + You've successfully requested {numRequestedResources}{' '} + {pluralize(numRequestedResources, 'resource')} + +
+ + + +
+ + + ) : ( + <> + {Header?.() || DefaultHeader()} + {createAttempt.status === 'failed' && ( + + )} + d.kind !== 'namespace')} + row={{ + customRow, + }} + columns={[ + { + key: 'clusterName', + headerText: 'Cluster Name', + isNonRender: !showClusterNameColumn, + }, + { + key: 'kind', + headerText: 'Type', + render: item => ( + {getPrettyResourceKind(item.kind)} + ), + }, + { + key: 'name', + headerText: 'Name', + }, + { + altKey: 'delete-btn', + render: resource => ( + + + + ), + }, + ]} + emptyText="No resources are selected" + /> + {userGroupFetchAttempt?.status === 'processing' && ( + + + + )} + {userGroupFetchAttempt?.status === 'failed' && ( + {userGroupFetchAttempt.statusText} + )} + {userGroupFetchAttempt?.status === 'success' && + appsGrantedByUserGroup.length > 0 && ( + + )} + {isResourceRequest && ( + + )} + + r.name) ?? [] + } + selectedReviewers={selectedReviewers} + setSelectedReviewers={setSelectedReviewers} + /> + {dryRunResponse && ( @@ -389,13 +456,13 @@ export function RequestCheckout({ - )} - - + + )} +
)} -
+ )} - + ); } @@ -538,7 +605,7 @@ function ResourceRequestRoles({ {roles.map((roleName, index) => { const checked = selectedRoles.includes(roleName); return ( - + ; export interface PendingListItem { - kind: ResourceKind; + kind: RequestableResourceKind; /** Name of the resource, for presentation purposes only. */ name: string; /** Identifier of the resource. Should be sent in requests. */ id: string; clusterName?: string; + /** + * This field must be defined if a user is requesting subresources. + * + * Example: + * "kube_cluster" resource can have subresources such as "namespace". + * Example PendingListItem values if user is requesting a kubes namespace: + * - kind: const "namespace" + * - id: name of the kube_cluster + * - subResourceName: name of the kube_cluster's namespace + * - clusterName: name of teleport cluster where kube_cluster is located + * - name: same as subResourceName as this is what we want to display to user + * */ + subResourceName?: string; } +export type PendingKubeResourceItem = Omit & { + kind: Extract; +}; + export type RequestCheckoutProps = { onClose(): void; @@ -805,6 +891,12 @@ export type RequestCheckoutProps = Header?: () => JSX.Element; startTime: Date; onStartTimeChange(t?: Date): void; + fetchKubeNamespaces(p: KubeNamespaceRequest): Promise; + bulkToggleKubeResources( + kubeResources: PendingKubeResourceItem[], + kubeCluster: T + ): void; + allowedKubeSubresourceKinds: KubeResourceKind[]; }; type SuccessComponentParams = {