Service accounts: Remove Add API keys buttons and remove one state of migrating for API keys tab (#63411)

* add: hide apikeys tab on start

* make use of store method

* added hiding of apikeys tab for new org creation

* missing err check

* removed unused files

* implemennted fake to make tests run

* move check for globalHideApikeys from org to admin

* refactor to remove the fake

* removed unused method calls for interface

* Update pkg/services/serviceaccounts/manager/service.go

Co-authored-by: Alexander Zobnin <alexanderzobnin@gmail.com>

* Update pkg/services/serviceaccounts/manager/service.go

Co-authored-by: Alexander Zobnin <alexanderzobnin@gmail.com>

* remove the checkglobal method

* removed duplicate global set const

* add count of apikeys for performance

* remove apikeys adding in UI

* added back deleted file

* added comment on component

* changed wording and copy for hiding and migrating service accounts

* refactor: remove migrationstatus in front/backend

This removes the migrationstatus state from the UI in favor of only
looking at the number of API keys to determine what to show to the user.
This simplifies the logic and makes less calls to the backend with each
page load. This was called both on the API keys page and the Service
accounts page.

- removes the state of migrationstatus from the UI
- removes the backend call
- removes the backend endpoint for migrationstatus

* Update pkg/services/apikey/apikeyimpl/xorm_store.go

Co-authored-by: Karl Persson <kalle.persson@grafana.com>

* changes the contet to also be primary

* change id of version for footer component

---------

Co-authored-by: Alexander Zobnin <alexanderzobnin@gmail.com>
Co-authored-by: Karl Persson <kalle.persson@grafana.com>
This commit is contained in:
Eric Leijonmarck 2023-03-01 15:34:53 +00:00 committed by GitHub
parent 8e9ccfc66e
commit 9d6ab92e39
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 96 additions and 564 deletions

View File

@ -2795,9 +2795,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "5"],
[0, 0, 0, "Unexpected any. Specify a different type.", "6"]
],
"public/app/features/api-keys/ApiKeysForm.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/canvas/element.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],

View File

@ -7,6 +7,7 @@ import (
type Service interface {
GetAPIKeys(ctx context.Context, query *GetApiKeysQuery) error
GetAllAPIKeys(ctx context.Context, orgID int64) ([]*APIKey, error)
CountAPIKeys(ctx context.Context, orgID int64) (int64, error)
DeleteApiKey(ctx context.Context, cmd *DeleteCommand) error
AddAPIKey(ctx context.Context, cmd *AddCommand) error
GetApiKeyById(ctx context.Context, query *GetByIDQuery) error

View File

@ -68,6 +68,9 @@ func (s *Service) AddAPIKey(ctx context.Context, cmd *apikey.AddCommand) error {
func (s *Service) UpdateAPIKeyLastUsedDate(ctx context.Context, tokenID int64) error {
return s.store.UpdateAPIKeyLastUsedDate(ctx, tokenID)
}
func (s *Service) CountAPIKeys(ctx context.Context, orgID int64) (int64, error) {
return s.store.CountAPIKeys(ctx, orgID)
}
func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) {
limits := &quota.Map{}

View File

@ -63,6 +63,18 @@ func (ss *sqlxStore) GetAllAPIKeys(ctx context.Context, orgID int64) ([]*apikey.
return result, err
}
func (ss *sqlxStore) CountAPIKeys(ctx context.Context, orgID int64) (int64, error) {
type result struct {
Count int64
}
r := result{}
err := ss.sess.Get(ctx, &r, `SELECT COUNT(*) AS count FROM api_key WHERE service_account_id IS NULL and org_id = ?`, orgID)
if err != nil {
return 0, err
}
return r.Count, err
}
func (ss *sqlxStore) DeleteApiKey(ctx context.Context, cmd *apikey.DeleteCommand) error {
res, err := ss.sess.Exec(ctx, "DELETE FROM api_key WHERE id=? and org_id=? and service_account_id IS NULL", cmd.ID, cmd.OrgID)
if err != nil {

View File

@ -10,6 +10,7 @@ import (
type store interface {
GetAPIKeys(ctx context.Context, query *apikey.GetApiKeysQuery) error
GetAllAPIKeys(ctx context.Context, orgID int64) ([]*apikey.APIKey, error)
CountAPIKeys(ctx context.Context, orgID int64) (int64, error)
DeleteApiKey(ctx context.Context, cmd *apikey.DeleteCommand) error
AddAPIKey(ctx context.Context, cmd *apikey.AddCommand) error
GetApiKeyById(ctx context.Context, query *apikey.GetByIDQuery) error

View File

@ -65,6 +65,25 @@ func (ss *sqlStore) GetAllAPIKeys(ctx context.Context, orgID int64) ([]*apikey.A
return result, err
}
func (ss *sqlStore) CountAPIKeys(ctx context.Context, orgID int64) (int64, error) {
type result struct {
Count int64
}
r := result{}
err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
rawSQL := "SELECT COUNT(*) AS count FROM api_key WHERE org_id = ? and service_account_id IS NULL"
if _, err := sess.SQL(rawSQL, orgID).Get(&r); err != nil {
return err
}
return nil
})
if err != nil {
return 0, err
}
return r.Count, err
}
func (ss *sqlStore) DeleteApiKey(ctx context.Context, cmd *apikey.DeleteCommand) error {
return ss.db.WithDbSession(ctx, func(sess *db.Session) error {
rawSQL := "DELETE FROM api_key WHERE id=? and org_id=? and service_account_id IS NULL"

View File

@ -8,6 +8,7 @@ import (
type Service struct {
ExpectedError error
ExpectedCount int64
ExpectedAPIKeys []*apikey.APIKey
ExpectedAPIKey *apikey.APIKey
}
@ -19,6 +20,9 @@ func (s *Service) GetAPIKeys(ctx context.Context, query *apikey.GetApiKeysQuery)
func (s *Service) GetAllAPIKeys(ctx context.Context, orgID int64) ([]*apikey.APIKey, error) {
return s.ExpectedAPIKeys, s.ExpectedError
}
func (s *Service) CountAPIKeys(ctx context.Context, orgID int64) (int64, error) {
return s.ExpectedCount, s.ExpectedError
}
func (s *Service) GetApiKeyById(ctx context.Context, query *apikey.GetByIDQuery) error {
query.Result = s.ExpectedAPIKey
return s.ExpectedError

View File

@ -80,12 +80,13 @@ func (s *ServiceImpl) getOrgAdminNode(c *contextmodel.ReqContext) (*navtree.NavL
}
hideApiKeys, _, _ := s.kvStore.Get(c.Req.Context(), c.OrgID, "serviceaccounts", "hideApiKeys")
apiKeys, err := s.apiKeyService.GetAllAPIKeys(c.Req.Context(), c.OrgID)
apiKeys, err := s.apiKeyService.CountAPIKeys(c.Req.Context(), c.OrgID)
if err != nil {
return nil, err
}
apiKeysHidden := hideApiKeys == "1" && len(apiKeys) == 0
// Hide API keys if the global setting is set or if the org setting is set and there are no API keys
apiKeysHidden := hideApiKeys == "1" && apiKeys == 0
if hasAccess(ac.ReqOrgAdmin, ac.ApiKeyAccessEvaluator) && !apiKeysHidden {
configNodes = append(configNodes, &navtree.NavLink{
Text: "API keys",

View File

@ -41,7 +41,6 @@ type service interface {
SearchOrgServiceAccounts(ctx context.Context, query *serviceaccounts.SearchOrgServiceAccountsQuery) (*serviceaccounts.SearchOrgServiceAccountsResult, error)
ListTokens(ctx context.Context, query *serviceaccounts.GetSATokensQuery) ([]apikey.APIKey, error)
DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error
GetAPIKeysMigrationStatus(ctx context.Context, orgID int64) (*serviceaccounts.APIKeysMigrationStatus, error)
HideApiKeysTab(ctx context.Context, orgID int64) error
MigrateApiKeysToServiceAccounts(ctx context.Context, orgID int64) error
MigrateApiKey(ctx context.Context, orgID int64, keyId int64) error
@ -89,8 +88,6 @@ func (api *ServiceAccountsAPI) RegisterAPIEndpoints() {
accesscontrol.EvalPermission(serviceaccounts.ActionWrite, serviceaccounts.ScopeID)), routing.Wrap(api.CreateToken))
serviceAccountsRoute.Delete("/:serviceAccountId/tokens/:tokenId", auth(middleware.ReqOrgAdmin,
accesscontrol.EvalPermission(serviceaccounts.ActionWrite, serviceaccounts.ScopeID)), routing.Wrap(api.DeleteToken))
serviceAccountsRoute.Get("/migrationstatus", auth(middleware.ReqOrgAdmin,
accesscontrol.EvalPermission(serviceaccounts.ActionRead)), routing.Wrap(api.GetAPIKeysMigrationStatus))
serviceAccountsRoute.Post("/hideApiKeys", auth(middleware.ReqOrgAdmin,
accesscontrol.EvalPermission(serviceaccounts.ActionCreate)), routing.Wrap(api.HideApiKeysTab))
serviceAccountsRoute.Post("/migrate", auth(middleware.ReqOrgAdmin,
@ -364,15 +361,6 @@ func (api *ServiceAccountsAPI) SearchOrgServiceAccountsWithPaging(c *contextmode
return response.JSON(http.StatusOK, serviceAccountSearch)
}
// GET /api/serviceaccounts/migrationstatus
func (api *ServiceAccountsAPI) GetAPIKeysMigrationStatus(ctx *contextmodel.ReqContext) response.Response {
upgradeStatus, err := api.service.GetAPIKeysMigrationStatus(ctx.Req.Context(), ctx.OrgID)
if err != nil {
return response.Error(http.StatusInternalServerError, "Internal server error", err)
}
return response.JSON(http.StatusOK, upgradeStatus)
}
// POST /api/serviceaccounts/hideapikeys
func (api *ServiceAccountsAPI) HideApiKeysTab(ctx *contextmodel.ReqContext) response.Response {
if err := api.service.HideApiKeysTab(ctx.Req.Context(), ctx.OrgID); err != nil {

View File

@ -81,7 +81,7 @@ func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) {
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
server := setupTests(t, func(a *ServiceAccountsAPI) {
a.service = &fakeService{ExpectedServiceAccount: tt.expectedSA, ExpectedErr: tt.expectedErr}
a.service = &fakeServiceAccountService{ExpectedServiceAccount: tt.expectedSA, ExpectedErr: tt.expectedErr}
})
req := server.NewRequest(http.MethodPost, "/api/serviceaccounts/", strings.NewReader(tt.body))
webtest.RequestWithSignedInUser(req, &user.SignedInUser{OrgRole: tt.basicRole, OrgID: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}})
@ -159,7 +159,7 @@ func TestServiceAccountsAPI_RetrieveServiceAccount(t *testing.T) {
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
server := setupTests(t, func(a *ServiceAccountsAPI) {
a.service = &fakeService{ExpectedServiceAccountProfile: tt.expectedSA}
a.service = &fakeServiceAccountService{ExpectedServiceAccountProfile: tt.expectedSA}
})
req := server.NewGetRequest(fmt.Sprintf("/api/serviceaccounts/%d", tt.id))
webtest.RequestWithSignedInUser(req, &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}})
@ -221,7 +221,7 @@ func TestServiceAccountsAPI_UpdateServiceAccount(t *testing.T) {
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
server := setupTests(t, func(a *ServiceAccountsAPI) {
a.service = &fakeService{ExpectedServiceAccountProfile: tt.expectedSA}
a.service = &fakeServiceAccountService{ExpectedServiceAccountProfile: tt.expectedSA}
})
req := server.NewRequest(http.MethodPatch, fmt.Sprintf("/api/serviceaccounts/%d", tt.id), strings.NewReader(tt.body))
@ -240,7 +240,7 @@ func setupTests(t *testing.T, opts ...func(a *ServiceAccountsAPI)) *webtest.Serv
cfg := setting.NewCfg()
api := &ServiceAccountsAPI{
cfg: cfg,
service: &fakeService{},
service: &fakeServiceAccountService{},
accesscontrolService: &actest.FakeService{},
accesscontrol: acimpl.ProvideAccessControl(cfg),
RouterRegister: routing.NewRouteRegister(),
@ -255,9 +255,9 @@ func setupTests(t *testing.T, opts ...func(a *ServiceAccountsAPI)) *webtest.Serv
return webtest.NewServer(t, api.RouterRegister)
}
var _ service = new(fakeService)
var _ service = new(fakeServiceAccountService)
type fakeService struct {
type fakeServiceAccountService struct {
service
ExpectedErr error
ExpectedAPIKey *apikey.APIKey
@ -266,30 +266,30 @@ type fakeService struct {
ExpectedServiceAccountProfile *serviceaccounts.ServiceAccountProfileDTO
}
func (f *fakeService) CreateServiceAccount(ctx context.Context, orgID int64, saForm *serviceaccounts.CreateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) {
func (f *fakeServiceAccountService) CreateServiceAccount(ctx context.Context, orgID int64, saForm *serviceaccounts.CreateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) {
return f.ExpectedServiceAccount, f.ExpectedErr
}
func (f *fakeService) DeleteServiceAccount(ctx context.Context, orgID, id int64) error {
func (f *fakeServiceAccountService) DeleteServiceAccount(ctx context.Context, orgID, id int64) error {
return f.ExpectedErr
}
func (f *fakeService) RetrieveServiceAccount(ctx context.Context, orgID, id int64) (*serviceaccounts.ServiceAccountProfileDTO, error) {
func (f *fakeServiceAccountService) RetrieveServiceAccount(ctx context.Context, orgID, id int64) (*serviceaccounts.ServiceAccountProfileDTO, error) {
return f.ExpectedServiceAccountProfile, f.ExpectedErr
}
func (f *fakeService) ListTokens(ctx context.Context, query *serviceaccounts.GetSATokensQuery) ([]apikey.APIKey, error) {
func (f *fakeServiceAccountService) ListTokens(ctx context.Context, query *serviceaccounts.GetSATokensQuery) ([]apikey.APIKey, error) {
return f.ExpectedServiceAccountTokens, f.ExpectedErr
}
func (f *fakeService) UpdateServiceAccount(ctx context.Context, orgID, id int64, cmd *serviceaccounts.UpdateServiceAccountForm) (*serviceaccounts.ServiceAccountProfileDTO, error) {
func (f *fakeServiceAccountService) UpdateServiceAccount(ctx context.Context, orgID, id int64, cmd *serviceaccounts.UpdateServiceAccountForm) (*serviceaccounts.ServiceAccountProfileDTO, error) {
return f.ExpectedServiceAccountProfile, f.ExpectedErr
}
func (f *fakeService) AddServiceAccountToken(ctx context.Context, id int64, cmd *serviceaccounts.AddServiceAccountTokenCommand) (*apikey.APIKey, error) {
func (f *fakeServiceAccountService) AddServiceAccountToken(ctx context.Context, id int64, cmd *serviceaccounts.AddServiceAccountTokenCommand) (*apikey.APIKey, error) {
return f.ExpectedAPIKey, f.ExpectedErr
}
func (f *fakeService) DeleteServiceAccountToken(ctx context.Context, orgID, id, tokenID int64) error {
func (f *fakeServiceAccountService) DeleteServiceAccountToken(ctx context.Context, orgID, id, tokenID int64) error {
return f.ExpectedErr
}

View File

@ -43,7 +43,7 @@ func TestServiceAccountsAPI_ListTokens(t *testing.T) {
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
server := setupTests(t, func(a *ServiceAccountsAPI) {
a.service = &fakeService{}
a.service = &fakeServiceAccountService{}
})
req := server.NewGetRequest(fmt.Sprintf("/api/serviceaccounts/%d/tokens", tt.id))
webtest.RequestWithSignedInUser(req, &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}})
@ -109,7 +109,7 @@ func TestServiceAccountsAPI_CreateToken(t *testing.T) {
t.Run(tt.desc, func(t *testing.T) {
server := setupTests(t, func(a *ServiceAccountsAPI) {
a.cfg.ApiKeyMaxSecondsToLive = tt.tokenTTL
a.service = &fakeService{
a.service = &fakeServiceAccountService{
ExpectedErr: tt.expectedErr,
ExpectedAPIKey: tt.expectedAPIKey,
}
@ -163,7 +163,7 @@ func TestServiceAccountsAPI_DeleteToken(t *testing.T) {
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
server := setupTests(t, func(a *ServiceAccountsAPI) {
a.service = &fakeService{ExpectedErr: tt.expectedErr}
a.service = &fakeServiceAccountService{ExpectedErr: tt.expectedErr}
})
req := server.NewRequest(http.MethodDelete, fmt.Sprintf("/api/serviceaccounts/%d/tokens/%d", tt.saID, tt.apikeyID), nil)

View File

@ -388,22 +388,6 @@ func (s *ServiceAccountsStoreImpl) SearchOrgServiceAccounts(ctx context.Context,
return searchResult, nil
}
func (s *ServiceAccountsStoreImpl) GetAPIKeysMigrationStatus(ctx context.Context, orgId int64) (status *serviceaccounts.APIKeysMigrationStatus, err error) {
migrationStatus, exists, err := s.kvStore.Get(ctx, orgId, "serviceaccounts", "migrationStatus")
if err != nil {
return nil, err
}
if exists && migrationStatus == "1" {
return &serviceaccounts.APIKeysMigrationStatus{
Migrated: true,
}, nil
} else {
return &serviceaccounts.APIKeysMigrationStatus{
Migrated: false,
}, nil
}
}
func (s *ServiceAccountsStoreImpl) HideApiKeysTab(ctx context.Context, orgId int64) error {
if err := s.kvStore.Set(ctx, orgId, "serviceaccounts", "hideApiKeys", "1"); err != nil {
s.log.Error("Failed to hide API keys tab", err)

View File

@ -226,13 +226,6 @@ func (sa *ServiceAccountsService) DeleteServiceAccountToken(ctx context.Context,
return sa.store.DeleteServiceAccountToken(ctx, orgID, serviceAccountID, tokenID)
}
func (sa *ServiceAccountsService) GetAPIKeysMigrationStatus(ctx context.Context, orgID int64) (status *serviceaccounts.APIKeysMigrationStatus, err error) {
if err := validOrgID(orgID); err != nil {
return nil, err
}
return sa.store.GetAPIKeysMigrationStatus(ctx, orgID)
}
func (sa *ServiceAccountsService) HideApiKeysTab(ctx context.Context, orgID int64) error {
if err := validOrgID(orgID); err != nil {
return err

View File

@ -17,10 +17,10 @@ type FakeServiceAccountStore struct {
ExpectedServiceAccountDTO *serviceaccounts.ServiceAccountDTO
ExpectedServiceAccountProfileDTO *serviceaccounts.ServiceAccountProfileDTO
ExpectedSearchServiceAccountQueryResult *serviceaccounts.SearchOrgServiceAccountsResult
ExpectedServiceAccountMigrationStatus *serviceaccounts.APIKeysMigrationStatus
ExpectedStats *serviceaccounts.Stats
ExpectedAPIKeys []apikey.APIKey
ExpectedAPIKey *apikey.APIKey
ExpectedBoolean bool
ExpectedError error
}
@ -59,11 +59,6 @@ func (f *FakeServiceAccountStore) DeleteServiceAccount(ctx context.Context, orgI
return f.ExpectedError
}
// GetAPIKeysMigrationStatus is a fake getting the api keys migration status.
func (f *FakeServiceAccountStore) GetAPIKeysMigrationStatus(ctx context.Context, orgID int64) (*serviceaccounts.APIKeysMigrationStatus, error) {
return f.ExpectedServiceAccountMigrationStatus, f.ExpectedError
}
// HideApiKeysTab is a fake hiding the api keys tab.
func (f *FakeServiceAccountStore) HideApiKeysTab(ctx context.Context, orgID int64) error {
return f.ExpectedError

View File

@ -26,7 +26,6 @@ type store interface {
RetrieveServiceAccount(ctx context.Context, orgID, serviceAccountID int64) (*serviceaccounts.ServiceAccountProfileDTO, error)
RetrieveServiceAccountIdByName(ctx context.Context, orgID int64, name string) (int64, error)
DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error
GetAPIKeysMigrationStatus(ctx context.Context, orgID int64) (*serviceaccounts.APIKeysMigrationStatus, error)
HideApiKeysTab(ctx context.Context, orgID int64) error
MigrateApiKeysToServiceAccounts(ctx context.Context, orgID int64) error
MigrateApiKey(ctx context.Context, orgID int64, keyId int64) error

View File

@ -130,10 +130,6 @@ type ServiceAccountProfileDTO struct {
type ServiceAccountFilter string // used for filtering
type APIKeysMigrationStatus struct {
Migrated bool `json:"migrated"`
}
const (
FilterOnlyExpiredTokens ServiceAccountFilter = "expiredTokens"
FilterOnlyDisabled ServiceAccountFilter = "disabled"

View File

@ -55,7 +55,7 @@ export function getVersionLinks(): FooterLink[] {
links.push({
target: '_blank',
id: 'version',
id: 'license',
text: `${buildInfo.edition}${stateInfo}`,
url: licenseInfo.licenseUrl,
});

View File

@ -6,29 +6,31 @@ import { Alert, ConfirmModal, useStyles2, Button } from '@grafana/ui';
interface Props {
onHideApiKeys: () => void;
apikeys: number;
}
export const APIKeysMigratedCard = ({ onHideApiKeys }: Props): JSX.Element => {
export const APIKeysMigratedCard = ({ onHideApiKeys, apikeys }: Props): JSX.Element => {
const [isModalOpen, setIsModalOpen] = useState(false);
const styles = useStyles2(getStyles);
return (
<Alert title="API keys were migrated to Grafana service accounts. This tab is deprecated." severity="info">
<Alert title="If you see any API keys please migrate them to Service account tokens." severity="info">
<div className={styles.text}>
We have migrated API keys into Grafana service accounts. All API keys are safe and continue working as they used
to, you can find them inside the respective service account.
Migrated API keys are safe and continue working as they used to. You can find them inside the respective service
account.
</div>
<div className={styles.actionRow}>
<Button className={styles.actionButton} onClick={() => setIsModalOpen(true)}>
Hide API keys page forever
<Button className={styles.actionButton} onClick={() => setIsModalOpen(true)} disabled={apikeys !== 0}>
Hide API keys page
</Button>
<ConfirmModal
title={'Hide API Keys page forever'}
title={'Hide API Keys page'}
isOpen={isModalOpen}
body={'Are you sure you want to hide API keys page forever and use service accounts from now on?'}
body={'Did you want to hide the API keys page?'}
confirmText={'Yes, hide API keys page.'}
onConfirm={onHideApiKeys}
onDismiss={() => setIsModalOpen(false)}
confirmButtonVariant="primary"
/>
<a href="org/serviceaccounts">View service accounts page</a>
</div>

View File

@ -1,23 +1,19 @@
import React, { FC } from 'react';
import { Button, FilterInput } from '@grafana/ui';
import { FilterInput } from '@grafana/ui';
interface Props {
searchQuery: string;
disabled: boolean;
onAddClick: () => void;
onSearchChange: (value: string) => void;
}
export const ApiKeysActionBar: FC<Props> = ({ searchQuery, disabled, onAddClick, onSearchChange }) => {
export const ApiKeysActionBar: FC<Props> = ({ searchQuery, disabled, onSearchChange }) => {
return (
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<FilterInput placeholder="Search keys" value={searchQuery} onChange={onSearchChange} />
</div>
<Button className="pull-right" onClick={onAddClick} disabled={disabled}>
Add API key
</Button>
</div>
);
};

View File

@ -1,41 +0,0 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { ApiKeysAddedModal, Props } from './ApiKeysAddedModal';
describe('ApiKeysAddedModal', () => {
const props: Props = {
onDismiss: jest.fn(),
apiKey: 'myApiKey',
rootPath: 'test/path',
};
it('should render without throwing', () => {
expect(() => render(<ApiKeysAddedModal {...props} />)).not.toThrow();
});
it('displays the apiKey in a readOnly input', () => {
render(<ApiKeysAddedModal {...props} />);
const input = screen.getByRole('textbox');
expect(input).toHaveValue(props.apiKey);
expect(input).toHaveAttribute('readonly');
});
it('has a `Copy to clipboard` button', () => {
render(<ApiKeysAddedModal {...props} />);
expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument();
});
it('displays the correct curl path', () => {
render(<ApiKeysAddedModal {...props} />);
expect(
screen.getByText('curl -H "Authorization: Bearer myApiKey" test/path/api/dashboards/home')
).toBeInTheDocument();
});
it('calls onDismiss when the modal is closed', () => {
render(<ApiKeysAddedModal {...props} />);
screen.getByRole('button', { name: 'Close dialogue' }).click();
expect(props.onDismiss).toHaveBeenCalled();
});
});

View File

@ -1,55 +0,0 @@
import { css } from '@emotion/css';
import React, { useCallback } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, Field, Modal, useStyles2, Input, ClipboardButton } from '@grafana/ui';
export interface Props {
onDismiss: () => void;
apiKey: string;
rootPath: string;
}
export function ApiKeysAddedModal({ onDismiss, apiKey, rootPath }: Props): JSX.Element {
const styles = useStyles2(getStyles);
const getClipboardText = useCallback(() => apiKey, [apiKey]);
return (
<Modal title="API Key Created" onDismiss={onDismiss} onClickBackdrop={onDismiss} isOpen>
<Field label="Key">
<Input
id="Key"
value={apiKey}
readOnly
addonAfter={
<ClipboardButton icon="copy" variant="primary" getText={getClipboardText}>
Copy
</ClipboardButton>
}
/>
</Field>
<Alert severity="info" title="You will only be able to view this key here once!">
It is not stored in this form, so be sure to copy it now.
</Alert>
<p className="text-muted">You can authenticate a request using the Authorization HTTP header, example:</p>
<pre className={styles.small}>
curl -H &quot;Authorization: Bearer {apiKey}&quot; {rootPath}/api/dashboards/home
</pre>
</Modal>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
label: css`
padding: ${theme.spacing(1)};
background-color: ${theme.colors.background.secondary};
border-radius: ${theme.shape.borderRadius()};
`,
small: css`
font-size: ${theme.typography.bodySmall.fontSize};
font-weight: ${theme.typography.bodySmall.fontWeight};
`,
};
}

View File

@ -10,6 +10,8 @@ interface Props {
}
export const ApiKeysController: FC<Props> = ({ children }) => {
// FIXME(eleijonmarck): could not remove state from this component
// as component cannot render properly without it
const [isAdding, setIsAdding] = useState<boolean>(false);
const toggleIsAdding = useCallback(() => {
setIsAdding(!isAdding);

View File

@ -1,111 +0,0 @@
import React, { ChangeEvent, FC, FormEvent, useEffect, useState } from 'react';
import { rangeUtil, SelectableValue } from '@grafana/data';
import { EventsWithValidation, LegacyForms, ValidationEvents, Button, Select, InlineField } from '@grafana/ui';
import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
import { SlideDown } from '../../core/components/Animations/SlideDown';
import { NewApiKey, OrgRole } from '../../types';
const { Input } = LegacyForms;
const ROLE_OPTIONS: Array<SelectableValue<OrgRole>> = Object.keys(OrgRole).map((role) => ({
label: role,
value: role as OrgRole,
}));
interface Props {
show: boolean;
onClose: () => void;
onKeyAdded: (apiKey: NewApiKey) => void;
disabled: boolean;
}
function isValidInterval(value: string): boolean {
if (!value) {
return true;
}
try {
rangeUtil.intervalToSeconds(value);
return true;
} catch {}
return false;
}
const timeRangeValidationEvents: ValidationEvents = {
[EventsWithValidation.onBlur]: [
{
rule: isValidInterval,
errorMessage: 'Not a valid duration',
},
],
};
const tooltipText =
'The API key life duration. For example, 1d if your key is going to last for one day. Supported units are: s,m,h,d,w,M,y';
export const ApiKeysForm: FC<Props> = ({ show, onClose, onKeyAdded, disabled }) => {
const [name, setName] = useState<string>('');
const [role, setRole] = useState<OrgRole>(OrgRole.Viewer);
const [secondsToLive, setSecondsToLive] = useState<string>('');
useEffect(() => {
setName('');
setRole(OrgRole.Viewer);
setSecondsToLive('');
}, [show]);
const onSubmit = (event: FormEvent) => {
event.preventDefault();
if (isValidInterval(secondsToLive)) {
onKeyAdded({ name, role, secondsToLive });
onClose();
}
};
const onNameChange = (event: ChangeEvent<HTMLInputElement>) => {
setName(event.currentTarget.value);
};
const onRoleChange = (role: SelectableValue<OrgRole>) => {
setRole(role.value!);
};
const onSecondsToLiveChange = (event: ChangeEvent<HTMLInputElement>) => {
setSecondsToLive(event.currentTarget.value);
};
return (
<SlideDown in={show}>
<div className="gf-form-inline cta-form">
<CloseButton onClick={onClose} />
<form className="gf-form-group" onSubmit={onSubmit}>
<h5>Add API Key</h5>
<div className="gf-form-inline">
<div className="gf-form max-width-21">
<span className="gf-form-label">Key name</span>
<Input type="text" className="gf-form-input" value={name} placeholder="Name" onChange={onNameChange} />
</div>
<div className="gf-form">
<InlineField label="Role">
<Select inputId="role-select" value={role} onChange={onRoleChange} options={ROLE_OPTIONS} />
</InlineField>
</div>
<div className="gf-form max-width-21">
<InlineField tooltip={tooltipText} label="Time to live">
<Input
id="time-to-live-input"
type="text"
placeholder="1d"
validationEvents={timeRangeValidationEvents}
value={secondsToLive}
onChange={onSecondsToLiveChange}
/>
</InlineField>
</div>
<div className="gf-form">
<Button type="submit" disabled={disabled}>
Add
</Button>
</div>
</div>
</form>
</div>
</SlideDown>
);
};

View File

@ -3,7 +3,6 @@ import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event'
import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
import { selectors } from '@grafana/e2e-selectors';
import { ApiKey, OrgRole } from 'app/types';
import { mockToolkitActionCreator } from '../../../test/core/redux/mocks';
@ -30,7 +29,6 @@ const setup = (propOverrides: Partial<Props>) => {
const migrateAllMock = jest.fn();
const toggleIncludeExpiredMock = jest.fn();
const setSearchQueryMock = mockToolkitActionCreator(setSearchQuery);
const getApiKeysMigrationStatusMock = jest.fn();
const hideApiKeysMock = jest.fn();
const props: Props = {
apiKeys: [] as ApiKey[],
@ -39,8 +37,6 @@ const setup = (propOverrides: Partial<Props>) => {
loadApiKeys: loadApiKeysMock,
deleteApiKey: deleteApiKeyMock,
setSearchQuery: setSearchQueryMock,
addApiKey: addApiKeyMock,
getApiKeysMigrationStatus: getApiKeysMigrationStatusMock,
migrateApiKey: migrateApiKeyMock,
migrateAll: migrateAllMock,
hideApiKeys: hideApiKeysMock,
@ -50,7 +46,6 @@ const setup = (propOverrides: Partial<Props>) => {
includeExpiredDisabled: false,
toggleIncludeExpired: toggleIncludeExpiredMock,
canCreate: true,
apiKeysMigrated: false,
};
Object.assign(props, propOverrides);
@ -87,13 +82,6 @@ describe('ApiKeysPage', () => {
});
});
describe('when there are no API keys', () => {
it('then it should render CTA', () => {
setup({ apiKeys: getMultipleMockKeys(0), apiKeysCount: 0, hasFetched: true });
expect(screen.getByTestId(selectors.components.CallToActionCard.buttonV2('New API key'))).toBeInTheDocument();
});
});
describe('when there are API keys', () => {
it('then it should render API keys table', async () => {
const apiKeys = [
@ -165,66 +153,9 @@ describe('ApiKeysPage', () => {
expect(deleteApiKeyMock).toHaveBeenCalledWith(2);
});
});
describe('when a user adds an API key from CTA', () => {
it('then it should call addApiKey with correct parameters', async () => {
const apiKeys: ApiKey[] = [];
const { addApiKeyMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true });
addApiKeyMock.mockClear();
await userEvent.click(screen.getByTestId(selectors.components.CallToActionCard.buttonV2('New API key')));
await addAndVerifyApiKey(addApiKeyMock);
});
});
describe('when a user adds an API key from Add API key', () => {
it('then it should call addApiKey with correct parameters', async () => {
const apiKeys = getMultipleMockKeys(1);
const { addApiKeyMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true });
addApiKeyMock.mockClear();
await userEvent.click(screen.getByRole('button', { name: /add api key/i }));
await addAndVerifyApiKey(addApiKeyMock);
await toggleShowExpired();
addApiKeyMock.mockClear();
await userEvent.click(screen.getByRole('button', { name: /add api key/i }));
await addAndVerifyApiKey(addApiKeyMock);
});
});
describe('when a user adds an API key with an invalid expiration', () => {
it('then it should display a message', async () => {
const apiKeys = getMultipleMockKeys(1);
const { addApiKeyMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true });
addApiKeyMock.mockClear();
await userEvent.click(screen.getByRole('button', { name: /add api key/i }));
await userEvent.type(screen.getByPlaceholderText(/name/i), 'Test');
await userEvent.type(screen.getByPlaceholderText(/1d/i), '60x');
expect(screen.queryByText(/not a valid duration/i)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /^add$/i }));
expect(screen.getByText(/not a valid duration/i)).toBeInTheDocument();
expect(addApiKeyMock).toHaveBeenCalledTimes(0);
});
});
});
async function toggleShowExpired() {
expect(screen.queryByLabelText(/include expired keys/i)).toBeInTheDocument();
await userEvent.click(screen.getByLabelText(/include expired keys/i));
}
async function addAndVerifyApiKey(addApiKeyMock: jest.Mock) {
expect(screen.getByRole('heading', { name: /add api key/i })).toBeInTheDocument();
expect(screen.getByPlaceholderText(/name/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/1d/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^add$/i })).toBeInTheDocument();
await userEvent.type(screen.getByPlaceholderText(/name/i), 'Test');
await userEvent.type(screen.getByPlaceholderText(/1d/i), '60s');
await userEvent.click(screen.getByRole('button', { name: /^add$/i }));
expect(addApiKeyMock).toHaveBeenCalledTimes(1);
expect(addApiKeyMock).toHaveBeenCalledWith({ name: 'Test', role: 'Viewer', secondsToLive: 60 }, expect.anything());
}

View File

@ -2,33 +2,24 @@ import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
// Utils
import { rangeUtil } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { InlineField, InlineSwitch, VerticalGroup } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { Page } from 'app/core/components/Page/Page';
import config from 'app/core/config';
import { contextSrv } from 'app/core/core';
import { getTimeZone } from 'app/features/profile/state/selectors';
import { AccessControlAction, ApiKey, NewApiKey, StoreState } from 'app/types';
import { ShowModalReactEvent } from 'app/types/events';
import { AccessControlAction, ApiKey, StoreState } from 'app/types';
import { APIKeysMigratedCard } from './APIKeysMigratedCard';
import { ApiKeysActionBar } from './ApiKeysActionBar';
import { ApiKeysAddedModal } from './ApiKeysAddedModal';
import { ApiKeysController } from './ApiKeysController';
import { ApiKeysForm } from './ApiKeysForm';
import { ApiKeysTable } from './ApiKeysTable';
import { MigrateToServiceAccountsCard } from './MigrateToServiceAccountsCard';
import {
addApiKey,
deleteApiKey,
migrateApiKey,
migrateAll,
loadApiKeys,
toggleIncludeExpired,
getApiKeysMigrationStatus,
hideApiKeys,
} from './state/actions';
import { setSearchQuery } from './state/reducers';
@ -46,7 +37,6 @@ function mapStateToProps(state: StoreState) {
includeExpired: getIncludeExpired(state.apiKeys),
includeExpiredDisabled: getIncludeExpiredDisabled(state.apiKeys),
canCreate: canCreate,
apiKeysMigrated: state.apiKeys.apiKeysMigrated,
};
}
@ -61,8 +51,6 @@ const mapDispatchToProps = {
migrateAll,
setSearchQuery,
toggleIncludeExpired,
addApiKey,
getApiKeysMigrationStatus,
hideApiKeys,
};
@ -83,7 +71,6 @@ export class ApiKeysPageUnconnected extends PureComponent<Props, State> {
componentDidMount() {
this.fetchApiKeys();
this.props.getApiKeysMigrationStatus();
}
async fetchApiKeys() {
@ -110,40 +97,6 @@ export class ApiKeysPageUnconnected extends PureComponent<Props, State> {
this.props.toggleIncludeExpired();
};
onAddApiKey = (newApiKey: NewApiKey) => {
const openModal = (apiKey: string) => {
const rootPath = window.location.origin + config.appSubUrl;
appEvents.publish(
new ShowModalReactEvent({
props: {
apiKey,
rootPath,
},
component: ApiKeysAddedModal,
})
);
};
const secondsToLive = newApiKey.secondsToLive;
try {
const secondsToLiveAsNumber = secondsToLive ? rangeUtil.intervalToSeconds(secondsToLive) : null;
const apiKey: ApiKey = {
...newApiKey,
secondsToLive: secondsToLiveAsNumber,
};
this.props.addApiKey(apiKey, openModal);
this.setState((prevState: State) => {
return {
...prevState,
isAdding: false,
};
});
} catch (err) {
console.error(err);
}
};
onHideApiKeys = async () => {
try {
await this.props.hideApiKeys();
@ -165,7 +118,6 @@ export class ApiKeysPageUnconnected extends PureComponent<Props, State> {
includeExpired,
includeExpiredDisabled,
canCreate,
apiKeysMigrated,
} = this.props;
if (!hasFetched) {
@ -180,37 +132,21 @@ export class ApiKeysPageUnconnected extends PureComponent<Props, State> {
<Page {...defaultPageProps}>
<Page.Contents isLoading={false}>
<ApiKeysController>
{({ isAdding, toggleIsAdding }) => {
const showCTA = !isAdding && apiKeysCount === 0 && !apiKeysMigrated;
{({}) => {
const showTable = apiKeysCount > 0;
return (
<>
{!apiKeysMigrated && <MigrateToServiceAccountsCard onMigrate={this.onMigrateAll} />}
{apiKeysMigrated && <APIKeysMigratedCard onHideApiKeys={this.onHideApiKeys} />}
{showCTA ? (
<EmptyListCTA
title="You haven't added any API keys yet."
buttonIcon="key-skeleton-alt"
onClick={toggleIsAdding}
buttonTitle="New API key"
proTip="Remember, you can provide view-only API access to other applications."
buttonDisabled={!canCreate}
/>
) : null}
{apiKeysCount !== 0 && <MigrateToServiceAccountsCard onMigrate={this.onMigrateAll} />}
{apiKeysCount === 0 && (
<APIKeysMigratedCard onHideApiKeys={this.onHideApiKeys} apikeys={apiKeysCount} />
)}
{showTable ? (
<ApiKeysActionBar
searchQuery={searchQuery}
disabled={isAdding || !canCreate}
onAddClick={toggleIsAdding}
disabled={!canCreate}
onSearchChange={this.onSearchQueryChange}
/>
) : null}
<ApiKeysForm
show={isAdding}
onClose={toggleIsAdding}
onKeyAdded={this.onAddApiKey}
disabled={!canCreate}
/>
{showTable ? (
<VerticalGroup>
<InlineField disabled={includeExpiredDisabled} label="Include expired keys">

View File

@ -16,26 +16,24 @@ export const MigrateToServiceAccountsCard = ({ onMigrate, disabled }: Props): JS
const docsLink = (
<a
className="external-link"
href="https://grafana.com/docs/grafana/latest/administration/api-keys/#migrate-api-keys-to-grafana-service-accounts/"
href="https://grafana.com/docs/grafana/latest/administration/api-keys/#migrate-api-keys-to-grafana-service-accounts"
target="_blank"
rel="noopener noreferrer"
>
here.
Find out more about the migration here.
</a>
);
const migrationBoxDesc = (
<span>Are you sure you want to migrate all API keys to service accounts? Find out more {docsLink}</span>
);
const migrationBoxDesc = <span>Are you sure you want to migrate all API keys to service accounts? {docsLink}</span>;
return (
<Alert title="Switch from API keys to service accounts" severity="info">
<Alert title="Switch from API keys to service accounts" severity="warning">
<div className={styles.text}>
Each API key will be automatically migrated into a service account with a token. The service account will be
created with the same permission as the API Key and current API Keys will continue to work as they were.
We will soon deprecate API keys. Each API key will be migrated into a service account with a token and will
continue to work as they were. We encourage you to migrate your API keys to service accounts now. {docsLink}
</div>
<div className={styles.actionRow}>
<Button className={styles.actionButton} onClick={() => setIsModalOpen(true)}>
Migrate to service accounts now
Migrate all service accounts
</Button>
<ConfirmModal
title={'Migrate API keys to service accounts'}
@ -44,6 +42,8 @@ export const MigrateToServiceAccountsCard = ({ onMigrate, disabled }: Props): JS
confirmText={'Yes, migrate now'}
onConfirm={onMigrate}
onDismiss={() => setIsModalOpen(false)}
confirmVariant="primary"
confirmButtonVariant="primary"
/>
</div>
</Alert>

View File

@ -1,24 +1,7 @@
import { getBackendSrv } from 'app/core/services/backend_srv';
import store from 'app/core/store';
import { API_KEYS_MIGRATION_INFO_STORAGE_KEY } from 'app/features/serviceaccounts/constants';
import { ApiKey, ThunkResult } from 'app/types';
import { ThunkResult } from 'app/types';
import {
apiKeysLoaded,
includeExpiredToggled,
isFetching,
apiKeysMigrationStatusLoaded,
setSearchQuery,
} from './reducers';
export function addApiKey(apiKey: ApiKey, openModal: (key: string) => void): ThunkResult<void> {
return async (dispatch) => {
const result = await getBackendSrv().post('/api/auth/keys', apiKey);
dispatch(setSearchQuery(''));
dispatch(loadApiKeys());
openModal(result.key);
};
}
import { apiKeysLoaded, includeExpiredToggled, isFetching } from './reducers';
export function loadApiKeys(): ThunkResult<void> {
return async (dispatch) => {
@ -53,21 +36,12 @@ export function migrateAll(): ThunkResult<void> {
return async (dispatch) => {
try {
await getBackendSrv().post('/api/serviceaccounts/migrate');
store.set(API_KEYS_MIGRATION_INFO_STORAGE_KEY, true);
} finally {
dispatch(getApiKeysMigrationStatus());
dispatch(loadApiKeys());
}
};
}
export function getApiKeysMigrationStatus(): ThunkResult<void> {
return async (dispatch) => {
const result = await getBackendSrv().get('/api/serviceaccounts/migrationstatus');
dispatch(apiKeysMigrationStatusLoaded(!!result?.migrated));
};
}
export function hideApiKeys(): ThunkResult<void> {
return async (dispatch) => {
await getBackendSrv().post('/api/serviceaccounts/hideApiKeys');

View File

@ -8,7 +8,6 @@ export const initialApiKeysState: ApiKeysState = {
keys: [],
keysIncludingExpired: [],
searchQuery: '',
apiKeysMigrated: false,
};
const apiKeysSlice = createSlice({
@ -23,9 +22,6 @@ const apiKeysSlice = createSlice({
: state.includeExpired;
return { ...state, hasFetched: true, keys, keysIncludingExpired, includeExpired };
},
apiKeysMigrationStatusLoaded: (state, action): ApiKeysState => {
return { ...state, apiKeysMigrated: action.payload };
},
setSearchQuery: (state, action): ApiKeysState => {
return { ...state, searchQuery: action.payload };
},
@ -38,8 +34,7 @@ const apiKeysSlice = createSlice({
},
});
export const { apiKeysLoaded, includeExpiredToggled, isFetching, setSearchQuery, apiKeysMigrationStatusLoaded } =
apiKeysSlice.actions;
export const { apiKeysLoaded, includeExpiredToggled, isFetching, setSearchQuery } = apiKeysSlice.actions;
export const apiKeysReducer = apiKeysSlice.reducer;

View File

@ -16,7 +16,6 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: false,
apiKeysMigrated: false,
};
const keyCount = getApiKeysCount(mockState);
expect(keyCount).toBe(5);
@ -29,7 +28,6 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: true,
apiKeysMigrated: false,
};
const keyCount = getApiKeysCount(mockState);
expect(keyCount).toBe(8);
@ -45,7 +43,6 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: false,
apiKeysMigrated: false,
};
const keys = getApiKeys(mockState);
expect(keys).toEqual(mockKeys);
@ -58,7 +55,6 @@ describe('API Keys selectors', () => {
searchQuery: '5',
hasFetched: true,
includeExpired: false,
apiKeysMigrated: false,
};
const keys = getApiKeys(mockState);
expect(keys.length).toEqual(1);
@ -73,7 +69,6 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: true,
apiKeysMigrated: false,
};
const keys = getApiKeys(mockState);
expect(keys).toEqual(mockKeysIncludingExpired);
@ -86,7 +81,6 @@ describe('API Keys selectors', () => {
searchQuery: '5',
hasFetched: true,
includeExpired: true,
apiKeysMigrated: false,
};
const keys = getApiKeys(mockState);
expect(keys.length).toEqual(1);
@ -102,7 +96,6 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: true,
apiKeysMigrated: false,
};
const includeExpired = getIncludeExpired(mockState);
expect(includeExpired).toBe(true);
@ -115,7 +108,6 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: false,
apiKeysMigrated: false,
};
const includeExpired = getIncludeExpired(mockState);
expect(includeExpired).toBe(false);
@ -130,7 +122,6 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: true,
apiKeysMigrated: false,
};
const includeExpiredDisabled = getIncludeExpiredDisabled(mockState);
expect(includeExpiredDisabled).toBe(true);
@ -143,7 +134,6 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: false,
apiKeysMigrated: false,
};
const includeExpiredDisabled = getIncludeExpired(mockState);
expect(includeExpiredDisabled).toBe(false);

View File

@ -23,9 +23,6 @@ const setup = (propOverrides: Partial<Props>) => {
const updateServiceAccountMock = jest.fn();
const changeStateFilterMock = jest.fn();
const createServiceAccountTokenMock = jest.fn();
const getApiKeysMigrationStatusMock = jest.fn();
const getApiKeysMigrationInfoMock = jest.fn();
const closeApiKeysMigrationInfoMock = jest.fn();
const props: Props = {
isLoading: false,
page: 0,
@ -36,8 +33,6 @@ const setup = (propOverrides: Partial<Props>) => {
showPaging: false,
totalPages: 1,
serviceAccounts: [],
apiKeysMigrated: false,
showApiKeysMigrationInfo: false,
changeQuery: changeQueryMock,
fetchACOptions: fetchACOptionsMock,
fetchServiceAccounts: fetchServiceAccountsMock,
@ -45,9 +40,6 @@ const setup = (propOverrides: Partial<Props>) => {
updateServiceAccount: updateServiceAccountMock,
changeStateFilter: changeStateFilterMock,
createServiceAccountToken: createServiceAccountTokenMock,
getApiKeysMigrationStatus: getApiKeysMigrationStatusMock,
getApiKeysMigrationInfo: getApiKeysMigrationInfoMock,
closeApiKeysMigrationInfo: closeApiKeysMigrationInfoMock,
};
Object.assign(props, propOverrides);

View File

@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { GrafanaTheme2, OrgRole } from '@grafana/data';
import { Alert, ConfirmModal, FilterInput, Icon, LinkButton, RadioButtonGroup, Tooltip, 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';
@ -21,9 +21,6 @@ import {
updateServiceAccount,
changeStateFilter,
createServiceAccountToken,
getApiKeysMigrationStatus,
getApiKeysMigrationInfo,
closeApiKeysMigrationInfo,
} from './state/actions';
interface OwnProps {}
@ -44,9 +41,6 @@ const mapDispatchToProps = {
updateServiceAccount,
changeStateFilter,
createServiceAccountToken,
getApiKeysMigrationStatus,
getApiKeysMigrationInfo,
closeApiKeysMigrationInfo,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
@ -57,8 +51,6 @@ export const ServiceAccountsListPageUnconnected = ({
roleOptions,
query,
serviceAccountStateFilter,
apiKeysMigrated,
showApiKeysMigrationInfo,
changeQuery,
fetchACOptions,
fetchServiceAccounts,
@ -66,9 +58,6 @@ export const ServiceAccountsListPageUnconnected = ({
updateServiceAccount,
changeStateFilter,
createServiceAccountToken,
getApiKeysMigrationStatus,
getApiKeysMigrationInfo,
closeApiKeysMigrationInfo,
}: Props): JSX.Element => {
const styles = useStyles2(getStyles);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
@ -79,12 +68,10 @@ export const ServiceAccountsListPageUnconnected = ({
useEffect(() => {
fetchServiceAccounts({ withLoadingIndicator: true });
getApiKeysMigrationStatus();
getApiKeysMigrationInfo();
if (contextSrv.licensedAccessControlEnabled()) {
fetchACOptions();
}
}, [fetchACOptions, fetchServiceAccounts, getApiKeysMigrationStatus, getApiKeysMigrationInfo]);
}, [fetchACOptions, fetchServiceAccounts]);
const noServiceAccountsCreated =
serviceAccounts.length === 0 && serviceAccountStateFilter === ServiceAccountStateFilter.All && !query;
@ -160,10 +147,6 @@ export const ServiceAccountsListPageUnconnected = ({
setCurrentServiceAccount(null);
};
const onMigrationInfoClose = () => {
closeApiKeysMigrationInfo();
};
const docsLink = (
<a
className="external-link"
@ -183,14 +166,6 @@ export const ServiceAccountsListPageUnconnected = ({
return (
<Page navId="serviceaccounts" subTitle={subTitle}>
<Page.Contents>
{apiKeysMigrated && showApiKeysMigrationInfo && (
<Alert
title="API keys migrated to Service accounts. Your keys are now called tokens and live inside respective service
accounts. Learn more."
severity="success"
onRemove={onMigrationInfoClose}
></Alert>
)}
<Page.OldNavOnly>
<div className={styles.pageHeader}>
<h2>Service accounts</h2>

View File

@ -1 +0,0 @@
export const API_KEYS_MIGRATION_INFO_STORAGE_KEY = 'grafana.serviceaccounts.showApiKeysMigrationInfo';

View File

@ -3,11 +3,9 @@ import { debounce } from 'lodash';
import { getBackendSrv } from '@grafana/runtime';
import { fetchRoleOptions } from 'app/core/components/RolePicker/api';
import { contextSrv } from 'app/core/services/context_srv';
import store from 'app/core/store';
import { AccessControlAction, ServiceAccountDTO, ServiceAccountStateFilter, ThunkResult } from 'app/types';
import { ServiceAccountToken } from '../components/CreateTokenModal';
import { API_KEYS_MIGRATION_INFO_STORAGE_KEY } from '../constants';
import {
acOptionsLoaded,
@ -16,9 +14,7 @@ import {
serviceAccountsFetchBegin,
serviceAccountsFetched,
serviceAccountsFetchEnd,
apiKeysMigrationStatusLoaded,
stateFilterChanged,
showApiKeysMigrationInfoLoaded,
} from './reducers';
const BASE_URL = `/api/serviceaccounts`;
@ -36,15 +32,6 @@ export function fetchACOptions(): ThunkResult<void> {
};
}
export function getApiKeysMigrationStatus(): ThunkResult<void> {
return async (dispatch) => {
if (contextSrv.hasPermission(AccessControlAction.ServiceAccountsRead)) {
const result = await getBackendSrv().get('/api/serviceaccounts/migrationstatus');
dispatch(apiKeysMigrationStatusLoaded(!!result?.migrated));
}
};
}
interface FetchServiceAccountsParams {
withLoadingIndicator: boolean;
}
@ -138,17 +125,3 @@ export function changePage(page: number): ThunkResult<void> {
dispatch(fetchServiceAccounts());
};
}
export function getApiKeysMigrationInfo(): ThunkResult<void> {
return async (dispatch) => {
const showApiKeysMigrationInfo = store.getBool(API_KEYS_MIGRATION_INFO_STORAGE_KEY, false);
dispatch(showApiKeysMigrationInfoLoaded(showApiKeysMigrationInfo));
};
}
export function closeApiKeysMigrationInfo(): ThunkResult<void> {
return async (dispatch) => {
store.set(API_KEYS_MIGRATION_INFO_STORAGE_KEY, false);
dispatch(getApiKeysMigrationInfo());
};
}

View File

@ -50,8 +50,6 @@ export const initialStateList: ServiceAccountsState = {
totalPages: 1,
showPaging: false,
serviceAccountStateFilter: ServiceAccountStateFilter.All,
apiKeysMigrated: false,
showApiKeysMigrationInfo: false,
};
interface ServiceAccountsFetched {
@ -87,12 +85,6 @@ const serviceAccountsSlice = createSlice({
acOptionsLoaded: (state, action: PayloadAction<Role[]>): ServiceAccountsState => {
return { ...state, roleOptions: action.payload };
},
apiKeysMigrationStatusLoaded: (state, action): ServiceAccountsState => {
return { ...state, apiKeysMigrated: action.payload };
},
showApiKeysMigrationInfoLoaded: (state, action): ServiceAccountsState => {
return { ...state, showApiKeysMigrationInfo: action.payload };
},
queryChanged: (state, action: PayloadAction<string>) => {
return {
...state,
@ -117,8 +109,6 @@ export const {
serviceAccountsFetchEnd,
serviceAccountsFetched,
acOptionsLoaded,
apiKeysMigrationStatusLoaded,
showApiKeysMigrationInfoLoaded,
pageChanged,
stateFilterChanged,
queryChanged,

View File

@ -15,17 +15,10 @@ export interface ApiKey extends WithAccessControlMetadata {
lastUsedAt?: string;
}
export interface NewApiKey {
name: string;
role: OrgRole;
secondsToLive: string;
}
export interface ApiKeysState {
includeExpired: boolean;
keys: ApiKey[];
keysIncludingExpired: ApiKey[];
searchQuery: string;
hasFetched: boolean;
apiKeysMigrated: boolean;
}

View File

@ -65,8 +65,6 @@ export interface ServiceAccountsState {
serviceAccounts: ServiceAccountDTO[];
isLoading: boolean;
roleOptions: Role[];
apiKeysMigrated: boolean;
showApiKeysMigrationInfo: boolean;
// search / filtering
query: string;