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 });
});