mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Serviceaccounts: feat - tabview for serviceaccounts (#43573)
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/services/query"
|
"github.com/grafana/grafana/pkg/services/query"
|
||||||
|
"github.com/grafana/grafana/pkg/services/serviceaccounts"
|
||||||
"github.com/grafana/grafana/pkg/services/thumbs"
|
"github.com/grafana/grafana/pkg/services/thumbs"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/api/routing"
|
"github.com/grafana/grafana/pkg/api/routing"
|
||||||
@@ -113,6 +114,7 @@ type HTTPServer struct {
|
|||||||
updateChecker *updatechecker.Service
|
updateChecker *updatechecker.Service
|
||||||
searchUsersService searchusers.Service
|
searchUsersService searchusers.Service
|
||||||
queryDataService *query.Service
|
queryDataService *query.Service
|
||||||
|
serviceAccountsService serviceaccounts.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerOptions struct {
|
type ServerOptions struct {
|
||||||
@@ -137,7 +139,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
|
|||||||
quotaService *quota.QuotaService, socialService social.Service, tracingService tracing.Tracer,
|
quotaService *quota.QuotaService, socialService social.Service, tracingService tracing.Tracer,
|
||||||
encryptionService encryption.Internal, updateChecker *updatechecker.Service, searchUsersService searchusers.Service,
|
encryptionService encryption.Internal, updateChecker *updatechecker.Service, searchUsersService searchusers.Service,
|
||||||
dataSourcesService *datasources.Service, secretsService secrets.Service,
|
dataSourcesService *datasources.Service, secretsService secrets.Service,
|
||||||
queryDataService *query.Service) (*HTTPServer, error) {
|
queryDataService *query.Service, serviceaccountsService serviceaccounts.Service) (*HTTPServer, error) {
|
||||||
web.Env = cfg.Env
|
web.Env = cfg.Env
|
||||||
m := web.New()
|
m := web.New()
|
||||||
|
|
||||||
@@ -189,6 +191,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
|
|||||||
DataSourcesService: dataSourcesService,
|
DataSourcesService: dataSourcesService,
|
||||||
searchUsersService: searchUsersService,
|
searchUsersService: searchUsersService,
|
||||||
queryDataService: queryDataService,
|
queryDataService: queryDataService,
|
||||||
|
serviceAccountsService: serviceaccountsService,
|
||||||
}
|
}
|
||||||
if hs.Listener != nil {
|
if hs.Listener != nil {
|
||||||
hs.log.Debug("Using provided listener")
|
hs.log.Debug("Using provided listener")
|
||||||
|
|||||||
@@ -142,6 +142,14 @@ func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error)
|
|||||||
return appLinks, nil
|
return appLinks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func enableServiceAccount(hs *HTTPServer, c *models.ReqContext) bool {
|
||||||
|
return c.OrgRole == models.ROLE_ADMIN && hs.Cfg.IsServiceAccountEnabled() && hs.serviceAccountsService.Migrated(c.Req.Context(), c.OrgId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func enableTeams(hs *HTTPServer, c *models.ReqContext) bool {
|
||||||
|
return c.OrgRole == models.ROLE_ADMIN || (hs.Cfg.EditorsCanAdmin && c.OrgRole == models.ROLE_EDITOR)
|
||||||
|
}
|
||||||
|
|
||||||
func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dtos.NavLink, error) {
|
func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dtos.NavLink, error) {
|
||||||
hasAccess := ac.HasAccess(hs.AccessControl, c)
|
hasAccess := ac.HasAccess(hs.AccessControl, c)
|
||||||
navTree := []*dtos.NavLink{}
|
navTree := []*dtos.NavLink{}
|
||||||
@@ -253,7 +261,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.OrgRole == models.ROLE_ADMIN || (hs.Cfg.EditorsCanAdmin && c.OrgRole == models.ROLE_EDITOR) {
|
if enableTeams(hs, c) {
|
||||||
configNodes = append(configNodes, &dtos.NavLink{
|
configNodes = append(configNodes, &dtos.NavLink{
|
||||||
Text: "Teams",
|
Text: "Teams",
|
||||||
Id: "teams",
|
Id: "teams",
|
||||||
@@ -292,6 +300,17 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
|
|||||||
Url: hs.Cfg.AppSubURL + "/org/apikeys",
|
Url: hs.Cfg.AppSubURL + "/org/apikeys",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// needs both feature flag and migration to be able to show service accounts
|
||||||
|
if enableServiceAccount(hs, c) {
|
||||||
|
configNodes = append(configNodes, &dtos.NavLink{
|
||||||
|
Text: "Service accounts",
|
||||||
|
Id: "serviceaccounts",
|
||||||
|
Description: "Manage service accounts",
|
||||||
|
// TODO: change icon to "key-skeleton-alt" when it's available
|
||||||
|
Icon: "key-skeleton-alt",
|
||||||
|
Url: hs.Cfg.AppSubURL + "/org/serviceaccounts",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if hs.Cfg.FeatureToggles["live-pipeline"] {
|
if hs.Cfg.FeatureToggles["live-pipeline"] {
|
||||||
liveNavLinks := []*dtos.NavLink{}
|
liveNavLinks := []*dtos.NavLink{}
|
||||||
|
|||||||
@@ -61,3 +61,9 @@ func (sa *ServiceAccountsService) DeleteServiceAccount(ctx context.Context, orgI
|
|||||||
}
|
}
|
||||||
return sa.store.DeleteServiceAccount(ctx, orgID, serviceAccountID)
|
return sa.store.DeleteServiceAccount(ctx, orgID, serviceAccountID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (sa *ServiceAccountsService) Migrated(ctx context.Context, orgID int64) bool {
|
||||||
|
// TODO: implement migration logic
|
||||||
|
// change this to return true for development of service accounts page
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
type Service interface {
|
type Service interface {
|
||||||
CreateServiceAccount(ctx context.Context, saForm *CreateServiceaccountForm) (*models.User, error)
|
CreateServiceAccount(ctx context.Context, saForm *CreateServiceaccountForm) (*models.User, error)
|
||||||
DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error
|
DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error
|
||||||
|
Migrated(ctx context.Context, orgID int64) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Store interface {
|
type Store interface {
|
||||||
CreateServiceAccount(ctx context.Context, saForm *CreateServiceaccountForm) (*models.User, error)
|
CreateServiceAccount(ctx context.Context, saForm *CreateServiceaccountForm) (*models.User, error)
|
||||||
DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error
|
DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ func (s *ServiceAccountMock) DeleteServiceAccount(ctx context.Context, orgID, se
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ServiceAccountMock) Migrated(ctx context.Context, orgID int64) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func SetupMockAccesscontrol(t *testing.T, userpermissionsfunc func(c context.Context, siu *models.SignedInUser) ([]*accesscontrol.Permission, error), disableAccessControl bool) *accesscontrolmock.Mock {
|
func SetupMockAccesscontrol(t *testing.T, userpermissionsfunc func(c context.Context, siu *models.SignedInUser) ([]*accesscontrol.Permission, error), disableAccessControl bool) *accesscontrolmock.Mock {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
acmock := accesscontrolmock.New()
|
acmock := accesscontrolmock.New()
|
||||||
|
|||||||
@@ -459,6 +459,10 @@ func (cfg Cfg) IsNewNavigationEnabled() bool {
|
|||||||
return cfg.FeatureToggles["newNavigation"]
|
return cfg.FeatureToggles["newNavigation"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cfg Cfg) IsServiceAccountEnabled() bool {
|
||||||
|
return cfg.FeatureToggles["service-accounts"]
|
||||||
|
}
|
||||||
|
|
||||||
type CommandLineArgs struct {
|
type CommandLineArgs struct {
|
||||||
Config string
|
Config string
|
||||||
HomePath string
|
HomePath string
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import templatingReducers from 'app/features/variables/state/reducers';
|
|||||||
import importDashboardReducers from 'app/features/manage-dashboards/state/reducers';
|
import importDashboardReducers from 'app/features/manage-dashboards/state/reducers';
|
||||||
import panelEditorReducers from 'app/features/dashboard/components/PanelEditor/state/reducers';
|
import panelEditorReducers from 'app/features/dashboard/components/PanelEditor/state/reducers';
|
||||||
import panelsReducers from 'app/features/panel/state/reducers';
|
import panelsReducers from 'app/features/panel/state/reducers';
|
||||||
|
import serviceAccountsReducer from 'app/features/serviceaccounts/state/reducers';
|
||||||
|
|
||||||
const rootReducers = {
|
const rootReducers = {
|
||||||
...sharedReducers,
|
...sharedReducers,
|
||||||
@@ -28,6 +29,7 @@ const rootReducers = {
|
|||||||
...exploreReducers,
|
...exploreReducers,
|
||||||
...dataSourcesReducers,
|
...dataSourcesReducers,
|
||||||
...usersReducers,
|
...usersReducers,
|
||||||
|
...serviceAccountsReducer,
|
||||||
...userReducers,
|
...userReducers,
|
||||||
...organizationReducers,
|
...organizationReducers,
|
||||||
...ldapReducers,
|
...ldapReducers,
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import React, { PureComponent } from 'react';
|
||||||
|
import { connect, ConnectedProps } from 'react-redux';
|
||||||
|
import { HorizontalGroup, Pagination, VerticalGroup } from '@grafana/ui';
|
||||||
|
|
||||||
|
import Page from 'app/core/components/Page/Page';
|
||||||
|
import ServiceAccountsTable from './ServiceAccountsTable';
|
||||||
|
import { OrgServiceAccount, OrgRole, StoreState } from 'app/types';
|
||||||
|
import { loadServiceAccounts, removeServiceAccount, updateServiceAccount } from './state/actions';
|
||||||
|
import { getNavModel } from 'app/core/selectors/navModel';
|
||||||
|
import { getServiceAccounts, getServiceAccountsSearchPage, getServiceAccountsSearchQuery } from './state/selectors';
|
||||||
|
import { setServiceAccountsSearchPage } from './state/reducers';
|
||||||
|
export type Props = ConnectedProps<typeof connector>;
|
||||||
|
|
||||||
|
export interface State {}
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 30;
|
||||||
|
|
||||||
|
export class ServiceAccountsListPage extends PureComponent<Props, State> {
|
||||||
|
componentDidMount() {
|
||||||
|
this.fetchServiceAccounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchServiceAccounts() {
|
||||||
|
return this.props.loadServiceAccounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
onRoleChange = (role: OrgRole, serviceAccount: OrgServiceAccount) => {
|
||||||
|
const updatedServiceAccount = { ...serviceAccount, role: role };
|
||||||
|
|
||||||
|
this.props.updateServiceAccount(updatedServiceAccount);
|
||||||
|
};
|
||||||
|
|
||||||
|
getPaginatedServiceAccounts = (serviceAccounts: OrgServiceAccount[]) => {
|
||||||
|
const offset = (this.props.searchPage - 1) * ITEMS_PER_PAGE;
|
||||||
|
return serviceAccounts.slice(offset, offset + ITEMS_PER_PAGE);
|
||||||
|
};
|
||||||
|
|
||||||
|
renderTable() {
|
||||||
|
const { serviceAccounts } = this.props;
|
||||||
|
const paginatedServiceAccounts = this.getPaginatedServiceAccounts(serviceAccounts);
|
||||||
|
const totalPages = Math.ceil(serviceAccounts.length / ITEMS_PER_PAGE);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VerticalGroup spacing="md">
|
||||||
|
<ServiceAccountsTable
|
||||||
|
serviceAccounts={paginatedServiceAccounts}
|
||||||
|
onRoleChange={(role, serviceAccount) => this.onRoleChange(role, serviceAccount)}
|
||||||
|
onRemoveServiceaccount={(serviceAccount) => this.props.removeServiceAccount(serviceAccount.serviceAccountId)}
|
||||||
|
/>
|
||||||
|
<HorizontalGroup justify="flex-end">
|
||||||
|
<Pagination
|
||||||
|
onNavigate={setServiceAccountsSearchPage}
|
||||||
|
currentPage={this.props.searchPage}
|
||||||
|
numberOfPages={totalPages}
|
||||||
|
hideWhenSinglePage={true}
|
||||||
|
/>
|
||||||
|
</HorizontalGroup>
|
||||||
|
</VerticalGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { navModel, hasFetched } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page navModel={navModel}>
|
||||||
|
<Page.Contents isLoading={!hasFetched}>
|
||||||
|
<>{hasFetched && this.renderTable()}</>
|
||||||
|
</Page.Contents>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStateToProps(state: StoreState) {
|
||||||
|
return {
|
||||||
|
navModel: getNavModel(state.navIndex, 'serviceaccounts'),
|
||||||
|
serviceAccounts: getServiceAccounts(state.serviceAccounts),
|
||||||
|
searchQuery: getServiceAccountsSearchQuery(state.serviceAccounts),
|
||||||
|
searchPage: getServiceAccountsSearchPage(state.serviceAccounts),
|
||||||
|
hasFetched: state.serviceAccounts.isLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
loadServiceAccounts,
|
||||||
|
updateServiceAccount,
|
||||||
|
removeServiceAccount,
|
||||||
|
};
|
||||||
|
|
||||||
|
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||||
|
|
||||||
|
export default connector(ServiceAccountsListPage);
|
||||||
133
public/app/features/serviceaccounts/ServiceAccountsTable.tsx
Normal file
133
public/app/features/serviceaccounts/ServiceAccountsTable.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import React, { FC, useEffect, useState } from 'react';
|
||||||
|
import { AccessControlAction, Role, OrgServiceAccount } from 'app/types';
|
||||||
|
import { OrgRolePicker } from '../admin/OrgRolePicker';
|
||||||
|
import { Button, ConfirmModal } from '@grafana/ui';
|
||||||
|
import { OrgRole } from '@grafana/data';
|
||||||
|
import { contextSrv } from 'app/core/core';
|
||||||
|
import { fetchBuiltinRoles, fetchRoleOptions, UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
serviceAccounts: OrgServiceAccount[];
|
||||||
|
orgId?: number;
|
||||||
|
onRoleChange: (role: OrgRole, serviceaccount: OrgServiceAccount) => void;
|
||||||
|
onRemoveServiceaccount: (serviceaccount: OrgServiceAccount) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ServiceaccountsTable: FC<Props> = (props) => {
|
||||||
|
const { serviceAccounts, orgId, onRoleChange, onRemoveServiceaccount: onRemoveserviceaccount } = props;
|
||||||
|
const canUpdateRole = contextSrv.hasPermission(AccessControlAction.OrgUsersRoleUpdate);
|
||||||
|
const canRemoveFromOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersRemove);
|
||||||
|
const rolePickerDisabled = !canUpdateRole;
|
||||||
|
|
||||||
|
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
||||||
|
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
|
||||||
|
const [builtinRoles, setBuiltinRoles] = useState<Record<string, Role[]>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchOptions() {
|
||||||
|
try {
|
||||||
|
let options = await fetchRoleOptions(orgId);
|
||||||
|
setRoleOptions(options);
|
||||||
|
const builtInRoles = await fetchBuiltinRoles(orgId);
|
||||||
|
setBuiltinRoles(builtInRoles);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading options');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (contextSrv.accessControlEnabled()) {
|
||||||
|
fetchOptions();
|
||||||
|
}
|
||||||
|
}, [orgId]);
|
||||||
|
|
||||||
|
const getRoleOptions = async () => roleOptions;
|
||||||
|
const getBuiltinRoles = async () => builtinRoles;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="filter-table form-inline">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th />
|
||||||
|
<th>Login</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Seen</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th style={{ width: '34px' }} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{serviceAccounts.map((serviceAccount, index) => {
|
||||||
|
return (
|
||||||
|
<tr key={`${serviceAccount.serviceAccountId}-${index}`}>
|
||||||
|
<td className="width-2 text-center">
|
||||||
|
<img className="filter-table__avatar" src={serviceAccount.avatarUrl} alt="serviceaccount avatar" />
|
||||||
|
</td>
|
||||||
|
<td className="max-width-6">
|
||||||
|
<span className="ellipsis" title={serviceAccount.login}>
|
||||||
|
{serviceAccount.login}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="max-width-5">
|
||||||
|
<span className="ellipsis" title={serviceAccount.email}>
|
||||||
|
{serviceAccount.email}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="max-width-5">
|
||||||
|
<span className="ellipsis" title={serviceAccount.name}>
|
||||||
|
{serviceAccount.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="width-1">{serviceAccount.lastSeenAtAge}</td>
|
||||||
|
|
||||||
|
<td className="width-8">
|
||||||
|
{contextSrv.accessControlEnabled() ? (
|
||||||
|
<UserRolePicker
|
||||||
|
userId={serviceAccount.serviceAccountId}
|
||||||
|
orgId={orgId}
|
||||||
|
builtInRole={serviceAccount.role}
|
||||||
|
onBuiltinRoleChange={(newRole) => onRoleChange(newRole, serviceAccount)}
|
||||||
|
getRoleOptions={getRoleOptions}
|
||||||
|
getBuiltinRoles={getBuiltinRoles}
|
||||||
|
disabled={rolePickerDisabled}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<OrgRolePicker
|
||||||
|
aria-label="Role"
|
||||||
|
value={serviceAccount.role}
|
||||||
|
disabled={!canUpdateRole}
|
||||||
|
onChange={(newRole) => onRoleChange(newRole, serviceAccount)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{canRemoveFromOrg && (
|
||||||
|
<td>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setShowRemoveModal(Boolean(serviceAccount.login))}
|
||||||
|
icon="times"
|
||||||
|
aria-label="Delete serviceaccount"
|
||||||
|
/>
|
||||||
|
<ConfirmModal
|
||||||
|
body={`Are you sure you want to delete serviceaccount ${serviceAccount.login}?`}
|
||||||
|
confirmText="Delete"
|
||||||
|
title="Delete"
|
||||||
|
onDismiss={() => setShowRemoveModal(false)}
|
||||||
|
isOpen={Boolean(serviceAccount.login) === showRemoveModal}
|
||||||
|
onConfirm={() => {
|
||||||
|
onRemoveserviceaccount(serviceAccount);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServiceaccountsTable;
|
||||||
28
public/app/features/serviceaccounts/state/actions.ts
Normal file
28
public/app/features/serviceaccounts/state/actions.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { ThunkResult } from '../../../types';
|
||||||
|
import { getBackendSrv } from '@grafana/runtime';
|
||||||
|
import { OrgServiceAccount as OrgServiceAccount } from 'app/types';
|
||||||
|
import { serviceAccountsLoaded } from './reducers';
|
||||||
|
|
||||||
|
export function loadServiceAccounts(): ThunkResult<void> {
|
||||||
|
return async (dispatch) => {
|
||||||
|
const serviceAccounts = await getBackendSrv().get('/api/serviceaccounts');
|
||||||
|
dispatch(serviceAccountsLoaded(serviceAccounts));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateServiceAccount(serviceAccount: OrgServiceAccount): ThunkResult<void> {
|
||||||
|
return async (dispatch) => {
|
||||||
|
// TODO: implement on backend
|
||||||
|
await getBackendSrv().patch(`/api/serviceaccounts/${serviceAccount.serviceAccountId}`, {
|
||||||
|
role: serviceAccount.role,
|
||||||
|
});
|
||||||
|
dispatch(loadServiceAccounts());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeServiceAccount(serviceAccountId: number): ThunkResult<void> {
|
||||||
|
return async (dispatch) => {
|
||||||
|
await getBackendSrv().delete(`/api/serviceaccounts/${serviceAccountId}`);
|
||||||
|
dispatch(loadServiceAccounts());
|
||||||
|
};
|
||||||
|
}
|
||||||
39
public/app/features/serviceaccounts/state/reducers.ts
Normal file
39
public/app/features/serviceaccounts/state/reducers.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
import { OrgServiceAccount, ServiceAccountsState } from 'app/types';
|
||||||
|
|
||||||
|
export const initialState: ServiceAccountsState = {
|
||||||
|
serviceAccounts: [] as OrgServiceAccount[],
|
||||||
|
searchQuery: '',
|
||||||
|
searchPage: 1,
|
||||||
|
isLoading: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const serviceAccountsSlice = createSlice({
|
||||||
|
name: 'serviceaccounts',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
serviceAccountsLoaded: (state, action: PayloadAction<OrgServiceAccount[]>): ServiceAccountsState => {
|
||||||
|
return { ...state, isLoading: true, serviceAccounts: action.payload };
|
||||||
|
},
|
||||||
|
setServiceAccountsSearchQuery: (state, action: PayloadAction<string>): ServiceAccountsState => {
|
||||||
|
// reset searchPage otherwise search results won't appear
|
||||||
|
return { ...state, searchQuery: action.payload, searchPage: initialState.searchPage };
|
||||||
|
},
|
||||||
|
setServiceAccountsSearchPage: (state, action: PayloadAction<number>): ServiceAccountsState => {
|
||||||
|
return { ...state, searchPage: action.payload };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
setServiceAccountsSearchQuery,
|
||||||
|
setServiceAccountsSearchPage,
|
||||||
|
serviceAccountsLoaded,
|
||||||
|
} = serviceAccountsSlice.actions;
|
||||||
|
|
||||||
|
export const serviceAccountsReducer = serviceAccountsSlice.reducer;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
serviceAccounts: serviceAccountsReducer,
|
||||||
|
};
|
||||||
12
public/app/features/serviceaccounts/state/selectors.ts
Normal file
12
public/app/features/serviceaccounts/state/selectors.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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.login) || regex.test(serviceaccount.email) || regex.test(serviceaccount.name);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getServiceAccountsSearchQuery = (state: ServiceAccountsState) => state.searchQuery;
|
||||||
|
export const getServiceAccountsSearchPage = (state: ServiceAccountsState) => state.searchPage;
|
||||||
@@ -16,7 +16,7 @@ export interface Props {
|
|||||||
const UsersTable: FC<Props> = (props) => {
|
const UsersTable: FC<Props> = (props) => {
|
||||||
const { users, orgId, onRoleChange, onRemoveUser } = props;
|
const { users, orgId, onRoleChange, onRemoveUser } = props;
|
||||||
|
|
||||||
const [showRemoveModal, setShowRemoveModal] = useState<string | boolean>(false);
|
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
||||||
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
|
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
|
||||||
const [builtinRoles, setBuiltinRoles] = useState<{ [key: string]: Role[] }>({});
|
const [builtinRoles, setBuiltinRoles] = useState<{ [key: string]: Role[] }>({});
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ const UsersTable: FC<Props> = (props) => {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => setShowRemoveModal(user.login)}
|
onClick={() => setShowRemoveModal(Boolean(user.login))}
|
||||||
icon="times"
|
icon="times"
|
||||||
aria-label="Delete user"
|
aria-label="Delete user"
|
||||||
/>
|
/>
|
||||||
@@ -112,7 +112,7 @@ const UsersTable: FC<Props> = (props) => {
|
|||||||
confirmText="Delete"
|
confirmText="Delete"
|
||||||
title="Delete"
|
title="Delete"
|
||||||
onDismiss={() => setShowRemoveModal(false)}
|
onDismiss={() => setShowRemoveModal(false)}
|
||||||
isOpen={user.login === showRemoveModal}
|
isOpen={Boolean(user.login) === showRemoveModal}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
onRemoveUser(user);
|
onRemoveUser(user);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -182,6 +182,14 @@ export function getAppRoutes(): RouteDescriptor[] {
|
|||||||
() => import(/* webpackChunkName: "ApiKeysPage" */ 'app/features/api-keys/ApiKeysPage')
|
() => import(/* webpackChunkName: "ApiKeysPage" */ 'app/features/api-keys/ApiKeysPage')
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/org/serviceaccounts',
|
||||||
|
roles: () => ['Editor', 'Admin'],
|
||||||
|
component: SafeDynamicImport(
|
||||||
|
() =>
|
||||||
|
import(/* webpackChunkName: "ServiceAccountsPage" */ 'app/features/serviceaccounts/ServiceAccountsListPage')
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/org/teams',
|
path: '/org/teams',
|
||||||
roles: () => (config.editorsCanAdmin ? [] : ['Editor', 'Admin']),
|
roles: () => (config.editorsCanAdmin ? [] : ['Editor', 'Admin']),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export * from './dashboard';
|
|||||||
export * from './acl';
|
export * from './acl';
|
||||||
export * from './apiKeys';
|
export * from './apiKeys';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
|
export * from './serviceaccount';
|
||||||
export * from './datasources';
|
export * from './datasources';
|
||||||
export * from './plugins';
|
export * from './plugins';
|
||||||
export * from './organization';
|
export * from './organization';
|
||||||
|
|||||||
76
public/app/types/serviceaccount.ts
Normal file
76
public/app/types/serviceaccount.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { OrgRole, Unit } from '.';
|
||||||
|
import { SelectableValue } from '@grafana/data';
|
||||||
|
|
||||||
|
export interface OrgServiceAccount {
|
||||||
|
avatarUrl: string;
|
||||||
|
email: string;
|
||||||
|
lastSeenAt: string;
|
||||||
|
lastSeenAtAge: string;
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
orgId: number;
|
||||||
|
role: OrgRole;
|
||||||
|
serviceAccountId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceAccount {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
login: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
orgId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceAccountDTO {
|
||||||
|
id: number;
|
||||||
|
login: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
isGrafanaAdmin: boolean;
|
||||||
|
isDisabled: boolean;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
updatedAt?: string;
|
||||||
|
authLabels?: string[];
|
||||||
|
avatarUrl?: string;
|
||||||
|
orgId?: number;
|
||||||
|
lastSeenAtAge?: string;
|
||||||
|
licensedRole?: string;
|
||||||
|
permissions?: string[];
|
||||||
|
teams?: Unit[];
|
||||||
|
orgs?: Unit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceAccountsState {
|
||||||
|
serviceAccounts: OrgServiceAccount[];
|
||||||
|
searchQuery: string;
|
||||||
|
searchPage: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceAccountSession {
|
||||||
|
id: number;
|
||||||
|
createdAt: string;
|
||||||
|
clientIp: string;
|
||||||
|
isActive: boolean;
|
||||||
|
seenAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceAccountOrg {
|
||||||
|
name: string;
|
||||||
|
orgId: number;
|
||||||
|
role: OrgRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServiceAccountFilter = Record<string, string | boolean | SelectableValue[]>;
|
||||||
|
export interface ServiceaccountListAdminState {
|
||||||
|
serviceaccounts: ServiceAccountDTO[];
|
||||||
|
query: string;
|
||||||
|
perPage: number;
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
showPaging: boolean;
|
||||||
|
filters: ServiceAccountFilter[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user