diff --git a/web/packages/shared/components/Search/SearchPanel.tsx b/web/packages/shared/components/Search/SearchPanel.tsx index d303d3579b0c5..32f581b5cf326 100644 --- a/web/packages/shared/components/Search/SearchPanel.tsx +++ b/web/packages/shared/components/Search/SearchPanel.tsx @@ -36,8 +36,8 @@ export function SearchPanel({ hideAdvancedSearch, extraChildren, }: { - updateQuery(s: string): void; - updateSearch(s: string): void; + updateQuery?: (s: string) => void; + updateSearch: (s: string) => void; pageIndicators?: { from: number; to: number; total: number }; filter: ResourceFilter; disableSearch: boolean; @@ -60,11 +60,11 @@ export function SearchPanel({ setQuery(newQuery); if (isAdvancedSearch) { - updateQuery(newQuery); + updateQuery?.(newQuery); return; } - updateSearch(newQuery); + updateSearch?.(newQuery); } return ( diff --git a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx index 1a162d7a2949f..8791983f6e1f6 100644 --- a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx +++ b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx @@ -83,7 +83,6 @@ export function BotInstancesList({ serversideSearchPanel: ( . + */ + +import { ReactElement } from 'react'; + +import { Cell, LabelCell } from 'design/DataTable/Cells'; +import Table from 'design/DataTable/Table'; +import { FetchingConfig, SortType } from 'design/DataTable/types'; +import Flex from 'design/Flex'; +import Text from 'design/Text'; +import { SearchPanel } from 'shared/components/Search/SearchPanel'; +import { CopyButton } from 'shared/components/UnifiedResources/shared/CopyButton'; + +import { WorkloadIdentity } from 'teleport/services/workloadIdentity/types'; + +export function WorkloadIdetitiesList({ + data, + fetchStatus, + onFetchNext, + onFetchPrev, + sortType, + onSortChanged, + searchTerm, + onSearchChange, +}: { + data: WorkloadIdentity[]; + sortType: SortType; + onSortChanged: (sortType: SortType) => void; + searchTerm: string; + onSearchChange: (term: string) => void; +} & Omit) { + const tableData = data.map(d => ({ + ...d, + spiffe_hint: valueOrEmpty(d.spiffe_hint), + })); + + return ( + + data={tableData} + fetching={{ + fetchStatus, + onFetchNext, + onFetchPrev, + disableLoadingIndicator: true, + }} + serversideProps={{ + sort: sortType, + setSort: onSortChanged, + serversideSearchPanel: ( + + ), + }} + columns={[ + { + key: 'name', + headerText: 'Name', + isSortable: true, + }, + { + key: 'spiffe_id', + headerText: 'SPIFFE ID', + isSortable: true, + render: ({ spiffe_id }) => { + return spiffe_id ? ( + + + + {spiffe_id + .split('/') + .reduce<(ReactElement | string)[]>((acc, cur, i) => { + if (i === 0) { + acc.push(cur); + } else { + // Add break opportunities after each slash + acc.push('/', , cur); + } + return acc; + }, [])} + + + + + ) : ( + {valueOrEmpty(spiffe_id)} + ); + }, + }, + { + key: 'labels', + headerText: 'Labels', + isSortable: false, + render: ({ labels: labelsMap }) => { + const labels = labelsMap ? Object.entries(labelsMap) : undefined; + return labels?.length ? ( + `${k}: ${v || '-'}`)} /> + ) : ( + {valueOrEmpty('')} + ); + }, + }, + { + key: 'spiffe_hint', + headerText: 'Hint', + isSortable: false, + }, + ]} + emptyText="No workload identities found" + /> + ); +} + +function valueOrEmpty(value: string | null | undefined, empty = '-') { + return value || empty; +} diff --git a/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.story.tsx b/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.story.tsx new file mode 100644 index 0000000000000..257c75d4dfd45 --- /dev/null +++ b/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.story.tsx @@ -0,0 +1,167 @@ +/** + * 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 { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createMemoryHistory } from 'history'; +import { MemoryRouter, Route, Router } from 'react-router'; + +import cfg from 'teleport/config'; +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { TeleportProviderBasic } from 'teleport/mocks/providers'; +import { defaultAccess, makeAcl } from 'teleport/services/user/makeAcl'; +import { + listWorkloadIdentitiesError, + listWorkloadIdentitiesForever, + listWorkloadIdentitiesSuccess, +} from 'teleport/test/helpers/workloadIdentities'; + +import { WorkloadIdentities } from './WorkloadIdentities'; + +const meta = { + title: 'Teleport/WorkloadIdentity', + component: Wrapper, + beforeEach: () => { + queryClient.clear(); // Prevent cached data sharing between stories + }, +} satisfies Meta; + +type Story = StoryObj; + +export default meta; + +export const Happy: Story = { + parameters: { + msw: { + handlers: [listWorkloadIdentitiesSuccess()], + }, + }, +}; + +export const Empty: Story = { + parameters: { + msw: { + handlers: [ + listWorkloadIdentitiesSuccess({ + items: [], + next_page_token: null, + }), + ], + }, + }, +}; + +export const NoListPermission: Story = { + args: { hasListPermission: false }, + parameters: { + msw: { + handlers: [ + /* should never make a call */ + ], + }, + }, +}; + +export const Error: Story = { + parameters: { + msw: { + handlers: [listWorkloadIdentitiesError(500, 'something went wrong')], + }, + }, +}; + +export const OutdatedProxy: Story = { + parameters: { + msw: { + handlers: [ + listWorkloadIdentitiesError(404, 'path not found', { + proxyVersion: { + major: 18, + minor: 0, + patch: 0, + preRelease: '', + string: '18.0.0', + }, + }), + ], + }, + }, +}; + +export const UnsupportedSort: Story = { + parameters: { + msw: { + handlers: [ + listWorkloadIdentitiesError( + 400, + 'unsupported sort, with some more info' + ), + ], + }, + }, +}; + +export const Loading: Story = { + parameters: { + msw: { + handlers: [listWorkloadIdentitiesForever()], + }, + }, +}; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + }, + }, +}); + +function Wrapper(props?: { hasListPermission?: boolean }) { + const { hasListPermission = true } = props ?? {}; + + const history = createMemoryHistory({ + initialEntries: [cfg.routes.workloadIdentities], + }); + + const customAcl = makeAcl({ + workloadIdentity: { + ...defaultAccess, + list: hasListPermission, + }, + }); + + const ctx = createTeleportContext({ + customAcl, + }); + + return ( + + + + + + + + + + + + ); +} diff --git a/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.test.tsx b/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.test.tsx new file mode 100644 index 0000000000000..48a9e788c76c1 --- /dev/null +++ b/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.test.tsx @@ -0,0 +1,409 @@ +/** + * 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 { QueryClientProvider } from '@tanstack/react-query'; +import { setupServer } from 'msw/node'; +import { PropsWithChildren } from 'react'; +import { MemoryRouter } from 'react-router'; + +import { darkTheme } from 'design/theme'; +import { ConfiguredThemeProvider } from 'design/ThemeProvider'; +import { + fireEvent, + render, + screen, + testQueryClient, + userEvent, + waitFor, + waitForElementToBeRemoved, +} from 'design/utils/testing'; +import { InfoGuidePanelProvider } from 'shared/components/SlidingSidePanel/InfoGuide'; + +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { defaultAccess, makeAcl } from 'teleport/services/user/makeAcl'; +import { listWorkloadIdentities } from 'teleport/services/workloadIdentity/workloadIdentity'; +import { + listWorkloadIdentitiesError, + listWorkloadIdentitiesSuccess, +} from 'teleport/test/helpers/workloadIdentities'; + +import { ContextProvider } from '..'; +import { WorkloadIdentities } from './WorkloadIdentities'; + +jest.mock('teleport/services/workloadIdentity/workloadIdentity', () => { + const actual = jest.requireActual( + 'teleport/services/workloadIdentity/workloadIdentity' + ); + return { + listWorkloadIdentities: jest.fn((...all) => { + return actual.listWorkloadIdentities(...all); + }), + }; +}); + +const server = setupServer(); + +beforeAll(() => { + server.listen(); +}); + +afterEach(async () => { + server.resetHandlers(); + await testQueryClient.resetQueries(); + + jest.clearAllMocks(); +}); + +afterAll(() => server.close()); + +describe('WorkloadIdentities', () => { + it('Shows an empty state', async () => { + server.use( + listWorkloadIdentitiesSuccess({ + items: [], + next_page_token: '', + }) + ); + + render(, { wrapper: makeWrapper() }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + expect(screen.getByText('What is Workload Identity')).toBeInTheDocument(); + }); + + it('Shows an error state', async () => { + server.use(listWorkloadIdentitiesError(500, 'server error')); + + render(, { wrapper: makeWrapper() }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + expect(screen.getByText('server error')).toBeInTheDocument(); + }); + + it('Shows an unsupported sort error state', async () => { + server.use(listWorkloadIdentitiesSuccess()); + + render(, { + wrapper: makeWrapper(), + }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + const testErrorMessage = + 'unsupported sort, only name:asc is supported, but got "blah" (desc = true)'; + server.use(listWorkloadIdentitiesError(400, testErrorMessage)); + + fireEvent.click(screen.getByText('SPIFFE ID')); + + await waitFor(() => { + expect(screen.getByText(testErrorMessage)).toBeInTheDocument(); + }); + + server.use(listWorkloadIdentitiesSuccess()); + + const resetButton = screen.getByText('Reset sort'); + expect(resetButton).toBeInTheDocument(); + fireEvent.click(resetButton); + + await waitFor(() => { + expect(screen.queryByText(testErrorMessage)).not.toBeInTheDocument(); + }); + }); + + it('Shows an unauthorised error state', async () => { + render(, { + wrapper: makeWrapper( + makeAcl({ + workloadIdentity: { + ...defaultAccess, + list: false, + }, + }) + ), + }); + + expect( + screen.getByText( + 'You do not have permission to access Workload Identities. Missing role permissions:', + { exact: false } + ) + ).toBeInTheDocument(); + + expect(screen.getByText('workload_identity.list')).toBeInTheDocument(); + }); + + it('Shows a list', async () => { + server.use(listWorkloadIdentitiesSuccess()); + + render(, { + wrapper: makeWrapper(), + }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + expect(screen.getByText('test-workload-identity-1')).toBeInTheDocument(); + expect( + screen.getByText('/test/spiffe/abb53fc8-eba6-40a9-8801-221db41f3c21') + ).toBeInTheDocument(); + expect(screen.getByText('test-label-1: test-value-1')).toBeInTheDocument(); + expect(screen.getByText('test-label-2: test-value-2')).toBeInTheDocument(); + expect(screen.getByText('test-label-3: test-value-3')).toBeInTheDocument(); + expect( + screen.getByText( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.' + ) + ).toBeInTheDocument(); + }); + + it('Allows paging', async () => { + jest.mocked(listWorkloadIdentities).mockImplementation( + ({ pageToken }) => + new Promise(resolve => { + resolve({ + items: [ + { + name: 'test-workload-identity-1', + spiffe_id: '/test/spiffe/abb53fc8-eba6-40a9-8801-221db41f3c21', + spiffe_hint: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + labels: { + 'test-label-1': 'test-value-1', + 'test-label-2': 'test-value-2', + 'test-label-3': 'test-value-3', + }, + }, + ], + next_page_token: pageToken + '.next', + }); + }) + ); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(0); + + render(, { wrapper: makeWrapper() }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + const [nextButton] = screen.getAllByTitle('Next page'); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(1); + expect(listWorkloadIdentities).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '', + searchTerm: '', + sortField: 'name', + sortDir: 'ASC', + }); + + await waitFor(() => expect(nextButton).toBeEnabled()); + fireEvent.click(nextButton); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(2); + expect(listWorkloadIdentities).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '.next', + searchTerm: '', + sortField: 'name', + sortDir: 'ASC', + }); + + await waitFor(() => expect(nextButton).toBeEnabled()); + fireEvent.click(nextButton); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(3); + expect(listWorkloadIdentities).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '.next.next', + searchTerm: '', + sortField: 'name', + sortDir: 'ASC', + }); + + const [prevButton] = screen.getAllByTitle('Previous page'); + + await waitFor(() => expect(prevButton).toBeEnabled()); + fireEvent.click(prevButton); + + // This page's data will have been cached + expect(listWorkloadIdentities).toHaveBeenCalledTimes(3); + + await waitFor(() => expect(prevButton).toBeEnabled()); + fireEvent.click(prevButton); + + // This page's data will have been cached + expect(listWorkloadIdentities).toHaveBeenCalledTimes(3); + }); + + it('Allows filtering (search)', async () => { + jest.mocked(listWorkloadIdentities).mockImplementation( + ({ pageToken }) => + new Promise(resolve => { + resolve({ + items: [ + { + name: 'test-workload-identity-1', + spiffe_id: '/test/spiffe/abb53fc8-eba6-40a9-8801-221db41f3c21', + spiffe_hint: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + labels: { + 'test-label-1': 'test-value-1', + 'test-label-2': 'test-value-2', + 'test-label-3': 'test-value-3', + }, + }, + ], + next_page_token: pageToken + '.next', + }); + }) + ); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(0); + + render(, { wrapper: makeWrapper() }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(1); + expect(listWorkloadIdentities).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '', + searchTerm: '', + sortField: 'name', + sortDir: 'ASC', + }); + + const [nextButton] = screen.getAllByTitle('Next page'); + await waitFor(() => expect(nextButton).toBeEnabled()); + fireEvent.click(nextButton); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(2); + expect(listWorkloadIdentities).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '.next', + searchTerm: '', + sortField: 'name', + sortDir: 'ASC', + }); + + const search = screen.getByPlaceholderText('Search...'); + await waitFor(() => expect(search).toBeEnabled()); + await userEvent.type(search, 'test-search-term'); + await userEvent.type(search, '{enter}'); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(3); + expect(listWorkloadIdentities).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '', // Search should reset to the first page + searchTerm: 'test-search-term', + sortField: 'name', + sortDir: 'ASC', + }); + }); + + it('Allows sorting', async () => { + jest.mocked(listWorkloadIdentities).mockImplementation( + ({ pageToken }) => + new Promise(resolve => { + resolve({ + items: [ + { + name: 'test-workload-identity-1', + spiffe_id: '/test/spiffe/abb53fc8-eba6-40a9-8801-221db41f3c21', + spiffe_hint: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + labels: { + 'test-label-1': 'test-value-1', + 'test-label-2': 'test-value-2', + 'test-label-3': 'test-value-3', + }, + }, + ], + next_page_token: pageToken, + }); + }) + ); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(0); + + render(, { wrapper: makeWrapper() }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(1); + expect(listWorkloadIdentities).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '', + searchTerm: '', + sortField: 'name', + sortDir: 'ASC', + }); + + fireEvent.click(screen.getByText('Name')); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(2); + expect(listWorkloadIdentities).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '', + searchTerm: '', + sortField: 'name', + sortDir: 'DESC', + }); + + fireEvent.click(screen.getByText('SPIFFE ID')); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(3); + expect(listWorkloadIdentities).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '', + searchTerm: '', + sortField: 'spiffe_id', + sortDir: 'ASC', + }); + }); +}); + +function makeWrapper( + customAcl: ReturnType = makeAcl({ + workloadIdentity: { + list: true, + create: true, + edit: true, + remove: true, + read: true, + }, + }) +) { + return ({ children }: PropsWithChildren) => { + const ctx = createTeleportContext({ + customAcl, + }); + return ( + + + + + {children} + + + + + ); + }; +} diff --git a/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.tsx b/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.tsx new file mode 100644 index 0000000000000..abb177a7590ec --- /dev/null +++ b/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.tsx @@ -0,0 +1,277 @@ +/** + * 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 { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { useHistory, useLocation } from 'react-router'; + +import { Alert } from 'design/Alert/Alert'; +import Box from 'design/Box/Box'; +import { SortType } from 'design/DataTable/types'; +import { Indicator } from 'design/Indicator/Indicator'; +import { + InfoExternalTextLink, + InfoGuideButton, + InfoParagraph, + ReferenceLinks, +} from 'shared/components/SlidingSidePanel/InfoGuide/InfoGuide'; + +import { + FeatureBox, + FeatureHeader, + FeatureHeaderTitle, +} from 'teleport/components/Layout/Layout'; +import { listWorkloadIdentities } from 'teleport/services/workloadIdentity/workloadIdentity'; +import useTeleport from 'teleport/useTeleport'; + +import { EmptyState } from './EmptyState/EmptyState'; +import { WorkloadIdetitiesList } from './List/WorkloadIdentitiesList'; + +export function WorkloadIdentities() { + const history = useHistory(); + const location = useLocation<{ prevPageTokens?: readonly string[] }>(); + const queryParams = new URLSearchParams(location.search); + const pageToken = queryParams.get('page') ?? ''; + const sortField = queryParams.get('sort_field') || 'name'; + const sortDir = queryParams.get('sort_dir') || 'ASC'; + const searchTerm = queryParams.get('search') ?? ''; + + const ctx = useTeleport(); + const flags = ctx.getFeatureFlags(); + const canList = flags.listWorkloadIdentities; + + const { isPending, isFetching, isSuccess, isError, error, data } = useQuery({ + enabled: canList, + queryKey: [ + 'workload_identities', + 'list', + pageToken, + sortField, + sortDir, + searchTerm, + ], + queryFn: () => + listWorkloadIdentities({ + pageSize: 20, + pageToken, + sortField, + sortDir, + searchTerm, + }), + placeholderData: keepPreviousData, + staleTime: 30_000, // Cached pages are valid for 30 seconds + }); + + const { prevPageTokens = [] } = location.state ?? {}; + const hasNextPage = !!data?.next_page_token; + const hasPrevPage = !!pageToken; + + const handleFetchNext = useCallback(() => { + const search = new URLSearchParams(location.search); + search.set('page', data?.next_page_token ?? ''); + + history.replace( + { + pathname: location.pathname, + search: search.toString(), + }, + { + prevPageTokens: [...prevPageTokens, pageToken], + } + ); + }, [ + data?.next_page_token, + history, + location.pathname, + location.search, + pageToken, + prevPageTokens, + ]); + + const handleFetchPrev = useCallback(() => { + const prevTokens = [...prevPageTokens]; + const nextToken = prevTokens.pop(); + + const search = new URLSearchParams(location.search); + search.set('page', nextToken ?? ''); + + history.replace( + { + pathname: location.pathname, + search: search.toString(), + }, + { + prevPageTokens: prevTokens, + } + ); + }, [history, location.pathname, location.search, prevPageTokens]); + + const sortType: SortType = { + fieldName: sortField, + dir: sortDir.toLowerCase() === 'desc' ? 'DESC' : 'ASC', + }; + + const handleSortChanged = useCallback( + (sortType: SortType) => { + const search = new URLSearchParams(location.search); + search.set('sort_field', sortType.fieldName); + search.set('sort_dir', sortType.dir); + search.set('page', ''); + + history.replace({ + pathname: location.pathname, + search: search.toString(), + }); + }, + [history, location.pathname, location.search] + ); + + const handleSearchChange = useCallback( + (term: string) => { + const search = new URLSearchParams(location.search); + search.set('search', term); + search.set('page', ''); + + history.replace({ + pathname: `${location.pathname}`, + search: search.toString(), + }); + }, + [history, location.pathname, location.search] + ); + + const hasUnsupportedSortError = isError && isUnsupportedSortError(error); + + if (!canList) { + return ( + + + You do not have permission to access Workload Identities. Missing role + permissions: workload_identity.list + + + + ); + } + + const isFiltering = !!queryParams.get('search'); + + if (isSuccess && !data.items?.length && !isFiltering) { + return ( + + + + ); + } + + return ( + + + Workload Identities + }} /> + + + {isPending ? ( + + + + ) : undefined} + + {isError && hasUnsupportedSortError ? ( + { + handleSortChanged({ fieldName: 'name', dir: 'ASC' }); + }, + }} + > + {error.message} + + ) : undefined} + + {isError && !hasUnsupportedSortError ? ( + {error.message} + ) : undefined} + + {isSuccess ? ( + + ) : undefined} + + ); +} + +const InfoGuide = () => ( + + + Teleport{' '} + + Workload Identity + {' '} + securely issues short-lived cryptographic identities to workloads. It is a + flexible foundation for workload identity across your infrastructure, + creating a uniform way for your workloads to authenticate regardless of + where they are running. + + + Teleport Workload Identity is compatible with the open-source{' '} + + Secure Production Identity Framework For Everyone (SPIFFE) + {' '} + standard. This enables interoperability between workload identity + implementations and also provides a wealth of off-the-shelf tools and SDKs + to simplify integration with your workloads. + + + +); + +const InfoGuideReferenceLinks = { + WorkloadIdentity: { + title: 'Workload Identity', + href: 'https://goteleport.com/docs/machine-workload-identity/workload-identity', + }, + Spiffe: { + title: 'Introduction to SPIFFE', + href: 'https://goteleport.com/docs/machine-workload-identity/workload-identity/spiffe/', + }, + GettingStarted: { + title: 'Getting Started with Workload Identity', + href: 'https://goteleport.com/docs/machine-workload-identity/workload-identity/getting-started/', + }, +}; + +const isUnsupportedSortError = (error: Error) => { + return !!error?.message && error.message.includes('unsupported sort'); +}; diff --git a/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentity.story.tsx b/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentity.story.tsx deleted file mode 100644 index ecbfd9d732f75..0000000000000 --- a/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentity.story.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/** - * 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 { EmptyState } from './EmptyState/EmptyState'; - -export default { - title: 'Teleport/WorkloadIdentity', -}; - -export const Empty = () => { - return ; -}; diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 5d068d6bc8381..6f1eb54686fd4 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -171,7 +171,6 @@ const cfg = { joinTokens: '/web/tokens', deviceTrust: `/web/devices`, deviceTrustAuthorize: '/web/device/authorize/:id?/:token?', - workloadIdentity: `/web/workloadidentity`, sso: '/web/sso', cluster: '/web/cluster/:clusterId/', clusters: '/web/clusters', @@ -191,6 +190,7 @@ const cfg = { botInstances: '/web/bots/instances', botInstance: '/web/bot/:botName/instance/:instanceId', botsNew: '/web/bots/new/:type?', + workloadIdentities: '/web/workloadidentities', console: '/web/cluster/:clusterId/console', consoleNodes: '/web/cluster/:clusterId/console/nodes', consoleConnect: '/web/cluster/:clusterId/console/node/:serverId/:login', @@ -845,6 +845,10 @@ const cfg = { return generatePath(cfg.routes.botInstances); }, + getWorkloadIdentitiesRoute() { + return generatePath(cfg.routes.workloadIdentities); + }, + getBotInstanceDetailsRoute(params: { botName: string; instanceId: string }) { return generatePath(cfg.routes.botInstance, params); }, diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx index 207be583d05cc..c12a0b38325a7 100644 --- a/web/packages/teleport/src/features.tsx +++ b/web/packages/teleport/src/features.tsx @@ -71,7 +71,7 @@ import { TrustedClusters } from './TrustedClusters'; import { NavTitle, type FeatureFlags, type TeleportFeature } from './types'; import { UnifiedResources } from './UnifiedResources'; import { Users } from './Users'; -import { EmptyState as WorkloadIdentityEmptyState } from './WorkloadIdentity/EmptyState/EmptyState'; +import { WorkloadIdentities } from './WorkloadIdentity/WorkloadIdentities'; // to promote feature discoverability, most features should be visible in the navigation even if a user doesnt have access. // However, there are some cases where hiding the feature is explicitly requested. Use this as a backdoor to hide the features that @@ -695,17 +695,17 @@ export class FeatureTrust implements TeleportFeature { export class FeatureWorkloadIdentity implements TeleportFeature { category = NavigationCategory.MachineWorkloadId; route = { - title: 'Workload Identity', - path: cfg.routes.workloadIdentity, + title: 'Workload Identities', + path: cfg.routes.workloadIdentities, exact: true, - component: WorkloadIdentityEmptyState, + component: WorkloadIdentities, }; - // for now, workload identity page is just a placeholder so everyone has - // access, unless feature hiding is off - hasAccess(): boolean { + hasAccess(flags: FeatureFlags): boolean { + // if feature hiding is enabled, only show + // if the user has access if (shouldHideFromNavigation(cfg)) { - return false; + return flags.listWorkloadIdentities; } return true; } @@ -713,7 +713,7 @@ export class FeatureWorkloadIdentity implements TeleportFeature { title: NavTitle.WorkloadIdentity, icon: License, getLink() { - return cfg.routes.workloadIdentity; + return cfg.routes.workloadIdentities; }, searchableTags: ['workload identity', 'workload', 'identity'], }; diff --git a/web/packages/teleport/src/services/workloadIdentity/workloadIdentity.ts b/web/packages/teleport/src/services/workloadIdentity/workloadIdentity.ts index e85e4d405a534..850d5ca8e6832 100644 --- a/web/packages/teleport/src/services/workloadIdentity/workloadIdentity.ts +++ b/web/packages/teleport/src/services/workloadIdentity/workloadIdentity.ts @@ -26,20 +26,24 @@ export async function listWorkloadIdentities( variables: { pageToken: string; pageSize: number; - sort?: string; + sortField: string; + sortDir: string; searchTerm?: string; }, signal?: AbortSignal ) { - const { pageToken, pageSize, sort, searchTerm } = variables; + const { pageToken, pageSize, sortField, sortDir, searchTerm } = variables; const path = cfg.getWorkloadIdentityUrl({ action: 'list' }); const qs = new URLSearchParams(); qs.set('page_size', pageSize.toFixed()); qs.set('page_token', pageToken); - if (sort) { - qs.set('sort', sort); + if (sortField) { + qs.set('sort_field', sortField); + } + if (sortDir) { + qs.set('sort_dir', sortDir); } if (searchTerm) { qs.set('search', searchTerm); diff --git a/web/packages/teleport/src/test/helpers/workloadIdentities.ts b/web/packages/teleport/src/test/helpers/workloadIdentities.ts index a75227bc06a03..0c241f2358fb4 100644 --- a/web/packages/teleport/src/test/helpers/workloadIdentities.ts +++ b/web/packages/teleport/src/test/helpers/workloadIdentities.ts @@ -20,6 +20,7 @@ import { http, HttpResponse } from 'msw'; import cfg from 'teleport/config'; import { ListWorkloadIdentitiesResponse } from 'teleport/services/workloadIdentity/types'; +import { JsonObject } from 'teleport/types'; export const listWorkloadIdentitiesSuccess = ( mock: ListWorkloadIdentitiesResponse = { @@ -38,15 +39,14 @@ export const listWorkloadIdentitiesSuccess = ( { name: 'test-workload-identity-2', spiffe_id: '', - spiffe_hint: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + spiffe_hint: 'This is a hint', labels: {}, }, { name: 'test-workload-identity-3', spiffe_id: '/test/spiffe/6bfd8c2d-83eb-4a6f-97ba-f8b187f08339', spiffe_hint: '', - labels: { 'test-label-1': 'test-value-1' }, + labels: { 'test-label-4': 'test-value-4' }, }, ], next_page_token: 'page-token-1', @@ -67,8 +67,9 @@ export const listWorkloadIdentitiesForever = () => export const listWorkloadIdentitiesError = ( status: number, - error: string | null = null + error: string | null = null, + fields: JsonObject = {} ) => http.get(cfg.api.workloadIdentity.list, () => { - return HttpResponse.json({ error: { message: error } }, { status }); + return HttpResponse.json({ error: { message: error }, fields }, { status }); });