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 (