From 3d168eb34b3b35b3fcf033965c9d3da1c869d215 Mon Sep 17 00:00:00 2001 From: Eric Leijonmarck Date: Fri, 4 Mar 2022 12:04:07 +0100 Subject: [PATCH] Searchable service accounts (#45844) * WIP * draft of WIP * feat: search and filtering works :rainbow: * Update pkg/models/org_user.go * Apply suggestions from code review * refactor: remove unsed function * refactor: formatting * Apply suggestions from code review Co-authored-by: J Guerreiro * WIP * comment * Update public/app/features/serviceaccounts/ServiceAccountsListPage.tsx Co-authored-by: Alex Khomenko * review comments * wip * working search and initial load of service accounts * number of tokens working * removed api call * Apply suggestions from code review * added accescontrol param * accesscontrol prefix corrected Co-authored-by: J Guerreiro Co-authored-by: Alex Khomenko --- pkg/services/serviceaccounts/api/api.go | 57 ++++++++- .../serviceaccounts/database/database.go | 81 +++++++++++++ pkg/services/serviceaccounts/models.go | 10 +- .../serviceaccounts/serviceaccounts.go | 1 + pkg/services/serviceaccounts/tests/common.go | 6 + .../ServiceAccountsListPage.tsx | 76 +++++++----- .../features/serviceaccounts/state/actions.ts | 78 ++++++++++--- .../serviceaccounts/state/reducers.ts | 110 +++++++++++++----- .../serviceaccounts/state/selectors.ts | 12 -- public/app/types/serviceaccount.ts | 13 ++- 10 files changed, 354 insertions(+), 90 deletions(-) delete mode 100644 public/app/features/serviceaccounts/state/selectors.ts diff --git a/pkg/services/serviceaccounts/api/api.go b/pkg/services/serviceaccounts/api/api.go index 628597878ff..a2660525a1a 100644 --- a/pkg/services/serviceaccounts/api/api.go +++ b/pkg/services/serviceaccounts/api/api.go @@ -60,6 +60,7 @@ func (api *ServiceAccountsAPI) RegisterAPIEndpoints( auth := acmiddleware.Middleware(api.accesscontrol) api.RouterRegister.Group("/api/serviceaccounts", func(serviceAccountsRoute routing.RouteRegister) { serviceAccountsRoute.Get("/", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeAll)), routing.Wrap(api.ListServiceAccounts)) + serviceAccountsRoute.Get("/search", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionRead)), routing.Wrap(api.SearchOrgServiceAccountsWithPaging)) serviceAccountsRoute.Post("/", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionCreate)), routing.Wrap(api.CreateServiceAccount)) serviceAccountsRoute.Get("/:serviceAccountId", auth(middleware.ReqOrgAdmin, @@ -150,7 +151,6 @@ func (api *ServiceAccountsAPI) ListServiceAccounts(c *models.ReqContext) respons serviceAccounts[i].AccessControl = metadata[strconv.FormatInt(serviceAccounts[i].Id, 10)] } } - return response.JSON(http.StatusOK, serviceAccounts) } @@ -221,3 +221,58 @@ func (api *ServiceAccountsAPI) updateServiceAccount(c *models.ReqContext) respon return response.JSON(http.StatusOK, resp) } + +// SearchOrgServiceAccountsWithPaging is an HTTP handler to search for org users with paging. +// GET /api/serviceaccounts/search +func (api *ServiceAccountsAPI) SearchOrgServiceAccountsWithPaging(c *models.ReqContext) response.Response { + ctx := c.Req.Context() + perPage := c.QueryInt("perpage") + if perPage <= 0 { + perPage = 1000 + } + page := c.QueryInt("page") + if page < 1 { + page = 1 + } + query := &models.SearchOrgUsersQuery{ + OrgID: c.OrgId, + Query: c.Query("query"), + Page: page, + Limit: perPage, + User: c.SignedInUser, + IsServiceAccount: true, + } + serviceAccounts, err := api.store.SearchOrgServiceAccounts(ctx, query) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to get service accounts for current organization", err) + } + + saIDs := map[string]bool{} + for i := range serviceAccounts { + serviceAccounts[i].AvatarUrl = dtos.GetGravatarUrlWithDefault("", serviceAccounts[i].Name) + + saIDString := strconv.FormatInt(serviceAccounts[i].Id, 10) + saIDs[saIDString] = true + metadata := api.getAccessControlMetadata(c, map[string]bool{saIDString: true}) + serviceAccounts[i].AccessControl = metadata[strconv.FormatInt(serviceAccounts[i].Id, 10)] + tokens, err := api.store.ListTokens(ctx, serviceAccounts[i].OrgId, serviceAccounts[i].Id) + if err != nil { + api.log.Warn("Failed to list tokens for service account", "serviceAccount", serviceAccounts[i].Id) + } + serviceAccounts[i].Tokens = int64(len(tokens)) + } + + type searchOrgServiceAccountsQueryResult struct { + TotalCount int64 `json:"totalCount"` + ServiceAccounts []*serviceaccounts.ServiceAccountDTO `json:"serviceAccounts"` + Page int `json:"page"` + PerPage int `json:"perPage"` + } + result := searchOrgServiceAccountsQueryResult{ + TotalCount: query.Result.TotalCount, + ServiceAccounts: serviceAccounts, + Page: query.Result.Page, + PerPage: query.Result.PerPage, + } + return response.JSON(http.StatusOK, result) +} diff --git a/pkg/services/serviceaccounts/database/database.go b/pkg/services/serviceaccounts/database/database.go index c83abb94db9..8a4115542ad 100644 --- a/pkg/services/serviceaccounts/database/database.go +++ b/pkg/services/serviceaccounts/database/database.go @@ -10,6 +10,8 @@ import ( "github.com/google/uuid" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/sqlstore" "xorm.io/xorm" @@ -291,6 +293,85 @@ func (s *ServiceAccountsStoreImpl) UpdateServiceAccount(ctx context.Context, return updatedUser, err } +func (s *ServiceAccountsStoreImpl) SearchOrgServiceAccounts(ctx context.Context, query *models.SearchOrgUsersQuery) ([]*serviceaccounts.ServiceAccountDTO, error) { + query.IsServiceAccount = true + + serviceAccounts := make([]*serviceaccounts.ServiceAccountDTO, 0) + + err := s.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { + sess := dbSession.Table("org_user") + sess.Join("INNER", s.sqlStore.Dialect.Quote("user"), fmt.Sprintf("org_user.user_id=%s.id", s.sqlStore.Dialect.Quote("user"))) + + whereConditions := make([]string, 0) + whereParams := make([]interface{}, 0) + + whereConditions = append(whereConditions, "org_user.org_id = ?") + whereParams = append(whereParams, query.OrgID) + + // TODO: add to chore, for cleaning up after we have created + // service accounts table in the modelling + whereConditions = append(whereConditions, fmt.Sprintf("%s.is_service_account = %t", s.sqlStore.Dialect.Quote("user"), query.IsServiceAccount)) + + if s.sqlStore.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagAccesscontrol) { + acFilter, err := accesscontrol.Filter(ctx, "org_user.user_id", "serviceaccounts", "serviceaccounts:read", query.User) + if err != nil { + return err + } + whereConditions = append(whereConditions, acFilter.Where) + whereParams = append(whereParams, acFilter.Args...) + } + + if query.Query != "" { + queryWithWildcards := "%" + query.Query + "%" + whereConditions = append(whereConditions, "(email "+s.sqlStore.Dialect.LikeStr()+" ? OR name "+s.sqlStore.Dialect.LikeStr()+" ? OR login "+s.sqlStore.Dialect.LikeStr()+" ?)") + whereParams = append(whereParams, queryWithWildcards, queryWithWildcards, queryWithWildcards) + } + + if len(whereConditions) > 0 { + sess.Where(strings.Join(whereConditions, " AND "), whereParams...) + } + if query.Limit > 0 { + offset := query.Limit * (query.Page - 1) + sess.Limit(query.Limit, offset) + } + + sess.Cols( + "org_user.user_id", + "org_user.org_id", + "org_user.role", + "user.email", + "user.name", + "user.login", + "user.last_seen_at", + ) + sess.Asc("user.email", "user.login") + if err := sess.Find(&serviceAccounts); err != nil { + return err + } + + // get total + serviceaccount := serviceaccounts.ServiceAccountDTO{} + countSess := dbSession.Table("org_user") + sess.Join("INNER", s.sqlStore.Dialect.Quote("user"), fmt.Sprintf("org_user.user_id=%s.id", s.sqlStore.Dialect.Quote("user"))) + + if len(whereConditions) > 0 { + countSess.Where(strings.Join(whereConditions, " AND "), whereParams...) + } + count, err := countSess.Count(&serviceaccount) + if err != nil { + return err + } + query.Result.TotalCount = count + + return nil + }) + if err != nil { + return nil, err + } + + return serviceAccounts, nil +} + func contains(s []int64, e int64) bool { for _, a := range s { if a == e { diff --git a/pkg/services/serviceaccounts/models.go b/pkg/services/serviceaccounts/models.go index 7bd167dad25..4a5fc81e347 100644 --- a/pkg/services/serviceaccounts/models.go +++ b/pkg/services/serviceaccounts/models.go @@ -35,12 +35,12 @@ type CreateServiceAccountForm struct { } type ServiceAccountDTO struct { - Id int64 `json:"id"` - Name string `json:"name"` - Login string `json:"login"` - OrgId int64 `json:"orgId"` + Id int64 `json:"id" xorm:"user_id"` + Name string `json:"name" xorm:"name"` + Login string `json:"login" xorm:"login"` + OrgId int64 `json:"orgId" xorm:"org_id"` + Role string `json:"role" xorm:"role"` Tokens int64 `json:"tokens"` - Role string `json:"role"` AvatarUrl string `json:"avatarUrl"` AccessControl map[string]bool `json:"accessControl,omitempty"` } diff --git a/pkg/services/serviceaccounts/serviceaccounts.go b/pkg/services/serviceaccounts/serviceaccounts.go index 7ba3b1b8fc3..cedc29a84d1 100644 --- a/pkg/services/serviceaccounts/serviceaccounts.go +++ b/pkg/services/serviceaccounts/serviceaccounts.go @@ -15,6 +15,7 @@ type Service interface { type Store interface { CreateServiceAccount(ctx context.Context, saForm *CreateServiceAccountForm) (*ServiceAccountDTO, error) ListServiceAccounts(ctx context.Context, orgID, serviceAccountID int64) ([]*ServiceAccountDTO, error) + SearchOrgServiceAccounts(ctx context.Context, query *models.SearchOrgUsersQuery) ([]*ServiceAccountDTO, error) UpdateServiceAccount(ctx context.Context, orgID, serviceAccountID int64, saForm *UpdateServiceAccountForm) (*ServiceAccountProfileDTO, error) RetrieveServiceAccount(ctx context.Context, orgID, serviceAccountID int64) (*ServiceAccountProfileDTO, error) DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error diff --git a/pkg/services/serviceaccounts/tests/common.go b/pkg/services/serviceaccounts/tests/common.go index 0321741ce25..4b2a3731a19 100644 --- a/pkg/services/serviceaccounts/tests/common.go +++ b/pkg/services/serviceaccounts/tests/common.go @@ -77,6 +77,7 @@ type Calls struct { DeleteServiceAccountToken []interface{} UpdateServiceAccount []interface{} AddServiceAccountToken []interface{} + SearchOrgServiceAccounts []interface{} } type ServiceAccountsStoreMock struct { @@ -127,6 +128,11 @@ func (s *ServiceAccountsStoreMock) UpdateServiceAccount(ctx context.Context, return nil, nil } +func (s *ServiceAccountsStoreMock) SearchOrgServiceAccounts(ctx context.Context, query *models.SearchOrgUsersQuery) ([]*serviceaccounts.ServiceAccountDTO, error) { + s.Calls.SearchOrgServiceAccounts = append(s.Calls.SearchOrgServiceAccounts, []interface{}{ctx, query}) + return nil, nil +} + func (s *ServiceAccountsStoreMock) DeleteServiceAccountToken(ctx context.Context, orgID, serviceAccountID, tokenID int64) error { s.Calls.DeleteServiceAccountToken = append(s.Calls.DeleteServiceAccountToken, []interface{}{ctx, orgID, serviceAccountID, tokenID}) return nil diff --git a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx index f7e017e2864..75b8bc9894b 100644 --- a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx @@ -1,52 +1,52 @@ import React, { memo, useEffect } from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import { Button, ConfirmModal, Icon, LinkButton, useStyles2 } from '@grafana/ui'; +import { Button, ConfirmModal, FilterInput, Icon, LinkButton, RadioButtonGroup, useStyles2 } from '@grafana/ui'; import { css, cx } from '@emotion/css'; import Page from 'app/core/components/Page/Page'; import { StoreState, ServiceAccountDTO, AccessControlAction, Role } from 'app/types'; import { + changeFilter, + changeQuery, fetchACOptions, - loadServiceAccounts, + fetchServiceAccounts, removeServiceAccount, updateServiceAccount, setServiceAccountToRemove, } from './state/actions'; import { getNavModel } from 'app/core/selectors/navModel'; -import { getServiceAccounts, getServiceAccountsSearchPage, getServiceAccountsSearchQuery } from './state/selectors'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; import { GrafanaTheme2, OrgRole } from '@grafana/data'; import { contextSrv } from 'app/core/core'; import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; import { OrgRolePicker } from '../admin/OrgRolePicker'; import pluralize from 'pluralize'; -export type Props = ConnectedProps; + +interface OwnProps {} + +type Props = OwnProps & ConnectedProps; function mapStateToProps(state: StoreState) { return { navModel: getNavModel(state.navIndex, 'serviceaccounts'), - serviceAccounts: getServiceAccounts(state.serviceAccounts), - searchQuery: getServiceAccountsSearchQuery(state.serviceAccounts), - searchPage: getServiceAccountsSearchPage(state.serviceAccounts), - isLoading: state.serviceAccounts.isLoading, - roleOptions: state.serviceAccounts.roleOptions, - builtInRoles: state.serviceAccounts.builtInRoles, - toRemove: state.serviceAccounts.serviceAccountToRemove, + ...state.serviceAccounts, }; } const mapDispatchToProps = { - loadServiceAccounts, + fetchServiceAccounts, fetchACOptions, updateServiceAccount, removeServiceAccount, setServiceAccountToRemove, + changeFilter, + changeQuery, }; const connector = connect(mapStateToProps, mapDispatchToProps); const ServiceAccountsListPage = ({ - loadServiceAccounts, + fetchServiceAccounts, removeServiceAccount, fetchACOptions, updateServiceAccount, @@ -56,34 +56,51 @@ const ServiceAccountsListPage = ({ isLoading, roleOptions, builtInRoles, - toRemove, + changeFilter, + changeQuery, + query, + filters, + serviceAccountToRemove, }: Props) => { const styles = useStyles2(getStyles); useEffect(() => { - loadServiceAccounts(); + fetchServiceAccounts(); if (contextSrv.accessControlEnabled()) { fetchACOptions(); } - }, [loadServiceAccounts, fetchACOptions]); + }, [fetchServiceAccounts, fetchACOptions]); const onRoleChange = (role: OrgRole, serviceAccount: ServiceAccountDTO) => { const updatedServiceAccount = { ...serviceAccount, role: role }; - updateServiceAccount(updatedServiceAccount); }; - return (

Service accounts

- {contextSrv.hasPermission(AccessControlAction.ServiceAccountsCreate) && ( - - New service account - - )} + + changeFilter({ name: 'Expired', value })} + value={filters.find((f) => f.name === 'Expired')?.value} + className={styles.filter} + />
+ {contextSrv.hasPermission(AccessControlAction.ServiceAccountsCreate) && ( + + New service account + + )} {isLoading ? ( ) : ( @@ -116,13 +133,16 @@ const ServiceAccountsListPage = ({ )} - {toRemove && ( + {serviceAccountToRemove && ( - Are you sure you want to delete '{toRemove.name}' - {Boolean(toRemove.tokens) && - ` and ${toRemove.tokens} accompanying ${pluralize('token', toRemove.tokens)}`} + Are you sure you want to delete '{serviceAccountToRemove.name}' + {Boolean(serviceAccountToRemove.tokens) && + ` and ${serviceAccountToRemove.tokens} accompanying ${pluralize( + 'token', + serviceAccountToRemove.tokens + )}`} ? } @@ -133,7 +153,7 @@ const ServiceAccountsListPage = ({ }} isOpen={true} onConfirm={() => { - removeServiceAccount(toRemove.id); + removeServiceAccount(serviceAccountToRemove.id); setServiceAccountToRemove(null); }} /> diff --git a/public/app/features/serviceaccounts/state/actions.ts b/public/app/features/serviceaccounts/state/actions.ts index f8dccf7d1d1..0b8863c6cf4 100644 --- a/public/app/features/serviceaccounts/state/actions.ts +++ b/public/app/features/serviceaccounts/state/actions.ts @@ -1,15 +1,21 @@ -import { ApiKey, ServiceAccountDTO, ThunkResult } from '../../../types'; +import { ApiKey, ServiceAccountDTO, ThunkResult, ServiceAccountFilter } from '../../../types'; import { getBackendSrv, locationService } from '@grafana/runtime'; import { acOptionsLoaded, builtInRolesLoaded, + filterChanged, + pageChanged, + queryChanged, serviceAccountLoaded, - serviceAccountsLoaded, + serviceAccountsFetchBegin, + serviceAccountsFetchEnd, + serviceAccountsFetched, serviceAccountTokensLoaded, serviceAccountToRemoveLoaded, } from './reducers'; import { accessControlQueryParam } from 'app/core/utils/accessControl'; import { fetchBuiltinRoles, fetchRoleOptions } from 'app/core/components/RolePicker/api'; +import { debounce } from 'lodash'; const BASE_URL = `/api/serviceaccounts`; @@ -77,17 +83,6 @@ export function loadServiceAccountTokens(saID: number): ThunkResult { }; } -export function loadServiceAccounts(): ThunkResult { - return async (dispatch) => { - try { - const response = await getBackendSrv().get(BASE_URL, accessControlQueryParam()); - dispatch(serviceAccountsLoaded(response)); - } catch (error) { - console.error(error); - } - }; -} - export function updateServiceAccount(serviceAccount: ServiceAccountDTO): ThunkResult { return async (dispatch) => { const response = await getBackendSrv().patch(`${BASE_URL}/${serviceAccount.id}`, { ...serviceAccount }); @@ -98,7 +93,62 @@ export function updateServiceAccount(serviceAccount: ServiceAccountDTO): ThunkRe export function removeServiceAccount(serviceAccountId: number): ThunkResult { return async (dispatch) => { await getBackendSrv().delete(`${BASE_URL}/${serviceAccountId}`); - dispatch(loadServiceAccounts()); + dispatch(fetchServiceAccounts()); + }; +} + +// search / filtering of serviceAccounts +const getFilters = (filters: ServiceAccountFilter[]) => { + return filters + .map((filter) => { + if (Array.isArray(filter.value)) { + return filter.value.map((v) => `${filter.name}=${v.value}`).join('&'); + } + return `${filter.name}=${filter.value}`; + }) + .join('&'); +}; + +export function fetchServiceAccounts(): ThunkResult { + return async (dispatch, getState) => { + try { + const { perPage, page, query, filters } = getState().serviceAccounts; + const result = await getBackendSrv().get( + `/api/serviceaccounts/search?perpage=${perPage}&page=${page}&query=${query}&${getFilters( + filters + )}&accesscontrol=true` + ); + dispatch(serviceAccountsFetched(result)); + } catch (error) { + serviceAccountsFetchEnd(); + console.error(error); + } + }; +} + +const fetchServiceAccountsWithDebounce = debounce((dispatch) => dispatch(fetchServiceAccounts()), 500); + +export function changeQuery(query: string): ThunkResult { + return async (dispatch) => { + dispatch(serviceAccountsFetchBegin()); + dispatch(queryChanged(query)); + fetchServiceAccountsWithDebounce(dispatch); + }; +} + +export function changeFilter(filter: ServiceAccountFilter): ThunkResult { + return async (dispatch) => { + dispatch(serviceAccountsFetchBegin()); + dispatch(filterChanged(filter)); + fetchServiceAccountsWithDebounce(dispatch); + }; +} + +export function changePage(page: number): ThunkResult { + return async (dispatch) => { + dispatch(serviceAccountsFetchBegin()); + dispatch(pageChanged(page)); + dispatch(fetchServiceAccounts()); }; } diff --git a/public/app/features/serviceaccounts/state/reducers.ts b/public/app/features/serviceaccounts/state/reducers.ts index ee97055dfce..77be98add3f 100644 --- a/public/app/features/serviceaccounts/state/reducers.ts +++ b/public/app/features/serviceaccounts/state/reducers.ts @@ -1,17 +1,15 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { ApiKey, Role, ServiceAccountDTO, ServiceAccountProfileState, ServiceAccountsState } from 'app/types'; - -export const initialState: ServiceAccountsState = { - serviceAccounts: [] as ServiceAccountDTO[], - searchQuery: '', - searchPage: 1, - isLoading: true, - builtInRoles: {}, - roleOptions: [], - serviceAccountToRemove: null, -}; +import { + ApiKey, + Role, + ServiceAccountDTO, + ServiceAccountFilter, + ServiceAccountProfileState, + ServiceAccountsState, +} from 'app/types'; +// serviceAccountsProfilePage export const initialStateProfile: ServiceAccountProfileState = { serviceAccount: {} as ServiceAccountDTO, isLoading: true, @@ -31,19 +29,53 @@ export const serviceAccountProfileSlice = createSlice({ }, }); +export const serviceAccountProfileReducer = serviceAccountProfileSlice.reducer; +export const { serviceAccountLoaded, serviceAccountTokensLoaded } = serviceAccountProfileSlice.actions; + +// serviceAccountsListPage +export const initialStateList: ServiceAccountsState = { + serviceAccounts: [] as ServiceAccountDTO[], + isLoading: true, + builtInRoles: {}, + roleOptions: [], + serviceAccountToRemove: null, + query: '', + page: 0, + perPage: 50, + totalPages: 1, + showPaging: false, + filters: [{ name: 'Expired', value: true }], +}; + +interface ServiceAccountsFetched { + serviceAccounts: ServiceAccountDTO[]; + perPage: number; + page: number; + totalCount: number; +} + const serviceAccountsSlice = createSlice({ name: 'serviceaccounts', - initialState, + initialState: initialStateList, reducers: { - serviceAccountsLoaded: (state, action: PayloadAction): ServiceAccountsState => { - return { ...state, isLoading: false, serviceAccounts: action.payload }; + serviceAccountsFetched: (state, action: PayloadAction): ServiceAccountsState => { + const { totalCount, perPage, ...rest } = action.payload; + const totalPages = Math.ceil(totalCount / perPage); + + return { + ...state, + ...rest, + totalPages, + perPage, + showPaging: totalPages > 1, + isLoading: false, + }; }, - setServiceAccountsSearchQuery: (state, action: PayloadAction): ServiceAccountsState => { - // reset searchPage otherwise search results won't appear - return { ...state, searchQuery: action.payload, searchPage: initialState.searchPage }; + serviceAccountsFetchBegin: (state) => { + return { ...state, isLoading: true }; }, - setServiceAccountsSearchPage: (state, action: PayloadAction): ServiceAccountsState => { - return { ...state, searchPage: action.payload }; + serviceAccountsFetchEnd: (state) => { + return { ...state, isLoading: false }; }, acOptionsLoaded: (state, action: PayloadAction): ServiceAccountsState => { return { ...state, roleOptions: action.payload }; @@ -54,23 +86,47 @@ const serviceAccountsSlice = createSlice({ serviceAccountToRemoveLoaded: (state, action: PayloadAction): ServiceAccountsState => { return { ...state, serviceAccountToRemove: action.payload }; }, + queryChanged: (state, action: PayloadAction) => { + return { + ...state, + query: action.payload, + page: 0, + }; + }, + pageChanged: (state, action: PayloadAction) => ({ + ...state, + page: action.payload, + }), + filterChanged: (state, action: PayloadAction) => { + const { name, value } = action.payload; + + if (state.filters.some((filter) => filter.name === name)) { + return { + ...state, + filters: state.filters.map((filter) => (filter.name === name ? { ...filter, value } : filter)), + }; + } + return { + ...state, + filters: [...state.filters, action.payload], + }; + }, }, }); +export const serviceAccountsReducer = serviceAccountsSlice.reducer; export const { - setServiceAccountsSearchQuery, - setServiceAccountsSearchPage, - serviceAccountsLoaded, + serviceAccountsFetchBegin, + serviceAccountsFetchEnd, + serviceAccountsFetched, acOptionsLoaded, builtInRolesLoaded, serviceAccountToRemoveLoaded, + pageChanged, + filterChanged, + queryChanged, } = serviceAccountsSlice.actions; -export const { serviceAccountLoaded, serviceAccountTokensLoaded } = serviceAccountProfileSlice.actions; - -export const serviceAccountProfileReducer = serviceAccountProfileSlice.reducer; -export const serviceAccountsReducer = serviceAccountsSlice.reducer; - export default { serviceAccountProfile: serviceAccountProfileReducer, serviceAccounts: serviceAccountsReducer, diff --git a/public/app/features/serviceaccounts/state/selectors.ts b/public/app/features/serviceaccounts/state/selectors.ts deleted file mode 100644 index 25781a219fd..00000000000 --- a/public/app/features/serviceaccounts/state/selectors.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ServiceAccountsState } from 'app/types'; - -export const getServiceAccounts = (state: ServiceAccountsState) => { - const regex = new RegExp(state.searchQuery, 'i'); - - return state.serviceAccounts.filter((serviceaccount) => { - return regex.test(serviceaccount.name) || regex.test(serviceaccount.login); - }); -}; - -export const getServiceAccountsSearchQuery = (state: ServiceAccountsState) => state.searchQuery; -export const getServiceAccountsSearchPage = (state: ServiceAccountsState) => state.searchPage; diff --git a/public/app/types/serviceaccount.ts b/public/app/types/serviceaccount.ts index 1a5c728b0dc..40c4b533676 100644 --- a/public/app/types/serviceaccount.ts +++ b/public/app/types/serviceaccount.ts @@ -1,4 +1,4 @@ -import { WithAccessControlMetadata } from '@grafana/data'; +import { SelectableValue, WithAccessControlMetadata } from '@grafana/data'; import { ApiKey, OrgRole, Role } from '.'; export interface OrgServiceAccount extends WithAccessControlMetadata { @@ -43,12 +43,19 @@ export interface ServiceAccountProfileState { tokens: ApiKey[]; } +export type ServiceAccountFilter = Record; export interface ServiceAccountsState { serviceAccounts: ServiceAccountDTO[]; - searchQuery: string; - searchPage: number; isLoading: boolean; roleOptions: Role[]; serviceAccountToRemove: ServiceAccountDTO | null; builtInRoles: Record; + + // search / filtering + query: string; + perPage: number; + page: number; + totalPages: number; + showPaging: boolean; + filters: ServiceAccountFilter[]; }