mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
ServiceAccounts: add role picker to service accounts list (#45127)
This commit is contained in:
parent
a771cbd871
commit
7b397184a0
@ -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) => {
|
||||
|
@ -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;
|
@ -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());
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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[]>;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user