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 { css, cx } from '@emotion/css';
import Page from 'app/core/components/Page/Page'; import Page from 'app/core/components/Page/Page';
import { StoreState, ServiceAccountDTO, AccessControlAction } from 'app/types'; import { StoreState, ServiceAccountDTO, AccessControlAction, Role } from 'app/types';
import { loadServiceAccounts, removeServiceAccount, updateServiceAccount } from './state/actions'; import { fetchACOptions, 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 PageLoader from 'app/core/components/PageLoader/PageLoader'; 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 { 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 type Props = ConnectedProps<typeof connector>;
export interface State {}
function mapStateToProps(state: StoreState) { function mapStateToProps(state: StoreState) {
return { return {
navModel: getNavModel(state.navIndex, 'serviceaccounts'), navModel: getNavModel(state.navIndex, 'serviceaccounts'),
@ -22,23 +22,44 @@ function mapStateToProps(state: StoreState) {
searchQuery: getServiceAccountsSearchQuery(state.serviceAccounts), searchQuery: getServiceAccountsSearchQuery(state.serviceAccounts),
searchPage: getServiceAccountsSearchPage(state.serviceAccounts), searchPage: getServiceAccountsSearchPage(state.serviceAccounts),
isLoading: state.serviceAccounts.isLoading, isLoading: state.serviceAccounts.isLoading,
roleOptions: state.serviceAccounts.roleOptions,
builtInRoles: state.serviceAccounts.builtInRoles,
}; };
} }
const mapDispatchToProps = { const mapDispatchToProps = {
loadServiceAccounts, loadServiceAccounts,
fetchACOptions,
updateServiceAccount, updateServiceAccount,
removeServiceAccount, removeServiceAccount,
}; };
const connector = connect(mapStateToProps, mapDispatchToProps); 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); const styles = useStyles2(getStyles);
useEffect(() => { useEffect(() => {
loadServiceAccounts(); loadServiceAccounts();
}, [loadServiceAccounts]); if (contextSrv.accessControlEnabled()) {
fetchACOptions();
}
}, [loadServiceAccounts, fetchACOptions]);
const onRoleChange = (role: OrgRole, serviceAccount: ServiceAccountDTO) => {
const updatedServiceAccount = { ...serviceAccount, role: role };
updateServiceAccount(updatedServiceAccount);
};
return ( return (
<Page navModel={navModel}> <Page navModel={navModel}>
<Page.Contents> <Page.Contents>
@ -66,8 +87,14 @@ const ServiceAccountsListPage: React.FC<Props> = ({ loadServiceAccounts, navMode
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{serviceAccounts.map((serviceaccount: ServiceAccountDTO) => ( {serviceAccounts.map((serviceAccount: ServiceAccountDTO) => (
<ServiceAccountListItem serviceaccount={serviceaccount} key={serviceaccount.id} /> <ServiceAccountListItem
serviceAccount={serviceAccount}
key={serviceAccount.id}
builtInRoles={builtInRoles}
roleOptions={roleOptions}
onRoleChange={onRoleChange}
/>
))} ))}
</tbody> </tbody>
</table> </table>
@ -80,78 +107,91 @@ const ServiceAccountsListPage: React.FC<Props> = ({ loadServiceAccounts, navMode
}; };
type ServiceAccountListItemProps = { type ServiceAccountListItemProps = {
serviceaccount: ServiceAccountDTO; serviceAccount: ServiceAccountDTO;
onRoleChange: (role: OrgRole, serviceAccount: ServiceAccountDTO) => void;
roleOptions: Role[];
builtInRoles: Record<string, Role[]>;
}; };
const getServiceAccountsAriaLabel = (name: string) => { const getServiceAccountsAriaLabel = (name: string) => {
return `Edit service account's ${name} details`; return `Edit service account's ${name} details`;
}; };
const ServiceAccountListItem = memo(({ serviceaccount }: ServiceAccountListItemProps) => { const ServiceAccountListItem = memo(
const editUrl = `org/serviceaccounts/${serviceaccount.id}`; ({ serviceAccount, onRoleChange, roleOptions, builtInRoles }: ServiceAccountListItemProps) => {
const styles = useStyles2(getStyles); const editUrl = `org/serviceAccounts/${serviceAccount.id}`;
const styles = useStyles2(getStyles);
const canUpdateRole = contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, serviceAccount);
const rolePickerDisabled = !canUpdateRole;
return ( return (
<tr key={serviceaccount.id}> <tr key={serviceAccount.id}>
<td className="width-4 text-center link-td"> <td className="width-4 text-center link-td">
<a href={editUrl} aria-label={getServiceAccountsAriaLabel(serviceaccount.name)}> <a href={editUrl} aria-label={getServiceAccountsAriaLabel(serviceAccount.name)}>
<img <img
className="filter-table__avatar" className="filter-table__avatar"
src={serviceaccount.avatarUrl} src={serviceAccount.avatarUrl}
alt={`Avatar for user ${serviceaccount.name}`} alt={`Avatar for user ${serviceAccount.name}`}
/> />
</a> </a>
</td> </td>
<td className="link-td max-width-10"> <td className="link-td max-width-10">
<a <a
className="ellipsis" className="ellipsis"
href={editUrl} href={editUrl}
title={serviceaccount.name} title={serviceAccount.name}
aria-label={getServiceAccountsAriaLabel(serviceaccount.name)} aria-label={getServiceAccountsAriaLabel(serviceAccount.name)}
> >
{serviceaccount.name} {serviceAccount.name}
</a> </a>
</td> </td>
<td className="link-td max-width-10"> <td className="link-td max-width-10">
<a <a
className="ellipsis" className="ellipsis"
href={editUrl} href={editUrl}
title={serviceaccount.login} title={serviceAccount.login}
aria-label={getServiceAccountsAriaLabel(serviceaccount.name)} aria-label={getServiceAccountsAriaLabel(serviceAccount.name)}
> >
{serviceaccount.login} {serviceAccount.login}
</a> </a>
</td> </td>
<td className={cx('link-td', styles.iconRow)}> <td className={cx('link-td', styles.iconRow)}>
<a {contextSrv.licensedAccessControlEnabled() ? (
className="ellipsis" <UserRolePicker
href={editUrl} userId={serviceAccount.id}
title={serviceaccount.name} orgId={serviceAccount.orgId}
aria-label={getServiceAccountsAriaLabel(serviceaccount.name)} builtInRole={serviceAccount.role}
> onBuiltinRoleChange={(newRole) => onRoleChange(newRole, serviceAccount)}
{serviceaccount.role === 'None' ? ( roleOptions={roleOptions}
<span className={styles.disabled}>Not assigned </span> builtInRoles={builtInRoles}
disabled={rolePickerDisabled}
/>
) : ( ) : (
serviceaccount.role <OrgRolePicker
aria-label="Role"
value={serviceAccount.role}
disabled={!canUpdateRole}
onChange={(newRole) => onRoleChange(newRole, serviceAccount)}
/>
)} )}
</a> </td>
</td> <td className="link-td max-width-10">
<td className="link-td max-width-10"> <a
<a className="ellipsis"
className="ellipsis" href={editUrl}
href={editUrl} title="tokens"
title="tokens" aria-label={getServiceAccountsAriaLabel(serviceAccount.name)}
aria-label={getServiceAccountsAriaLabel(serviceaccount.name)} >
> <span>
<span> <Icon name={'key-skeleton-alt'}></Icon>
<Icon name={'key-skeleton-alt'}></Icon> </span>
</span> {serviceAccount.tokens}
{serviceaccount.tokens} </a>
</a> </td>
</td> </tr>
</tr> );
); }
}); );
ServiceAccountListItem.displayName = 'ServiceAccountListItem'; ServiceAccountListItem.displayName = 'ServiceAccountListItem';
const getStyles = (theme: GrafanaTheme2) => { 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 { 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`; 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> { export function loadServiceAccount(saID: number): ThunkResult<void> {
return async (dispatch) => { return async (dispatch) => {
try { try {
@ -48,7 +69,7 @@ export function loadServiceAccountTokens(saID: number): ThunkResult<void> {
export function loadServiceAccounts(): ThunkResult<void> { export function loadServiceAccounts(): ThunkResult<void> {
return async (dispatch) => { return async (dispatch) => {
try { try {
const response = await getBackendSrv().get(BASE_URL); const response = await getBackendSrv().get(BASE_URL, accessControlQueryParam());
dispatch(serviceAccountsLoaded(response)); dispatch(serviceAccountsLoaded(response));
} catch (error) { } catch (error) {
console.error(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) => { return async (dispatch) => {
// TODO: implement on backend await getBackendSrv().patch(`/api/org/users/${serviceAccount.id}`, { role: serviceAccount.role });
await getBackendSrv().patch(`${BASE_URL}/${saID}`, {});
dispatch(loadServiceAccounts()); dispatch(loadServiceAccounts());
}; };
} }

View File

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

View File

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

View File

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