diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 8fe0a1ef357c5..51358021518e3 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -789,7 +789,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.upsertRoleHandle)) h.PUT("/webapi/roles/:name", h.WithAuth(h.upsertRoleHandle)) h.DELETE("/webapi/roles/:name", h.WithAuth(h.deleteRole)) diff --git a/lib/web/resources.go b/lib/web/resources.go index 10bf6fae81816..9b77795800794 100644 --- a/lib/web/resources.go +++ b/lib/web/resources.go @@ -75,12 +75,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) } @@ -93,6 +100,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 { @@ -457,6 +497,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. @@ -468,6 +515,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) error // UpsertGithubConnector creates or updates a Github connector diff --git a/lib/web/resources_test.go b/lib/web/resources_test.go index 8f8fb7cc167a2..f3c80f2224d07 100644 --- a/lib/web/resources_test.go +++ b/lib/web/resources_test.go @@ -291,7 +291,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"}, @@ -299,14 +299,17 @@ func TestGetRoles(t *testing.T) { }) require.Nil(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.Nil(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 TestUpsertRole(t *testing.T) { @@ -640,6 +643,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) error mockUpsertGithubConnector func(ctx context.Context, connector types.GithubConnector) error mockGetGithubConnectors func(ctx context.Context, withSecrets bool) ([]types.GithubConnector, error) @@ -666,6 +670,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) 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 f3b8f3d4f1eb0..ef41370feb7cb 100644 --- a/web/packages/design/src/DataTable/Pager/ServerSidePager.tsx +++ b/web/packages/design/src/DataTable/Pager/ServerSidePager.tsx @@ -21,9 +21,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 ( @@ -50,6 +50,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 fea7a43682172..27466f522a0c1 100644 --- a/web/packages/design/src/DataTable/Table.tsx +++ b/web/packages/design/src/DataTable/Table.tsx @@ -20,7 +20,7 @@ import { Text, Indicator, Box, Flex } from 'design'; import * as Icons from 'design/Icon'; import { StyledTable, StyledPanel, StyledTableWrapper } from './StyledTable'; -import { TableProps } from './types'; +import { TableProps, FetchStatus } from './types'; import { SortHeaderCell, TextCell } from './Cells'; import { ClientSidePager, ServerSidePager } from './Pager'; import InputSearch from './InputSearch'; @@ -147,6 +147,7 @@ export function Table({ prevPage={fetching.onFetchPrev} pagination={state.pagination} serversideProps={serversideProps} + fetchStatus={fetching.fetchStatus} /> ); @@ -322,6 +323,7 @@ function ServersideTable({ className, style, serversideProps, + fetchStatus, }: ServersideTableProps) { return ( <> @@ -331,7 +333,11 @@ function ServersideTable({ {renderBody(data)} - + ); @@ -438,4 +444,5 @@ 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 f51541bd7bd4b..d9b63c94bfabd 100644 --- a/web/packages/shared/components/Search/SearchPanel.tsx +++ b/web/packages/shared/components/Search/SearchPanel.tsx @@ -30,14 +30,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 || ''); @@ -79,22 +81,26 @@ export function SearchPanel({ > {showSearchBar && ( - + {!hideAdvancedSearch && ( + + )} )} - - {extraChildren && extraChildren} + {pageIndicators && ( + + )} + {extraChildren} diff --git a/web/packages/teleport/src/Apps/Apps.story.tsx b/web/packages/teleport/src/Apps/Apps.story.tsx index 41ba0c26f5869..ef2a35224c8f9 100644 --- a/web/packages/teleport/src/Apps/Apps.story.tsx +++ b/web/packages/teleport/src/Apps/Apps.story.tsx @@ -98,4 +98,5 @@ export const props: State = { replaceHistory: () => null, isSearchEmpty: false, onLabelClick: () => null, + modifyFetchedData: () => null, }; diff --git a/web/packages/teleport/src/Databases/Databases.story.tsx b/web/packages/teleport/src/Databases/Databases.story.tsx index d612ec1bb75ac..ee279f92c7e3b 100644 --- a/web/packages/teleport/src/Databases/Databases.story.tsx +++ b/web/packages/teleport/src/Databases/Databases.story.tsx @@ -100,4 +100,5 @@ export const props: State = { isSearchEmpty: false, onLabelClick: () => null, accessRequestId: null, + modifyFetchedData: () => null, }; diff --git a/web/packages/teleport/src/Desktops/Desktops.story.tsx b/web/packages/teleport/src/Desktops/Desktops.story.tsx index 00e2dbfde551a..8bc44f8db7e8a 100644 --- a/web/packages/teleport/src/Desktops/Desktops.story.tsx +++ b/web/packages/teleport/src/Desktops/Desktops.story.tsx @@ -89,4 +89,5 @@ export const props: State = { replaceHistory: () => null, isSearchEmpty: false, onLabelClick: () => null, + modifyFetchedData: () => null, }; diff --git a/web/packages/teleport/src/Kubes/Kubes.story.tsx b/web/packages/teleport/src/Kubes/Kubes.story.tsx index 742bdf85b7fd6..41cda476c7e9d 100644 --- a/web/packages/teleport/src/Kubes/Kubes.story.tsx +++ b/web/packages/teleport/src/Kubes/Kubes.story.tsx @@ -102,4 +102,5 @@ export const props: State = { isSearchEmpty: false, onLabelClick: () => null, accessRequestId: null, + modifyFetchedData: () => null, }; 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 81% 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 d1b5794bec13a..b9e0016729570 100644 --- a/web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/Roles.tsx +++ b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/Roles.tsx @@ -17,13 +17,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, @@ -48,8 +46,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 cecdbf7760eee..ad9eaf248db05 100644 --- a/web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/ServerSideSupportedList.tsx +++ b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/ServerSideSupportedList.tsx @@ -28,16 +28,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(); @@ -56,7 +63,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, @@ -110,6 +117,10 @@ export function ServerSideSupportedList(props: CommonListProps) { }; switch (props.selectedResourceKind) { + case 'role': + return ( + + ); case 'node': return ; case 'windows_desktop': @@ -140,6 +151,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'} @@ -182,7 +194,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 bf6b4c06e9f57..e53776b286129 100644 --- a/web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/SimpleList.tsx +++ b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/SimpleList.tsx @@ -19,12 +19,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'; @@ -44,9 +42,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; @@ -83,10 +78,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 e24067890eca6..7a831fee10f9f 100644 --- a/web/packages/teleport/src/LocksV2/NewLock/common.tsx +++ b/web/packages/teleport/src/LocksV2/NewLock/common.tsx @@ -105,7 +105,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 857ccd271275e..7b33a58826121 100644 --- a/web/packages/teleport/src/Nodes/Nodes.story.tsx +++ b/web/packages/teleport/src/Nodes/Nodes.story.tsx @@ -97,6 +97,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 c15dde0d1cfe0..ee179e5c7ba49 100644 --- a/web/packages/teleport/src/Roles/RoleList/RoleList.tsx +++ b/web/packages/teleport/src/Roles/RoleList/RoleList.tsx @@ -18,19 +18,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', @@ -38,40 +65,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 f354016c4eee1..26ba1735b7c89 100644 --- a/web/packages/teleport/src/Roles/RoleList/index.ts +++ b/web/packages/teleport/src/Roles/RoleList/index.ts @@ -14,5 +14,4 @@ See the License for the specific language governing permissions and limitations under the License. */ -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 b925a27d626d5..788ca66a3b73b 100644 --- a/web/packages/teleport/src/Roles/Roles.story.tsx +++ b/web/packages/teleport/src/Roles/Roles.story.tsx @@ -11,7 +11,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import { Roles } from './Roles'; @@ -20,7 +20,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() { @@ -28,14 +33,18 @@ export function Loaded() { } export function Empty() { - return ; + return ( + ({ items: [], startKey: '' })} /> + ); } export function Failed() { return ( { + throw new Error('some error message'); + }} /> ); } @@ -63,7 +72,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 01b4a789a071b..c54e0efc8a187 100644 --- a/web/packages/teleport/src/Roles/Roles.tsx +++ b/web/packages/teleport/src/Roles/Roles.tsx @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -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, @@ -28,9 +28,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'; @@ -41,17 +43,59 @@ export default function Container() { } 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'); @@ -60,6 +104,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 ( @@ -68,50 +120,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 a7e87621c0f65..dd1dae6d4152d 100644 --- a/web/packages/teleport/src/Roles/useRoles.ts +++ b/web/packages/teleport/src/Roles/useRoles.ts @@ -14,49 +14,29 @@ See the License for the specific language governing permissions and limitations under the License. */ -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 42529e990f0c7..7a36a4ef5e0d2 100644 --- a/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.story.tsx +++ b/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.story.tsx @@ -27,7 +27,7 @@ export const Create = () => { ...props, isNew: true, name: '', - roles: [], + fetchRoles: async () => [], selectedRoles: [], attempt: { status: '' as const }, }; @@ -53,7 +53,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 3ab0a8ff993a4..579852750fc9f 100644 --- a/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.tsx +++ b/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.tsx @@ -24,7 +24,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'; @@ -41,7 +41,7 @@ export function UserAddEdit(props: ReturnType) { onChangeName, onChangeRoles, onClose, - roles, + fetchRoles, attempt, name, selectedRoles, @@ -54,11 +54,6 @@ export function UserAddEdit(props: ReturnType) { return ; } - const selectOptions: Option[] = roles.map(r => ({ - value: r, - label: r, - })); - function save(validator) { if (!validator.validate()) { return; @@ -96,7 +91,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 53380390ee159..012457a8da83a 100644 --- a/web/packages/teleport/src/Users/UserAddEdit/useDialog.tsx +++ b/web/packages/teleport/src/Users/UserAddEdit/useDialog.tsx @@ -72,7 +72,7 @@ export default function useUserDialog(props: Props) { onSave, onChangeName, onChangeRoles, - roles: props.roles, + fetchRoles: props.fetchRoles, isNew: props.isNew, attempt, name, @@ -84,7 +84,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 9488c39ad3a85..b50a34f1b47fd 100644 --- a/web/packages/teleport/src/Users/Users.story.tsx +++ b/web/packages/teleport/src/Users/Users.story.tsx @@ -95,7 +95,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 c771766e2aad8..e9a7b293ef530 100644 --- a/web/packages/teleport/src/Users/Users.test.tsx +++ b/web/packages/teleport/src/Users/Users.test.tsx @@ -37,7 +37,7 @@ describe('invite collaborators integration', () => { isFailed: false, }, users: [], - roles: [], + fetchRoles: async () => [], operation: { type: 'invite-collaborators' }, onStartCreate: () => undefined, @@ -116,7 +116,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 57e1f2cc9789f..52bb2c197581e 100644 --- a/web/packages/teleport/src/Users/Users.tsx +++ b/web/packages/teleport/src/Users/Users.tsx @@ -38,7 +38,7 @@ export function Users(props: State) { const { attempt, users, - roles, + fetchRoles, operation, onStartCreate, onStartDelete, @@ -96,7 +96,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 03bd2c58f82f9..c4159e4a99734 100644 --- a/web/packages/teleport/src/components/hooks/useServersidePagination.ts +++ b/web/packages/teleport/src/components/hooks/useServersidePagination.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useState } from 'react'; +import { useState, Dispatch, SetStateAction } from 'react'; import { FetchStatus, Page } from 'design/DataTable/types'; import useAttempt, { Attempt } from 'shared/hooks/useAttemptNext'; @@ -26,7 +26,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 }); @@ -131,6 +131,7 @@ export function useServerSidePagination({ page, pageSize, fetchedData, + modifyFetchedData: setFetchedData, }; } @@ -144,7 +145,7 @@ type Props = { pageSize?: number; }; -type State = { +export type SeversidePagination = { pageIndicators: PageIndicators; fetch: () => void; fetchNext: (() => void) | null; @@ -154,6 +155,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 fa9bdd9072aea..a676eea7903bb 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -210,7 +210,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?', @@ -733,8 +735,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) { @@ -1046,6 +1056,12 @@ export interface UrlDesktopParams { clusterId: string; } +export interface UrlListRolesParams { + search?: string; + limit?: number; + startKey?: string; +} + export interface UrlResourcesParams { query?: string; search?: string; @@ -1058,14 +1074,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 244a1879f3c9a..d63ec0118e45f 100644 --- a/web/packages/teleport/src/services/agents/types.ts +++ b/web/packages/teleport/src/services/agents/types.ts @@ -36,6 +36,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/resources/resource.ts b/web/packages/teleport/src/services/resources/resource.ts index d21726f983ff8..6c22f55c72dce 100644 --- a/web/packages/teleport/src/services/resources/resource.ts +++ b/web/packages/teleport/src/services/resources/resource.ts @@ -15,14 +15,14 @@ */ 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 { KeysEnum } from '../storageService'; import { makeUnifiedResource } from './makeUnifiedResource'; -import { makeResource, makeResourceList } from './'; +import { makeResource, makeResourceList, RoleResource } from './'; class ResourceService { fetchTrustedClusters() { @@ -74,10 +74,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() { @@ -94,7 +108,7 @@ class ResourceService { createRole(content: string) { return api - .post(cfg.getRolesUrl(), { content }) + .post(cfg.getRoleUrl(), { content }) .then(res => makeResource<'role'>(res)); } @@ -112,7 +126,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)); } @@ -127,7 +141,7 @@ class ResourceService { } deleteRole(name: string) { - return api.delete(cfg.getRolesUrl(name)); + return api.delete(cfg.getRoleUrl(name)); } deleteGithubConnector(name: string) { @@ -136,3 +150,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 15f323a415574..5c871b09ad14d 100644 --- a/web/packages/teleport/src/services/resources/types.ts +++ b/web/packages/teleport/src/services/resources/types.ts @@ -27,3 +27,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;