diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index ecfa3e40a0a9f..e860b78877866 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -845,7 +845,7 @@ func (h *Handler) bindDefaultEndpoints() { // User Status (used by client to check if user session is valid) h.GET("/webapi/user/status", h.WithAuth(h.getUserStatus)) - h.GET("/webapi/roles", h.WithAuth(h.getRolesHandle)) + h.GET("/webapi/roles", h.WithAuth(h.listRolesHandle)) h.POST("/webapi/roles", h.WithAuth(h.createRoleHandle)) h.PUT("/webapi/roles/:name", h.WithAuth(h.updateRoleHandle)) h.DELETE("/webapi/roles/:name", h.WithAuth(h.deleteRole)) diff --git a/lib/web/resources.go b/lib/web/resources.go index 7d1de9d626b01..c4a7dca1974b1 100644 --- a/lib/web/resources.go +++ b/lib/web/resources.go @@ -78,12 +78,19 @@ func (h *Handler) checkAccessToRegisteredResource(w http.ResponseWriter, r *http }, nil } -func (h *Handler) getRolesHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) { +func (h *Handler) listRolesHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) { clt, err := ctx.GetClient() if err != nil { return nil, trace.Wrap(err) } + values := r.URL.Query() + // If limit exists as a query parameter, this means its coming from a "new" webui + // and can return the new paginated response. + // TODO(gzdunek): DELETE IN 17.0.0: remove "getRoles". + if values.Has("limit") { + return listRoles(clt, values) + } return getRoles(clt) } @@ -96,6 +103,39 @@ func getRoles(clt resourcesAPIGetter) ([]ui.ResourceItem, error) { return ui.NewRoles(roles) } +func listRoles(clt resourcesAPIGetter, values url.Values) (*listResourcesWithoutCountGetResponse, error) { + limit, err := QueryLimitAsInt32(values, "limit", defaults.MaxIterationLimit) + if err != nil { + return nil, trace.Wrap(err) + } + + roles, err := clt.ListRoles(context.TODO(), &proto.ListRolesRequest{ + Limit: limit, + StartKey: values.Get("startKey"), + Filter: &types.RoleFilter{ + SearchKeywords: client.ParseSearchKeywords(values.Get("search"), ' '), + }, + }) + if err != nil { + return nil, trace.Wrap(err) + } + + var typeRoles []types.Role + for _, role := range roles.GetRoles() { + typeRoles = append(typeRoles, role) + } + + uiRoles, err := ui.NewRoles(typeRoles) + if err != nil { + return nil, trace.Wrap(err) + } + + return &listResourcesWithoutCountGetResponse{ + Items: uiRoles, + StartKey: roles.GetNextKey(), + }, nil +} + func (h *Handler) deleteRole(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) { clt, err := ctx.GetClient() if err != nil { @@ -505,6 +545,13 @@ type listResourcesGetResponse struct { TotalCount int `json:"totalCount"` } +type listResourcesWithoutCountGetResponse struct { + // Items is a list of resources retrieved. + Items interface{} `json:"items"` + // StartKey is the position to resume search events. + StartKey string `json:"startKey"` +} + type checkAccessToRegisteredResourceResponse struct { // HasResource is a flag to indicate if user has any access // to a registered resource or not. @@ -516,6 +563,8 @@ type resourcesAPIGetter interface { GetRole(ctx context.Context, name string) (types.Role, error) // GetRoles returns a list of roles GetRoles(ctx context.Context) ([]types.Role, error) + // ListRoles returns a paginated list of roles. + ListRoles(ctx context.Context, req *proto.ListRolesRequest) (*proto.ListRolesResponse, error) // UpsertRole creates or updates role UpsertRole(ctx context.Context, role types.Role) (types.Role, error) // GetGithubConnectors returns all configured Github connectors diff --git a/lib/web/resources_test.go b/lib/web/resources_test.go index 03f6c954c9897..6b50e61ca9c88 100644 --- a/lib/web/resources_test.go +++ b/lib/web/resources_test.go @@ -300,7 +300,7 @@ version: v2 func TestGetRoles(t *testing.T) { m := &mockedResourceAPIGetter{} - m.mockGetRoles = func(ctx context.Context) ([]types.Role, error) { + m.mockListRoles = func(ctx context.Context, req *proto.ListRolesRequest) (*proto.ListRolesResponse, error) { role, err := types.NewRole("test", types.RoleSpecV6{ Allow: types.RoleConditions{ Logins: []string{"test"}, @@ -308,14 +308,17 @@ func TestGetRoles(t *testing.T) { }) require.NoError(t, err) - return []types.Role{role}, nil + return &proto.ListRolesResponse{ + Roles: []*types.RoleV6{role.(*types.RoleV6)}, + NextKey: "", + }, nil } // Test response is converted to ui objects. - roles, err := getRoles(m) + roles, err := listRoles(m, url.Values{}) require.NoError(t, err) - require.Len(t, roles, 1) - require.Contains(t, roles[0].Content, "name: test") + require.Len(t, roles.Items, 1) + require.Contains(t, roles.Items.([]ui.ResourceItem)[0].Content, "name: test") } func TestRoleCRUD(t *testing.T) { @@ -402,15 +405,16 @@ func TestRoleCRUD(t *testing.T) { _, err = pack.clt.Delete(ctx, pack.clt.Endpoint("webapi", "roles", expected.GetName())) require.NoError(t, err, "unexpected error deleting role") - resp, err = pack.clt.Get(ctx, pack.clt.Endpoint("webapi", "roles"), nil) + resp, err = pack.clt.Get(ctx, pack.clt.Endpoint("webapi", "roles"), url.Values{"limit": []string{"15"}}) assert.NoError(t, err, "unexpected error listing role") - var items []ui.ResourceItem - require.NoError(t, json.Unmarshal(resp.Bytes(), &items), "invalid resource item received") + var getResponse listResourcesWithoutCountGetResponse + require.NoError(t, json.Unmarshal(resp.Bytes(), &getResponse), "invalid resource item received") assert.Equal(t, http.StatusOK, resp.Code(), "unexpected status code getting roles") - for _, item := range items { - assert.NotEqual(t, "test-role", item.Name, "expected test-role to be deleted") + assert.Equal(t, "", getResponse.StartKey) + for _, item := range getResponse.Items.([]interface{}) { + assert.NotEqual(t, "test-role", item.(map[string]interface{})["name"], "expected test-role to be deleted") } } @@ -609,6 +613,7 @@ func TestListResources(t *testing.T) { type mockedResourceAPIGetter struct { mockGetRole func(ctx context.Context, name string) (types.Role, error) mockGetRoles func(ctx context.Context) ([]types.Role, error) + mockListRoles func(ctx context.Context, req *proto.ListRolesRequest) (*proto.ListRolesResponse, error) mockUpsertRole func(ctx context.Context, role types.Role) (types.Role, error) mockGetGithubConnectors func(ctx context.Context, withSecrets bool) ([]types.GithubConnector, error) mockGetGithubConnector func(ctx context.Context, id string, withSecrets bool) (types.GithubConnector, error) @@ -634,6 +639,13 @@ func (m *mockedResourceAPIGetter) GetRoles(ctx context.Context) ([]types.Role, e return nil, trace.NotImplemented("mockGetRoles not implemented") } +func (m *mockedResourceAPIGetter) ListRoles(ctx context.Context, req *proto.ListRolesRequest) (*proto.ListRolesResponse, error) { + if m.mockListRoles != nil { + return m.mockListRoles(ctx, req) + } + return nil, trace.NotImplemented("mockListRoles not implemented") +} + func (m *mockedResourceAPIGetter) UpsertRole(ctx context.Context, role types.Role) (types.Role, error) { if m.mockUpsertRole != nil { return m.mockUpsertRole(ctx, role) diff --git a/web/packages/design/src/DataTable/Pager/ServerSidePager.tsx b/web/packages/design/src/DataTable/Pager/ServerSidePager.tsx index b308f209396b7..7f3c9449ea1da 100644 --- a/web/packages/design/src/DataTable/Pager/ServerSidePager.tsx +++ b/web/packages/design/src/DataTable/Pager/ServerSidePager.tsx @@ -23,9 +23,9 @@ import { CircleArrowLeft, CircleArrowRight } from 'design/Icon'; import { StyledArrowBtn } from './StyledPager'; -export function ServerSidePager({ nextPage, prevPage }: Props) { - const isNextDisabled = !nextPage; - const isPrevDisabled = !prevPage; +export function ServerSidePager({ nextPage, prevPage, isLoading }: Props) { + const isNextDisabled = !nextPage || isLoading; + const isPrevDisabled = !prevPage || isLoading; return ( @@ -52,6 +52,7 @@ export function ServerSidePager({ nextPage, prevPage }: Props) { } export type Props = { + isLoading: boolean; nextPage: (() => void) | null; prevPage: (() => void) | null; }; diff --git a/web/packages/design/src/DataTable/Table.tsx b/web/packages/design/src/DataTable/Table.tsx index f57af7a7f8b5d..1fffbeaa78abc 100644 --- a/web/packages/design/src/DataTable/Table.tsx +++ b/web/packages/design/src/DataTable/Table.tsx @@ -154,6 +154,7 @@ export function Table({ prevPage={fetching.onFetchPrev} pagination={state.pagination} serversideProps={serversideProps} + fetchStatus={fetching.fetchStatus} /> ); } @@ -325,6 +326,7 @@ function ServersideTable({ className, style, serversideProps, + fetchStatus, }: ServersideTableProps) { return ( <> @@ -335,7 +337,11 @@ function ServersideTable({ {(nextPage || prevPage) && ( - + )} diff --git a/web/packages/design/src/DataTable/types.ts b/web/packages/design/src/DataTable/types.ts index e0954b4860333..a1937bd74cb4c 100644 --- a/web/packages/design/src/DataTable/types.ts +++ b/web/packages/design/src/DataTable/types.ts @@ -178,4 +178,5 @@ export type ServersideTableProps = BasicTableProps & { prevPage: () => void; pagination: State['state']['pagination']; serversideProps: State['serversideProps']; + fetchStatus?: FetchStatus; }; diff --git a/web/packages/shared/components/Search/SearchPanel.tsx b/web/packages/shared/components/Search/SearchPanel.tsx index 6a0ea66f029fa..91a0b6c827642 100644 --- a/web/packages/shared/components/Search/SearchPanel.tsx +++ b/web/packages/shared/components/Search/SearchPanel.tsx @@ -33,14 +33,16 @@ export function SearchPanel({ filter, showSearchBar, disableSearch, + hideAdvancedSearch, extraChildren, }: { updateQuery(s: string): void; updateSearch(s: string): void; - pageIndicators: { from: number; to: number; total: number }; + pageIndicators?: { from: number; to: number; total: number }; filter: ResourceFilter; showSearchBar: boolean; disableSearch: boolean; + hideAdvancedSearch?: boolean; extraChildren?: JSX.Element; }) { const [query, setQuery] = useState(filter.search || filter.query || ''); @@ -82,22 +84,26 @@ export function SearchPanel({ > {showSearchBar && ( - + {!hideAdvancedSearch && ( + + )} )} - - {extraChildren && extraChildren} + {pageIndicators && ( + + )} + {extraChildren} diff --git a/web/packages/teleport/src/Bots/EditBot.test.tsx b/web/packages/teleport/src/Bots/EditBot.test.tsx index 4f2b223a7c7e3..3a0e2f8cb4b25 100644 --- a/web/packages/teleport/src/Bots/EditBot.test.tsx +++ b/web/packages/teleport/src/Bots/EditBot.test.tsx @@ -18,11 +18,13 @@ import { render, screen, userEvent } from 'design/utils/testing'; +import { waitFor } from '@testing-library/react'; + import { EditBot } from 'teleport/Bots/EditBot'; import { EditBotProps } from 'teleport/Bots/types'; const makeProps = (overrides: Partial = {}): EditBotProps => ({ - allRoles: [], + fetchRoles: jest.fn().mockResolvedValueOnce([]), attempt: { status: '' }, name: 'bot-007', onClose: () => {}, @@ -35,6 +37,7 @@ const makeProps = (overrides: Partial = {}): EditBotProps => ({ test('renders', async () => { const props = makeProps({ selectedRoles: ['foo-role'] }); render(); + await waitFor(() => expect(props.fetchRoles).toHaveBeenCalledTimes(1)); expect(screen.getByText('Edit Bot')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument(); @@ -72,6 +75,7 @@ test('edit calls onedit cb', async () => { test('disables buttons when processing', async () => { const props = makeProps({ attempt: { status: 'processing' } }); render(); + await waitFor(() => expect(props.fetchRoles).toHaveBeenCalledTimes(1)); expect(screen.queryByRole('button', { name: 'Save' })).toBeDisabled(); expect(screen.queryByRole('button', { name: 'Cancel' })).toBeDisabled(); @@ -82,6 +86,7 @@ test('displays error text', async () => { attempt: { status: 'failed', statusText: 'error editing' }, }); render(); + await waitFor(() => expect(props.fetchRoles).toHaveBeenCalledTimes(1)); expect(screen.getByText('error editing')).toBeInTheDocument(); }); diff --git a/web/packages/teleport/src/Bots/EditBot.tsx b/web/packages/teleport/src/Bots/EditBot.tsx index 9d1ddc515c75a..f3ee5ab05c898 100644 --- a/web/packages/teleport/src/Bots/EditBot.tsx +++ b/web/packages/teleport/src/Bots/EditBot.tsx @@ -27,14 +27,14 @@ import Dialog, { import FieldInput from 'shared/components/FieldInput'; import { requiredField } from 'shared/components/Validation/rules'; -import FieldSelect from 'shared/components/FieldSelect'; +import { FieldSelectAsync } from 'shared/components/FieldSelect'; import Validation from 'shared/components/Validation'; import { Option } from 'shared/components/Select'; import { EditBotProps } from 'teleport/Bots/types'; export function EditBot({ - allRoles, + fetchRoles, attempt, name, onClose, @@ -42,11 +42,6 @@ export function EditBot({ selectedRoles, setSelectedRoles, }: EditBotProps) { - const selectOptions: Option[] = allRoles.map(r => ({ - value: r, - label: r, - })); - return ( @@ -65,7 +60,7 @@ export function EditBot({ readonly={true} onChange={() => {}} /> - - setSelectedRoles(values.map(v => v.value)) + setSelectedRoles(values?.map(v => v.value) || []) } - options={selectOptions} + loadOptions={async input => { + const roles = await fetchRoles(input); + return roles.map(r => ({ + value: r, + label: r, + })); + }} + noOptionsMessage={() => 'No roles found'} elevated={true} /> diff --git a/web/packages/teleport/src/Bots/List/BotList.test.tsx b/web/packages/teleport/src/Bots/List/BotList.test.tsx index 02b281c37f59f..1439890e675cb 100644 --- a/web/packages/teleport/src/Bots/List/BotList.test.tsx +++ b/web/packages/teleport/src/Bots/List/BotList.test.tsx @@ -31,7 +31,7 @@ const makeProps = (): BotListProps => ({ onClose: () => {}, onDelete: () => {}, onEdit: () => {}, - roles: [], + fetchRoles: async () => [], selectedBot: null, selectedRoles: [], setSelectedBot: () => {}, diff --git a/web/packages/teleport/src/Bots/List/BotList.tsx b/web/packages/teleport/src/Bots/List/BotList.tsx index bd1449e009bca..4181bc674efca 100644 --- a/web/packages/teleport/src/Bots/List/BotList.tsx +++ b/web/packages/teleport/src/Bots/List/BotList.tsx @@ -40,7 +40,7 @@ export function BotList({ bots, disabledEdit, disabledDelete, - roles, + fetchRoles, onClose, onDelete, onEdit, @@ -107,7 +107,7 @@ export function BotList({ )} {selectedBot && interaction === Interaction.EDIT && ( { onClose={() => {}} onDelete={() => {}} onEdit={() => {}} - roles={[]} + fetchRoles={async () => []} selectedBot={null} selectedRoles={[]} setSelectedBot={() => {}} diff --git a/web/packages/teleport/src/Bots/List/Bots.test.tsx b/web/packages/teleport/src/Bots/List/Bots.test.tsx index 747c19d7d7a44..c66511df5919f 100644 --- a/web/packages/teleport/src/Bots/List/Bots.test.tsx +++ b/web/packages/teleport/src/Bots/List/Bots.test.tsx @@ -37,10 +37,7 @@ function renderWithContext(element) { } test('fetches bots on load', async () => { - jest - .spyOn(api, 'get') - .mockResolvedValueOnce({ ...botsApiResponseFixture }) - .mockResolvedValueOnce(['role-1', 'editor']); + jest.spyOn(api, 'get').mockResolvedValueOnce({ ...botsApiResponseFixture }); renderWithContext(); expect(screen.getByText('Bots')).toBeInTheDocument(); @@ -49,7 +46,7 @@ test('fetches bots on load', async () => { screen.getByText(botsApiResponseFixture.items[0].metadata.name) ).toBeInTheDocument(); }); - expect(api.get).toHaveBeenCalledTimes(2); + expect(api.get).toHaveBeenCalledTimes(1); }); test('calls edit endpoint', async () => { diff --git a/web/packages/teleport/src/Bots/List/Bots.tsx b/web/packages/teleport/src/Bots/List/Bots.tsx index 7c57c8b3f0328..0a67fa4c207b7 100644 --- a/web/packages/teleport/src/Bots/List/Bots.tsx +++ b/web/packages/teleport/src/Bots/List/Bots.tsx @@ -45,7 +45,6 @@ export function Bots() { const hasAddBotPermissions = flags.addBots; const [bots, setBots] = useState(); - const [roles, setRoles] = useState(); const [selectedBot, setSelectedBot] = useState(); const [selectedRoles, setSelectedRoles] = useState(); const { attempt: crudAttempt, run: crudRun } = useAttemptNext(); @@ -59,23 +58,22 @@ export function Bots() { return await fetchBots(signal, flags); } - async function roles(signal: AbortSignal) { - return await fetchRoles(signal, flags); - } - fetchRun(() => - Promise.all([bots(signal.signal), roles(signal.signal)]).then( - ([botRes, roleRes]) => { - setBots(botRes.bots); - setRoles(roleRes.map(r => r.name)); - } - ) + bots(signal.signal).then(botRes => { + setBots(botRes.bots); + }) ); return () => { signal.abort(); }; }, [ctx, fetchRun]); + async function fetchRoleNames(search: string): Promise { + const flags = ctx.getFeatureFlags(); + const roles = await fetchRoles(search, flags); + return roles.items.map(r => r.name); + } + function onDelete() { crudRun(() => deleteBot(flags, selectedBot.name)).then(() => { setBots(bots.filter(bot => bot.name !== selectedBot.name)); @@ -148,7 +146,7 @@ export function Bots() { bots={bots} disabledEdit={!flags.roles || !flags.editBots} disabledDelete={!flags.removeBots} - roles={roles} + fetchRoles={fetchRoleNames} onClose={onClose} onDelete={onDelete} onEdit={onEdit} diff --git a/web/packages/teleport/src/Bots/types.ts b/web/packages/teleport/src/Bots/types.ts index 98ed0984f60fb..1c222fbba1aec 100644 --- a/web/packages/teleport/src/Bots/types.ts +++ b/web/packages/teleport/src/Bots/types.ts @@ -34,7 +34,7 @@ export type BotListProps = { bots: FlatBot[]; disabledEdit: boolean; disabledDelete: boolean; - roles: string[]; + fetchRoles: (input: string) => Promise; onClose: () => void; onDelete: () => void; onEdit: () => void; @@ -61,7 +61,7 @@ export enum BotFlowType { } export type EditBotProps = { - allRoles: string[]; + fetchRoles: (input: string) => Promise; attempt: Attempt; name: string; onClose: () => void; diff --git a/web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/Roles.tsx b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/Roles.tsx similarity index 83% rename from web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/Roles.tsx rename to web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/Roles.tsx index c03e53d5b1bb0..a709af747dabe 100644 --- a/web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/Roles.tsx +++ b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/Roles.tsx @@ -19,13 +19,11 @@ import React from 'react'; import Table from 'design/DataTable'; -import { Resource, KindRole } from 'teleport/services/resources'; +import { RoleResource } from 'teleport/services/resources'; -import { renderActionCell, SimpleListProps } from '../common'; +import { renderActionCell, ServerSideListProps } from '../common'; -export function Roles( - props: SimpleListProps & { roles: Resource[] } -) { +export function Roles(props: ServerSideListProps & { roles: RoleResource[] }) { const { roles = [], selectedResources, @@ -50,8 +48,7 @@ export function Roles( }, ]} emptyText="No Roles Found" - pagination={{ pageSize: props.pageSize }} - isSearchable + disableFilter fetching={{ fetchStatus, }} diff --git a/web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/ServerSideSupportedList.tsx b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/ServerSideSupportedList.tsx index 48c335b3d63e0..d4306fe44194d 100644 --- a/web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/ServerSideSupportedList.tsx +++ b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/ServerSideSupportedList.tsx @@ -30,16 +30,23 @@ import { Desktop } from 'teleport/services/desktops'; import { Node } from 'teleport/services/nodes'; import { useServerSidePagination } from 'teleport/components/hooks'; import useTeleport from 'teleport/useTeleport'; -import cfg from 'teleport/config'; +import cfg, { UrlResourcesParams } from 'teleport/config'; import Ctx from 'teleport/teleportContext'; +import { RoleResource } from 'teleport/services/resources'; + import { TableWrapper, ServerSideListProps } from '../common'; import { CommonListProps, LockResourceKind } from '../../common'; import { Nodes } from './Nodes'; import { Desktops } from './Desktops'; +import { Roles } from './Roles'; -import type { ResourceLabel, ResourceFilter } from 'teleport/services/agents'; +import type { + ResourceLabel, + ResourceFilter, + ResourcesResponse, +} from 'teleport/services/agents'; export function ServerSideSupportedList(props: CommonListProps) { const ctx = useTeleport(); @@ -58,7 +65,7 @@ export function ServerSideSupportedList(props: CommonListProps) { fetchFunc: getFetchFuncForServerSidePaginating( ctx, props.selectedResourceKind - ) as any, + ), clusterId: cfg.proxyCluster, // Locking only supported with root cluster params: resourceFilter, pageSize: props.pageSize, @@ -112,6 +119,10 @@ export function ServerSideSupportedList(props: CommonListProps) { }; switch (props.selectedResourceKind) { + case 'role': + return ( + + ); case 'node': return ; case 'windows_desktop': @@ -141,6 +152,7 @@ export function ServerSideSupportedList(props: CommonListProps) { to: pageIndicators.to, total: pageIndicators.totalCount, }} + hideAdvancedSearch={props.selectedResourceKind === 'role'} // Roles don't support advanced search. filter={resourceFilter} showSearchBar={true} disableSearch={fetchStatus === 'loading'} @@ -183,7 +195,16 @@ function getDefaultSort(kind: LockResourceKind): SortType { function getFetchFuncForServerSidePaginating( ctx: Ctx, resourceKind: LockResourceKind -) { +): ( + clusterId: string, + params: UrlResourcesParams +) => Promise> { + if (resourceKind === 'role') { + return async (clusterId, params) => { + const { items, startKey } = await ctx.resourceService.fetchRoles(params); + return { agents: items, startKey }; + }; + } if (resourceKind === 'node') { return ctx.nodeService.fetchNodes; } diff --git a/web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/SimpleList.tsx b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/SimpleList.tsx index a267876f6060f..3b4fe0586cd7a 100644 --- a/web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/SimpleList.tsx +++ b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/SimpleList.tsx @@ -21,12 +21,10 @@ import React, { useState, useEffect, useMemo } from 'react'; import useTeleport from 'teleport/useTeleport'; import { User } from 'teleport/services/user'; import { MfaDevice } from 'teleport/services/mfa'; -import { KindRole, Resource } from 'teleport/services/resources'; import { TableWrapper, SimpleListProps } from '../common'; import { CommonListProps, LockResourceKind } from '../../common'; -import { Roles } from './Roles'; import Users from './Users'; import { MfaDevices } from './MfaDevices'; @@ -48,9 +46,6 @@ export function SimpleList(props: CommonListProps & { opts: SimpleListOpts }) { useEffect(() => { let fetchFn; switch (props.selectedResourceKind) { - case 'role': - fetchFn = ctx.resourceService.fetchRoles; - break; case 'user': fetchFn = ctx.userService.fetchUsers; break; @@ -87,10 +82,6 @@ export function SimpleList(props: CommonListProps & { opts: SimpleListOpts }) { toggleSelectResource: props.toggleSelectResource, }; switch (props.selectedResourceKind) { - case 'role': - return ( - []} {...listProps} /> - ); case 'user': return ; case 'mfa_device': diff --git a/web/packages/teleport/src/LocksV2/NewLock/common.tsx b/web/packages/teleport/src/LocksV2/NewLock/common.tsx index 37d878cb6bddd..d1e42deb9184f 100644 --- a/web/packages/teleport/src/LocksV2/NewLock/common.tsx +++ b/web/packages/teleport/src/LocksV2/NewLock/common.tsx @@ -107,7 +107,7 @@ export const baseResourceKindOpts: LockResourceOption[] = [ { value: 'role', label: 'Roles', - listKind: 'simple', + listKind: 'server-side', }, { value: 'mfa_device', diff --git a/web/packages/teleport/src/Nodes/Nodes.story.tsx b/web/packages/teleport/src/Nodes/Nodes.story.tsx index 2376c82827084..4b313fa6fadd0 100644 --- a/web/packages/teleport/src/Nodes/Nodes.story.tsx +++ b/web/packages/teleport/src/Nodes/Nodes.story.tsx @@ -99,6 +99,7 @@ const props: State = { query: '', sort: { fieldName: 'hostname', dir: 'ASC' }, }, + modifyFetchedData: () => null, setParams: () => null, setSort: () => null, pathname: '', diff --git a/web/packages/teleport/src/Roles/RoleList/RoleList.tsx b/web/packages/teleport/src/Roles/RoleList/RoleList.tsx index b5afe0ffaf876..ca1151bf909e9 100644 --- a/web/packages/teleport/src/Roles/RoleList/RoleList.tsx +++ b/web/packages/teleport/src/Roles/RoleList/RoleList.tsx @@ -20,19 +20,46 @@ import React from 'react'; import Table, { Cell } from 'design/DataTable'; import { MenuButton, MenuItem } from 'shared/components/MenuAction'; -import { State as ResourceState } from 'teleport/components/useResources'; +import { SearchPanel } from 'shared/components/Search'; -import { State as RolesState } from '../useRoles'; +import { SeversidePagination } from 'teleport/components/hooks/useServersidePagination'; +import { RoleResource } from 'teleport/services/resources'; -export default function RoleList({ - items = [], - pageSize = 20, +export function RoleList({ onEdit, onDelete, -}: Props) { + onSearchChange, + search, + serversidePagination, +}: { + onEdit(id: string): void; + onDelete(id: string): void; + onSearchChange(search: string): void; + search: string; + serversidePagination: SeversidePagination; +}) { return ( undefined, + serversideSearchPanel: ( + + ), + }} columns={[ { key: 'name', @@ -40,40 +67,27 @@ export default function RoleList({ }, { altKey: 'options-btn', - render: ({ id }) => ( - + render: (role: RoleResource) => ( + onEdit(role.id)} + onDelete={() => onDelete(role.id)} + /> ), }, ]} emptyText="No Roles Found" - pagination={{ pageSize }} isSearchable /> ); } -const ActionCell = ({ - id, - onEdit, - onDelete, -}: { - id: string; - onEdit: (id: string) => void; - onDelete: (id: string) => void; -}) => { +const ActionCell = (props: { onEdit(): void; onDelete(): void }) => { return ( - onEdit(id)}>Edit... - onDelete(id)}>Delete... + Edit... + Delete... ); }; - -type Props = { - items: RolesState['items']; - onEdit: ResourceState['edit']; - onDelete: ResourceState['remove']; - pageSize?: number; -}; diff --git a/web/packages/teleport/src/Roles/RoleList/index.ts b/web/packages/teleport/src/Roles/RoleList/index.ts index 5f225d3f1e3f7..45a4d53e500f4 100644 --- a/web/packages/teleport/src/Roles/RoleList/index.ts +++ b/web/packages/teleport/src/Roles/RoleList/index.ts @@ -16,5 +16,4 @@ * along with this program. If not, see . */ -import RoleList from './RoleList'; -export default RoleList; +export { RoleList } from './RoleList'; diff --git a/web/packages/teleport/src/Roles/Roles.story.tsx b/web/packages/teleport/src/Roles/Roles.story.tsx index 5a67356358f65..baab25adcbdf3 100644 --- a/web/packages/teleport/src/Roles/Roles.story.tsx +++ b/web/packages/teleport/src/Roles/Roles.story.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import { Roles } from './Roles'; @@ -25,7 +25,12 @@ export default { }; export function Processing() { - return ; + const promiseRef = useRef(Promise.withResolvers()); + useEffect(() => { + const promise = promiseRef.current; + return () => promise.resolve(undefined); + }, []); + return promiseRef.current.promise} />; } export function Loaded() { @@ -33,14 +38,18 @@ export function Loaded() { } export function Empty() { - return ; + return ( + ({ items: [], startKey: '' })} /> + ); } export function Failed() { return ( { + throw new Error('some error message'); + }} /> ); } @@ -68,7 +77,7 @@ const sample = { attempt: { status: 'success' as any, }, - items: roles, + fetch: async () => ({ items: roles, startKey: '' }), remove: () => null, save: () => null, }; diff --git a/web/packages/teleport/src/Roles/Roles.tsx b/web/packages/teleport/src/Roles/Roles.tsx index e51098c7feaff..8c552100e62cb 100644 --- a/web/packages/teleport/src/Roles/Roles.tsx +++ b/web/packages/teleport/src/Roles/Roles.tsx @@ -16,9 +16,9 @@ * along with this program. If not, see . */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; -import { Alert, Box, ButtonPrimary, Flex, Indicator, Link, Text } from 'design'; +import { Alert, Box, ButtonPrimary, Flex, Link, Text } from 'design'; import { FeatureBox, @@ -30,9 +30,11 @@ import useResources from 'teleport/components/useResources'; import useTeleport from 'teleport/useTeleport'; import { CaptureEvent, userEventService } from 'teleport/services/userEvent'; -import RoleList from './RoleList'; +import { useServerSidePagination } from 'teleport/components/hooks'; + +import { RoleList } from './RoleList'; import DeleteRole from './DeleteRole'; -import useRoles, { State } from './useRoles'; +import { useRoles, State } from './useRoles'; import templates from './templates'; @@ -43,17 +45,59 @@ export function RolesContainer() { } export function Roles(props: State) { - const { items, remove, save, attempt } = props; - const resources = useResources(items, templates); + const { remove, save, fetch } = props; + const [search, setSearch] = useState(''); + + const serverSidePagination = useServerSidePagination({ + pageSize: 20, + fetchFunc: async (_, params) => { + const { items, startKey } = await fetch(params); + return { agents: items, startKey }; + }, + clusterId: '', + params: { search }, + }); + const { modifyFetchedData } = serverSidePagination; + + const resources = useResources( + serverSidePagination.fetchedData.agents, + templates + ); const title = resources.status === 'creating' ? 'Create a new role' : 'Edit role'; - function handleSave(content: string) { + async function handleSave(content: string): Promise { const name = resources.item.name; const isNew = resources.status === 'creating'; - return save(name, content, isNew); + const response = await save(name, content, isNew); + // We cannot refetch the data right after saving because this backend + // operation is not atomic. + // There is a short delay between updating the resource + // and having the updated value propagate to the cache. + // Because of that, we have to update the current page manually. + // TODO(gzdunek): Refactor this into a reusable hook, like `useResourceUpdate`. + modifyFetchedData(p => { + const index = p.agents.findIndex(a => a.id === response.id); + if (index >= 0) { + const newAgents = [...p.agents]; + newAgents[index] = response; + return { + ...p, + agents: newAgents, + }; + } else { + return { + ...p, + agents: [response, ...p.agents], + }; + } + }); } + useEffect(() => { + serverSidePagination.fetch(); + }, [search]); + const handleCreate = () => { resources.create('role'); @@ -62,6 +106,14 @@ export function Roles(props: State) { }); }; + async function handleDelete(): Promise { + await remove(resources.item.name); + modifyFetchedData(p => ({ + ...p, + agents: p.agents.filter(r => r.id !== resources.item.id), + })); + } + return ( @@ -70,50 +122,47 @@ export function Roles(props: State) { CREATE NEW ROLE - {attempt.status === 'failed' && } - {attempt.status === 'processing' && ( - - - - )} - {attempt.status === 'success' && ( - - - - - - - Role-based access control - - - Teleport Role-based access control (RBAC) provides fine-grained - control over who can access resources and in which contexts. A - Teleport role can be assigned automatically based on user identity - when used with single sign-on (SSO). - - - Learn more in{' '} - - the cluster management (RBAC) - {' '} - section of online documentation. - - - + {serverSidePagination.attempt.status === 'failed' && ( + )} + + + + + + + Role-based access control + + + Teleport Role-based access control (RBAC) provides fine-grained + control over who can access resources and in which contexts. A + Teleport role can be assigned automatically based on user identity + when used with single sign-on (SSO). + + + Learn more in{' '} + + the cluster management (RBAC) + {' '} + section of online documentation. + + + {(resources.status === 'creating' || resources.status === 'editing') && ( remove(resources.item.name)} + onDelete={handleDelete} /> )} diff --git a/web/packages/teleport/src/Roles/useRoles.ts b/web/packages/teleport/src/Roles/useRoles.ts index df8cdfa832c86..63e15524a6128 100644 --- a/web/packages/teleport/src/Roles/useRoles.ts +++ b/web/packages/teleport/src/Roles/useRoles.ts @@ -16,49 +16,29 @@ * along with this program. If not, see . */ -import { useEffect, useState } from 'react'; -import useAttempt from 'shared/hooks/useAttemptNext'; - import TeleportContext from 'teleport/teleportContext'; -import { Resource, KindRole } from 'teleport/services/resources'; - -export default function useRoles(ctx: TeleportContext) { - const [items, setItems] = useState[]>([]); - const { attempt, run } = useAttempt('processing'); - function fetchData() { - return ctx.resourceService.fetchRoles().then(received => { - setItems(received); - }); - } +import type { UrlListRolesParams } from 'teleport/config'; - // TODO: we cannot refetch the data right after saving because this backend - // operation is not atomic. +export function useRoles(ctx: TeleportContext) { function save(name: string, yaml: string, isNew: boolean) { if (isNew) { - return ctx.resourceService.createRole(yaml).then(result => { - setItems([result, ...items]); - }); + return ctx.resourceService.createRole(yaml); } - return ctx.resourceService.updateRole(name, yaml).then(result => { - setItems([result, ...items.filter(r => r.name !== result.name)]); - }); + return ctx.resourceService.updateRole(name, yaml); } function remove(name: string) { - return ctx.resourceService.deleteRole(name).then(() => { - setItems(items.filter(r => r.name !== name)); - }); + return ctx.resourceService.deleteRole(name); } - useEffect(() => { - run(() => fetchData()); - }, []); + function fetch(params?: UrlListRolesParams) { + return ctx.resourceService.fetchRoles(params); + } return { - items, - attempt, + fetch, save, remove, }; diff --git a/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.story.tsx b/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.story.tsx index fd8cb4dfa8082..67a316c4e2bb8 100644 --- a/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.story.tsx +++ b/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.story.tsx @@ -29,7 +29,7 @@ export const Create = () => { ...props, isNew: true, name: '', - roles: [], + fetchRoles: async () => [], selectedRoles: [], attempt: { status: '' as const }, }; @@ -55,7 +55,8 @@ export const Failed = () => { }; const props = { - roles: ['Relupba', 'B', 'Pilhibo'], + fetchRoles: async (input: string) => + ['Relupba', 'B', 'Pilhibo'].filter(r => r.includes(input)), onClose: () => null, selectedRoles: [ { value: 'admin', label: 'admin' }, diff --git a/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.tsx b/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.tsx index 8ff4419e64698..cefb4c64a3528 100644 --- a/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.tsx +++ b/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.tsx @@ -26,7 +26,7 @@ import Dialog, { } from 'design/Dialog'; import Validation from 'shared/components/Validation'; import FieldInput from 'shared/components/FieldInput'; -import FieldSelect from 'shared/components/FieldSelect'; +import { FieldSelectAsync } from 'shared/components/FieldSelect'; import { Option } from 'shared/components/Select'; import { requiredField } from 'shared/components/Validation/rules'; @@ -43,7 +43,7 @@ export function UserAddEdit(props: ReturnType) { onChangeName, onChangeRoles, onClose, - roles, + fetchRoles, attempt, name, selectedRoles, @@ -56,11 +56,6 @@ export function UserAddEdit(props: ReturnType) { return ; } - const selectOptions: Option[] = roles.map(r => ({ - value: r, - label: r, - })); - function save(validator) { if (!validator.validate()) { return; @@ -98,7 +93,7 @@ export function UserAddEdit(props: ReturnType) { onChange={e => onChangeName(e.target.value)} readonly={isNew ? false : true} /> - ) { isClearable={false} value={selectedRoles} onChange={values => onChangeRoles(values as Option[])} - options={selectOptions} + noOptionsMessage={() => 'No roles found'} + loadOptions={async input => { + const roles = await fetchRoles(input); + return roles.map(r => ({ value: r, label: r })); + }} elevated={true} /> diff --git a/web/packages/teleport/src/Users/UserAddEdit/useDialog.tsx b/web/packages/teleport/src/Users/UserAddEdit/useDialog.tsx index 7ab118cf20814..8c65c90b9178f 100644 --- a/web/packages/teleport/src/Users/UserAddEdit/useDialog.tsx +++ b/web/packages/teleport/src/Users/UserAddEdit/useDialog.tsx @@ -74,7 +74,7 @@ export default function useUserDialog(props: Props) { onSave, onChangeName, onChangeRoles, - roles: props.roles, + fetchRoles: props.fetchRoles, isNew: props.isNew, attempt, name, @@ -86,7 +86,7 @@ export default function useUserDialog(props: Props) { export type Props = { isNew: boolean; user: User; - roles: string[]; + fetchRoles(search: string): Promise; onClose(): void; onCreate(user: User): Promise; onUpdate(user: User): Promise; diff --git a/web/packages/teleport/src/Users/Users.story.tsx b/web/packages/teleport/src/Users/Users.story.tsx index 8aa3f0075295f..3aaec35a810b7 100644 --- a/web/packages/teleport/src/Users/Users.story.tsx +++ b/web/packages/teleport/src/Users/Users.story.tsx @@ -97,7 +97,7 @@ const sample = { message: '', }, users: users, - roles: roles, + fetchRoles: async (input: string) => roles.filter(r => r.includes(input)), operation: { type: 'none', user: null, diff --git a/web/packages/teleport/src/Users/Users.test.tsx b/web/packages/teleport/src/Users/Users.test.tsx index 3e37b6c22d3c3..a05f70bf8550f 100644 --- a/web/packages/teleport/src/Users/Users.test.tsx +++ b/web/packages/teleport/src/Users/Users.test.tsx @@ -39,7 +39,7 @@ describe('invite collaborators integration', () => { isFailed: false, }, users: [], - roles: [], + fetchRoles: async () => [], operation: { type: 'invite-collaborators' }, onStartCreate: () => undefined, @@ -118,7 +118,7 @@ describe('email password reset integration', () => { isFailed: false, }, users: [], - roles: [], + fetchRoles: () => Promise.resolve([]), operation: { type: 'reset', user: { name: 'alice@example.com', roles: ['foo'] }, diff --git a/web/packages/teleport/src/Users/Users.tsx b/web/packages/teleport/src/Users/Users.tsx index ba69a64da231f..38907d59c425e 100644 --- a/web/packages/teleport/src/Users/Users.tsx +++ b/web/packages/teleport/src/Users/Users.tsx @@ -40,7 +40,7 @@ export function Users(props: State) { const { attempt, users, - roles, + fetchRoles, operation, onStartCreate, onStartDelete, @@ -98,7 +98,7 @@ export function Users(props: State) { {(operation.type === 'create' || operation.type === 'edit') && ( { - function fetchRoles() { - if (ctx.getFeatureFlags().roles) { - return ctx.resourceService - .fetchRoles() - .then(resources => resources.map(role => role.name)); - } - - return Promise.resolve([]); - } + async function fetchRoles(search: string): Promise { + const { items } = await ctx.resourceService.fetchRoles({ + search, + limit: 50, + }); + return items.map(r => r.name); + } - attemptActions.do(() => - Promise.all([fetchRoles(), ctx.userService.fetchUsers()]).then(values => { - setRoles(values[0]); - setUsers(values[1]); - }) - ); + useEffect(() => { + attemptActions.do(() => ctx.userService.fetchUsers().then(setUsers)); }, []); return { attempt, users, - roles, + fetchRoles, operation, onStartCreate, onStartDelete, diff --git a/web/packages/teleport/src/components/hooks/useServersidePagination.ts b/web/packages/teleport/src/components/hooks/useServersidePagination.ts index b96c47a5117f3..6524095bb0f0d 100644 --- a/web/packages/teleport/src/components/hooks/useServersidePagination.ts +++ b/web/packages/teleport/src/components/hooks/useServersidePagination.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { useState } from 'react'; +import { useState, Dispatch, SetStateAction } from 'react'; import { FetchStatus, Page } from 'design/DataTable/types'; import useAttempt, { Attempt } from 'shared/hooks/useAttemptNext'; @@ -28,7 +28,7 @@ export function useServerSidePagination({ clusterId, params, pageSize = 15, -}: Props): State { +}: Props): SeversidePagination { const { attempt, setAttempt } = useAttempt('processing'); const [fetchStatus, setFetchStatus] = useState(''); const [page, setPage] = useState({ keys: [], index: 0 }); @@ -133,6 +133,7 @@ export function useServerSidePagination({ page, pageSize, fetchedData, + modifyFetchedData: setFetchedData, }; } @@ -146,7 +147,7 @@ type Props = { pageSize?: number; }; -type State = { +export type SeversidePagination = { pageIndicators: PageIndicators; fetch: () => void; fetchNext: (() => void) | null; @@ -156,6 +157,8 @@ type State = { page: Page; pageSize: number; fetchedData: ResourcesResponse; + /** Allows modifying the fetched data. */ + modifyFetchedData: Dispatch>>; }; /** Contains the values needed to display 'Showing X - X of X' on the top right of the table. */ diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index bf29dbb71c59c..ad3c65a6774c0 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -220,7 +220,9 @@ const cfg = { userWithUsernamePath: '/v1/webapi/users/:username', createPrivilegeTokenPath: '/v1/webapi/users/privilege/token', - rolesPath: '/v1/webapi/roles/:name?', + listRolesPath: + '/v1/webapi/roles?startKey=:startKey?&search=:search?&limit=:limit?', + rolePath: '/v1/webapi/roles/:name?', presetRolesPath: '/v1/webapi/presetroles', githubConnectorsPath: '/v1/webapi/github/:name?', trustedClustersPath: '/v1/webapi/trustedcluster/:name?', @@ -772,8 +774,16 @@ const cfg = { return generatePath(cfg.api.trustedClustersPath, { name }); }, - getRolesUrl(name?: string) { - return generatePath(cfg.api.rolesPath, { name }); + getListRolesUrl(params?: UrlListRolesParams) { + return generatePath(cfg.api.listRolesPath, { + search: params?.search || undefined, + startKey: params?.startKey || undefined, + limit: params?.limit || undefined, + }); + }, + + getRoleUrl(name?: string) { + return generatePath(cfg.api.rolePath, { name }); }, getDiscoveryConfigUrl(clusterId: string) { @@ -1133,6 +1143,12 @@ export interface UrlDesktopParams { clusterId: string; } +export interface UrlListRolesParams { + search?: string; + limit?: number; + startKey?: string; +} + export interface UrlResourcesParams { query?: string; search?: string; @@ -1145,14 +1161,6 @@ export interface UrlResourcesParams { kinds?: string[]; } -export interface UrlIntegrationExecuteRequestParams { - // name is the name of integration to execute (use). - name: string; - // action is the expected backend string value - // used to describe what to use the integration for. - action: 'aws-oidc/list_databases'; -} - export interface UrlDeployServiceIamConfigureScriptParams { integrationName: string; region: Regions; diff --git a/web/packages/teleport/src/services/agents/types.ts b/web/packages/teleport/src/services/agents/types.ts index 9b57aef67698c..4526c46451ad8 100644 --- a/web/packages/teleport/src/services/agents/types.ts +++ b/web/packages/teleport/src/services/agents/types.ts @@ -38,6 +38,7 @@ export type UnifiedResource = export type UnifiedResourceKind = UnifiedResource['kind']; export type ResourcesResponse = { + //TODO(gzdunek): Rename to items. agents: T[]; startKey?: string; totalCount?: number; diff --git a/web/packages/teleport/src/services/bot/bot.ts b/web/packages/teleport/src/services/bot/bot.ts index 9db10d4d93b1f..f203fd28fb9f3 100644 --- a/web/packages/teleport/src/services/bot/bot.ts +++ b/web/packages/teleport/src/services/bot/bot.ts @@ -20,7 +20,7 @@ import api from 'teleport/services/api'; import cfg from 'teleport/config'; import { makeBot, toApiGitHubTokenSpec } from 'teleport/services/bot/consts'; -import { makeResourceList, Resource } from 'teleport/services/resources'; +import ResourceService, { RoleResource } from 'teleport/services/resources'; import { FeatureFlags } from 'teleport/types'; import { @@ -71,17 +71,16 @@ export function fetchBots( }); } -export function fetchRoles( - signal: AbortSignal, +export async function fetchRoles( + search: string, flags: FeatureFlags -): Promise[]> { +): Promise<{ startKey: string; items: RoleResource[] }> { if (!flags.roles) { - return; + return { startKey: '', items: [] }; } - return api.get(cfg.getRolesUrl(), signal).then(res => { - return makeResourceList<'role'>(res); - }); + const resourceSvc = new ResourceService(); + return resourceSvc.fetchRoles({ limit: 50, search }); } export function editBot( diff --git a/web/packages/teleport/src/services/resources/resource.ts b/web/packages/teleport/src/services/resources/resource.ts index 19ba8c91c744f..406b131b16389 100644 --- a/web/packages/teleport/src/services/resources/resource.ts +++ b/web/packages/teleport/src/services/resources/resource.ts @@ -17,13 +17,13 @@ */ import api from 'teleport/services/api'; -import cfg, { UrlResourcesParams } from 'teleport/config'; +import cfg, { UrlResourcesParams, UrlListRolesParams } from 'teleport/config'; import { UnifiedResource, ResourcesResponse } from '../agents'; import { makeUnifiedResource } from './makeUnifiedResource'; -import { makeResource, makeResourceList } from './'; +import { makeResource, makeResourceList, RoleResource } from './'; class ResourceService { fetchTrustedClusters() { @@ -56,10 +56,24 @@ class ResourceService { .then(res => makeResourceList<'github'>(res)); } - fetchRoles() { - return api - .get(cfg.getRolesUrl()) - .then(res => makeResourceList<'role'>(res)); + async fetchRoles(params?: UrlListRolesParams): Promise<{ + items: RoleResource[]; + startKey: string; + }> { + const response = await api.get(cfg.getListRolesUrl(params)); + + // This will handle backward compatibility with roles. + // The old roles API returns only an array of resources while + // the new one sends the paginated object with startKey/requests + // If this webclient requests an older proxy + // (this may happen in multi proxy deployments), + // this should allow the old request to not break the Web UI. + // TODO (gzdunek): DELETE in 17.0.0 + if (Array.isArray(response)) { + return makeRolesPageLocally(params, response); + } + + return response; } fetchPresetRoles() { @@ -76,7 +90,7 @@ class ResourceService { createRole(content: string) { return api - .post(cfg.getRolesUrl(), { content }) + .post(cfg.getRoleUrl(), { content }) .then(res => makeResource<'role'>(res)); } @@ -94,7 +108,7 @@ class ResourceService { updateRole(name: string, content: string) { return api - .put(cfg.getRolesUrl(name), { content }) + .put(cfg.getRoleUrl(name), { content }) .then(res => makeResource<'role'>(res)); } @@ -109,7 +123,7 @@ class ResourceService { } deleteRole(name: string) { - return api.delete(cfg.getRolesUrl(name)); + return api.delete(cfg.getRoleUrl(name)); } deleteGithubConnector(name: string) { @@ -118,3 +132,31 @@ class ResourceService { } export default ResourceService; + +// TODO (gzdunek): DELETE in 17.0.0. +// See the comment where this function is used. +function makeRolesPageLocally( + params: UrlListRolesParams, + response: RoleResource[] +): { + items: RoleResource[]; + startKey: string; +} { + if (params.search) { + // A serverside search would also match labels, here we only check the name. + response = response.filter(p => + p.name.toLowerCase().includes(params.search.toLowerCase()) + ); + } + + if (params.startKey) { + const startIndex = response.findIndex(p => p.name === params.startKey); + response = response.slice(startIndex); + } + + const limit = params.limit || 200; + const nextKey = response.at(limit)?.name; + response = response.slice(0, limit); + + return { items: response, startKey: nextKey }; +} diff --git a/web/packages/teleport/src/services/resources/types.ts b/web/packages/teleport/src/services/resources/types.ts index a3a61495fb382..c484518370afe 100644 --- a/web/packages/teleport/src/services/resources/types.ts +++ b/web/packages/teleport/src/services/resources/types.ts @@ -29,3 +29,6 @@ export type KindRole = 'role'; export type KindTrustedCluster = 'trusted_cluster'; export type KindAuthConnectors = 'github' | 'saml' | 'oidc'; export type Kind = KindRole | KindTrustedCluster | KindAuthConnectors; + +/** Describes a Teleport role. */ +export type RoleResource = Resource;