-
Notifications
You must be signed in to change notification settings - Fork 2k
feat(webui): Add Workload Identities list #58971
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ad276a8
4ae65c0
ec33356
9f81a86
07f4139
8e7628d
d217a5f
f1207b3
c684522
b75965e
7b2f320
77bc7fc
732294c
93553af
01c837a
d0479ac
e5de35c
90541bb
2c01c6d
ac445a2
a90cb3d
5dd7740
aaa84a1
4156c27
05a0063
ffdb2c8
16f489a
5e8c224
842b9cb
0a417d2
4da26de
9495c8a
036084d
1265ced
b3d80cc
88a164d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| /** | ||
| * 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 <http://www.gnu.org/licenses/>. | ||
| */ | ||
|
|
||
| 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<FetchingConfig, 'onFetchMore'>) { | ||
| const tableData = data.map(d => ({ | ||
| ...d, | ||
| spiffe_hint: valueOrEmpty(d.spiffe_hint), | ||
| })); | ||
|
|
||
| return ( | ||
| <Table<(typeof tableData)[number]> | ||
| data={tableData} | ||
| fetching={{ | ||
| fetchStatus, | ||
| onFetchNext, | ||
| onFetchPrev, | ||
| disableLoadingIndicator: true, | ||
| }} | ||
| serversideProps={{ | ||
| sort: sortType, | ||
| setSort: onSortChanged, | ||
| serversideSearchPanel: ( | ||
| <SearchPanel | ||
| updateSearch={onSearchChange} | ||
| hideAdvancedSearch={true} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we hide advanced search?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is not implemented for this endpoint. Only a text-contains search is supported. |
||
| filter={{ search: searchTerm }} | ||
| disableSearch={fetchStatus !== ''} | ||
| /> | ||
| ), | ||
| }} | ||
| columns={[ | ||
| { | ||
| key: 'name', | ||
| headerText: 'Name', | ||
| isSortable: true, | ||
| }, | ||
| { | ||
| key: 'spiffe_id', | ||
| headerText: 'SPIFFE ID', | ||
| isSortable: true, | ||
| render: ({ spiffe_id }) => { | ||
| return spiffe_id ? ( | ||
| <Cell> | ||
| <Flex inline alignItems={'center'} gap={1} mr={0}> | ||
| <Text> | ||
| {spiffe_id | ||
| .split('/') | ||
| .reduce<(ReactElement | string)[]>((acc, cur, i) => { | ||
| if (i === 0) { | ||
| acc.push(cur); | ||
| } else { | ||
| // Add break opportunities after each slash | ||
| acc.push('/', <wbr key={cur} />, cur); | ||
nicholasmarais1158 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| return acc; | ||
| }, [])} | ||
| </Text> | ||
| <CopyButton name={spiffe_id} /> | ||
| </Flex> | ||
| </Cell> | ||
| ) : ( | ||
| <Cell>{valueOrEmpty(spiffe_id)}</Cell> | ||
| ); | ||
| }, | ||
| }, | ||
| { | ||
| key: 'labels', | ||
| headerText: 'Labels', | ||
| isSortable: false, | ||
| render: ({ labels: labelsMap }) => { | ||
| const labels = labelsMap ? Object.entries(labelsMap) : undefined; | ||
| return labels?.length ? ( | ||
| <LabelCell data={labels.map(([k, v]) => `${k}: ${v || '-'}`)} /> | ||
| ) : ( | ||
| <Cell>{valueOrEmpty('')}</Cell> | ||
| ); | ||
| }, | ||
| }, | ||
| { | ||
| key: 'spiffe_hint', | ||
| headerText: 'Hint', | ||
| isSortable: false, | ||
| }, | ||
| ]} | ||
| emptyText="No workload identities found" | ||
nicholasmarais1158 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| /> | ||
| ); | ||
| } | ||
|
|
||
| function valueOrEmpty(value: string | null | undefined, empty = '-') { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, but I felt the function was more complete this way and self-documenting. |
||
| return value || empty; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <http://www.gnu.org/licenses/>. | ||
| */ | ||
|
|
||
| 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<typeof Wrapper>; | ||
|
|
||
| type Story = StoryObj<typeof meta>; | ||
|
|
||
| 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 ( | ||
| <QueryClientProvider client={queryClient}> | ||
| <MemoryRouter> | ||
| <TeleportProviderBasic teleportCtx={ctx}> | ||
| <Router history={history}> | ||
| <Route path={cfg.routes.workloadIdentities}> | ||
| <WorkloadIdentities /> | ||
| </Route> | ||
| </Router> | ||
| </TeleportProviderBasic> | ||
| </MemoryRouter> | ||
| </QueryClientProvider> | ||
| ); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.