Searchable service accounts (#45844)

* WIP

* draft of WIP

* feat: search and filtering works 🌈

* 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 <joao.guerreiro@grafana.com>

* WIP

* comment

* Update public/app/features/serviceaccounts/ServiceAccountsListPage.tsx

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* 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 <joao.guerreiro@grafana.com>
Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>
This commit is contained in:
Eric Leijonmarck 2022-03-04 12:04:07 +01:00 committed by GitHub
parent 45e4611807
commit 3d168eb34b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 354 additions and 90 deletions

View File

@ -60,6 +60,7 @@ func (api *ServiceAccountsAPI) RegisterAPIEndpoints(
auth := acmiddleware.Middleware(api.accesscontrol) auth := acmiddleware.Middleware(api.accesscontrol)
api.RouterRegister.Group("/api/serviceaccounts", func(serviceAccountsRoute routing.RouteRegister) { 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("/", 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, serviceAccountsRoute.Post("/", auth(middleware.ReqOrgAdmin,
accesscontrol.EvalPermission(serviceaccounts.ActionCreate)), routing.Wrap(api.CreateServiceAccount)) accesscontrol.EvalPermission(serviceaccounts.ActionCreate)), routing.Wrap(api.CreateServiceAccount))
serviceAccountsRoute.Get("/:serviceAccountId", auth(middleware.ReqOrgAdmin, 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)] serviceAccounts[i].AccessControl = metadata[strconv.FormatInt(serviceAccounts[i].Id, 10)]
} }
} }
return response.JSON(http.StatusOK, serviceAccounts) return response.JSON(http.StatusOK, serviceAccounts)
} }
@ -221,3 +221,58 @@ func (api *ServiceAccountsAPI) updateServiceAccount(c *models.ReqContext) respon
return response.JSON(http.StatusOK, resp) 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)
}

View File

@ -10,6 +10,8 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models" "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/serviceaccounts"
"github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore"
"xorm.io/xorm" "xorm.io/xorm"
@ -291,6 +293,85 @@ func (s *ServiceAccountsStoreImpl) UpdateServiceAccount(ctx context.Context,
return updatedUser, err 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 { func contains(s []int64, e int64) bool {
for _, a := range s { for _, a := range s {
if a == e { if a == e {

View File

@ -35,12 +35,12 @@ type CreateServiceAccountForm struct {
} }
type ServiceAccountDTO struct { type ServiceAccountDTO struct {
Id int64 `json:"id"` Id int64 `json:"id" xorm:"user_id"`
Name string `json:"name"` Name string `json:"name" xorm:"name"`
Login string `json:"login"` Login string `json:"login" xorm:"login"`
OrgId int64 `json:"orgId"` OrgId int64 `json:"orgId" xorm:"org_id"`
Role string `json:"role" xorm:"role"`
Tokens int64 `json:"tokens"` Tokens int64 `json:"tokens"`
Role string `json:"role"`
AvatarUrl string `json:"avatarUrl"` AvatarUrl string `json:"avatarUrl"`
AccessControl map[string]bool `json:"accessControl,omitempty"` AccessControl map[string]bool `json:"accessControl,omitempty"`
} }

View File

@ -15,6 +15,7 @@ type Service interface {
type Store interface { type Store interface {
CreateServiceAccount(ctx context.Context, saForm *CreateServiceAccountForm) (*ServiceAccountDTO, error) CreateServiceAccount(ctx context.Context, saForm *CreateServiceAccountForm) (*ServiceAccountDTO, error)
ListServiceAccounts(ctx context.Context, orgID, serviceAccountID int64) ([]*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) UpdateServiceAccount(ctx context.Context, orgID, serviceAccountID int64, saForm *UpdateServiceAccountForm) (*ServiceAccountProfileDTO, error)
RetrieveServiceAccount(ctx context.Context, orgID, serviceAccountID int64) (*ServiceAccountProfileDTO, error) RetrieveServiceAccount(ctx context.Context, orgID, serviceAccountID int64) (*ServiceAccountProfileDTO, error)
DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error

View File

@ -77,6 +77,7 @@ type Calls struct {
DeleteServiceAccountToken []interface{} DeleteServiceAccountToken []interface{}
UpdateServiceAccount []interface{} UpdateServiceAccount []interface{}
AddServiceAccountToken []interface{} AddServiceAccountToken []interface{}
SearchOrgServiceAccounts []interface{}
} }
type ServiceAccountsStoreMock struct { type ServiceAccountsStoreMock struct {
@ -127,6 +128,11 @@ func (s *ServiceAccountsStoreMock) UpdateServiceAccount(ctx context.Context,
return nil, nil 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 { func (s *ServiceAccountsStoreMock) DeleteServiceAccountToken(ctx context.Context, orgID, serviceAccountID, tokenID int64) error {
s.Calls.DeleteServiceAccountToken = append(s.Calls.DeleteServiceAccountToken, []interface{}{ctx, orgID, serviceAccountID, tokenID}) s.Calls.DeleteServiceAccountToken = append(s.Calls.DeleteServiceAccountToken, []interface{}{ctx, orgID, serviceAccountID, tokenID})
return nil return nil

View File

@ -1,52 +1,52 @@
import React, { memo, useEffect } from 'react'; import React, { memo, useEffect } from 'react';
import { connect, ConnectedProps } from 'react-redux'; 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 { css, cx } from '@emotion/css';
import Page from 'app/core/components/Page/Page'; import Page from 'app/core/components/Page/Page';
import { StoreState, ServiceAccountDTO, AccessControlAction, Role } from 'app/types'; import { StoreState, ServiceAccountDTO, AccessControlAction, Role } from 'app/types';
import { import {
changeFilter,
changeQuery,
fetchACOptions, fetchACOptions,
loadServiceAccounts, fetchServiceAccounts,
removeServiceAccount, removeServiceAccount,
updateServiceAccount, updateServiceAccount,
setServiceAccountToRemove, setServiceAccountToRemove,
} from './state/actions'; } from './state/actions';
import { getNavModel } from 'app/core/selectors/navModel'; import { getNavModel } from 'app/core/selectors/navModel';
import { getServiceAccounts, getServiceAccountsSearchPage, getServiceAccountsSearchQuery } from './state/selectors';
import PageLoader from 'app/core/components/PageLoader/PageLoader'; import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { GrafanaTheme2, OrgRole } from '@grafana/data'; import { GrafanaTheme2, OrgRole } from '@grafana/data';
import { contextSrv } from 'app/core/core'; import { contextSrv } from 'app/core/core';
import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
import { OrgRolePicker } from '../admin/OrgRolePicker'; import { OrgRolePicker } from '../admin/OrgRolePicker';
import pluralize from 'pluralize'; import pluralize from 'pluralize';
export type Props = ConnectedProps<typeof connector>;
interface OwnProps {}
type Props = OwnProps & ConnectedProps<typeof connector>;
function mapStateToProps(state: StoreState) { function mapStateToProps(state: StoreState) {
return { return {
navModel: getNavModel(state.navIndex, 'serviceaccounts'), navModel: getNavModel(state.navIndex, 'serviceaccounts'),
serviceAccounts: getServiceAccounts(state.serviceAccounts), ...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,
}; };
} }
const mapDispatchToProps = { const mapDispatchToProps = {
loadServiceAccounts, fetchServiceAccounts,
fetchACOptions, fetchACOptions,
updateServiceAccount, updateServiceAccount,
removeServiceAccount, removeServiceAccount,
setServiceAccountToRemove, setServiceAccountToRemove,
changeFilter,
changeQuery,
}; };
const connector = connect(mapStateToProps, mapDispatchToProps); const connector = connect(mapStateToProps, mapDispatchToProps);
const ServiceAccountsListPage = ({ const ServiceAccountsListPage = ({
loadServiceAccounts, fetchServiceAccounts,
removeServiceAccount, removeServiceAccount,
fetchACOptions, fetchACOptions,
updateServiceAccount, updateServiceAccount,
@ -56,34 +56,51 @@ const ServiceAccountsListPage = ({
isLoading, isLoading,
roleOptions, roleOptions,
builtInRoles, builtInRoles,
toRemove, changeFilter,
changeQuery,
query,
filters,
serviceAccountToRemove,
}: Props) => { }: Props) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
useEffect(() => { useEffect(() => {
loadServiceAccounts(); fetchServiceAccounts();
if (contextSrv.accessControlEnabled()) { if (contextSrv.accessControlEnabled()) {
fetchACOptions(); fetchACOptions();
} }
}, [loadServiceAccounts, fetchACOptions]); }, [fetchServiceAccounts, fetchACOptions]);
const onRoleChange = (role: OrgRole, serviceAccount: ServiceAccountDTO) => { const onRoleChange = (role: OrgRole, serviceAccount: ServiceAccountDTO) => {
const updatedServiceAccount = { ...serviceAccount, role: role }; const updatedServiceAccount = { ...serviceAccount, role: role };
updateServiceAccount(updatedServiceAccount); updateServiceAccount(updatedServiceAccount);
}; };
return ( return (
<Page navModel={navModel}> <Page navModel={navModel}>
<Page.Contents> <Page.Contents>
<h2>Service accounts</h2> <h2>Service accounts</h2>
<div className="page-action-bar" style={{ justifyContent: 'flex-end' }}> <div className="page-action-bar" style={{ justifyContent: 'flex-end' }}>
{contextSrv.hasPermission(AccessControlAction.ServiceAccountsCreate) && ( <FilterInput
<LinkButton href="org/serviceaccounts/create" variant="primary"> placeholder="Search service account by name."
New service account autoFocus={true}
</LinkButton> value={query}
)} onChange={changeQuery}
/>
<RadioButtonGroup
options={[
{ label: 'All service accounts', value: false },
{ label: 'Expired tokens', value: true },
]}
onChange={(value) => changeFilter({ name: 'Expired', value })}
value={filters.find((f) => f.name === 'Expired')?.value}
className={styles.filter}
/>
</div> </div>
{contextSrv.hasPermission(AccessControlAction.ServiceAccountsCreate) && (
<LinkButton href="org/serviceaccounts/create" variant="primary">
New service account
</LinkButton>
)}
{isLoading ? ( {isLoading ? (
<PageLoader /> <PageLoader />
) : ( ) : (
@ -116,13 +133,16 @@ const ServiceAccountsListPage = ({
</div> </div>
</> </>
)} )}
{toRemove && ( {serviceAccountToRemove && (
<ConfirmModal <ConfirmModal
body={ body={
<div> <div>
Are you sure you want to delete &apos;{toRemove.name}&apos; Are you sure you want to delete &apos;{serviceAccountToRemove.name}&apos;
{Boolean(toRemove.tokens) && {Boolean(serviceAccountToRemove.tokens) &&
` and ${toRemove.tokens} accompanying ${pluralize('token', toRemove.tokens)}`} ` and ${serviceAccountToRemove.tokens} accompanying ${pluralize(
'token',
serviceAccountToRemove.tokens
)}`}
? ?
</div> </div>
} }
@ -133,7 +153,7 @@ const ServiceAccountsListPage = ({
}} }}
isOpen={true} isOpen={true}
onConfirm={() => { onConfirm={() => {
removeServiceAccount(toRemove.id); removeServiceAccount(serviceAccountToRemove.id);
setServiceAccountToRemove(null); setServiceAccountToRemove(null);
}} }}
/> />

View File

@ -1,15 +1,21 @@
import { ApiKey, ServiceAccountDTO, ThunkResult } from '../../../types'; import { ApiKey, ServiceAccountDTO, ThunkResult, ServiceAccountFilter } from '../../../types';
import { getBackendSrv, locationService } from '@grafana/runtime'; import { getBackendSrv, locationService } from '@grafana/runtime';
import { import {
acOptionsLoaded, acOptionsLoaded,
builtInRolesLoaded, builtInRolesLoaded,
filterChanged,
pageChanged,
queryChanged,
serviceAccountLoaded, serviceAccountLoaded,
serviceAccountsLoaded, serviceAccountsFetchBegin,
serviceAccountsFetchEnd,
serviceAccountsFetched,
serviceAccountTokensLoaded, serviceAccountTokensLoaded,
serviceAccountToRemoveLoaded, serviceAccountToRemoveLoaded,
} from './reducers'; } from './reducers';
import { accessControlQueryParam } from 'app/core/utils/accessControl'; import { accessControlQueryParam } from 'app/core/utils/accessControl';
import { fetchBuiltinRoles, fetchRoleOptions } from 'app/core/components/RolePicker/api'; import { fetchBuiltinRoles, fetchRoleOptions } from 'app/core/components/RolePicker/api';
import { debounce } from 'lodash';
const BASE_URL = `/api/serviceaccounts`; const BASE_URL = `/api/serviceaccounts`;
@ -77,17 +83,6 @@ export function loadServiceAccountTokens(saID: number): ThunkResult<void> {
}; };
} }
export function loadServiceAccounts(): ThunkResult<void> {
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<void> { export function updateServiceAccount(serviceAccount: ServiceAccountDTO): ThunkResult<void> {
return async (dispatch) => { return async (dispatch) => {
const response = await getBackendSrv().patch(`${BASE_URL}/${serviceAccount.id}`, { ...serviceAccount }); 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<void> { export function removeServiceAccount(serviceAccountId: number): ThunkResult<void> {
return async (dispatch) => { return async (dispatch) => {
await getBackendSrv().delete(`${BASE_URL}/${serviceAccountId}`); 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<void> {
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<void> {
return async (dispatch) => {
dispatch(serviceAccountsFetchBegin());
dispatch(queryChanged(query));
fetchServiceAccountsWithDebounce(dispatch);
};
}
export function changeFilter(filter: ServiceAccountFilter): ThunkResult<void> {
return async (dispatch) => {
dispatch(serviceAccountsFetchBegin());
dispatch(filterChanged(filter));
fetchServiceAccountsWithDebounce(dispatch);
};
}
export function changePage(page: number): ThunkResult<void> {
return async (dispatch) => {
dispatch(serviceAccountsFetchBegin());
dispatch(pageChanged(page));
dispatch(fetchServiceAccounts());
}; };
} }

View File

@ -1,17 +1,15 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { ApiKey, Role, ServiceAccountDTO, ServiceAccountProfileState, ServiceAccountsState } from 'app/types'; import {
ApiKey,
export const initialState: ServiceAccountsState = { Role,
serviceAccounts: [] as ServiceAccountDTO[], ServiceAccountDTO,
searchQuery: '', ServiceAccountFilter,
searchPage: 1, ServiceAccountProfileState,
isLoading: true, ServiceAccountsState,
builtInRoles: {}, } from 'app/types';
roleOptions: [],
serviceAccountToRemove: null,
};
// serviceAccountsProfilePage
export const initialStateProfile: ServiceAccountProfileState = { export const initialStateProfile: ServiceAccountProfileState = {
serviceAccount: {} as ServiceAccountDTO, serviceAccount: {} as ServiceAccountDTO,
isLoading: true, 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({ const serviceAccountsSlice = createSlice({
name: 'serviceaccounts', name: 'serviceaccounts',
initialState, initialState: initialStateList,
reducers: { reducers: {
serviceAccountsLoaded: (state, action: PayloadAction<ServiceAccountDTO[]>): ServiceAccountsState => { serviceAccountsFetched: (state, action: PayloadAction<ServiceAccountsFetched>): ServiceAccountsState => {
return { ...state, isLoading: false, serviceAccounts: action.payload }; 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<string>): ServiceAccountsState => { serviceAccountsFetchBegin: (state) => {
// reset searchPage otherwise search results won't appear return { ...state, isLoading: true };
return { ...state, searchQuery: action.payload, searchPage: initialState.searchPage };
}, },
setServiceAccountsSearchPage: (state, action: PayloadAction<number>): ServiceAccountsState => { serviceAccountsFetchEnd: (state) => {
return { ...state, searchPage: action.payload }; return { ...state, isLoading: false };
}, },
acOptionsLoaded: (state, action: PayloadAction<Role[]>): ServiceAccountsState => { acOptionsLoaded: (state, action: PayloadAction<Role[]>): ServiceAccountsState => {
return { ...state, roleOptions: action.payload }; return { ...state, roleOptions: action.payload };
@ -54,23 +86,47 @@ const serviceAccountsSlice = createSlice({
serviceAccountToRemoveLoaded: (state, action: PayloadAction<ServiceAccountDTO | null>): ServiceAccountsState => { serviceAccountToRemoveLoaded: (state, action: PayloadAction<ServiceAccountDTO | null>): ServiceAccountsState => {
return { ...state, serviceAccountToRemove: action.payload }; return { ...state, serviceAccountToRemove: action.payload };
}, },
queryChanged: (state, action: PayloadAction<string>) => {
return {
...state,
query: action.payload,
page: 0,
};
},
pageChanged: (state, action: PayloadAction<number>) => ({
...state,
page: action.payload,
}),
filterChanged: (state, action: PayloadAction<ServiceAccountFilter>) => {
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 { export const {
setServiceAccountsSearchQuery, serviceAccountsFetchBegin,
setServiceAccountsSearchPage, serviceAccountsFetchEnd,
serviceAccountsLoaded, serviceAccountsFetched,
acOptionsLoaded, acOptionsLoaded,
builtInRolesLoaded, builtInRolesLoaded,
serviceAccountToRemoveLoaded, serviceAccountToRemoveLoaded,
pageChanged,
filterChanged,
queryChanged,
} = serviceAccountsSlice.actions; } = serviceAccountsSlice.actions;
export const { serviceAccountLoaded, serviceAccountTokensLoaded } = serviceAccountProfileSlice.actions;
export const serviceAccountProfileReducer = serviceAccountProfileSlice.reducer;
export const serviceAccountsReducer = serviceAccountsSlice.reducer;
export default { export default {
serviceAccountProfile: serviceAccountProfileReducer, serviceAccountProfile: serviceAccountProfileReducer,
serviceAccounts: serviceAccountsReducer, serviceAccounts: serviceAccountsReducer,

View File

@ -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;

View File

@ -1,4 +1,4 @@
import { WithAccessControlMetadata } from '@grafana/data'; import { SelectableValue, WithAccessControlMetadata } from '@grafana/data';
import { ApiKey, OrgRole, Role } from '.'; import { ApiKey, OrgRole, Role } from '.';
export interface OrgServiceAccount extends WithAccessControlMetadata { export interface OrgServiceAccount extends WithAccessControlMetadata {
@ -43,12 +43,19 @@ export interface ServiceAccountProfileState {
tokens: ApiKey[]; tokens: ApiKey[];
} }
export type ServiceAccountFilter = Record<string, string | boolean | SelectableValue[]>;
export interface ServiceAccountsState { export interface ServiceAccountsState {
serviceAccounts: ServiceAccountDTO[]; serviceAccounts: ServiceAccountDTO[];
searchQuery: string;
searchPage: number;
isLoading: boolean; isLoading: boolean;
roleOptions: Role[]; roleOptions: Role[];
serviceAccountToRemove: ServiceAccountDTO | null; serviceAccountToRemove: ServiceAccountDTO | null;
builtInRoles: Record<string, Role[]>; builtInRoles: Record<string, Role[]>;
// search / filtering
query: string;
perPage: number;
page: number;
totalPages: number;
showPaging: boolean;
filters: ServiceAccountFilter[];
} }