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