ServiceAccounts: refactor UI (#49508)

* ServiceAccounts: refactor ServiceAccountRoleRow

* Refactor ServiceAccountRoleRow

* Refactor ServiceAccountProfile

* Refactor components

* Change service accounts icon

* Refine service accounts page header

* Improve service accounts filtering

* Change delete button style

* Tweak account id

* Auto focus name field when create service account

* Add disable/enable button

* Enable/disable service accounts

* Optimize updating service account (do not fetch all)

* Remove status column (replace by enable/disable button)

* Add banner with service accounts description

* Add tokens from main page

* Update tokens count when add token from main page

* Fix action buttons column

* Fix tokens count when change role

* Refine table row classes

* Fix buttons

* Simplify working with state

* Show message when service account updated

* Able to filter disabled accounts

* Mark disabled accounts in a table

* Refine disabled account view

* Move non-critical components to separate folder

* Remove confusing focusing

* Fix date picker position when creating new token

* DatePicker: able to set minimum date that can be selected

* Don't allow to select expiration dates prior today

* Set tomorrow as a default token expiration date

* Fix displaying expiration period

* Rename Add token button

* Refine page styles

* Show modal when disabling SA from main page

* Arrange role picker

* Refine SA page styles

* Generate default token name

* More smooth navigation between SA pages

* Stop loading indicator in case of error

* Remove legacy styles usage

* Tweaks after code review

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Get rid of useDisapatch in favor of mapDispatchToProps

* Tests for ServiceAccountsListPage

* Tests for service account page

* Show new role picker only with license

* Get rid of deprecated css classes

* Apply suggestion from code review

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Fix service accounts icon

* Tests for service account create page

* Return service account info when update

* Add behaviour tests for ServiceAccountsListPage

* Fix disabled cursor on confirm button

* More behavior tests for service account page

* Temporary disable service account migration banner

* Use safe where condition

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

* Apply review suggestions

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Remove autofocus from search

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>
Co-authored-by: Jguer <joao.guerreiro@grafana.com>
This commit is contained in:
Alexander Zobnin
2022-06-01 10:35:16 +03:00
committed by GitHub
parent 0d7a3209e7
commit 50538d5309
26 changed files with 1468 additions and 770 deletions

View File

@@ -117,6 +117,7 @@ class UnThemedConfirmButton extends PureComponent<Props, State> {
return (
<span className={styles.buttonContainer}>
<div className={cx(disabled && styles.disabled)}>
{typeof children === 'string' ? (
<span className={buttonClass}>
<Button size={size} fill="text" onClick={onClick} ref={this.mainButtonRef}>
@@ -128,6 +129,7 @@ class UnThemedConfirmButton extends PureComponent<Props, State> {
{children}
</span>
)}
</div>
<span className={confirmButtonClass}>
<Button size={size} variant={confirmButtonVariant} onClick={this.onConfirm} ref={this.confirmButtonRef}>
{confirmText}
@@ -154,7 +156,6 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
text-decoration: none;
color: ${theme.colors.text};
opacity: 0.65;
cursor: not-allowed;
pointer-events: none;
`,
buttonShow: css`
@@ -188,6 +189,9 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
transition: opacity 0.12s ease-in, transform 0.14s ease-in, visibility 0s 0.12s;
transform: translateX(100px);
`,
disabled: css`
cursor: not-allowed;
`,
};
});

View File

@@ -318,7 +318,6 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
Text: "Service accounts",
Id: "serviceaccounts",
Description: "Manage service accounts",
// TODO: change icon to "key-skeleton-alt" when it's available
Icon: "keyhole-circle",
Url: hs.Cfg.AppSubURL + "/org/serviceaccounts",
})

View File

@@ -16,6 +16,7 @@ import (
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/serviceaccounts/database"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
)
@@ -169,6 +170,13 @@ func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *models.ReqContext) re
metadata := api.getAccessControlMetadata(ctx, map[string]bool{saIDString: true})
serviceAccount.AvatarUrl = dtos.GetGravatarUrlWithDefault("", serviceAccount.Name)
serviceAccount.AccessControl = metadata[saIDString]
tokens, err := api.store.ListTokens(ctx.Req.Context(), serviceAccount.OrgId, serviceAccount.Id)
if err != nil {
api.log.Warn("Failed to list tokens for service account", "serviceAccount", serviceAccount.Id)
}
serviceAccount.Tokens = int64(len(tokens))
return response.JSON(http.StatusOK, serviceAccount)
}
@@ -205,7 +213,12 @@ func (api *ServiceAccountsAPI) updateServiceAccount(c *models.ReqContext) respon
resp.AvatarUrl = dtos.GetGravatarUrlWithDefault("", resp.Name)
resp.AccessControl = metadata[saIDString]
return response.JSON(http.StatusOK, resp)
return response.JSON(http.StatusOK, util.DynMap{
"message": "Service account updated",
"id": resp.Id,
"name": resp.Name,
"serviceaccount": resp,
})
}
// SearchOrgServiceAccountsWithPaging is an HTTP handler to search for org users with paging.
@@ -222,10 +235,14 @@ func (api *ServiceAccountsAPI) SearchOrgServiceAccountsWithPaging(c *models.ReqC
}
// its okay that it fails, it is only filtering that might be weird, but to safe quard against any weird incoming query param
onlyWithExpiredTokens := c.QueryBool("expiredTokens")
onlyDisabled := c.QueryBool("disabled")
filter := serviceaccounts.FilterIncludeAll
if onlyWithExpiredTokens {
filter = serviceaccounts.FilterOnlyExpiredTokens
}
if onlyDisabled {
filter = serviceaccounts.FilterOnlyDisabled
}
serviceAccountSearch, err := api.store.SearchOrgServiceAccounts(ctx, c.OrgId, c.Query("query"), filter, page, perPage, c.SignedInUser)
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to get service accounts for current organization", err)

View File

@@ -452,9 +452,10 @@ func TestServiceAccountsAPI_UpdateServiceAccount(t *testing.T) {
err := json.Unmarshal(actual.Body.Bytes(), &actualBody)
require.NoError(t, err)
assert.Equal(t, scopeID, int(actualBody["id"].(float64)))
assert.Equal(t, string(*tc.body.Role), actualBody["role"].(string))
assert.Equal(t, *tc.body.Name, actualBody["name"].(string))
assert.Equal(t, tc.user.Login, actualBody["login"].(string))
serviceAccountData := actualBody["serviceaccount"].(map[string]interface{})
assert.Equal(t, string(*tc.body.Role), serviceAccountData["role"].(string))
assert.Equal(t, tc.user.Login, serviceAccountData["login"].(string))
// Ensure the user was updated in DB
sa, err := saAPI.store.RetrieveServiceAccount(context.Background(), 1, int64(scopeID))

View File

@@ -378,6 +378,11 @@ func (s *ServiceAccountsStoreImpl) SearchOrgServiceAccounts(
whereConditions,
"(SELECT count(*) FROM api_key WHERE api_key.service_account_id = org_user.user_id AND api_key.expires < ?) > 0")
whereParams = append(whereParams, now)
case serviceaccounts.FilterOnlyDisabled:
whereConditions = append(
whereConditions,
"is_disabled = ?")
whereParams = append(whereParams, s.sqlStore.Dialect.BooleanStr(true))
default:
s.log.Warn("invalid filter user for service account filtering", "service account search filtering", filter)
}

View File

@@ -67,6 +67,7 @@ type ServiceAccountProfileDTO struct {
AvatarUrl string `json:"avatarUrl" xorm:"-"`
Role string `json:"role" xorm:"role"`
Teams []string `json:"teams" xorm:"-"`
Tokens int64 `json:"tokens,omitempty"`
AccessControl map[string]bool `json:"accessControl,omitempty" xorm:"-"`
}
@@ -74,5 +75,6 @@ type ServiceAccountFilter string // used for filtering
const (
FilterOnlyExpiredTokens ServiceAccountFilter = "expiredTokens"
FilterOnlyDisabled ServiceAccountFilter = "disabled"
FilterIncludeAll ServiceAccountFilter = "all"
)

View File

@@ -10,6 +10,7 @@ interface Props {
inputId?: string;
onChange: (role: OrgRole) => void;
autoFocus?: boolean;
width?: number | 'auto';
}
const options = Object.keys(OrgRole).map((key) => ({ label: key, value: key }));

View File

@@ -146,6 +146,14 @@ export class ApiKeysPageUnconnected extends PureComponent<Props, State> {
const showTable = apiKeysCount > 0;
return (
<>
{/* TODO: enable when API keys to service accounts migration is ready
{config.featureToggles.serviceAccounts && (
<Alert title="Switch from API keys to Service accounts" severity="info">
Service accounts give you more control. API keys will be automatically migrated into tokens inside
respective service accounts. The current API keys will still work, but will be called tokens and
you will find them in the detail view of a respective service account.
</Alert>
)} */}
{showCTA ? (
<EmptyListCTA
title="You haven't added any API keys yet."

View File

@@ -0,0 +1,82 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { ServiceAccountCreatePageUnconnected, Props } from './ServiceAccountCreatePage';
const postMock = jest.fn().mockResolvedValue({});
const patchMock = jest.fn().mockResolvedValue({});
const putMock = jest.fn().mockResolvedValue({});
jest.mock('@grafana/runtime', () => ({
getBackendSrv: () => ({
post: postMock,
patch: patchMock,
put: putMock,
}),
config: {
loginError: false,
buildInfo: {
version: 'v1.0',
commit: '1',
env: 'production',
edition: 'Open Source',
},
licenseInfo: {
stateInfo: '',
licenseUrl: '',
},
appSubUrl: '',
},
}));
jest.mock('app/core/core', () => ({
contextSrv: {
licensedAccessControlEnabled: () => false,
hasPermission: () => true,
hasPermissionInMetadata: () => true,
user: { orgId: 1 },
},
}));
const setup = (propOverrides: Partial<Props>) => {
const props: Props = {
navModel: {
main: {
text: 'Configuration',
},
node: {
text: 'Service accounts',
},
},
};
Object.assign(props, propOverrides);
render(<ServiceAccountCreatePageUnconnected {...props} />);
};
describe('ServiceAccountCreatePage tests', () => {
it('Should display service account create page', () => {
setup({});
expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument();
});
it('Should fire form validation error if name is not set', async () => {
setup({});
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
expect(await screen.findByText('Display name is required')).toBeInTheDocument();
});
it('Should call API with proper params when creating new service account', async () => {
setup({});
await userEvent.type(screen.getByLabelText('Display name *'), 'Data source scavenger');
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() =>
expect(postMock).toHaveBeenCalledWith('/api/serviceaccounts/', {
name: 'Data source scavenger',
role: 'Viewer',
})
);
});
});

View File

@@ -13,16 +13,22 @@ import { AccessControlAction, OrgRole, Role, ServiceAccountCreateApiResponse, Se
import { getNavModel } from '../../core/selectors/navModel';
import { StoreState } from '../../types';
import { OrgRolePicker } from '../admin/OrgRolePicker';
interface ServiceAccountCreatePageProps {
export interface Props {
navModel: NavModel;
}
const mapStateToProps = (state: StoreState) => ({
navModel: getNavModel(state.navIndex, 'serviceaccounts'),
});
const createServiceAccount = async (sa: ServiceAccountDTO) => getBackendSrv().post('/api/serviceaccounts/', sa);
const updateServiceAccount = async (id: number, sa: ServiceAccountDTO) =>
getBackendSrv().patch(`/api/serviceaccounts/${id}`, sa);
const ServiceAccountCreatePage: React.FC<ServiceAccountCreatePageProps> = ({ navModel }) => {
export const ServiceAccountCreatePageUnconnected = ({ navModel }: Props): JSX.Element => {
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
const [builtinRoles, setBuiltinRoles] = useState<{ [key: string]: Role[] }>({});
const [pendingRoles, setPendingRoles] = useState<Role[]>([]);
@@ -104,7 +110,7 @@ const ServiceAccountCreatePage: React.FC<ServiceAccountCreatePageProps> = ({ nav
<Page navModel={navModel}>
<Page.Contents>
<h1>Create service account</h1>
<Form onSubmit={onSubmit} validateOn="onBlur">
<Form onSubmit={onSubmit} validateOn="onSubmit">
{({ register, errors }) => {
return (
<>
@@ -114,24 +120,26 @@ const ServiceAccountCreatePage: React.FC<ServiceAccountCreatePageProps> = ({ nav
invalid={!!errors.name}
error={errors.name ? 'Display name is required' : undefined}
>
<Input id="display-name-input" {...register('name', { required: true })} />
<Input id="display-name-input" {...register('name', { required: true })} autoFocus />
</Field>
{contextSrv.accessControlEnabled() && (
<Field label="Role">
{contextSrv.licensedAccessControlEnabled() ? (
<UserRolePicker
userId={serviceAccount.id || 0}
orgId={serviceAccount.orgId}
builtInRole={serviceAccount.role}
builtInRoles={builtinRoles}
onBuiltinRoleChange={(newRole) => onRoleChange(newRole)}
onBuiltinRoleChange={onRoleChange}
builtinRolesDisabled={false}
roleOptions={roleOptions}
updateDisabled={true}
onApplyRoles={onPendingRolesUpdate}
pendingRoles={pendingRoles}
/>
</Field>
) : (
<OrgRolePicker aria-label="Role" value={serviceAccount.role} onChange={onRoleChange} />
)}
</Field>
<Button type="submit">Create</Button>
</>
);
@@ -142,8 +150,4 @@ const ServiceAccountCreatePage: React.FC<ServiceAccountCreatePageProps> = ({ nav
);
};
const mapStateToProps = (state: StoreState) => ({
navModel: getNavModel(state.navIndex, 'serviceaccounts'),
});
export default connect(mapStateToProps)(ServiceAccountCreatePage);
export default connect(mapStateToProps)(ServiceAccountCreatePageUnconnected);

View File

@@ -0,0 +1,180 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { ApiKey, OrgRole, ServiceAccountDTO } from 'app/types';
import { ServiceAccountPageUnconnected, Props } from './ServiceAccountPage';
jest.mock('app/core/core', () => ({
contextSrv: {
licensedAccessControlEnabled: () => false,
hasPermission: () => true,
hasPermissionInMetadata: () => true,
},
}));
const setup = (propOverrides: Partial<Props>) => {
const createServiceAccountTokenMock = jest.fn();
const deleteServiceAccountMock = jest.fn();
const deleteServiceAccountTokenMock = jest.fn();
const loadServiceAccountMock = jest.fn();
const loadServiceAccountTokensMock = jest.fn();
const updateServiceAccountMock = jest.fn();
const props: Props = {
navModel: {
main: {
text: 'Configuration',
},
node: {
text: 'Service accounts',
},
},
serviceAccount: {} as ServiceAccountDTO,
tokens: [],
builtInRoles: {},
isLoading: false,
roleOptions: [],
match: {
params: { id: '1' },
isExact: true,
path: '/org/serviceaccounts/1',
url: 'http://localhost:3000/org/serviceaccounts/1',
},
history: {} as any,
location: {} as any,
queryParams: {},
route: {} as any,
timezone: '',
createServiceAccountToken: createServiceAccountTokenMock,
deleteServiceAccount: deleteServiceAccountMock,
deleteServiceAccountToken: deleteServiceAccountTokenMock,
loadServiceAccount: loadServiceAccountMock,
loadServiceAccountTokens: loadServiceAccountTokensMock,
updateServiceAccount: updateServiceAccountMock,
};
Object.assign(props, propOverrides);
const { rerender } = render(<ServiceAccountPageUnconnected {...props} />);
return {
rerender,
props,
createServiceAccountTokenMock,
deleteServiceAccountMock,
deleteServiceAccountTokenMock,
loadServiceAccountMock,
loadServiceAccountTokensMock,
updateServiceAccountMock,
};
};
const getDefaultServiceAccount = (): ServiceAccountDTO => ({
id: 42,
name: 'Data source scavenger',
login: 'sa-data-source-scavenger',
orgId: 1,
role: OrgRole.Editor,
isDisabled: false,
teams: [],
tokens: 1,
createdAt: '2022-01-01 00:00:00',
});
const getDefaultToken = (): ApiKey => ({
id: 142,
name: 'sa-data-source-scavenger-74f1634b-3273-4da6-994b-24bd32f5bdc6',
role: OrgRole.Viewer,
secondsToLive: null,
created: '2022-01-01 00:00:00',
});
describe('ServiceAccountPage tests', () => {
it('Should display service account info', () => {
setup({
serviceAccount: getDefaultServiceAccount(),
tokens: [getDefaultToken()],
});
expect(screen.getAllByText(/Data source scavenger/)).toHaveLength(2);
expect(screen.getByText(/^sa-data-source-scavenger$/)).toBeInTheDocument();
expect(screen.getByText(/Editor/)).toBeInTheDocument();
});
it('Should display enable button for disabled account', () => {
setup({
serviceAccount: {
...getDefaultServiceAccount(),
isDisabled: true,
},
tokens: [getDefaultToken()],
});
expect(screen.getByRole('button', { name: 'Enable service account' })).toBeInTheDocument();
});
it('Should display Add token button for account without tokens', () => {
setup({
serviceAccount: {
...getDefaultServiceAccount(),
tokens: 0,
},
});
expect(screen.getByRole('button', { name: 'Add service account token' })).toBeInTheDocument();
});
it('Should display token info', () => {
setup({
serviceAccount: getDefaultServiceAccount(),
tokens: [getDefaultToken()],
});
expect(screen.getByText(/sa-data-source-scavenger-74f1634b-3273-4da6-994b-24bd32f5bdc6/)).toBeInTheDocument();
});
it('Should display expired status for expired tokens', () => {
setup({
serviceAccount: getDefaultServiceAccount(),
tokens: [
{
...getDefaultToken(),
expiration: '2022-01-02 00:00:00',
hasExpired: true,
},
],
});
expect(screen.getByText(/Expired/)).toBeInTheDocument();
});
it('Should call API with proper params when edit service account info', async () => {
const updateServiceAccountMock = jest.fn();
setup({
serviceAccount: getDefaultServiceAccount(),
updateServiceAccount: updateServiceAccountMock,
});
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: 'Edit' }));
await userEvent.clear(screen.getByLabelText('Name'));
await userEvent.type(screen.getByLabelText('Name'), 'Foo bar');
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(updateServiceAccountMock).toHaveBeenCalledWith({
...getDefaultServiceAccount(),
name: 'Foo bar',
});
});
it('Should call API with proper params when delete service account token', async () => {
const deleteServiceAccountTokenMock = jest.fn();
setup({
serviceAccount: getDefaultServiceAccount(),
tokens: [getDefaultToken()],
deleteServiceAccountToken: deleteServiceAccountTokenMock,
});
const user = userEvent.setup();
await userEvent.click(screen.getByLabelText(/Delete service account token/));
await user.click(screen.getByRole('button', { name: /^Delete$/ }));
expect(deleteServiceAccountTokenMock).toHaveBeenCalledWith(42, 142);
});
});

View File

@@ -1,26 +1,27 @@
import { css } from '@emotion/css';
import React, { useEffect, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { getTimeZone, NavModel } from '@grafana/data';
import { Button } from '@grafana/ui';
import { getTimeZone, GrafanaTheme2, NavModel } from '@grafana/data';
import { Button, ConfirmModal, IconButton, useStyles2 } from '@grafana/ui';
import Page from 'app/core/components/Page/Page';
import { contextSrv } from 'app/core/core';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { getNavModel } from 'app/core/selectors/navModel';
import { StoreState, ServiceAccountDTO, ApiKey, Role, AccessControlAction } from 'app/types';
import { AccessControlAction, ApiKey, Role, ServiceAccountDTO, StoreState } from 'app/types';
import { CreateTokenModal, ServiceAccountToken } from './CreateServiceAccountTokenModal';
import { ServiceAccountProfile } from './ServiceAccountProfile';
import { ServiceAccountTokensTable } from './ServiceAccountTokensTable';
import { CreateTokenModal, ServiceAccountToken } from './components/CreateTokenModal';
import { ServiceAccountProfile } from './components/ServiceAccountProfile';
import { ServiceAccountTokensTable } from './components/ServiceAccountTokensTable';
import { fetchACOptions } from './state/actions';
import {
createServiceAccountToken,
deleteServiceAccount,
deleteServiceAccountToken,
loadServiceAccount,
loadServiceAccountTokens,
createServiceAccountToken,
fetchACOptions,
updateServiceAccount,
deleteServiceAccount,
} from './state/actions';
} from './state/actionsServiceAccountPage';
interface OwnProps extends GrafanaRouteComponentProps<{ id: string }> {
navModel: NavModel;
@@ -42,20 +43,21 @@ function mapStateToProps(state: StoreState) {
timezone: getTimeZone(state.user),
};
}
const mapDispatchToProps = {
createServiceAccountToken,
deleteServiceAccount,
deleteServiceAccountToken,
loadServiceAccount,
loadServiceAccountTokens,
createServiceAccountToken,
deleteServiceAccountToken,
deleteServiceAccount,
updateServiceAccount,
fetchACOptions,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
type Props = OwnProps & ConnectedProps<typeof connector>;
const ServiceAccountPageUnconnected = ({
export type Props = OwnProps & ConnectedProps<typeof connector>;
export const ServiceAccountPageUnconnected = ({
navModel,
match,
serviceAccount,
@@ -64,36 +66,67 @@ const ServiceAccountPageUnconnected = ({
isLoading,
roleOptions,
builtInRoles,
createServiceAccountToken,
deleteServiceAccount,
deleteServiceAccountToken,
loadServiceAccount,
loadServiceAccountTokens,
createServiceAccountToken,
deleteServiceAccountToken,
deleteServiceAccount,
updateServiceAccount,
fetchACOptions,
}: Props) => {
const [isModalOpen, setIsModalOpen] = useState(false);
}: Props): JSX.Element => {
const [newToken, setNewToken] = useState('');
const [isTokenModalOpen, setIsTokenModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isDisableModalOpen, setIsDisableModalOpen] = useState(false);
const styles = useStyles2(getStyles);
const serviceAccountId = parseInt(match.params.id, 10);
const tokenActionsDisabled =
!contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite) || serviceAccount.isDisabled;
const ableToWrite = contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite);
useEffect(() => {
const serviceAccountId = parseInt(match.params.id, 10);
loadServiceAccount(serviceAccountId);
loadServiceAccountTokens(serviceAccountId);
if (contextSrv.licensedAccessControlEnabled()) {
fetchACOptions();
}
}, [match, loadServiceAccount, loadServiceAccountTokens, fetchACOptions]);
}, [loadServiceAccount, loadServiceAccountTokens, serviceAccountId]);
const onProfileChange = (serviceAccount: ServiceAccountDTO) => {
updateServiceAccount(serviceAccount);
};
const showDeleteServiceAccountModal = (show: boolean) => () => {
setIsDeleteModalOpen(show);
};
const showDisableServiceAccountModal = (show: boolean) => () => {
setIsDisableModalOpen(show);
};
const handleServiceAccountDelete = () => {
deleteServiceAccount(serviceAccount.id);
};
const handleServiceAccountDisable = () => {
updateServiceAccount({ ...serviceAccount, isDisabled: true });
setIsDisableModalOpen(false);
};
const handleServiceAccountEnable = () => {
updateServiceAccount({ ...serviceAccount, isDisabled: false });
};
const onDeleteServiceAccountToken = (key: ApiKey) => {
deleteServiceAccountToken(parseInt(match.params.id, 10), key.id!);
deleteServiceAccountToken(serviceAccount?.id, key.id!);
};
const onCreateToken = (token: ServiceAccountToken) => {
createServiceAccountToken(serviceAccount.id, token, setNewToken);
createServiceAccountToken(serviceAccount?.id, token, setNewToken);
};
const onModalClose = () => {
setIsModalOpen(false);
const onTokenModalClose = () => {
setIsTokenModalOpen(false);
setNewToken('');
};
@@ -101,42 +134,142 @@ const ServiceAccountPageUnconnected = ({
<Page navModel={navModel}>
<Page.Contents isLoading={isLoading}>
{serviceAccount && (
<>
<div className={styles.headerContainer}>
<a href="org/serviceaccounts">
<IconButton
size="xxl"
variant="secondary"
name="arrow-left"
className={styles.returnButton}
aria-label="Back to service accounts list"
/>
</a>
<div className={styles.headerAvatar}>
<img src={serviceAccount.avatarUrl} alt={`Avatar for user ${serviceAccount.name}`} />
</div>
<h3>{serviceAccount.name}</h3>
<div className={styles.buttonRow}>
<Button
type={'button'}
variant="destructive"
onClick={showDeleteServiceAccountModal(true)}
disabled={!contextSrv.hasPermission(AccessControlAction.ServiceAccountsDelete)}
>
Delete service account
</Button>
{serviceAccount.isDisabled ? (
<Button
type={'button'}
variant="secondary"
onClick={handleServiceAccountEnable}
disabled={!ableToWrite}
>
Enable service account
</Button>
) : (
<Button
type={'button'}
variant="secondary"
onClick={showDisableServiceAccountModal(true)}
disabled={!ableToWrite}
>
Disable service account
</Button>
)}
</div>
</div>
)}
<div className={styles.pageBody}>
{serviceAccount && (
<ServiceAccountProfile
serviceAccount={serviceAccount}
timeZone={timezone}
roleOptions={roleOptions}
builtInRoles={builtInRoles}
updateServiceAccount={updateServiceAccount}
deleteServiceAccount={deleteServiceAccount}
onChange={onProfileChange}
/>
</>
)}
<div className="page-action-bar" style={{ justifyContent: 'space-between', alignItems: 'center' }}>
<h3 className="page-heading" style={{ marginBottom: '0px' }}>
Tokens
</h3>
<Button
onClick={() => setIsModalOpen(true)}
disabled={!contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite)}
>
Add token
<div className={styles.tokensListHeader}>
<h4>Tokens</h4>
<Button onClick={() => setIsTokenModalOpen(true)} disabled={tokenActionsDisabled}>
Add service account token
</Button>
</div>
{tokens && (
<ServiceAccountTokensTable tokens={tokens} timeZone={timezone} onDelete={onDeleteServiceAccountToken} />
)}
{contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite) && (
<CreateTokenModal
isOpen={isModalOpen}
token={newToken}
onCreateToken={onCreateToken}
onClose={onModalClose}
<ServiceAccountTokensTable
tokens={tokens}
timeZone={timezone}
onDelete={onDeleteServiceAccountToken}
tokenActionsDisabled={tokenActionsDisabled}
/>
)}
</div>
<ConfirmModal
isOpen={isDeleteModalOpen}
title="Delete service account"
body="Are you sure you want to delete this service account?"
confirmText="Delete service account"
onConfirm={handleServiceAccountDelete}
onDismiss={showDeleteServiceAccountModal(false)}
/>
<ConfirmModal
isOpen={isDisableModalOpen}
title="Disable service account"
body="Are you sure you want to disable this service account?"
confirmText="Disable service account"
onConfirm={handleServiceAccountDisable}
onDismiss={showDisableServiceAccountModal(false)}
/>
<CreateTokenModal
isOpen={isTokenModalOpen}
token={newToken}
serviceAccountLogin={serviceAccount.login}
onCreateToken={onCreateToken}
onClose={onTokenModalClose}
/>
</Page.Contents>
</Page>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
headerContainer: css`
display: flex;
margin-bottom: ${theme.spacing(2)};
align-items: center;
h3 {
margin-bottom: ${theme.spacing(0.5)};
flex-grow: 1;
}
`,
headerAvatar: css`
margin-right: ${theme.spacing(1)};
margin-bottom: ${theme.spacing(0.6)};
img {
width: 25px;
height: 25px;
border-radius: 50%;
}
`,
returnButton: css`
margin-right: ${theme.spacing(1)};
`,
buttonRow: css`
> * {
margin-right: ${theme.spacing(2)};
}
`,
pageBody: css`
padding-left: ${theme.spacing(5.5)};
`,
tokensListHeader: css`
display: flex;
justify-content: space-between;
align-items: center;
`,
};
};
export const ServiceAccountPage = connector(ServiceAccountPageUnconnected);

View File

@@ -1,292 +0,0 @@
import { css, cx } from '@emotion/css';
import React, { PureComponent, useRef, useState } from 'react';
import { dateTimeFormat, GrafanaTheme2, OrgRole, TimeZone } from '@grafana/data';
import { Button, ConfirmButton, ConfirmModal, Input, LegacyInputStatus, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { Role, ServiceAccountDTO, AccessControlAction } from 'app/types';
import { ServiceAccountRoleRow } from './ServiceAccountRoleRow';
interface Props {
serviceAccount: ServiceAccountDTO;
timeZone: TimeZone;
roleOptions: Role[];
builtInRoles: Record<string, Role[]>;
deleteServiceAccount: (serviceAccountId: number) => void;
updateServiceAccount: (serviceAccount: ServiceAccountDTO) => void;
}
export function ServiceAccountProfile({
serviceAccount,
timeZone,
roleOptions,
builtInRoles,
deleteServiceAccount,
updateServiceAccount,
}: Props) {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showDisableModal, setShowDisableModal] = useState(false);
const ableToWrite = contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite);
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 = () => {
deleteServiceAccount(serviceAccount.id);
};
const handleServiceAccountDisable = () => {
updateServiceAccount({ ...serviceAccount, isDisabled: true });
setShowDisableModal(false);
};
const handleServiceAccountEnable = () => {
updateServiceAccount({ ...serviceAccount, isDisabled: false });
};
const handleServiceAccountRoleChange = (role: OrgRole) => {
updateServiceAccount({ ...serviceAccount, role: role });
};
const onServiceAccountNameChange = (newValue: string) => {
updateServiceAccount({ ...serviceAccount, name: newValue });
};
const styles = useStyles2(getStyles);
return (
<>
<div style={{ marginBottom: '10px' }}>
<a href="org/serviceaccounts" style={{ display: 'inline-block', verticalAlign: 'middle' }}>
<Button fill="text" icon="backward" />
</a>
<h1
className="page-heading"
style={{ display: 'inline-block', verticalAlign: 'middle', margin: '0!important', marginBottom: '0px' }}
>
{serviceAccount.name}
</h1>
</div>
<span style={{ marginBottom: '10px' }}>Information</span>
<div className="gf-form-group">
<div className="gf-form">
<table className="filter-table form-inline">
<tbody>
<ServiceAccountProfileRow
label="Name"
value={serviceAccount.name}
onChange={onServiceAccountNameChange}
disabled={!ableToWrite}
/>
<ServiceAccountProfileRow label="ID" value={serviceAccount.login} />
<ServiceAccountRoleRow
label="Roles"
serviceAccount={serviceAccount}
onRoleChange={handleServiceAccountRoleChange}
builtInRoles={builtInRoles}
roleOptions={roleOptions}
/>
{/* <ServiceAccountProfileRow label="Teams" value={serviceAccount.teams.join(', ')} /> */}
<ServiceAccountProfileRow
label="Creation date"
value={dateTimeFormat(serviceAccount.createdAt, { timeZone })}
/>
</tbody>
</table>
</div>
<div className={styles.buttonRow}>
<>
<Button
type={'button'}
variant="destructive"
onClick={showDeleteServiceAccountModal(true)}
ref={deleteServiceAccountRef}
disabled={!contextSrv.hasPermission(AccessControlAction.ServiceAccountsDelete)}
>
Delete service account
</Button>
<ConfirmModal
isOpen={showDeleteModal}
title="Delete service account"
body="Are you sure you want to delete this service account?"
confirmText="Delete service account"
onConfirm={handleServiceAccountDelete}
onDismiss={showDeleteServiceAccountModal(false)}
/>
</>
{serviceAccount.isDisabled ? (
<Button type={'button'} variant="secondary" onClick={handleServiceAccountEnable} disabled={!ableToWrite}>
Enable service account
</Button>
) : (
<>
<Button
type={'button'}
variant="secondary"
onClick={showDisableServiceAccountModal(true)}
ref={disableServiceAccountRef}
disabled={!ableToWrite}
>
Disable service account
</Button>
<ConfirmModal
isOpen={showDisableModal}
title="Disable service account"
body="Are you sure you want to disable this service account?"
confirmText="Disable service account"
onConfirm={handleServiceAccountDisable}
onDismiss={showDisableServiceAccountModal(false)}
/>
</>
)}
</div>
</div>
</>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
buttonRow: css`
margin-top: ${theme.spacing(1.5)};
> * {
margin-right: ${theme.spacing(2)};
}
`,
};
};
interface ServiceAccountProfileRowProps {
label: string;
value?: string;
inputType?: string;
onChange?: (value: string) => void;
disabled?: boolean;
}
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 = () => {
this.setState({ editing: false });
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.props.disabled && 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>
{this.props.onChange && (
<ConfirmButton
closeOnConfirm
confirmText="Save"
onConfirm={this.onSave}
onClick={this.onEditClick}
onCancel={this.onCancelClick}
disabled={this.props.disabled}
>
Edit
</ConfirmButton>
)}
</td>
</tr>
);
}
}

View File

@@ -1,60 +0,0 @@
import { css, cx } from '@emotion/css';
import React, { PureComponent } from 'react';
import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
import { contextSrv } from 'app/core/core';
import { AccessControlAction, OrgRole, Role, ServiceAccountDTO } from 'app/types';
import { OrgRolePicker } from '../admin/OrgRolePicker';
interface Props {
label: string;
serviceAccount: ServiceAccountDTO;
onRoleChange: (role: OrgRole) => void;
roleOptions: Role[];
builtInRoles: Record<string, Role[]>;
}
export class ServiceAccountRoleRow extends PureComponent<Props> {
render() {
const { label, serviceAccount, roleOptions, builtInRoles, onRoleChange } = this.props;
const canUpdateRole = contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, serviceAccount);
const rolePickerDisabled = !canUpdateRole;
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}>
{contextSrv.licensedAccessControlEnabled() ? (
<UserRolePicker
userId={serviceAccount.id}
orgId={serviceAccount.orgId}
builtInRole={serviceAccount.role}
onBuiltinRoleChange={onRoleChange}
roleOptions={roleOptions}
builtInRoles={builtInRoles}
disabled={rolePickerDisabled}
/>
) : (
<OrgRolePicker
aria-label="Role"
value={serviceAccount.role}
disabled={!canUpdateRole}
onChange={onRoleChange}
/>
)}
</td>
<td></td>
</tr>
);
}
}

View File

@@ -0,0 +1,163 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { OrgRole, ServiceAccountDTO, ServiceAccountStateFilter } from 'app/types';
import { Props, ServiceAccountsListPageUnconnected } from './ServiceAccountsListPage';
jest.mock('app/core/core', () => ({
contextSrv: {
licensedAccessControlEnabled: () => false,
hasPermission: () => true,
hasPermissionInMetadata: () => true,
},
}));
const setup = (propOverrides: Partial<Props>) => {
const changeQueryMock = jest.fn();
const fetchACOptionsMock = jest.fn();
const fetchServiceAccountsMock = jest.fn();
const deleteServiceAccountMock = jest.fn();
const updateServiceAccountMock = jest.fn();
const changeStateFilterMock = jest.fn();
const createServiceAccountTokenMock = jest.fn();
const props: Props = {
navModel: {
main: {
text: 'Configuration',
},
node: {
text: 'Service accounts',
},
},
builtInRoles: {},
isLoading: false,
page: 0,
perPage: 10,
query: '',
roleOptions: [],
serviceAccountStateFilter: ServiceAccountStateFilter.All,
showPaging: false,
totalPages: 1,
serviceAccounts: [],
changeQuery: changeQueryMock,
fetchACOptions: fetchACOptionsMock,
fetchServiceAccounts: fetchServiceAccountsMock,
deleteServiceAccount: deleteServiceAccountMock,
updateServiceAccount: updateServiceAccountMock,
changeStateFilter: changeStateFilterMock,
createServiceAccountToken: createServiceAccountTokenMock,
};
Object.assign(props, propOverrides);
const { rerender } = render(<ServiceAccountsListPageUnconnected {...props} />);
return {
rerender,
props,
changeQueryMock,
fetchACOptionsMock,
fetchServiceAccountsMock,
deleteServiceAccountMock,
updateServiceAccountMock,
changeStateFilterMock,
createServiceAccountTokenMock,
};
};
const getDefaultServiceAccount: () => ServiceAccountDTO = () => ({
id: 42,
name: 'Data source scavenger',
login: 'sa-data-source-scavenger',
orgId: 1,
role: OrgRole.Editor,
isDisabled: false,
teams: [],
tokens: 1,
createdAt: '2022-01-01 00:00:00',
});
describe('ServiceAccountsListPage tests', () => {
it('Should display list of service accounts', () => {
setup({
serviceAccounts: [getDefaultServiceAccount()],
});
expect(screen.getByText(/Data source scavenger/)).toBeInTheDocument();
expect(screen.getByText(/sa-data-source-scavenger/)).toBeInTheDocument();
expect(screen.getByText(/Editor/)).toBeInTheDocument();
});
it('Should display enable button for disabled account', () => {
setup({
serviceAccounts: [
{
...getDefaultServiceAccount(),
isDisabled: true,
},
],
});
expect(screen.getByRole('button', { name: 'Enable' })).toBeInTheDocument();
});
it('Should display Add token button for account without tokens', () => {
setup({
serviceAccounts: [
{
...getDefaultServiceAccount(),
tokens: 0,
},
],
});
expect(screen.getByRole('button', { name: 'Add token' })).toBeInTheDocument();
expect(screen.getByText(/No tokens/)).toBeInTheDocument();
});
it('Should update service account role', async () => {
const updateServiceAccountMock = jest.fn();
setup({
serviceAccounts: [getDefaultServiceAccount()],
updateServiceAccount: updateServiceAccountMock,
});
const user = userEvent.setup();
await user.click(screen.getByText('Editor'));
await user.click(screen.getByText('Admin'));
expect(updateServiceAccountMock).toHaveBeenCalledWith({
...getDefaultServiceAccount(),
role: OrgRole.Admin,
});
});
it('Should disable service account', async () => {
const updateServiceAccountMock = jest.fn();
setup({
serviceAccounts: [getDefaultServiceAccount()],
updateServiceAccount: updateServiceAccountMock,
});
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /Disable/ }));
await user.click(screen.getByLabelText(/Confirm Modal Danger Button/));
expect(updateServiceAccountMock).toHaveBeenCalledWith({
...getDefaultServiceAccount(),
isDisabled: true,
});
});
it('Should remove service account', async () => {
const deleteServiceAccountMock = jest.fn();
setup({
serviceAccounts: [getDefaultServiceAccount()],
deleteServiceAccount: deleteServiceAccountMock,
});
const user = userEvent.setup();
await user.click(screen.getByLabelText(/Delete service account/));
await user.click(screen.getByLabelText(/Confirm Modal Danger Button/));
expect(deleteServiceAccountMock).toHaveBeenCalledWith(42);
});
});

View File

@@ -1,31 +1,32 @@
import { css, cx } from '@emotion/css';
import pluralize from 'pluralize';
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { GrafanaTheme2, OrgRole } from '@grafana/data';
import { ConfirmModal, FilterInput, LinkButton, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { ConfirmModal, FilterInput, Icon, LinkButton, RadioButtonGroup, Tooltip, useStyles2 } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import Page from 'app/core/components/Page/Page';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { contextSrv } from 'app/core/core';
import { getNavModel } from 'app/core/selectors/navModel';
import { StoreState, ServiceAccountDTO, AccessControlAction } from 'app/types';
import { StoreState, ServiceAccountDTO, AccessControlAction, ServiceAccountStateFilter } from 'app/types';
import ServiceAccountListItem from './ServiceAccountsListItem';
import { CreateTokenModal, ServiceAccountToken } from './components/CreateTokenModal';
import ServiceAccountListItem from './components/ServiceAccountsListItem';
import {
changeFilter,
changeQuery,
fetchACOptions,
fetchServiceAccounts,
removeServiceAccount,
deleteServiceAccount,
updateServiceAccount,
setServiceAccountToRemove,
changeStateFilter,
createServiceAccountToken,
} from './state/actions';
interface OwnProps {}
type Props = OwnProps & ConnectedProps<typeof connector>;
export type Props = OwnProps & ConnectedProps<typeof connector>;
function mapStateToProps(state: StoreState) {
return {
@@ -35,84 +36,162 @@ function mapStateToProps(state: StoreState) {
}
const mapDispatchToProps = {
fetchServiceAccounts,
fetchACOptions,
updateServiceAccount,
removeServiceAccount,
setServiceAccountToRemove,
changeFilter,
changeQuery,
fetchACOptions,
fetchServiceAccounts,
deleteServiceAccount,
updateServiceAccount,
changeStateFilter,
createServiceAccountToken,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
const ServiceAccountsListPage = ({
fetchServiceAccounts,
removeServiceAccount,
fetchACOptions,
updateServiceAccount,
setServiceAccountToRemove,
export const ServiceAccountsListPageUnconnected = ({
navModel,
serviceAccounts,
isLoading,
roleOptions,
builtInRoles,
changeFilter,
changeQuery,
query,
filters,
serviceAccountToRemove,
serviceAccountStateFilter,
changeQuery,
fetchACOptions,
fetchServiceAccounts,
deleteServiceAccount,
updateServiceAccount,
changeStateFilter,
createServiceAccountToken,
}: Props): JSX.Element => {
const styles = useStyles2(getStyles);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isRemoveModalOpen, setIsRemoveModalOpen] = useState(false);
const [isDisableModalOpen, setIsDisableModalOpen] = useState(false);
const [newToken, setNewToken] = useState('');
const [currentServiceAccount, setCurrentServiceAccount] = useState<ServiceAccountDTO | null>(null);
useEffect(() => {
const fetchData = async () => {
await fetchServiceAccounts();
fetchServiceAccounts({ withLoadingIndicator: true });
if (contextSrv.licensedAccessControlEnabled()) {
await fetchACOptions();
fetchACOptions();
}
};
fetchData();
}, [fetchServiceAccounts, fetchACOptions]);
}, [fetchACOptions, fetchServiceAccounts]);
const noServiceAccountsCreated =
serviceAccounts.length === 0 && serviceAccountStateFilter === ServiceAccountStateFilter.All && !query;
const onRoleChange = async (role: OrgRole, serviceAccount: ServiceAccountDTO) => {
const updatedServiceAccount = { ...serviceAccount, role: role };
await updateServiceAccount(updatedServiceAccount);
// need to refetch to display the new value in the list
await fetchServiceAccounts();
updateServiceAccount(updatedServiceAccount);
if (contextSrv.licensedAccessControlEnabled()) {
fetchACOptions();
}
};
const onQueryChange = (value: string) => {
changeQuery(value);
};
const onStateFilterChange = (value: ServiceAccountStateFilter) => {
changeStateFilter(value);
};
const onRemoveButtonClick = (serviceAccount: ServiceAccountDTO) => {
setCurrentServiceAccount(serviceAccount);
setIsRemoveModalOpen(true);
};
const onServiceAccountRemove = async () => {
if (currentServiceAccount) {
deleteServiceAccount(currentServiceAccount.id);
}
onRemoveModalClose();
};
const onDisableButtonClick = (serviceAccount: ServiceAccountDTO) => {
setCurrentServiceAccount(serviceAccount);
setIsDisableModalOpen(true);
};
const onDisable = () => {
if (currentServiceAccount) {
updateServiceAccount({ ...currentServiceAccount, isDisabled: true });
}
onDisableModalClose();
};
const onEnable = (serviceAccount: ServiceAccountDTO) => {
updateServiceAccount({ ...serviceAccount, isDisabled: false });
};
const onTokenAdd = (serviceAccount: ServiceAccountDTO) => {
setCurrentServiceAccount(serviceAccount);
setIsAddModalOpen(true);
};
const onTokenCreate = async (token: ServiceAccountToken) => {
if (currentServiceAccount) {
createServiceAccountToken(currentServiceAccount.id, token, setNewToken);
}
};
const onAddModalClose = () => {
setIsAddModalOpen(false);
setCurrentServiceAccount(null);
setNewToken('');
};
const onRemoveModalClose = () => {
setIsRemoveModalOpen(false);
setCurrentServiceAccount(null);
};
const onDisableModalClose = () => {
setIsDisableModalOpen(false);
setCurrentServiceAccount(null);
};
return (
<Page navModel={navModel}>
<Page.Contents>
<div className={styles.pageHeader}>
<h2>Service accounts</h2>
<div className="page-action-bar" style={{ justifyContent: 'flex-end' }}>
<FilterInput
placeholder="Search service account by name."
autoFocus={true}
value={query}
onChange={changeQuery}
/>
<RadioButtonGroup
options={[
{ label: 'All service accounts', value: false },
{ label: 'Expired tokens', value: true },
]}
onChange={(value) => changeFilter({ name: 'expiredTokens', value })}
value={filters.find((f) => f.name === 'expiredTokens')?.value}
className={styles.filter}
/>
{serviceAccounts.length !== 0 && contextSrv.hasPermission(AccessControlAction.ServiceAccountsCreate) && (
<div className={styles.apiKeyInfoLabel}>
<Tooltip
placement="bottom"
interactive
content={
<>
API keys are now service Accounts with tokens. <a href="">Read more</a>
</>
}
>
<Icon name="question-circle" />
</Tooltip>
<span>Looking for API keys?</span>
</div>
{!noServiceAccountsCreated && contextSrv.hasPermission(AccessControlAction.ServiceAccountsCreate) && (
<LinkButton href="org/serviceaccounts/create" variant="primary">
Add service account
</LinkButton>
)}
</div>
<div className={styles.filterRow}>
<FilterInput placeholder="Search service account by name" value={query} onChange={onQueryChange} width={50} />
<div className={styles.filterDelimiter}></div>
<RadioButtonGroup
options={[
{ label: 'All', value: ServiceAccountStateFilter.All },
{ label: 'With expiring tokens', value: ServiceAccountStateFilter.WithExpiredTokens },
{ label: 'Disabled', value: ServiceAccountStateFilter.Disabled },
]}
onChange={onStateFilterChange}
value={serviceAccountStateFilter}
className={styles.filter}
/>
</div>
{isLoading && <PageLoader />}
{!isLoading && serviceAccounts.length === 0 && (
{!isLoading && noServiceAccountsCreated && (
<>
<EmptyListCTA
title="You haven't created any service accounts yet."
@@ -127,61 +206,73 @@ const ServiceAccountsListPage = ({
/>
</>
)}
{!isLoading && serviceAccounts.length !== 0 && (
<>
<div className={cx(styles.table, 'admin-list-table')}>
<table className="filter-table form-inline filter-table--hover">
<table className="filter-table filter-table--hover">
<thead>
<tr>
<th></th>
<th>Account</th>
<th>ID</th>
<th>Roles</th>
<th>Status</th>
<th>Tokens</th>
<th style={{ width: '34px' }} />
</tr>
</thead>
<tbody>
{serviceAccounts.map((serviceAccount: ServiceAccountDTO) => (
{!isLoading &&
serviceAccounts.length !== 0 &&
serviceAccounts.map((serviceAccount: ServiceAccountDTO) => (
<ServiceAccountListItem
serviceAccount={serviceAccount}
key={serviceAccount.id}
builtInRoles={builtInRoles}
roleOptions={roleOptions}
onRoleChange={onRoleChange}
onSetToRemove={setServiceAccountToRemove}
onRemoveButtonClick={onRemoveButtonClick}
onDisable={onDisableButtonClick}
onEnable={onEnable}
onAddTokenClick={onTokenAdd}
/>
))}
</tbody>
</table>
</div>
</>
)}
{serviceAccountToRemove && (
{currentServiceAccount && (
<>
<ConfirmModal
body={
<div>
Are you sure you want to delete &apos;{serviceAccountToRemove.name}&apos;
{Boolean(serviceAccountToRemove.tokens) &&
` and ${serviceAccountToRemove.tokens} accompanying ${pluralize(
isOpen={isRemoveModalOpen}
body={`Are you sure you want to delete '${currentServiceAccount.name}'${
!!currentServiceAccount.tokens
? ` and ${currentServiceAccount.tokens} accompanying ${pluralize(
'token',
serviceAccountToRemove.tokens
)}`}
?
</div>
}
currentServiceAccount.tokens
)}`
: ''
}?`}
confirmText="Delete"
title="Delete service account"
onDismiss={() => {
setServiceAccountToRemove(null);
}}
isOpen={true}
onConfirm={() => {
removeServiceAccount(serviceAccountToRemove.id);
setServiceAccountToRemove(null);
}}
onConfirm={onServiceAccountRemove}
onDismiss={onRemoveModalClose}
/>
<ConfirmModal
isOpen={isDisableModalOpen}
title="Disable service account"
body={`Are you sure you want to disable '${currentServiceAccount.name}'?`}
confirmText="Disable service account"
onConfirm={onDisable}
onDismiss={onDisableModalClose}
/>
<CreateTokenModal
isOpen={isAddModalOpen}
token={newToken}
serviceAccountLogin={currentServiceAccount.login}
onCreateToken={onTokenCreate}
onClose={onAddModalClose}
/>
</>
)}
</Page.Contents>
</Page>
@@ -196,11 +287,6 @@ export const getStyles = (theme: GrafanaTheme2) => {
filter: css`
margin: 0 ${theme.spacing(1)};
`,
iconRow: css`
svg {
margin-left: ${theme.spacing(0.5)};
}
`,
row: css`
display: flex;
align-items: center;
@@ -227,7 +313,32 @@ export const getStyles = (theme: GrafanaTheme2) => {
cursor: pointer;
text-decoration: underline;
`,
pageHeader: css`
display: flex;
margin-bottom: ${theme.spacing(2)};
`,
apiKeyInfoLabel: css`
margin-left: ${theme.spacing(1)};
line-height: 2.2;
flex-grow: 1;
color: ${theme.colors.text.secondary};
span {
padding: ${theme.spacing(0.5)};
}
`,
filterRow: cx(
'page-action-bar',
css`
display: flex;
justifycontent: flex-end;
`
),
filterDelimiter: css`
flex-grow: 1;
`,
};
};
export default connector(ServiceAccountsListPage);
const ServiceAccountsListPage = connector(ServiceAccountsListPageUnconnected);
export default ServiceAccountsListPage;

View File

@@ -1,5 +1,6 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import {
@@ -24,34 +25,54 @@ const EXPIRATION_OPTIONS = [
export type ServiceAccountToken = {
name: string;
secondsToLive: number;
secondsToLive?: number;
};
interface CreateTokenModalProps {
interface Props {
isOpen: boolean;
token: string;
serviceAccountLogin: string;
onCreateToken: (token: ServiceAccountToken) => void;
onClose: () => void;
}
export const CreateTokenModal = ({ isOpen, token, onCreateToken, onClose }: CreateTokenModalProps) => {
export const CreateTokenModal = ({ isOpen, token, serviceAccountLogin, onCreateToken, onClose }: Props) => {
let tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const [defaultTokenName, setDefaultTokenName] = useState('');
const [newTokenName, setNewTokenName] = useState('');
const [isWithExpirationDate, setIsWithExpirationDate] = useState(false);
const [newTokenExpirationDate, setNewTokenExpirationDate] = useState<Date | string>('');
const [isExpirationDateValid, setIsExpirationDateValid] = useState(false);
const [newTokenExpirationDate, setNewTokenExpirationDate] = useState<Date | string>(tomorrow);
const [isExpirationDateValid, setIsExpirationDateValid] = useState(newTokenExpirationDate !== '');
const styles = useStyles2(getStyles);
useEffect(() => {
// Generate new token name every time we open modal
if (isOpen) {
setDefaultTokenName(`${serviceAccountLogin}-${uuidv4()}`);
}
}, [serviceAccountLogin, isOpen]);
const onExpirationDateChange = (value: Date | string) => {
const isValid = value !== '';
setIsExpirationDateValid(isValid);
setNewTokenExpirationDate(value);
};
const onGenerateToken = () => {
onCreateToken({
name: newTokenName || defaultTokenName,
secondsToLive: isWithExpirationDate ? getSecondsToLive(newTokenExpirationDate) : undefined,
});
};
const onCloseInternal = () => {
setNewTokenName('');
setDefaultTokenName('');
setIsWithExpirationDate(false);
setNewTokenExpirationDate('');
setIsExpirationDateValid(false);
setNewTokenExpirationDate(tomorrow);
setIsExpirationDateValid(newTokenExpirationDate !== '');
onClose();
};
@@ -63,13 +84,19 @@ export const CreateTokenModal = ({ isOpen, token, onCreateToken, onClose }: Crea
);
return (
<Modal isOpen={isOpen} title={modalTitle} onDismiss={onCloseInternal} className={styles.modal}>
<Modal
isOpen={isOpen}
title={modalTitle}
onDismiss={onCloseInternal}
className={styles.modal}
contentClassName={styles.modalContent}
>
{!token ? (
<>
<div>
<FieldSet>
<Field
label="Display name"
description="name to easily identify the token"
description="Name to easily identify the token"
className={styles.modalRow}
// for now this is required
// need to make this optional in backend as well
@@ -78,6 +105,7 @@ export const CreateTokenModal = ({ isOpen, token, onCreateToken, onClose }: Crea
<Input
name="tokenName"
value={newTokenName}
placeholder={defaultTokenName}
onChange={(e) => {
setNewTokenName(e.currentTarget.value);
}}
@@ -92,22 +120,19 @@ export const CreateTokenModal = ({ isOpen, token, onCreateToken, onClose }: Crea
/>
{isWithExpirationDate && (
<Field label="Expiration date" className={styles.modalRow}>
<DatePickerWithInput onChange={onExpirationDateChange} value={newTokenExpirationDate} placeholder="" />
<DatePickerWithInput
onChange={onExpirationDateChange}
value={newTokenExpirationDate}
placeholder=""
minDate={tomorrow}
/>
</Field>
)}
</FieldSet>
<Button
onClick={() =>
onCreateToken({
name: newTokenName,
secondsToLive: getSecondsToLive(newTokenExpirationDate),
})
}
disabled={isWithExpirationDate && !isExpirationDateValid}
>
<Button onClick={onGenerateToken} disabled={isWithExpirationDate && !isExpirationDateValid}>
Generate token
</Button>
</>
</div>
) : (
<>
<FieldSet>
@@ -157,6 +182,9 @@ const getStyles = (theme: GrafanaTheme2) => {
modal: css`
width: 550px;
`,
modalContent: css`
overflow: visible;
`,
modalRow: css`
margin-bottom: ${theme.spacing(4)};
`,

View File

@@ -0,0 +1,72 @@
import { css } from '@emotion/css';
import React from 'react';
import { dateTimeFormat, GrafanaTheme2, OrgRole, TimeZone } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { AccessControlAction, Role, ServiceAccountDTO } from 'app/types';
import { ServiceAccountProfileRow } from './ServiceAccountProfileRow';
import { ServiceAccountRoleRow } from './ServiceAccountRoleRow';
interface Props {
serviceAccount: ServiceAccountDTO;
timeZone: TimeZone;
roleOptions: Role[];
builtInRoles: Record<string, Role[]>;
onChange: (serviceAccount: ServiceAccountDTO) => void;
}
export function ServiceAccountProfile({
serviceAccount,
timeZone,
roleOptions,
builtInRoles,
onChange,
}: Props): JSX.Element {
const styles = useStyles2(getStyles);
const ableToWrite = contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite);
const onRoleChange = (role: OrgRole) => {
onChange({ ...serviceAccount, role: role });
};
const onNameChange = (newValue: string) => {
onChange({ ...serviceAccount, name: newValue });
};
return (
<div className={styles.section}>
<h4>Information</h4>
<table className="filter-table">
<tbody>
<ServiceAccountProfileRow
label="Name"
value={serviceAccount.name}
onChange={onNameChange}
disabled={!ableToWrite || serviceAccount.isDisabled}
/>
<ServiceAccountProfileRow label="ID" value={serviceAccount.login} disabled={serviceAccount.isDisabled} />
<ServiceAccountRoleRow
label="Roles"
serviceAccount={serviceAccount}
onRoleChange={onRoleChange}
builtInRoles={builtInRoles}
roleOptions={roleOptions}
/>
<ServiceAccountProfileRow
label="Creation date"
value={dateTimeFormat(serviceAccount.createdAt, { timeZone })}
disabled={serviceAccount.isDisabled}
/>
</tbody>
</table>
</div>
);
}
export const getStyles = (theme: GrafanaTheme2) => ({
section: css`
margin-bottom: ${theme.spacing(4)};
`,
});

View File

@@ -0,0 +1,106 @@
import { css, cx } from '@emotion/css';
import React, { useEffect, useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { ConfirmButton, Input, Label, LegacyInputStatus, useStyles2 } from '@grafana/ui';
interface Props {
label: string;
value?: string;
inputType?: string;
disabled?: boolean;
onChange?: (value: string) => void;
}
export const ServiceAccountProfileRow = ({ label, value, inputType, disabled, onChange }: Props): JSX.Element => {
const inputElem = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState(value);
const [isEditing, setIsEditing] = useState(false);
const styles = useStyles2(getStyles);
const inputId = `${label}-input`;
useEffect(() => {
if (isEditing) {
focusInput();
}
}, [isEditing]);
const onEditClick = () => {
setIsEditing(true);
};
const onCancelClick = () => {
setIsEditing(false);
setInputValue(value || '');
};
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>, status?: LegacyInputStatus) => {
if (status === LegacyInputStatus.Invalid) {
return;
}
setInputValue(event.target.value);
};
const onInputBlur = (event: React.FocusEvent<HTMLInputElement>, status?: LegacyInputStatus) => {
if (status === LegacyInputStatus.Invalid) {
return;
}
setInputValue(event.target.value);
};
const focusInput = () => {
inputElem?.current?.focus();
};
const onSave = () => {
setIsEditing(false);
if (onChange) {
onChange(inputValue!);
}
};
return (
<tr>
<td>
<Label htmlFor={inputId}>{label}</Label>
</td>
<td className="width-25" colSpan={2}>
{!disabled && isEditing ? (
<Input
id={inputId}
type={inputType}
defaultValue={value}
onBlur={onInputBlur}
onChange={onInputChange}
ref={inputElem}
width={30}
/>
) : (
<span className={cx({ [styles.disabled]: disabled })}>{value}</span>
)}
</td>
<td>
{onChange && (
<ConfirmButton
closeOnConfirm
confirmText="Save"
onConfirm={onSave}
onClick={onEditClick}
onCancel={onCancelClick}
disabled={disabled}
>
Edit
</ConfirmButton>
)}
</td>
</tr>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
disabled: css`
color: ${theme.colors.text.secondary};
`,
};
};

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { Label } from '@grafana/ui';
import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
import { contextSrv } from 'app/core/core';
import { OrgRolePicker } from 'app/features/admin/OrgRolePicker';
import { AccessControlAction, OrgRole, Role, ServiceAccountDTO } from 'app/types';
interface Props {
label: string;
serviceAccount: ServiceAccountDTO;
onRoleChange: (role: OrgRole) => void;
roleOptions: Role[];
builtInRoles: Record<string, Role[]>;
}
export const ServiceAccountRoleRow = ({
label,
serviceAccount,
roleOptions,
builtInRoles,
onRoleChange,
}: Props): JSX.Element => {
const inputId = `${label}-input`;
const canUpdateRole = contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, serviceAccount);
const rolePickerDisabled = !canUpdateRole || serviceAccount.isDisabled;
return (
<tr>
<td>
<Label htmlFor={inputId}>{label}</Label>
</td>
{contextSrv.licensedAccessControlEnabled() ? (
<td className="width-25" colSpan={3}>
<UserRolePicker
userId={serviceAccount.id}
orgId={serviceAccount.orgId}
builtInRole={serviceAccount.role}
onBuiltinRoleChange={onRoleChange}
roleOptions={roleOptions}
builtInRoles={builtInRoles}
disabled={rolePickerDisabled}
/>
</td>
) : (
<>
<td>
<OrgRolePicker
width={24}
inputId={inputId}
aria-label="Role"
value={serviceAccount.role}
disabled={rolePickerDisabled}
onChange={onRoleChange}
/>
</td>
<td colSpan={2}></td>
</>
)}
</tr>
);
};

View File

@@ -1,32 +1,29 @@
import { css } from '@emotion/css';
import React, { FC } from 'react';
import React from 'react';
import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data';
import { DeleteButton, Icon, Tooltip, useStyles2, useTheme2 } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { AccessControlAction } from 'app/types';
import { ApiKey } from '../../types';
import { ApiKey } from 'app/types';
interface Props {
tokens: ApiKey[];
timeZone: TimeZone;
tokenActionsDisabled?: boolean;
onDelete: (token: ApiKey) => void;
}
export const ServiceAccountTokensTable: FC<Props> = ({ tokens, timeZone, onDelete }) => {
export const ServiceAccountTokensTable = ({ tokens, timeZone, tokenActionsDisabled, onDelete }: Props): JSX.Element => {
const theme = useTheme2();
const styles = getStyles(theme);
return (
<>
<table className="filter-table">
<thead>
<tr>
<th>Name</th>
<th>Expires</th>
<th>Created</th>
<th style={{ width: '34px' }} />
<th />
</tr>
</thead>
<tbody>
@@ -38,17 +35,19 @@ export const ServiceAccountTokensTable: FC<Props> = ({ tokens, timeZone, onDelet
<TokenExpiration timeZone={timeZone} token={key} />
</td>
<td>{formatDate(timeZone, key.created)}</td>
{contextSrv.hasPermission(AccessControlAction.ServiceAccountsDelete) && (
<td>
<DeleteButton aria-label="Delete service account token" size="sm" onConfirm={() => onDelete(key)} />
<DeleteButton
aria-label={`Delete service account token ${key.name}`}
size="sm"
onConfirm={() => onDelete(key)}
disabled={tokenActionsDisabled}
/>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</>
);
};
@@ -60,7 +59,7 @@ function formatDate(timeZone: TimeZone, expiration?: string): string {
}
function formatSecondsLeftUntilExpiration(secondsUntilExpiration: number): string {
const days = Math.floor(secondsUntilExpiration / (3600 * 24));
const days = Math.ceil(secondsUntilExpiration / (3600 * 24));
const daysFormat = days > 1 ? `${days} days` : `${days} day`;
return `Expires in ${daysFormat}`;
}

View File

@@ -1,33 +1,39 @@
import { cx } from '@emotion/css';
import { css, cx } from '@emotion/css';
import React, { memo } from 'react';
import { OrgRole } from '@grafana/data';
import { Button, Icon, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2, OrgRole } from '@grafana/data';
import { Button, HorizontalGroup, Icon, IconButton, useStyles2 } from '@grafana/ui';
import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
import { contextSrv } from 'app/core/core';
import { OrgRolePicker } from 'app/features/admin/OrgRolePicker';
import { AccessControlAction, Role, ServiceAccountDTO } from 'app/types';
import { OrgRolePicker } from '../admin/OrgRolePicker';
import { getStyles } from './ServiceAccountsListPage';
type ServiceAccountListItemProps = {
serviceAccount: ServiceAccountDTO;
onRoleChange: (role: OrgRole, serviceAccount: ServiceAccountDTO) => void;
roleOptions: Role[];
builtInRoles: Record<string, Role[]>;
onSetToRemove: (serviceAccount: ServiceAccountDTO) => void;
onRemoveButtonClick: (serviceAccount: ServiceAccountDTO) => void;
onDisable: (serviceAccount: ServiceAccountDTO) => void;
onEnable: (serviceAccount: ServiceAccountDTO) => void;
onAddTokenClick: (serviceAccount: ServiceAccountDTO) => void;
};
const getServiceAccountsAriaLabel = (name: string) => {
return `Edit service account's ${name} details`;
};
const getServiceAccountsEnabledStatus = (disabled: boolean) => {
return disabled ? 'Disabled' : 'Enabled';
};
const ServiceAccountListItem = memo(
({ serviceAccount, onRoleChange, roleOptions, builtInRoles, onSetToRemove }: ServiceAccountListItemProps) => {
({
serviceAccount,
onRoleChange,
roleOptions,
builtInRoles,
onRemoveButtonClick,
onDisable,
onEnable,
onAddTokenClick,
}: ServiceAccountListItemProps) => {
const editUrl = `org/serviceaccounts/${serviceAccount.id}`;
const styles = useStyles2(getStyles);
const canUpdateRole = contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, serviceAccount);
@@ -37,7 +43,7 @@ const ServiceAccountListItem = memo(
const enableRolePicker = contextSrv.hasPermission(AccessControlAction.OrgUsersRoleUpdate) && canUpdateRole;
return (
<tr key={serviceAccount.id}>
<tr key={serviceAccount.id} className={cx({ [styles.disabled]: serviceAccount.isDisabled })}>
<td className="width-4 text-center link-td">
<a href={editUrl} aria-label={getServiceAccountsAriaLabel(serviceAccount.name)}>
<img
@@ -59,7 +65,7 @@ const ServiceAccountListItem = memo(
</td>
<td className="link-td max-width-10">
<a
className="ellipsis"
className={styles.accountId}
href={editUrl}
title={serviceAccount.login}
aria-label={getServiceAccountsAriaLabel(serviceAccount.name)}
@@ -68,7 +74,7 @@ const ServiceAccountListItem = memo(
</a>
</td>
{contextSrv.licensedAccessControlEnabled() ? (
<td className={cx('link-td', styles.iconRow)}>
<td>
{displayRolePicker && (
<UserRolePicker
userId={serviceAccount.id}
@@ -77,30 +83,20 @@ const ServiceAccountListItem = memo(
onBuiltinRoleChange={(newRole) => onRoleChange(newRole, serviceAccount)}
roleOptions={roleOptions}
builtInRoles={builtInRoles}
disabled={!enableRolePicker}
disabled={!enableRolePicker || serviceAccount.isDisabled}
/>
)}
</td>
) : (
<td className={cx('link-td', styles.iconRow)}>
<td>
<OrgRolePicker
aria-label="Role"
value={serviceAccount.role}
disabled={!canUpdateRole}
disabled={!canUpdateRole || serviceAccount.isDisabled}
onChange={(newRole) => onRoleChange(newRole, serviceAccount)}
/>
</td>
)}
<td className="link-td max-width-10">
<a
className="ellipsis"
href={editUrl}
title={getServiceAccountsEnabledStatus(serviceAccount.isDisabled)}
aria-label={getServiceAccountsAriaLabel(serviceAccount.name)}
>
{getServiceAccountsEnabledStatus(serviceAccount.isDisabled)}
</a>
</td>
<td className="link-td max-width-10">
<a
className="ellipsis"
@@ -108,30 +104,78 @@ const ServiceAccountListItem = memo(
title="Tokens"
aria-label={getServiceAccountsAriaLabel(serviceAccount.name)}
>
<div className={cx(styles.tokensInfo, { [styles.tokensInfoSecondary]: !serviceAccount.tokens })}>
<span>
<Icon name={'key-skeleton-alt'}></Icon>
<Icon name="key-skeleton-alt"></Icon>
</span>
&nbsp;
{serviceAccount.tokens}
{serviceAccount.tokens || 'No tokens'}
</div>
</a>
</td>
{contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsDelete, serviceAccount) && (
<td>
<Button
size="sm"
variant="destructive"
onClick={() => {
onSetToRemove(serviceAccount);
}}
icon="times"
aria-label="Delete service account"
/>
</td>
<HorizontalGroup justify="flex-end">
{contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite) && !serviceAccount.tokens && (
<Button onClick={() => onAddTokenClick(serviceAccount)} disabled={serviceAccount.isDisabled}>
Add token
</Button>
)}
{contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, serviceAccount) &&
(serviceAccount.isDisabled ? (
<Button variant="primary" onClick={() => onEnable(serviceAccount)}>
Enable
</Button>
) : (
<Button variant="secondary" onClick={() => onDisable(serviceAccount)}>
Disable
</Button>
))}
{contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsDelete, serviceAccount) && (
<IconButton
className={styles.deleteButton}
name="trash-alt"
size="md"
onClick={() => onRemoveButtonClick(serviceAccount)}
aria-label={`Delete service account ${serviceAccount.name}`}
/>
)}
</HorizontalGroup>
</td>
</tr>
);
}
);
ServiceAccountListItem.displayName = 'ServiceAccountListItem';
const getStyles = (theme: GrafanaTheme2) => {
return {
iconRow: css`
svg {
margin-left: ${theme.spacing(0.5)};
}
`,
accountId: cx(
'ellipsis',
css`
color: ${theme.colors.text.secondary};
`
),
deleteButton: css`
color: ${theme.colors.text.secondary};
`,
tokensInfo: css`
span {
margin-right: ${theme.spacing(1)};
}
`,
tokensInfoSecondary: css`
color: ${theme.colors.text.secondary};
`,
disabled: css`
td a {
color: ${theme.colors.text.secondary};
}
`,
};
};
export default ServiceAccountListItem;

View File

@@ -1,25 +1,21 @@
import { debounce } from 'lodash';
import { getBackendSrv, locationService } from '@grafana/runtime';
import { getBackendSrv } from '@grafana/runtime';
import { fetchBuiltinRoles, fetchRoleOptions } from 'app/core/components/RolePicker/api';
import { accessControlQueryParam } from 'app/core/utils/accessControl';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction, ServiceAccountDTO, ServiceAccountStateFilter, ThunkResult } from 'app/types';
import { contextSrv } from '../../../core/services/context_srv';
import { ServiceAccountDTO, ThunkResult, ServiceAccountFilter, AccessControlAction } from '../../../types';
import { ServiceAccountToken } from '../CreateServiceAccountTokenModal';
import { ServiceAccountToken } from '../components/CreateTokenModal';
import {
acOptionsLoaded,
builtInRolesLoaded,
filterChanged,
pageChanged,
queryChanged,
serviceAccountLoaded,
serviceAccountsFetchBegin,
serviceAccountsFetchEnd,
serviceAccountsFetched,
serviceAccountTokensLoaded,
serviceAccountToRemoveLoaded,
serviceAccountsFetchEnd,
stateFilterChanged,
} from './reducers';
const BASE_URL = `/api/serviceaccounts`;
@@ -45,24 +41,50 @@ export function fetchACOptions(): ThunkResult<void> {
};
}
export function setServiceAccountToRemove(serviceAccount: ServiceAccountDTO | null): ThunkResult<void> {
return async (dispatch) => {
interface FetchServiceAccountsParams {
withLoadingIndicator: boolean;
}
export function fetchServiceAccounts(
{ withLoadingIndicator }: FetchServiceAccountsParams = { withLoadingIndicator: false }
): ThunkResult<void> {
return async (dispatch, getState) => {
try {
dispatch(serviceAccountToRemoveLoaded(serviceAccount));
if (withLoadingIndicator) {
dispatch(serviceAccountsFetchBegin());
}
const { perPage, page, query, serviceAccountStateFilter } = getState().serviceAccounts;
const result = await getBackendSrv().get(
`/api/serviceaccounts/search?perpage=${perPage}&page=${page}&query=${query}${getStateFilter(
serviceAccountStateFilter
)}&accesscontrol=true`
);
dispatch(serviceAccountsFetched(result));
} catch (error) {
console.error(error);
} finally {
serviceAccountsFetchEnd();
}
};
}
export function loadServiceAccount(saID: number): ThunkResult<void> {
const fetchServiceAccountsWithDebounce = debounce((dispatch) => dispatch(fetchServiceAccounts()), 500, {
leading: true,
});
export function updateServiceAccount(serviceAccount: ServiceAccountDTO): ThunkResult<void> {
return async (dispatch) => {
try {
const response = await getBackendSrv().get(`${BASE_URL}/${saID}`, accessControlQueryParam());
dispatch(serviceAccountLoaded(response));
} catch (error) {
console.error(error);
}
await getBackendSrv().patch(`${BASE_URL}/${serviceAccount.id}?accesscontrol=true`, {
...serviceAccount,
});
dispatch(fetchServiceAccounts());
};
}
export function deleteServiceAccount(serviceAccountId: number): ThunkResult<void> {
return async (dispatch) => {
await getBackendSrv().delete(`${BASE_URL}/${serviceAccountId}`);
dispatch(fetchServiceAccounts());
};
}
@@ -74,102 +96,39 @@ export function createServiceAccountToken(
return async (dispatch) => {
const result = await getBackendSrv().post(`${BASE_URL}/${saID}/tokens`, token);
onTokenCreated(result.key);
dispatch(loadServiceAccountTokens(saID));
};
}
export function deleteServiceAccountToken(saID: number, id: number): ThunkResult<void> {
return async (dispatch) => {
await getBackendSrv().delete(`${BASE_URL}/${saID}/tokens/${id}`);
dispatch(loadServiceAccountTokens(saID));
};
}
export function loadServiceAccountTokens(saID: number): ThunkResult<void> {
return async (dispatch) => {
try {
const response = await getBackendSrv().get(`${BASE_URL}/${saID}/tokens`);
dispatch(serviceAccountTokensLoaded(response));
} catch (error) {
console.error(error);
}
};
}
export function updateServiceAccount(serviceAccount: ServiceAccountDTO): ThunkResult<void> {
return async (dispatch) => {
const response = await getBackendSrv().patch(`${BASE_URL}/${serviceAccount.id}?accesscontrol=true`, {
...serviceAccount,
});
dispatch(serviceAccountLoaded(response));
};
}
export function removeServiceAccount(serviceAccountId: number): ThunkResult<void> {
return async (dispatch) => {
await getBackendSrv().delete(`${BASE_URL}/${serviceAccountId}`);
dispatch(fetchServiceAccounts());
};
}
// search / filtering of serviceAccounts
const getFilters = (filters: ServiceAccountFilter[]) => {
return filters
.map((filter) => {
if (Array.isArray(filter.value)) {
return filter.value.map((v) => `${filter.name}=${v.value}`).join('&');
const getStateFilter = (value: ServiceAccountStateFilter) => {
switch (value) {
case ServiceAccountStateFilter.WithExpiredTokens:
return '&expiredTokens=true';
case ServiceAccountStateFilter.Disabled:
return '&disabled=true';
default:
return '';
}
return `${filter.name}=${filter.value}`;
})
.join('&');
};
export function fetchServiceAccounts(): ThunkResult<void> {
return async (dispatch, getState) => {
try {
const { perPage, page, query, filters } = getState().serviceAccounts;
const result = await getBackendSrv().get(
`/api/serviceaccounts/search?perpage=${perPage}&page=${page}&query=${query}&${getFilters(
filters
)}&accesscontrol=true`
);
dispatch(serviceAccountsFetched(result));
} catch (error) {
serviceAccountsFetchEnd();
console.error(error);
}
};
}
const fetchServiceAccountsWithDebounce = debounce((dispatch) => dispatch(fetchServiceAccounts()), 500);
export function changeQuery(query: string): ThunkResult<void> {
return async (dispatch) => {
dispatch(serviceAccountsFetchBegin());
dispatch(queryChanged(query));
fetchServiceAccountsWithDebounce(dispatch);
};
}
export function changeFilter(filter: ServiceAccountFilter): ThunkResult<void> {
export function changeStateFilter(filter: ServiceAccountStateFilter): ThunkResult<void> {
return async (dispatch) => {
dispatch(serviceAccountsFetchBegin());
dispatch(filterChanged(filter));
fetchServiceAccountsWithDebounce(dispatch);
dispatch(stateFilterChanged(filter));
dispatch(fetchServiceAccounts());
};
}
export function changePage(page: number): ThunkResult<void> {
return async (dispatch) => {
dispatch(serviceAccountsFetchBegin());
dispatch(pageChanged(page));
dispatch(fetchServiceAccounts());
};
}
export function deleteServiceAccount(serviceAccountId: number): ThunkResult<void> {
return async () => {
await getBackendSrv().delete(`${BASE_URL}/${serviceAccountId}`);
locationService.push('/org/serviceaccounts');
};
}

View File

@@ -0,0 +1,74 @@
import { getBackendSrv, locationService } from '@grafana/runtime';
import { accessControlQueryParam } from 'app/core/utils/accessControl';
import { ServiceAccountDTO, ThunkResult } from 'app/types';
import { ServiceAccountToken } from '../components/CreateTokenModal';
import {
serviceAccountFetchBegin,
serviceAccountFetchEnd,
serviceAccountLoaded,
serviceAccountTokensLoaded,
} from './reducers';
const BASE_URL = `/api/serviceaccounts`;
export function loadServiceAccount(saID: number): ThunkResult<void> {
return async (dispatch) => {
dispatch(serviceAccountFetchBegin());
try {
const response = await getBackendSrv().get(`${BASE_URL}/${saID}`, accessControlQueryParam());
dispatch(serviceAccountLoaded(response));
} catch (error) {
console.error(error);
} finally {
dispatch(serviceAccountFetchEnd());
}
};
}
export function updateServiceAccount(serviceAccount: ServiceAccountDTO): ThunkResult<void> {
return async (dispatch) => {
await getBackendSrv().patch(`${BASE_URL}/${serviceAccount.id}?accesscontrol=true`, {
...serviceAccount,
});
dispatch(loadServiceAccount(serviceAccount.id));
};
}
export function deleteServiceAccount(serviceAccountId: number): ThunkResult<void> {
return async () => {
await getBackendSrv().delete(`${BASE_URL}/${serviceAccountId}`);
locationService.push('/org/serviceaccounts');
};
}
export function createServiceAccountToken(
saID: number,
token: ServiceAccountToken,
onTokenCreated: (key: string) => void
): ThunkResult<void> {
return async (dispatch) => {
const result = await getBackendSrv().post(`${BASE_URL}/${saID}/tokens`, token);
onTokenCreated(result.key);
dispatch(loadServiceAccountTokens(saID));
};
}
export function deleteServiceAccountToken(saID: number, id: number): ThunkResult<void> {
return async (dispatch) => {
await getBackendSrv().delete(`${BASE_URL}/${saID}/tokens/${id}`);
dispatch(loadServiceAccountTokens(saID));
};
}
export function loadServiceAccountTokens(saID: number): ThunkResult<void> {
return async (dispatch) => {
try {
const response = await getBackendSrv().get(`${BASE_URL}/${saID}/tokens`);
dispatch(serviceAccountTokensLoaded(response));
} catch (error) {
console.error(error);
}
};
}

View File

@@ -4,9 +4,9 @@ import {
ApiKey,
Role,
ServiceAccountDTO,
ServiceAccountFilter,
ServiceAccountProfileState,
ServiceAccountsState,
ServiceAccountStateFilter,
} from 'app/types';
// serviceAccountsProfilePage
@@ -20,6 +20,12 @@ export const serviceAccountProfileSlice = createSlice({
name: 'serviceaccount',
initialState: initialStateProfile,
reducers: {
serviceAccountFetchBegin: (state) => {
return { ...state, isLoading: true };
},
serviceAccountFetchEnd: (state) => {
return { ...state, isLoading: false };
},
serviceAccountLoaded: (state, action: PayloadAction<ServiceAccountDTO>): ServiceAccountProfileState => {
return { ...state, serviceAccount: action.payload, isLoading: false };
},
@@ -30,7 +36,8 @@ export const serviceAccountProfileSlice = createSlice({
});
export const serviceAccountProfileReducer = serviceAccountProfileSlice.reducer;
export const { serviceAccountLoaded, serviceAccountTokensLoaded } = serviceAccountProfileSlice.actions;
export const { serviceAccountLoaded, serviceAccountTokensLoaded, serviceAccountFetchBegin, serviceAccountFetchEnd } =
serviceAccountProfileSlice.actions;
// serviceAccountsListPage
export const initialStateList: ServiceAccountsState = {
@@ -38,13 +45,12 @@ export const initialStateList: ServiceAccountsState = {
isLoading: true,
builtInRoles: {},
roleOptions: [],
serviceAccountToRemove: null,
query: '',
page: 0,
perPage: 50,
totalPages: 1,
showPaging: false,
filters: [{ name: 'expiredTokens', value: false }],
serviceAccountStateFilter: ServiceAccountStateFilter.All,
};
interface ServiceAccountsFetched {
@@ -83,9 +89,6 @@ const serviceAccountsSlice = createSlice({
builtInRolesLoaded: (state, action: PayloadAction<Record<string, Role[]>>): ServiceAccountsState => {
return { ...state, builtInRoles: action.payload };
},
serviceAccountToRemoveLoaded: (state, action: PayloadAction<ServiceAccountDTO | null>): ServiceAccountsState => {
return { ...state, serviceAccountToRemove: action.payload };
},
queryChanged: (state, action: PayloadAction<string>) => {
return {
...state,
@@ -97,20 +100,10 @@ const serviceAccountsSlice = createSlice({
...state,
page: action.payload,
}),
filterChanged: (state, action: PayloadAction<ServiceAccountFilter>) => {
const { name, value } = action.payload;
if (state.filters.some((filter) => filter.name === name)) {
return {
stateFilterChanged: (state, action: PayloadAction<ServiceAccountStateFilter>) => ({
...state,
filters: state.filters.map((filter) => (filter.name === name ? { ...filter, value } : filter)),
};
}
return {
...state,
filters: [...state.filters, action.payload],
};
},
serviceAccountStateFilter: action.payload,
}),
},
});
export const serviceAccountsReducer = serviceAccountsSlice.reducer;
@@ -121,9 +114,8 @@ export const {
serviceAccountsFetched,
acOptionsLoaded,
builtInRolesLoaded,
serviceAccountToRemoveLoaded,
pageChanged,
filterChanged,
stateFilterChanged,
queryChanged,
} = serviceAccountsSlice.actions;

View File

@@ -1,4 +1,4 @@
import { SelectableValue, WithAccessControlMetadata } from '@grafana/data';
import { WithAccessControlMetadata } from '@grafana/data';
import { ApiKey, OrgRole, Role } from '.';
@@ -55,12 +55,16 @@ export interface ServiceAccountProfileState {
tokens: ApiKey[];
}
export type ServiceAccountFilter = Record<string, string | boolean | SelectableValue[]>;
export enum ServiceAccountStateFilter {
All = 'All',
WithExpiredTokens = 'WithExpiredTokens',
Disabled = 'Disabled',
}
export interface ServiceAccountsState {
serviceAccounts: ServiceAccountDTO[];
isLoading: boolean;
roleOptions: Role[];
serviceAccountToRemove: ServiceAccountDTO | null;
builtInRoles: Record<string, Role[]>;
// search / filtering
@@ -69,5 +73,5 @@ export interface ServiceAccountsState {
page: number;
totalPages: number;
showPaging: boolean;
filters: ServiceAccountFilter[];
serviceAccountStateFilter: ServiceAccountStateFilter;
}