Service Accounts: Polish service account detail page (#45846)

* ServiceAccounts: add teams to service account DTO

* ServiceAccounts: Add team display to service accounts

* ServiceAccounts: add AC metadata to detail route

* ServiceAccounts: add role picker to detail page

* ServiceAccounts: Add role to profile DTO

* ServiceAccounts: remove wip mention of created by
pull/45905/head
J Guerreiro 3 years ago committed by GitHub
parent 2334b98802
commit 14ec0cbd3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      pkg/services/serviceaccounts/api/api.go
  2. 14
      pkg/services/serviceaccounts/database/database.go
  3. 19
      pkg/services/serviceaccounts/database/database_test.go
  4. 2
      pkg/services/serviceaccounts/models.go
  5. 27
      public/app/features/serviceaccounts/ServiceAccountPage.tsx
  6. 23
      public/app/features/serviceaccounts/ServiceAccountProfile.tsx
  7. 58
      public/app/features/serviceaccounts/ServiceAccountRoleRow.tsx
  8. 2
      public/app/features/serviceaccounts/state/actions.ts
  9. 1
      public/app/types/serviceaccount.ts

@ -197,8 +197,10 @@ func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *models.ReqContext) re
}
}
saIDString := strconv.FormatInt(serviceAccount.Id, 10)
metadata := api.getAccessControlMetadata(ctx, map[string]bool{saIDString: true})
serviceAccount.AvatarUrl = dtos.GetGravatarUrlWithDefault("", serviceAccount.Name)
serviceAccount.AccessControl = metadata[saIDString]
return response.JSON(http.StatusOK, serviceAccount)
}

@ -178,6 +178,18 @@ func (s *ServiceAccountsStoreImpl) RetrieveServiceAccount(ctx context.Context, o
return nil, serviceaccounts.ErrServiceAccountNotFound
}
// Get Teams of service account. Can be optimized by combining with the query above
// in refactor
getTeamQuery := models.GetTeamsByUserQuery{UserId: serviceAccountID, OrgId: orgID}
if err := s.sqlStore.GetTeamsByUser(ctx, &getTeamQuery); err != nil {
return nil, err
}
teams := make([]string, len(getTeamQuery.Result))
for i := range getTeamQuery.Result {
teams[i] = getTeamQuery.Result[i].Name
}
saProfile := &serviceaccounts.ServiceAccountProfileDTO{
Id: query.Result[0].UserId,
Name: query.Result[0].Name,
@ -185,6 +197,8 @@ func (s *ServiceAccountsStoreImpl) RetrieveServiceAccount(ctx context.Context, o
OrgId: query.Result[0].OrgId,
UpdatedAt: query.Result[0].Updated,
CreatedAt: query.Result[0].Created,
Role: query.Result[0].Role,
Teams: teams,
}
return saProfile, nil
}

@ -4,6 +4,7 @@ import (
"context"
"testing"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/serviceaccounts/tests"
"github.com/grafana/grafana/pkg/services/sqlstore"
@ -76,7 +77,25 @@ func TestStore_RetrieveServiceAccount(t *testing.T) {
} else {
require.NoError(t, err)
require.Equal(t, c.user.Login, dto.Login)
require.Len(t, dto.Teams, 0)
}
})
}
}
func TestStore_RetrieveServiceAccountWithTeams(t *testing.T) {
userToCreate := tests.TestUser{Login: "servicetestwithTeam@admin", IsServiceAccount: true}
db, store := setupTestDatabase(t)
user := tests.SetupUserServiceAccount(t, db, userToCreate)
team, err := store.sqlStore.CreateTeam("serviceTeam", "serviceTeam", user.OrgId)
require.NoError(t, err)
err = store.sqlStore.AddTeamMember(user.Id, user.OrgId, team.Id, false, models.PERMISSION_VIEW)
require.NoError(t, err)
dto, err := store.RetrieveServiceAccount(context.Background(), user.OrgId, user.Id)
require.NoError(t, err)
require.Equal(t, userToCreate.Login, dto.Login)
require.Len(t, dto.Teams, 1)
require.Equal(t, "serviceTeam", dto.Teams[0])
}

@ -53,5 +53,7 @@ type ServiceAccountProfileDTO struct {
UpdatedAt time.Time `json:"updatedAt"`
CreatedAt time.Time `json:"createdAt"`
AvatarUrl string `json:"avatarUrl"`
Role string `json:"role"`
Teams []string `json:"teams"`
AccessControl map[string]bool `json:"accessControl,omitempty"`
}

@ -3,24 +3,29 @@ import { connect, ConnectedProps } from 'react-redux';
import { getNavModel } from 'app/core/selectors/navModel';
import Page from 'app/core/components/Page/Page';
import { ServiceAccountProfile } from './ServiceAccountProfile';
import { StoreState, ServiceAccountDTO, ApiKey } from 'app/types';
import { StoreState, ServiceAccountDTO, ApiKey, Role } from 'app/types';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import {
deleteServiceAccountToken,
loadServiceAccount,
loadServiceAccountTokens,
createServiceAccountToken,
fetchACOptions,
updateServiceAccount,
} from './state/actions';
import { ServiceAccountTokensTable } from './ServiceAccountTokensTable';
import { getTimeZone, NavModel } from '@grafana/data';
import { Button, VerticalGroup } from '@grafana/ui';
import { CreateTokenModal } from './CreateTokenModal';
import { contextSrv } from 'app/core/core';
interface OwnProps extends GrafanaRouteComponentProps<{ id: string }> {
navModel: NavModel;
serviceAccount?: ServiceAccountDTO;
tokens: ApiKey[];
isLoading: boolean;
roleOptions: Role[];
builtInRoles: Record<string, Role[]>;
}
function mapStateToProps(state: StoreState) {
@ -29,6 +34,8 @@ function mapStateToProps(state: StoreState) {
serviceAccount: state.serviceAccountProfile.serviceAccount,
tokens: state.serviceAccountProfile.tokens,
isLoading: state.serviceAccountProfile.isLoading,
roleOptions: state.serviceAccounts.roleOptions,
builtInRoles: state.serviceAccounts.builtInRoles,
timezone: getTimeZone(state.user),
};
}
@ -37,6 +44,7 @@ const mapDispatchToProps = {
loadServiceAccountTokens,
createServiceAccountToken,
deleteServiceAccountToken,
fetchACOptions,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
@ -49,10 +57,13 @@ const ServiceAccountPageUnconnected = ({
tokens,
timezone,
isLoading,
roleOptions,
builtInRoles,
loadServiceAccount,
loadServiceAccountTokens,
createServiceAccountToken,
deleteServiceAccountToken,
fetchACOptions,
}: Props) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [newToken, setNewToken] = useState('');
@ -61,7 +72,10 @@ const ServiceAccountPageUnconnected = ({
const serviceAccountId = parseInt(match.params.id, 10);
loadServiceAccount(serviceAccountId);
loadServiceAccountTokens(serviceAccountId);
}, [match, loadServiceAccount, loadServiceAccountTokens]);
if (contextSrv.accessControlEnabled()) {
fetchACOptions();
}
}, [match, loadServiceAccount, loadServiceAccountTokens, fetchACOptions]);
const onDeleteServiceAccountToken = (key: ApiKey) => {
deleteServiceAccountToken(parseInt(match.params.id, 10), key.id!);
@ -76,6 +90,12 @@ const ServiceAccountPageUnconnected = ({
setNewToken('');
};
const onRoleChange = (role: OrgRole, serviceAccount: ServiceAccountDTO) => {
const updatedServiceAccount = { ...serviceAccount, role: role };
updateServiceAccount(updatedServiceAccount);
};
return (
<Page navModel={navModel}>
<Page.Contents isLoading={isLoading}>
@ -96,6 +116,9 @@ const ServiceAccountPageUnconnected = ({
onServiceAccountEnable={() => {
console.log(`not implemented`);
}}
onRoleChange={onRoleChange}
roleOptions={roleOptions}
builtInRoles={builtInRoles}
/>
</>
)}

@ -1,9 +1,10 @@
import React, { PureComponent, useRef, useState } from 'react';
import { ServiceAccountDTO } from 'app/types';
import { Role, ServiceAccountDTO } from 'app/types';
import { css, cx } from '@emotion/css';
import { config } from 'app/core/config';
import { dateTimeFormat, GrafanaTheme, TimeZone } from '@grafana/data';
import { dateTimeFormat, GrafanaTheme, OrgRole, TimeZone } from '@grafana/data';
import { Button, ConfirmButton, ConfirmModal, Input, LegacyInputStatus, stylesFactory } from '@grafana/ui';
import { ServiceAccountRoleRow } from './ServiceAccountRoleRow';
interface Props {
serviceAccount: ServiceAccountDTO;
@ -13,6 +14,10 @@ interface Props {
onServiceAccountDelete: (serviceAccountId: number) => void;
onServiceAccountDisable: (serviceAccountId: number) => void;
onServiceAccountEnable: (serviceAccountId: number) => void;
onRoleChange: (role: OrgRole, serviceAccount: ServiceAccountDTO) => void;
roleOptions: Role[];
builtInRoles: Record<string, Role[]>;
}
export function ServiceAccountProfile({
@ -22,6 +27,9 @@ export function ServiceAccountProfile({
onServiceAccountDelete,
onServiceAccountDisable,
onServiceAccountEnable,
onRoleChange,
roleOptions,
builtInRoles,
}: Props) {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showDisableModal, setShowDisableModal] = useState(false);
@ -73,9 +81,14 @@ export function ServiceAccountProfile({
onChange={onServiceAccountNameChange}
/>
<ServiceAccountProfileRow label="ID" value={serviceAccount.login} />
<ServiceAccountProfileRow label="Roles" value="WIP" />
<ServiceAccountProfileRow label="Teams" value="WIP" />
<ServiceAccountProfileRow label="Created by" value="WIP" />
<ServiceAccountRoleRow
label="Roles"
serviceAccount={serviceAccount}
onRoleChange={onRoleChange}
builtInRoles={builtInRoles}
roleOptions={roleOptions}
/>
<ServiceAccountProfileRow label="Teams" value={serviceAccount.teams.join(', ')} />
<ServiceAccountProfileRow
label="Creation date"
value={dateTimeFormat(serviceAccount.createdAt, { timeZone })}

@ -0,0 +1,58 @@
import React, { PureComponent } from 'react';
import { css, cx } from '@emotion/css';
import { AccessControlAction, OrgRole, Role, ServiceAccountDTO } from 'app/types';
import { OrgRolePicker } from '../admin/OrgRolePicker';
import { contextSrv } from 'app/core/core';
import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
interface Props {
label: string;
serviceAccount: ServiceAccountDTO;
onRoleChange: (role: OrgRole, serviceAccount: ServiceAccountDTO) => 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={(newRole) => onRoleChange(newRole, serviceAccount)}
roleOptions={roleOptions}
builtInRoles={builtInRoles}
disabled={rolePickerDisabled}
/>
) : (
<OrgRolePicker
aria-label="Role"
value={serviceAccount.role}
disabled={!canUpdateRole}
onChange={(newRole) => onRoleChange(newRole, serviceAccount)}
/>
)}
</td>
<td></td>
</tr>
);
}
}

@ -39,7 +39,7 @@ export function setServiceAccountToRemove(serviceAccount: ServiceAccountDTO | nu
export function loadServiceAccount(saID: number): ThunkResult<void> {
return async (dispatch) => {
try {
const response = await getBackendSrv().get(`${BASE_URL}/${saID}`);
const response = await getBackendSrv().get(`${BASE_URL}/${saID}`, accessControlQueryParam());
dispatch(serviceAccountLoaded(response));
} catch (error) {
console.error(error);

@ -33,6 +33,7 @@ export interface ServiceAccountDTO extends WithAccessControlMetadata {
avatarUrl?: string;
createdAt: string;
isDisabled: boolean;
teams: string[];
role: OrgRole;
}

Loading…
Cancel
Save