mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
180
public/app/features/serviceaccounts/ServiceAccountPage.test.tsx
Normal file
180
public/app/features/serviceaccounts/ServiceAccountPage.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 '{serviceAccountToRemove.name}'
|
||||
{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;
|
||||
|
||||
@@ -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)};
|
||||
`,
|
||||
@@ -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)};
|
||||
`,
|
||||
});
|
||||
@@ -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};
|
||||
`,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
{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;
|
||||
@@ -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('&');
|
||||
}
|
||||
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 getStateFilter = (value: ServiceAccountStateFilter) => {
|
||||
switch (value) {
|
||||
case ServiceAccountStateFilter.WithExpiredTokens:
|
||||
return '&expiredTokens=true';
|
||||
case ServiceAccountStateFilter.Disabled:
|
||||
return '&disabled=true';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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');
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user