diff --git a/web/packages/shared/components/UnifiedResources/CardsView/ResourceCard.story.tsx b/web/packages/shared/components/UnifiedResources/CardsView/ResourceCard.story.tsx index ea05565de2150..a8b95d64ba316 100644 --- a/web/packages/shared/components/UnifiedResources/CardsView/ResourceCard.story.tsx +++ b/web/packages/shared/components/UnifiedResources/CardsView/ResourceCard.story.tsx @@ -33,6 +33,7 @@ import { nodes } from 'teleport/Nodes/fixtures'; import makeApp from 'teleport/services/apps/makeApps'; import { ResourceActionButton } from 'teleport/UnifiedResources/ResourceActionButton'; +import { SamlAppActionProvider } from 'teleport/SamlApplications/useSamlAppActions'; import { makeUnifiedResourceViewItemApp, @@ -102,56 +103,51 @@ const ActionButton = Action; export const Cards: Story = { render() { return ( - - {[ - ...apps.map(resource => - makeUnifiedResourceViewItemApp(resource, { - ActionButton: ( - - alert('Sets resource spec and opens update dialog') - } - /> - ), - }) - ), - ...databases.map(resource => - makeUnifiedResourceViewItemDatabase(resource, { - ActionButton, - }) - ), - ...kubes.map(resource => - makeUnifiedResourceViewItemKube(resource, { ActionButton }) - ), - ...nodes.map(resource => - makeUnifiedResourceViewItemNode(resource, { - ActionButton, - }) - ), - ...additionalResources.map(resource => - makeUnifiedResourceViewItemApp(resource, { ActionButton }) - ), - ...desktops.map(resource => - makeUnifiedResourceViewItemDesktop(resource, { ActionButton }) - ), - ].map((res, i) => ( - {}} - selectResource={() => {}} - selected={false} - pinningSupport={PinningSupport.Supported} - name={res.name} - primaryIconName={res.primaryIconName} - SecondaryIcon={res.SecondaryIcon} - cardViewProps={res.cardViewProps} - labels={res.labels} - ActionButton={res.ActionButton} - /> - ))} - + + + {[ + ...apps.map(resource => + makeUnifiedResourceViewItemApp(resource, { + ActionButton: , + }) + ), + ...databases.map(resource => + makeUnifiedResourceViewItemDatabase(resource, { + ActionButton, + }) + ), + ...kubes.map(resource => + makeUnifiedResourceViewItemKube(resource, { ActionButton }) + ), + ...nodes.map(resource => + makeUnifiedResourceViewItemNode(resource, { + ActionButton, + }) + ), + ...additionalResources.map(resource => + makeUnifiedResourceViewItemApp(resource, { ActionButton }) + ), + ...desktops.map(resource => + makeUnifiedResourceViewItemDesktop(resource, { ActionButton }) + ), + ].map((res, i) => ( + {}} + selectResource={() => {}} + selected={false} + pinningSupport={PinningSupport.Supported} + name={res.name} + primaryIconName={res.primaryIconName} + SecondaryIcon={res.SecondaryIcon} + cardViewProps={res.cardViewProps} + labels={res.labels} + ActionButton={res.ActionButton} + /> + ))} + + ); }, }; diff --git a/web/packages/teleport/src/Main/Main.tsx b/web/packages/teleport/src/Main/Main.tsx index a0105ea9cfe7c..bfaec119415b1 100644 --- a/web/packages/teleport/src/Main/Main.tsx +++ b/web/packages/teleport/src/Main/Main.tsx @@ -327,7 +327,7 @@ export const useNoMinWidth = () => { }, []); }; -const ContentMinWidth = ({ children }: { children: ReactNode }) => { +export const ContentMinWidth = ({ children }: { children: ReactNode }) => { const [enforceMinWidth, setEnforceMinWidth] = useState(true); return ( diff --git a/web/packages/teleport/src/SamlApplications/useSamlAppActions.tsx b/web/packages/teleport/src/SamlApplications/useSamlAppActions.tsx new file mode 100644 index 0000000000000..929f4ff149e78 --- /dev/null +++ b/web/packages/teleport/src/SamlApplications/useSamlAppActions.tsx @@ -0,0 +1,118 @@ +/** + * 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 React, { createContext, useContext } from 'react'; +import { Attempt } from 'shared/hooks/useAsync'; + +import { SamlMeta } from 'teleport/Discover/useDiscover'; + +import type { SamlAppToDelete } from 'teleport/services/samlidp/types'; +import type { ResourceSpec } from 'teleport/Discover/SelectResource/types'; +import type { Access } from 'teleport/services/user'; + +/** + * SamlAppAction defines Saml application edit and delete actions. + */ +export interface SamlAppAction { + /** + * actions controls Saml menu button view and edit and delete onClick behaviour. + */ + actions: { + /** + * showActions dictates whether to show or hide the Saml menu button. + */ + showActions: boolean; + /** + * startEdit triggers Saml app edit flow. + */ + startEdit: (resourceSpec: ResourceSpec) => void; + /** + * startDelete triggers Saml app delete flow. + */ + startDelete: (resourceSpec: ResourceSpec) => void; + }; + /** + * currentAction specifies edit or delete mode. + */ + currentAction?: SamlAppActionMode; + /** + * deleteSamlAppAttempt is an attempt to delete Saml + * app in the backend. + */ + deleteSamlAppAttempt?: Attempt; + /** + * samlAppToDelete defines Saml app item that is to be + * deleted from the unified view. + */ + samlAppToDelete?: SamlAppToDelete; + /** + * fetchSamlResourceAttempt is an attempt to fetch + * Saml resource spec from the backend. It is used to + * pre-populate input fields in the Saml Discover flow. + */ + fetchSamlResourceAttempt?: Attempt; + /** + * resourceSpec holds current Saml app resource spec. + */ + resourceSpec?: ResourceSpec; + /** + * userSamlIdPPerm holds user's RBAC permissions to + * saml_idp_service_provider resource. + */ + userSamlIdPPerm?: Access; + /** + * clearAction clears edit or delete flow. + */ + clearAction?: () => void; + /** + * onDelete handles Saml app delete in the backend. + */ + onDelete?: () => void; +} + +export const SamlAppActionContext = createContext(null); + +export function useSamlAppAction() { + return useContext(SamlAppActionContext); +} + +/** + * SamlAppActionProvider is a dummy provider to satisfy + * SamlAppActionContext in Teleport community edition. + */ +export function SamlAppActionProvider({ + children, +}: { + children: React.ReactNode; +}) { + const value: SamlAppAction = { + actions: { + showActions: false, + startEdit: null, + startDelete: null, + }, + }; + + return ( + + {children} + + ); +} + +export type SamlAppActionMode = 'edit' | 'delete'; diff --git a/web/packages/teleport/src/UnifiedResources/ResourceActionButton.tsx b/web/packages/teleport/src/UnifiedResources/ResourceActionButton.tsx index ce9e23e6e0008..05fcfe3de05ce 100644 --- a/web/packages/teleport/src/UnifiedResources/ResourceActionButton.tsx +++ b/web/packages/teleport/src/UnifiedResources/ResourceActionButton.tsx @@ -16,14 +16,13 @@ * along with this program. If not, see . */ -import React, { useState, Dispatch, SetStateAction } from 'react'; +import React, { useState } from 'react'; import { ButtonBorder, ButtonWithMenu, MenuItem } from 'design'; import { LoginItem, MenuLogin } from 'shared/components/MenuLogin'; import { AwsLaunchButton } from 'shared/components/AwsLaunchButton'; import { UnifiedResource } from 'teleport/services/agents'; import cfg from 'teleport/config'; - import useTeleport from 'teleport/useTeleport'; import { Database } from 'teleport/services/databases'; import { openNewTab } from 'teleport/lib/util'; @@ -34,23 +33,22 @@ import KubeConnectDialog from 'teleport/Kubes/ConnectDialog'; import useStickyClusterId from 'teleport/useStickyClusterId'; import { Node, sortNodeLogins } from 'teleport/services/nodes'; import { App } from 'teleport/services/apps'; - import { ResourceKind } from 'teleport/Discover/Shared'; import { DiscoverEventResource } from 'teleport/services/userEvent'; +import { useSamlAppAction } from 'teleport/SamlApplications/useSamlAppActions'; import type { ResourceSpec } from 'teleport/Discover/SelectResource/types'; type Props = { resource: UnifiedResource; - setResourceSpec?: Dispatch>; }; -export const ResourceActionButton = ({ resource, setResourceSpec }: Props) => { +export const ResourceActionButton = ({ resource }: Props) => { switch (resource.kind) { case 'node': return ; case 'app': - return ; + return ; case 'db': return ; case 'kube_cluster': @@ -144,9 +142,8 @@ const DesktopConnect = ({ desktop }: { desktop: Desktop }) => { type AppLaunchProps = { app: App; - setResourceSpec?: Dispatch>; }; -const AppLaunch = ({ app, setResourceSpec }: AppLaunchProps) => { +const AppLaunch = ({ app }: AppLaunchProps) => { const { name, launchUrl, @@ -160,6 +157,7 @@ const AppLaunch = ({ app, setResourceSpec }: AppLaunchProps) => { samlAppSsoUrl, samlAppPreset, } = app; + const { actions, userSamlIdPPerm } = useSamlAppAction(); if (awsConsole) { return ( { ); } - function handleSamlAppEditButtonClick() { - setResourceSpec({ - name: name, - event: DiscoverEventResource.SamlApplication, - kind: ResourceKind.SamlApplication, - samlMeta: { preset: samlAppPreset }, - icon: 'application', - keywords: 'saml', - }); - } if (samlApp) { - if (setResourceSpec) { + if (actions.showActions) { + const currentSamlAppSpec: ResourceSpec = { + name: name, + event: DiscoverEventResource.SamlApplication, + kind: ResourceKind.SamlApplication, + samlMeta: { preset: samlAppPreset }, + icon: 'application', + keywords: 'saml', + }; return ( { forwardedAs="a" title="Log in to SAML application" > - Edit + actions.startEdit(currentSamlAppSpec)} + disabled={!userSamlIdPPerm.edit} // disable props does not disable onClick + > + Edit + + actions.startDelete(currentSamlAppSpec)} + disabled={!userSamlIdPPerm.remove} // disable props does not disable onClick + > + Delete + ); } else { diff --git a/web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx b/web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx index c7fff86888bb3..df2215f13a40d 100644 --- a/web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx +++ b/web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx @@ -16,8 +16,7 @@ * along with this program. If not, see . */ -import React, { useCallback, useState } from 'react'; - +import React, { useCallback, useState, useMemo } from 'react'; import { Flex } from 'design'; import { Danger } from 'design/Alert'; @@ -50,6 +49,10 @@ import { encodeUrlQueryParams } from 'teleport/components/hooks/useUrlFiltering' import Empty, { EmptyStateInfo } from 'teleport/components/Empty'; import { FeatureFlags } from 'teleport/types'; import { UnifiedResource } from 'teleport/services/agents'; +import { + useSamlAppAction, + SamlAppActionProvider, +} from 'teleport/SamlApplications/useSamlAppActions'; import { ResourceActionButton } from './ResourceActionButton'; import SearchPanel from './SearchPanel'; @@ -59,11 +62,13 @@ export function UnifiedResources() { return ( - + + + ); } @@ -149,7 +154,12 @@ export function ClusterResources({ getClusterPinnedResources: getCurrentClusterPinnedResources, }; - const { fetch, resources, attempt, clear } = useUnifiedResourcesFetch({ + const { + fetch, + resources: unfilteredResources, + attempt, + clear, + } = useUnifiedResourcesFetch({ fetchFunc: useCallback( async (paginationParams, signal) => { const response = await teleCtx.resourceService.fetchUnifiedResources( @@ -187,6 +197,22 @@ export function ClusterResources({ ), }); + const { samlAppToDelete } = useSamlAppAction(); + const resources = useMemo( + () => + samlAppToDelete?.backendDeleted + ? unfilteredResources.filter( + res => + !( + res.kind === 'app' && + res.samlApp && + res.name === samlAppToDelete.name + ) + ) + : unfilteredResources, + [samlAppToDelete, unfilteredResources] + ); + // This state is used to recognize when the `params` value has changed, // and reset the overall state of `useUnifiedResourcesFetch` hook. It's tempting to use a // `useEffect` here, but doing so can cause unwanted behavior where the previous, diff --git a/web/packages/teleport/src/services/samlidp/types.ts b/web/packages/teleport/src/services/samlidp/types.ts index 12add6ec1ed78..2270fed85ed43 100644 --- a/web/packages/teleport/src/services/samlidp/types.ts +++ b/web/packages/teleport/src/services/samlidp/types.ts @@ -75,3 +75,21 @@ export type SamlGcpWorkforce = { poolName: string; poolProviderName: string; }; + +/** + * SamlAppToDelete is used to define the name of an + * SAML app item to be deleted and its deletion state in the + * backend. Intended to be used in the unified resource view. + */ +export type SamlAppToDelete = { + /** + * name is the name of Saml app item to delete. + */ + name: string; + // kind: string; + /** + * backendDeleted specifies if the item is deleted + * in the backend. + */ + backendDeleted: boolean; +};