mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -59,6 +59,8 @@ func (hs *HTTPServer) registerRoutes() {
|
|||||||
r.Get("/org/users/invite", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionUsersCreate)), hs.Index)
|
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/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("/org/apikeys/", reqOrgAdmin, hs.Index)
|
||||||
r.Get("/dashboard/import/", reqSignedIn, hs.Index)
|
r.Get("/dashboard/import/", reqSignedIn, hs.Index)
|
||||||
r.Get("/configuration", reqGrafanaAdmin, hs.Index)
|
r.Get("/configuration", reqGrafanaAdmin, hs.Index)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ func (api *ServiceAccountsAPI) RegisterAPIEndpoints(
|
|||||||
if !cfg.FeatureToggles["service-accounts"] {
|
if !cfg.FeatureToggles["service-accounts"] {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
auth := acmiddleware.Middleware(api.accesscontrol)
|
auth := acmiddleware.Middleware(api.accesscontrol)
|
||||||
api.RouterRegister.Group("/api/org/serviceaccounts", func(serviceAccountsRoute routing.RouteRegister) {
|
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))
|
serviceAccountsRoute.Get("/", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeAll)), routing.Wrap(api.ListServiceAccounts))
|
||||||
|
|||||||
67
public/app/features/serviceaccounts/ServiceAccountPage.tsx
Normal file
67
public/app/features/serviceaccounts/ServiceAccountPage.tsx
Normal 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);
|
||||||
237
public/app/features/serviceaccounts/ServiceAccountProfile.tsx
Normal file
237
public/app/features/serviceaccounts/ServiceAccountProfile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,85 +1,26 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { memo, useEffect } from 'react';
|
||||||
import { connect, ConnectedProps } from 'react-redux';
|
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 Page from 'app/core/components/Page/Page';
|
||||||
import ServiceAccountsTable from './ServiceAccountsTable';
|
import { StoreState, ServiceAccountDTO } from 'app/types';
|
||||||
import { OrgServiceAccount, OrgRole, StoreState } from 'app/types';
|
|
||||||
import { loadServiceAccounts, removeServiceAccount, updateServiceAccount } from './state/actions';
|
import { loadServiceAccounts, removeServiceAccount, updateServiceAccount } 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 { 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 type Props = ConnectedProps<typeof connector>;
|
||||||
|
|
||||||
export interface State {}
|
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) {
|
function mapStateToProps(state: StoreState) {
|
||||||
return {
|
return {
|
||||||
navModel: getNavModel(state.navIndex, 'serviceaccounts'),
|
navModel: getNavModel(state.navIndex, 'serviceaccounts'),
|
||||||
serviceAccounts: getServiceAccounts(state.serviceAccounts),
|
serviceAccounts: getServiceAccounts(state.serviceAccounts),
|
||||||
searchQuery: getServiceAccountsSearchQuery(state.serviceAccounts),
|
searchQuery: getServiceAccountsSearchQuery(state.serviceAccounts),
|
||||||
searchPage: getServiceAccountsSearchPage(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);
|
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);
|
||||||
|
|||||||
@@ -1,23 +1,36 @@
|
|||||||
import { ThunkResult } from '../../../types';
|
import { ThunkResult } from '../../../types';
|
||||||
import { getBackendSrv } from '@grafana/runtime';
|
import { getBackendSrv } from '@grafana/runtime';
|
||||||
import { OrgServiceAccount as OrgServiceAccount } from 'app/types';
|
import { ServiceAccountDTO } from 'app/types';
|
||||||
import { serviceAccountsLoaded } from './reducers';
|
import { serviceAccountLoaded, serviceAccountsLoaded } from './reducers';
|
||||||
|
|
||||||
const BASE_URL = `/api/org/serviceaccounts`;
|
const BASE_URL = `/api/org/serviceaccounts`;
|
||||||
|
|
||||||
export function loadServiceAccounts(): ThunkResult<void> {
|
export function loadServiceAccount(id: number): ThunkResult<void> {
|
||||||
return async (dispatch) => {
|
return async (dispatch) => {
|
||||||
const serviceAccounts = await getBackendSrv().get(BASE_URL);
|
try {
|
||||||
dispatch(serviceAccountsLoaded(serviceAccounts));
|
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) => {
|
return async (dispatch) => {
|
||||||
// TODO: implement on backend
|
// TODO: implement on backend
|
||||||
await getBackendSrv().patch(`${BASE_URL}/${serviceAccount.serviceAccountId}`, {
|
await getBackendSrv().patch(`${BASE_URL}/${serviceAccount.userId}`, {});
|
||||||
role: serviceAccount.role,
|
|
||||||
});
|
|
||||||
dispatch(loadServiceAccounts());
|
dispatch(loadServiceAccounts());
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import { OrgServiceAccount, ServiceAccountsState } from 'app/types';
|
import { ServiceAccountDTO, ServiceAccountProfileState, ServiceAccountsState } from 'app/types';
|
||||||
|
|
||||||
export const initialState: ServiceAccountsState = {
|
export const initialState: ServiceAccountsState = {
|
||||||
serviceAccounts: [] as OrgServiceAccount[],
|
serviceAccounts: [] as ServiceAccountDTO[],
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
searchPage: 1,
|
searchPage: 1,
|
||||||
isLoading: true,
|
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({
|
const serviceAccountsSlice = createSlice({
|
||||||
name: 'serviceaccounts',
|
name: 'serviceaccounts',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
serviceAccountsLoaded: (state, action: PayloadAction<OrgServiceAccount[]>): ServiceAccountsState => {
|
serviceAccountsLoaded: (state, action: PayloadAction<ServiceAccountDTO[]>): ServiceAccountsState => {
|
||||||
return { ...state, isLoading: true, serviceAccounts: action.payload };
|
return { ...state, isLoading: false, serviceAccounts: action.payload };
|
||||||
},
|
},
|
||||||
setServiceAccountsSearchQuery: (state, action: PayloadAction<string>): ServiceAccountsState => {
|
setServiceAccountsSearchQuery: (state, action: PayloadAction<string>): ServiceAccountsState => {
|
||||||
// reset searchPage otherwise search results won't appear
|
// reset searchPage otherwise search results won't appear
|
||||||
@@ -32,8 +47,12 @@ export const {
|
|||||||
serviceAccountsLoaded,
|
serviceAccountsLoaded,
|
||||||
} = serviceAccountsSlice.actions;
|
} = serviceAccountsSlice.actions;
|
||||||
|
|
||||||
|
export const { serviceAccountLoaded } = serviceAccountProfileSlice.actions;
|
||||||
|
|
||||||
|
export const serviceAccountProfileReducer = serviceAccountProfileSlice.reducer;
|
||||||
export const serviceAccountsReducer = serviceAccountsSlice.reducer;
|
export const serviceAccountsReducer = serviceAccountsSlice.reducer;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
serviceAccountProfile: serviceAccountProfileReducer,
|
||||||
serviceAccounts: serviceAccountsReducer,
|
serviceAccounts: serviceAccountsReducer,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { getRoutes as getPluginCatalogRoutes } from 'app/features/plugins/admin/
|
|||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import { getLiveRoutes } from 'app/features/live/pages/routes';
|
import { getLiveRoutes } from 'app/features/live/pages/routes';
|
||||||
import { getAlertingRoutes } from 'app/features/alerting/routes';
|
import { getAlertingRoutes } from 'app/features/alerting/routes';
|
||||||
|
import ServiceAccountPage from 'app/features/serviceaccounts/ServiceAccountPage';
|
||||||
|
|
||||||
export const extraRoutes: RouteDescriptor[] = [];
|
export const extraRoutes: RouteDescriptor[] = [];
|
||||||
|
|
||||||
@@ -190,6 +191,10 @@ export function getAppRoutes(): RouteDescriptor[] {
|
|||||||
import(/* webpackChunkName: "ServiceAccountsPage" */ 'app/features/serviceaccounts/ServiceAccountsListPage')
|
import(/* webpackChunkName: "ServiceAccountsPage" */ 'app/features/serviceaccounts/ServiceAccountsListPage')
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/org/serviceaccounts/:id',
|
||||||
|
component: ServiceAccountPage,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/org/teams',
|
path: '/org/teams',
|
||||||
roles: () => (config.editorsCanAdmin ? [] : ['Editor', 'Admin']),
|
roles: () => (config.editorsCanAdmin ? [] : ['Editor', 'Admin']),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { OrgRole, Unit } from '.';
|
import { WithAccessControlMetadata } from '@grafana/data';
|
||||||
|
import { OrgRole } from '.';
|
||||||
|
|
||||||
export interface OrgServiceAccount {
|
export interface OrgServiceAccount {
|
||||||
serviceAccountId: number;
|
serviceAccountId: number;
|
||||||
@@ -23,26 +24,25 @@ export interface ServiceAccount {
|
|||||||
orgId?: number;
|
orgId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServiceAccountDTO {
|
export interface ServiceAccountDTO extends WithAccessControlMetadata {
|
||||||
id: number;
|
orgId: number;
|
||||||
login: string;
|
userId: number;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
isGrafanaAdmin: boolean;
|
|
||||||
isDisabled: boolean;
|
|
||||||
isAdmin?: boolean;
|
|
||||||
updatedAt?: string;
|
|
||||||
authLabels?: string[];
|
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
orgId?: number;
|
login: string;
|
||||||
licensedRole?: string;
|
role: string;
|
||||||
permissions?: string[];
|
lastSeenAt: string;
|
||||||
teams?: Unit[];
|
lastSeenAtAge: string;
|
||||||
orgs?: Unit[];
|
}
|
||||||
|
|
||||||
|
export interface ServiceAccountProfileState {
|
||||||
|
serviceAccount: ServiceAccountDTO;
|
||||||
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServiceAccountsState {
|
export interface ServiceAccountsState {
|
||||||
serviceAccounts: OrgServiceAccount[];
|
serviceAccounts: ServiceAccountDTO[];
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
searchPage: number;
|
searchPage: number;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user