mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
45e4611807
commit
3d168eb34b
@ -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)
|
||||||
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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 '{toRemove.name}'
|
Are you sure you want to delete '{serviceAccountToRemove.name}'
|
||||||
{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);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -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());
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
|
@ -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[];
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user