Serviceaccounts: refactor list using server admin page (#44122)

* refactor: use server admin listing serviceaccounts

Co-authored-by: Jguer <joao.guerreiro@grafana.com>

* setup route for specifc service account

* add routes to index

* main issue with spelling mistakes

* feat: make routes /serviceacconts/id for navModel

Co-authored-by: Jguer <joao.guerreiro@grafana.com>
Co-authored-by: Alexander Zobnin <alexanderzobnin@gmail.com>

* Update pkg/services/serviceaccounts/manager/service.go

Co-authored-by: Jguer <joao.guerreiro@grafana.com>
Co-authored-by: Alexander Zobnin <alexanderzobnin@gmail.com>
This commit is contained in:
Eric Leijonmarck 2022-01-19 17:03:45 +01:00 committed by GitHub
parent 9f97f05fcc
commit bf4c217b95
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 532 additions and 95 deletions

View File

@ -59,6 +59,8 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/org/users/invite", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionUsersCreate)), hs.Index)
r.Get("/org/teams", reqCanAccessTeams, hs.Index)
r.Get("/org/teams/*", reqCanAccessTeams, hs.Index)
r.Get("/org/serviceaccounts", middleware.ReqOrgAdmin, hs.Index)
r.Get("/org/serviceaccounts/:serviceAccountId", middleware.ReqOrgAdmin, hs.Index)
r.Get("/org/apikeys/", reqOrgAdmin, hs.Index)
r.Get("/dashboard/import/", reqSignedIn, hs.Index)
r.Get("/configuration", reqGrafanaAdmin, hs.Index)

View File

@ -45,6 +45,7 @@ func (api *ServiceAccountsAPI) RegisterAPIEndpoints(
if !cfg.FeatureToggles["service-accounts"] {
return
}
auth := acmiddleware.Middleware(api.accesscontrol)
api.RouterRegister.Group("/api/org/serviceaccounts", func(serviceAccountsRoute routing.RouteRegister) {
serviceAccountsRoute.Get("/", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeAll)), routing.Wrap(api.ListServiceAccounts))

View File

@ -0,0 +1,67 @@
import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { NavModel } from '@grafana/data';
import { getNavModel } from 'app/core/selectors/navModel';
import Page from 'app/core/components/Page/Page';
import { ServiceAccountProfile } from './ServiceAccountProfile';
import { StoreState, ServiceAccountDTO } from 'app/types';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { loadServiceAccount } from './state/actions';
interface OwnProps extends GrafanaRouteComponentProps<{ id: string }> {
navModel: NavModel;
serviceAccount?: ServiceAccountDTO;
isLoading: boolean;
}
export class ServiceAccountPage extends PureComponent<Props> {
async componentDidMount() {
const { match } = this.props;
this.props.loadServiceAccount(parseInt(match.params.id, 10));
}
render() {
const { navModel, serviceAccount, isLoading } = this.props;
return (
<Page navModel={navModel}>
<Page.Contents isLoading={isLoading}>
{serviceAccount && (
<>
<ServiceAccountProfile
serviceaccount={serviceAccount}
onServiceAccountDelete={() => {
console.log(`not implemented`);
}}
onServiceAccountUpdate={() => {
console.log(`not implemented`);
}}
onServiceAccountDisable={() => {
console.log(`not implemented`);
}}
onServiceAccountEnable={() => {
console.log(`not implemented`);
}}
/>
</>
)}
</Page.Contents>
</Page>
);
}
}
function mapStateToProps(state: StoreState) {
return {
navModel: getNavModel(state.navIndex, 'serviceaccounts'),
serviceAccount: state.serviceAccountProfile.serviceAccount,
isLoading: state.serviceAccountProfile.isLoading,
};
}
const mapDispatchToProps = {
loadServiceAccount,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
type Props = OwnProps & ConnectedProps<typeof connector>;
export default connector(ServiceAccountPage);

View File

@ -0,0 +1,237 @@
import React, { PureComponent, useRef, useState } from 'react';
import { ServiceAccountDTO } from 'app/types';
import { css, cx } from '@emotion/css';
import { config } from 'app/core/config';
import { GrafanaTheme } from '@grafana/data';
import { Button, ConfirmButton, ConfirmModal, Input, LegacyInputStatus, stylesFactory } from '@grafana/ui';
interface Props {
serviceaccount: ServiceAccountDTO;
onServiceAccountUpdate: (serviceaccount: ServiceAccountDTO) => void;
onServiceAccountDelete: (serviceaccountId: number) => void;
onServiceAccountDisable: (serviceaccountId: number) => void;
onServiceAccountEnable: (serviceaccountId: number) => void;
}
export function ServiceAccountProfile({
serviceaccount,
onServiceAccountUpdate,
onServiceAccountDelete,
onServiceAccountDisable,
onServiceAccountEnable,
}: Props) {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showDisableModal, setShowDisableModal] = useState(false);
const deleteServiceAccountRef = useRef<HTMLButtonElement | null>(null);
const showDeleteServiceAccountModal = (show: boolean) => () => {
setShowDeleteModal(show);
if (!show && deleteServiceAccountRef.current) {
deleteServiceAccountRef.current.focus();
}
};
const disableServiceAccountRef = useRef<HTMLButtonElement | null>(null);
const showDisableServiceAccountModal = (show: boolean) => () => {
setShowDisableModal(show);
if (!show && disableServiceAccountRef.current) {
disableServiceAccountRef.current.focus();
}
};
const handleServiceAccountDelete = () => onServiceAccountDelete(serviceaccount.userId);
const handleServiceAccountDisable = () => onServiceAccountDisable(serviceaccount.userId);
const handleServiceAccountEnable = () => onServiceAccountEnable(serviceaccount.userId);
const onServiceAccountNameChange = (newValue: string) => {
onServiceAccountUpdate({
...serviceaccount,
name: newValue,
});
};
const styles = getStyles(config.theme);
return (
<>
<h3 className="page-heading">Service account information</h3>
<div className="gf-form-group">
<div className="gf-form">
<table className="filter-table form-inline">
<tbody>
<ServiceAccountProfileRow
label="Display Name"
value={serviceaccount.name}
onChange={onServiceAccountNameChange}
/>
<ServiceAccountProfileRow label="ID" value={serviceaccount.login} />
<ServiceAccountProfileRow label="Roles" value="WIP" />
<ServiceAccountProfileRow label="Teams" value="WIP" />
<ServiceAccountProfileRow label="Created by" value="WIP" />
<ServiceAccountProfileRow label="Creation date" value="WIP" />
</tbody>
</table>
</div>
<div className={styles.buttonRow}>
<>
<Button variant="destructive" onClick={showDeleteServiceAccountModal(true)} ref={deleteServiceAccountRef}>
Delete service account
</Button>
<ConfirmModal
isOpen={showDeleteModal}
title="Delete serviceaccount"
body="Are you sure you want to delete this serviceaccount?"
confirmText="Delete serviceaccount"
onConfirm={handleServiceAccountDelete}
onDismiss={showDeleteServiceAccountModal(false)}
/>
</>
<Button variant="secondary" onClick={handleServiceAccountEnable}>
Enable service account
</Button>
<>
<Button variant="secondary" onClick={showDisableServiceAccountModal(true)} ref={disableServiceAccountRef}>
Disable service account
</Button>
<ConfirmModal
isOpen={showDisableModal}
title="Disable serviceaccount"
body="Are you sure you want to disable this serviceaccount?"
confirmText="Disable serviceaccount"
onConfirm={handleServiceAccountDisable}
onDismiss={showDisableServiceAccountModal(false)}
/>
</>
</div>
</div>
</>
);
}
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
buttonRow: css`
margin-top: 0.8rem;
> * {
margin-right: 16px;
}
`,
};
});
interface ServiceAccountProfileRowProps {
label: string;
value?: string;
inputType?: string;
onChange?: (value: string) => void;
}
interface ServiceAccountProfileRowState {
value: string;
editing: boolean;
}
export class ServiceAccountProfileRow extends PureComponent<
ServiceAccountProfileRowProps,
ServiceAccountProfileRowState
> {
inputElem?: HTMLInputElement;
static defaultProps: Partial<ServiceAccountProfileRowProps> = {
value: '',
inputType: 'text',
};
state = {
editing: false,
value: this.props.value || '',
};
setInputElem = (elem: any) => {
this.inputElem = elem;
};
onEditClick = () => {
this.setState({ editing: true }, this.focusInput);
};
onCancelClick = () => {
this.setState({ editing: false, value: this.props.value || '' });
};
onInputChange = (event: React.ChangeEvent<HTMLInputElement>, status?: LegacyInputStatus) => {
if (status === LegacyInputStatus.Invalid) {
return;
}
this.setState({ value: event.target.value });
};
onInputBlur = (event: React.FocusEvent<HTMLInputElement>, status?: LegacyInputStatus) => {
if (status === LegacyInputStatus.Invalid) {
return;
}
this.setState({ value: event.target.value });
};
focusInput = () => {
if (this.inputElem && this.inputElem.focus) {
this.inputElem.focus();
}
};
onSave = () => {
if (this.props.onChange) {
this.props.onChange(this.state.value);
}
};
render() {
const { label, inputType } = this.props;
const { value } = this.state;
const labelClass = cx(
'width-16',
css`
font-weight: 500;
`
);
const inputId = `${label}-input`;
return (
<tr>
<td className={labelClass}>
<label htmlFor={inputId}>{label}</label>
</td>
<td className="width-25" colSpan={2}>
{this.state.editing ? (
<Input
id={inputId}
type={inputType}
defaultValue={value}
onBlur={this.onInputBlur}
onChange={this.onInputChange}
ref={this.setInputElem}
width={30}
/>
) : (
<span>{this.props.value}</span>
)}
</td>
<td>
<ConfirmButton
confirmText="Save"
onClick={this.onEditClick}
onConfirm={this.onSave}
onCancel={this.onCancelClick}
>
Edit
</ConfirmButton>
</td>
</tr>
);
}
}

View File

@ -1,85 +1,26 @@
import React, { PureComponent } from 'react';
import React, { memo, useEffect } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { HorizontalGroup, Pagination, VerticalGroup } from '@grafana/ui';
import { useStyles2 } from '@grafana/ui';
import { css, cx } from '@emotion/css';
import Page from 'app/core/components/Page/Page';
import ServiceAccountsTable from './ServiceAccountsTable';
import { OrgServiceAccount, OrgRole, StoreState } from 'app/types';
import { StoreState, ServiceAccountDTO } 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';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { GrafanaTheme2 } from '@grafana/data';
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">
<h1>Service Accounts</h1>
<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,
isLoading: state.serviceAccounts.isLoading,
};
}
@ -91,4 +32,156 @@ const mapDispatchToProps = {
const connector = connect(mapStateToProps, mapDispatchToProps);
export default connector(ServiceAccountsListPage);
const ServiceAccountsListPage2: React.FC<Props> = ({ loadServiceAccounts, navModel, serviceAccounts, isLoading }) => {
const styles = useStyles2(getStyles);
useEffect(() => {
loadServiceAccounts();
}, [loadServiceAccounts]);
return (
<Page navModel={navModel}>
<Page.Contents>
{isLoading ? (
<PageLoader />
) : (
<>
<div className={cx(styles.table, 'admin-list-table')}>
<table className="filter-table form-inline filter-table--hover">
<thead>
<tr>
<th></th>
<th>Account</th>
<th>ID</th>
<th>Roles</th>
<th>Tokens</th>
</tr>
</thead>
<tbody>
{serviceAccounts.map((serviceaccount: ServiceAccountDTO) => (
<ServiceAccountListItem serviceaccount={serviceaccount} key={serviceaccount.userId} />
))}
</tbody>
</table>
</div>
</>
)}
</Page.Contents>
</Page>
);
};
type ServiceAccountListItemProps = {
serviceaccount: ServiceAccountDTO;
};
const getServiceAccountsAriaLabel = (name: string) => {
return `Edit service account's ${name} details`;
};
const ServiceAccountListItem = memo(({ serviceaccount }: ServiceAccountListItemProps) => {
const editUrl = `org/serviceaccounts/${serviceaccount.userId}`;
const styles = useStyles2(getStyles);
return (
<tr key={serviceaccount.userId}>
<td className="width-4 text-center link-td">
<a href={editUrl} aria-label={getServiceAccountsAriaLabel(serviceaccount.name)}>
<img
className="filter-table__avatar"
src={serviceaccount.avatarUrl}
alt={`Avatar for user ${serviceaccount.name}`}
/>
</a>
</td>
<td className="link-td max-width-10">
<a
className="ellipsis"
href={editUrl}
title={serviceaccount.login}
aria-label={getServiceAccountsAriaLabel(serviceaccount.name)}
>
{serviceaccount.login}
</a>
</td>
<td className="link-td max-width-10">
<a
className="ellipsis"
href={editUrl}
title={serviceaccount.name}
aria-label={getServiceAccountsAriaLabel(serviceaccount.name)}
>
{serviceaccount.name}
</a>
</td>
<td className={cx('link-td', styles.iconRow)}>
<a
className="ellipsis"
href={editUrl}
title={serviceaccount.name}
aria-label={getServiceAccountsAriaLabel(serviceaccount.name)}
>
{serviceaccount.role === 'None' ? (
<span className={styles.disabled}>Not assigned </span>
) : (
serviceaccount.role
)}
</a>
</td>
<td className="link-td max-width-10">
<a
className="ellipsis"
href={editUrl}
title="tokens"
aria-label={getServiceAccountsAriaLabel(serviceaccount.name)}
>
0
</a>
</td>
</tr>
);
});
ServiceAccountListItem.displayName = 'ServiceAccountListItem';
const getStyles = (theme: GrafanaTheme2) => {
return {
table: css`
margin-top: ${theme.spacing(3)};
`,
filter: css`
margin: 0 ${theme.spacing(1)};
`,
iconRow: css`
svg {
margin-left: ${theme.spacing(0.5)};
}
`,
row: css`
display: flex;
align-items: center;
height: 100% !important;
a {
padding: ${theme.spacing(0.5)} 0 !important;
}
`,
unitTooltip: css`
display: flex;
flex-direction: column;
`,
unitItem: css`
cursor: pointer;
padding: ${theme.spacing(0.5)} 0;
margin-right: ${theme.spacing(1)};
`,
disabled: css`
color: ${theme.colors.text.disabled};
`,
link: css`
color: inherit;
cursor: pointer;
text-decoration: underline;
`,
};
};
export default connector(ServiceAccountsListPage2);

View File

@ -1,23 +1,36 @@
import { ThunkResult } from '../../../types';
import { getBackendSrv } from '@grafana/runtime';
import { OrgServiceAccount as OrgServiceAccount } from 'app/types';
import { serviceAccountsLoaded } from './reducers';
import { ServiceAccountDTO } from 'app/types';
import { serviceAccountLoaded, serviceAccountsLoaded } from './reducers';
const BASE_URL = `/api/org/serviceaccounts`;
export function loadServiceAccounts(): ThunkResult<void> {
export function loadServiceAccount(id: number): ThunkResult<void> {
return async (dispatch) => {
const serviceAccounts = await getBackendSrv().get(BASE_URL);
dispatch(serviceAccountsLoaded(serviceAccounts));
try {
const response = await getBackendSrv().get(`${BASE_URL}/${id}`);
dispatch(serviceAccountLoaded(response));
} catch (error) {
console.error(error);
}
};
}
export function updateServiceAccount(serviceAccount: OrgServiceAccount): ThunkResult<void> {
export function loadServiceAccounts(): ThunkResult<void> {
return async (dispatch) => {
try {
const response = await getBackendSrv().get(BASE_URL);
dispatch(serviceAccountsLoaded(response));
} catch (error) {
console.error(error);
}
};
}
export function updateServiceAccount(serviceAccount: ServiceAccountDTO): ThunkResult<void> {
return async (dispatch) => {
// TODO: implement on backend
await getBackendSrv().patch(`${BASE_URL}/${serviceAccount.serviceAccountId}`, {
role: serviceAccount.role,
});
await getBackendSrv().patch(`${BASE_URL}/${serviceAccount.userId}`, {});
dispatch(loadServiceAccounts());
};
}

View File

@ -1,20 +1,35 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { OrgServiceAccount, ServiceAccountsState } from 'app/types';
import { ServiceAccountDTO, ServiceAccountProfileState, ServiceAccountsState } from 'app/types';
export const initialState: ServiceAccountsState = {
serviceAccounts: [] as OrgServiceAccount[],
serviceAccounts: [] as ServiceAccountDTO[],
searchQuery: '',
searchPage: 1,
isLoading: true,
};
export const initialStateProfile: ServiceAccountProfileState = {
serviceAccount: {} as ServiceAccountDTO,
isLoading: true,
};
export const serviceAccountProfileSlice = createSlice({
name: 'serviceaccount',
initialState: initialStateProfile,
reducers: {
serviceAccountLoaded: (state, action: PayloadAction<ServiceAccountDTO>): ServiceAccountProfileState => {
return { ...state, serviceAccount: action.payload, isLoading: false };
},
},
});
const serviceAccountsSlice = createSlice({
name: 'serviceaccounts',
initialState,
reducers: {
serviceAccountsLoaded: (state, action: PayloadAction<OrgServiceAccount[]>): ServiceAccountsState => {
return { ...state, isLoading: true, serviceAccounts: action.payload };
serviceAccountsLoaded: (state, action: PayloadAction<ServiceAccountDTO[]>): ServiceAccountsState => {
return { ...state, isLoading: false, serviceAccounts: action.payload };
},
setServiceAccountsSearchQuery: (state, action: PayloadAction<string>): ServiceAccountsState => {
// reset searchPage otherwise search results won't appear
@ -32,8 +47,12 @@ export const {
serviceAccountsLoaded,
} = serviceAccountsSlice.actions;
export const { serviceAccountLoaded } = serviceAccountProfileSlice.actions;
export const serviceAccountProfileReducer = serviceAccountProfileSlice.reducer;
export const serviceAccountsReducer = serviceAccountsSlice.reducer;
export default {
serviceAccountProfile: serviceAccountProfileReducer,
serviceAccounts: serviceAccountsReducer,
};

View File

@ -12,6 +12,7 @@ import { getRoutes as getPluginCatalogRoutes } from 'app/features/plugins/admin/
import { contextSrv } from 'app/core/services/context_srv';
import { getLiveRoutes } from 'app/features/live/pages/routes';
import { getAlertingRoutes } from 'app/features/alerting/routes';
import ServiceAccountPage from 'app/features/serviceaccounts/ServiceAccountPage';
export const extraRoutes: RouteDescriptor[] = [];
@ -190,6 +191,10 @@ export function getAppRoutes(): RouteDescriptor[] {
import(/* webpackChunkName: "ServiceAccountsPage" */ 'app/features/serviceaccounts/ServiceAccountsListPage')
),
},
{
path: '/org/serviceaccounts/:id',
component: ServiceAccountPage,
},
{
path: '/org/teams',
roles: () => (config.editorsCanAdmin ? [] : ['Editor', 'Admin']),

View File

@ -1,4 +1,5 @@
import { OrgRole, Unit } from '.';
import { WithAccessControlMetadata } from '@grafana/data';
import { OrgRole } from '.';
export interface OrgServiceAccount {
serviceAccountId: number;
@ -23,26 +24,25 @@ export interface ServiceAccount {
orgId?: number;
}
export interface ServiceAccountDTO {
id: number;
login: string;
export interface ServiceAccountDTO extends WithAccessControlMetadata {
orgId: number;
userId: number;
email: string;
name: string;
isGrafanaAdmin: boolean;
isDisabled: boolean;
isAdmin?: boolean;
updatedAt?: string;
authLabels?: string[];
avatarUrl?: string;
orgId?: number;
licensedRole?: string;
permissions?: string[];
teams?: Unit[];
orgs?: Unit[];
login: string;
role: string;
lastSeenAt: string;
lastSeenAtAge: string;
}
export interface ServiceAccountProfileState {
serviceAccount: ServiceAccountDTO;
isLoading: boolean;
}
export interface ServiceAccountsState {
serviceAccounts: OrgServiceAccount[];
serviceAccounts: ServiceAccountDTO[];
searchQuery: string;
searchPage: number;
isLoading: boolean;