ServiceAccounts: add role picker to service accounts list (#45127)

This commit is contained in:
J Guerreiro 2022-02-10 13:04:07 +00:00 committed by GitHub
parent a771cbd871
commit 7b397184a0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 162 additions and 222 deletions

View File

@ -4,17 +4,17 @@ import { Icon, LinkButton, useStyles2 } from '@grafana/ui';
import { css, cx } from '@emotion/css';
import Page from 'app/core/components/Page/Page';
import { StoreState, ServiceAccountDTO, AccessControlAction } from 'app/types';
import { loadServiceAccounts, removeServiceAccount, updateServiceAccount } from './state/actions';
import { StoreState, ServiceAccountDTO, AccessControlAction, Role } from 'app/types';
import { fetchACOptions, loadServiceAccounts, removeServiceAccount, updateServiceAccount } from './state/actions';
import { getNavModel } from 'app/core/selectors/navModel';
import { getServiceAccounts, getServiceAccountsSearchPage, getServiceAccountsSearchQuery } from './state/selectors';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { GrafanaTheme2 } from '@grafana/data';
import { GrafanaTheme2, OrgRole } from '@grafana/data';
import { contextSrv } from 'app/core/core';
import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
import { OrgRolePicker } from '../admin/OrgRolePicker';
export type Props = ConnectedProps<typeof connector>;
export interface State {}
function mapStateToProps(state: StoreState) {
return {
navModel: getNavModel(state.navIndex, 'serviceaccounts'),
@ -22,23 +22,44 @@ function mapStateToProps(state: StoreState) {
searchQuery: getServiceAccountsSearchQuery(state.serviceAccounts),
searchPage: getServiceAccountsSearchPage(state.serviceAccounts),
isLoading: state.serviceAccounts.isLoading,
roleOptions: state.serviceAccounts.roleOptions,
builtInRoles: state.serviceAccounts.builtInRoles,
};
}
const mapDispatchToProps = {
loadServiceAccounts,
fetchACOptions,
updateServiceAccount,
removeServiceAccount,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
const ServiceAccountsListPage: React.FC<Props> = ({ loadServiceAccounts, navModel, serviceAccounts, isLoading }) => {
const ServiceAccountsListPage = ({
loadServiceAccounts,
fetchACOptions,
updateServiceAccount,
navModel,
serviceAccounts,
isLoading,
roleOptions,
builtInRoles,
}: Props) => {
const styles = useStyles2(getStyles);
useEffect(() => {
loadServiceAccounts();
}, [loadServiceAccounts]);
if (contextSrv.accessControlEnabled()) {
fetchACOptions();
}
}, [loadServiceAccounts, fetchACOptions]);
const onRoleChange = (role: OrgRole, serviceAccount: ServiceAccountDTO) => {
const updatedServiceAccount = { ...serviceAccount, role: role };
updateServiceAccount(updatedServiceAccount);
};
return (
<Page navModel={navModel}>
<Page.Contents>
@ -66,8 +87,14 @@ const ServiceAccountsListPage: React.FC<Props> = ({ loadServiceAccounts, navMode
</tr>
</thead>
<tbody>
{serviceAccounts.map((serviceaccount: ServiceAccountDTO) => (
<ServiceAccountListItem serviceaccount={serviceaccount} key={serviceaccount.id} />
{serviceAccounts.map((serviceAccount: ServiceAccountDTO) => (
<ServiceAccountListItem
serviceAccount={serviceAccount}
key={serviceAccount.id}
builtInRoles={builtInRoles}
roleOptions={roleOptions}
onRoleChange={onRoleChange}
/>
))}
</tbody>
</table>
@ -80,78 +107,91 @@ const ServiceAccountsListPage: React.FC<Props> = ({ loadServiceAccounts, navMode
};
type ServiceAccountListItemProps = {
serviceaccount: ServiceAccountDTO;
serviceAccount: ServiceAccountDTO;
onRoleChange: (role: OrgRole, serviceAccount: ServiceAccountDTO) => void;
roleOptions: Role[];
builtInRoles: Record<string, Role[]>;
};
const getServiceAccountsAriaLabel = (name: string) => {
return `Edit service account's ${name} details`;
};
const ServiceAccountListItem = memo(({ serviceaccount }: ServiceAccountListItemProps) => {
const editUrl = `org/serviceaccounts/${serviceaccount.id}`;
const styles = useStyles2(getStyles);
const ServiceAccountListItem = memo(
({ serviceAccount, onRoleChange, roleOptions, builtInRoles }: ServiceAccountListItemProps) => {
const editUrl = `org/serviceAccounts/${serviceAccount.id}`;
const styles = useStyles2(getStyles);
const canUpdateRole = contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, serviceAccount);
const rolePickerDisabled = !canUpdateRole;
return (
<tr key={serviceaccount.id}>
<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.name}
aria-label={getServiceAccountsAriaLabel(serviceaccount.name)}
>
{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={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>
return (
<tr key={serviceAccount.id}>
<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.name}
aria-label={getServiceAccountsAriaLabel(serviceAccount.name)}
>
{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={cx('link-td', styles.iconRow)}>
{contextSrv.licensedAccessControlEnabled() ? (
<UserRolePicker
userId={serviceAccount.id}
orgId={serviceAccount.orgId}
builtInRole={serviceAccount.role}
onBuiltinRoleChange={(newRole) => onRoleChange(newRole, serviceAccount)}
roleOptions={roleOptions}
builtInRoles={builtInRoles}
disabled={rolePickerDisabled}
/>
) : (
serviceaccount.role
<OrgRolePicker
aria-label="Role"
value={serviceAccount.role}
disabled={!canUpdateRole}
onChange={(newRole) => onRoleChange(newRole, serviceAccount)}
/>
)}
</a>
</td>
<td className="link-td max-width-10">
<a
className="ellipsis"
href={editUrl}
title="tokens"
aria-label={getServiceAccountsAriaLabel(serviceaccount.name)}
>
<span>
<Icon name={'key-skeleton-alt'}></Icon>
</span>
{serviceaccount.tokens}
</a>
</td>
</tr>
);
});
</td>
<td className="link-td max-width-10">
<a
className="ellipsis"
href={editUrl}
title="tokens"
aria-label={getServiceAccountsAriaLabel(serviceAccount.name)}
>
<span>
<Icon name={'key-skeleton-alt'}></Icon>
</span>
{serviceAccount.tokens}
</a>
</td>
</tr>
);
}
);
ServiceAccountListItem.displayName = 'ServiceAccountListItem';
const getStyles = (theme: GrafanaTheme2) => {

View File

@ -1,137 +0,0 @@
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 { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
import { fetchBuiltinRoles, fetchRoleOptions } from 'app/core/components/RolePicker/api';
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 } = props;
const canUpdateRole = contextSrv.hasPermission(AccessControlAction.OrgUsersRoleUpdate);
const canRemoveFromOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersRemove);
const rolePickerDisabled = !canUpdateRole;
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
const [toRemove, setToRemove] = useState<OrgServiceAccount | null>(null);
const [builtinRoles, setBuiltinRoles] = useState<Record<string, Role[]>>({});
const [showRemoveModal, setShowRemoveModal] = useState<boolean | string>(false);
useEffect(() => {
async function fetchOptions() {
try {
if (contextSrv.hasPermission(AccessControlAction.ActionRolesList)) {
let options = await fetchRoleOptions(orgId);
setRoleOptions(options);
}
if (contextSrv.hasPermission(AccessControlAction.ActionBuiltinRolesList)) {
const builtInRoles = await fetchBuiltinRoles(orgId);
setBuiltinRoles(builtInRoles);
}
} catch (e) {
console.error('Error loading options');
}
}
if (contextSrv.accessControlEnabled()) {
fetchOptions();
}
}, [orgId]);
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-8">
{contextSrv.accessControlEnabled() ? (
<UserRolePicker
userId={serviceAccount.serviceAccountId}
orgId={orgId}
builtInRole={serviceAccount.role}
onBuiltinRoleChange={(newRole) => onRoleChange(newRole, serviceAccount)}
roleOptions={roleOptions}
builtInRoles={builtinRoles}
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(serviceAccount.login)}
icon="times"
aria-label="Delete serviceaccount"
/>
</td>
)}
</tr>
);
})}
</tbody>
</table>
{toRemove !== null && (
<ConfirmModal
body={`Are you sure you want to delete ${toRemove.login} service account?`}
confirmText="Delete"
title="Delete"
onDismiss={() => setToRemove(null)}
isOpen={toRemove.login === showRemoveModal}
onConfirm={() => onRemoveServiceAccount(toRemove)}
/>
)}
</>
);
};
export default ServiceAccountsTable;

View File

@ -1,9 +1,30 @@
import { ThunkResult } from '../../../types';
import { ServiceAccountDTO, ThunkResult } from '../../../types';
import { getBackendSrv } from '@grafana/runtime';
import { serviceAccountLoaded, serviceAccountsLoaded, serviceAccountTokensLoaded } from './reducers';
import {
acOptionsLoaded,
builtInRolesLoaded,
serviceAccountLoaded,
serviceAccountsLoaded,
serviceAccountTokensLoaded,
} from './reducers';
import { accessControlQueryParam } from 'app/core/utils/accessControl';
import { fetchBuiltinRoles, fetchRoleOptions } from 'app/core/components/RolePicker/api';
const BASE_URL = `/api/serviceaccounts`;
export function fetchACOptions(): ThunkResult<void> {
return async (dispatch) => {
try {
const options = await fetchRoleOptions();
dispatch(acOptionsLoaded(options));
const builtInRoles = await fetchBuiltinRoles();
dispatch(builtInRolesLoaded(builtInRoles));
} catch (error) {
console.error(error);
}
};
}
export function loadServiceAccount(saID: number): ThunkResult<void> {
return async (dispatch) => {
try {
@ -48,7 +69,7 @@ export function loadServiceAccountTokens(saID: number): ThunkResult<void> {
export function loadServiceAccounts(): ThunkResult<void> {
return async (dispatch) => {
try {
const response = await getBackendSrv().get(BASE_URL);
const response = await getBackendSrv().get(BASE_URL, accessControlQueryParam());
dispatch(serviceAccountsLoaded(response));
} catch (error) {
console.error(error);
@ -56,10 +77,9 @@ export function loadServiceAccounts(): ThunkResult<void> {
};
}
export function updateServiceAccount(saID: number): ThunkResult<void> {
export function updateServiceAccount(serviceAccount: ServiceAccountDTO): ThunkResult<void> {
return async (dispatch) => {
// TODO: implement on backend
await getBackendSrv().patch(`${BASE_URL}/${saID}`, {});
await getBackendSrv().patch(`/api/org/users/${serviceAccount.id}`, { role: serviceAccount.role });
dispatch(loadServiceAccounts());
};
}

View File

@ -1,12 +1,14 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { ApiKey, ServiceAccountDTO, ServiceAccountProfileState, ServiceAccountsState } from 'app/types';
import { ApiKey, Role, ServiceAccountDTO, ServiceAccountProfileState, ServiceAccountsState } from 'app/types';
export const initialState: ServiceAccountsState = {
serviceAccounts: [] as ServiceAccountDTO[],
searchQuery: '',
searchPage: 1,
isLoading: true,
builtInRoles: {},
roleOptions: [],
};
export const initialStateProfile: ServiceAccountProfileState = {
@ -42,11 +44,22 @@ const serviceAccountsSlice = createSlice({
setServiceAccountsSearchPage: (state, action: PayloadAction<number>): ServiceAccountsState => {
return { ...state, searchPage: action.payload };
},
acOptionsLoaded: (state, action: PayloadAction<Role[]>): ServiceAccountsState => {
return { ...state, roleOptions: action.payload };
},
builtInRolesLoaded: (state, action: PayloadAction<Record<string, Role[]>>): ServiceAccountsState => {
return { ...state, builtInRoles: action.payload };
},
},
});
export const { setServiceAccountsSearchQuery, setServiceAccountsSearchPage, serviceAccountsLoaded } =
serviceAccountsSlice.actions;
export const {
setServiceAccountsSearchQuery,
setServiceAccountsSearchPage,
serviceAccountsLoaded,
acOptionsLoaded,
builtInRolesLoaded,
} = serviceAccountsSlice.actions;
export const { serviceAccountLoaded, serviceAccountTokensLoaded } = serviceAccountProfileSlice.actions;

View File

@ -26,6 +26,8 @@ export enum AccessControlAction {
UsersQuotasUpdate = 'users.quotas:update',
ServiceAccountsCreate = 'serviceaccounts:create',
ServiceAccountsWrite = 'serviceaccounts:write',
ServiceAccountsDelete = 'serviceaccounts:delete',
OrgsRead = 'orgs:read',
OrgsPreferencesRead = 'orgs.preferences:read',

View File

@ -1,7 +1,7 @@
import { WithAccessControlMetadata } from '@grafana/data';
import { ApiKey, OrgRole } from '.';
import { ApiKey, OrgRole, Role } from '.';
export interface OrgServiceAccount {
export interface OrgServiceAccount extends WithAccessControlMetadata {
serviceAccountId: number;
avatarUrl: string;
email: string;
@ -31,7 +31,7 @@ export interface ServiceAccountDTO extends WithAccessControlMetadata {
name: string;
login: string;
avatarUrl?: string;
role: string;
role: OrgRole;
}
export interface ServiceAccountProfileState {
@ -45,4 +45,6 @@ export interface ServiceAccountsState {
searchQuery: string;
searchPage: number;
isLoading: boolean;
roleOptions: Role[];
builtInRoles: Record<string, Role[]>;
}