Auth: Remove apikeys related components from frontend (#106061)
* remove apikeys related pages from frontend * remove translations * remove navBar titles * revert translations for non-english files
This commit is contained in:
@@ -496,7 +496,6 @@ playwright.config.ts @grafana/plugins-platform-frontend
|
||||
/public/app/features/actions/ @grafana/dataviz-squad
|
||||
/public/app/features/auth-config/ @grafana/identity-squad
|
||||
/public/app/features/annotations/ @grafana/dashboards-squad
|
||||
/public/app/features/api-keys/ @grafana/identity-squad
|
||||
/public/app/features/canvas/ @grafana/dataviz-squad
|
||||
/public/app/features/geo/ @grafana/dataviz-squad
|
||||
/public/app/features/visualization/data-hover/ @grafana/dataviz-squad
|
||||
|
||||
@@ -4,7 +4,6 @@ import { AnyAction, combineReducers } from 'redux';
|
||||
import sharedReducers from 'app/core/reducers';
|
||||
import ldapReducers from 'app/features/admin/state/reducers';
|
||||
import alertingReducers from 'app/features/alerting/state/reducers';
|
||||
import apiKeysReducers from 'app/features/api-keys/state/reducers';
|
||||
import authConfigReducers from 'app/features/auth-config/state/reducers';
|
||||
import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||
import browseDashboardsReducers from 'app/features/browse-dashboards/state/slice';
|
||||
@@ -42,7 +41,6 @@ const rootReducers = {
|
||||
...sharedReducers,
|
||||
...alertingReducers,
|
||||
...teamsReducers,
|
||||
...apiKeysReducers,
|
||||
...foldersReducers,
|
||||
...dashboardReducers,
|
||||
...exploreReducers,
|
||||
|
||||
@@ -117,8 +117,6 @@ export function getNavTitle(navId: string | undefined) {
|
||||
return t('nav.plugins.title', 'Plugins');
|
||||
case 'org-settings':
|
||||
return t('nav.org-settings.title', 'Default preferences');
|
||||
case 'apikeys':
|
||||
return t('nav.api-keys.title', 'API keys');
|
||||
case 'serviceaccounts':
|
||||
return t('nav.service-accounts.title', 'Service accounts');
|
||||
case 'admin':
|
||||
@@ -269,8 +267,6 @@ export function getNavSubTitle(navId: string | undefined) {
|
||||
return t('nav.plugins.subtitle', 'Extend the Grafana experience with plugins');
|
||||
case 'org-settings':
|
||||
return t('nav.org-settings.subtitle', 'Manage preferences across an organization');
|
||||
case 'apikeys':
|
||||
return t('nav.api-keys.subtitle', 'Manage and create API keys that are used to interact with Grafana HTTP APIs');
|
||||
case 'serviceaccounts':
|
||||
return t('nav.service-accounts.subtitle', 'Use service accounts to run automated workloads in Grafana');
|
||||
case 'groupsync':
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { useTranslate } from '@grafana/i18n';
|
||||
import { FilterInput, InlineField } from '@grafana/ui';
|
||||
|
||||
interface Props {
|
||||
searchQuery: string;
|
||||
disabled: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const ApiKeysActionBar = ({ searchQuery, disabled, onSearchChange }: Props) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
return (
|
||||
<div className="page-action-bar">
|
||||
<InlineField grow>
|
||||
<FilterInput
|
||||
placeholder={t('api-keys.api-keys-action-bar.placeholder-search-keys', 'Search keys')}
|
||||
value={searchQuery}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
</InlineField>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,159 +0,0 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event';
|
||||
import { TestProvider } from 'test/helpers/TestProvider';
|
||||
|
||||
import { ApiKey, OrgRole } from 'app/types';
|
||||
|
||||
import { mockToolkitActionCreator } from '../../../test/core/redux/mocks';
|
||||
import { silenceConsoleOutput } from '../../../test/core/utils/silenceConsoleOutput';
|
||||
|
||||
import { ApiKeysPageUnconnected, Props } from './ApiKeysPage';
|
||||
import { getMultipleMockKeys } from './__mocks__/apiKeysMock';
|
||||
import { setSearchQuery } from './state/reducers';
|
||||
|
||||
jest.mock('app/core/core', () => {
|
||||
return {
|
||||
contextSrv: {
|
||||
hasPermission: () => true,
|
||||
hasPermissionInMetadata: () => true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const setup = (propOverrides: Partial<Props>) => {
|
||||
const loadApiKeysMock = jest.fn();
|
||||
const deleteApiKeyMock = jest.fn();
|
||||
const migrateApiKeyMock = jest.fn();
|
||||
const addApiKeyMock = jest.fn();
|
||||
const migrateAllMock = jest.fn();
|
||||
const toggleIncludeExpiredMock = jest.fn();
|
||||
const setSearchQueryMock = mockToolkitActionCreator(setSearchQuery);
|
||||
const props: Props = {
|
||||
apiKeys: [] as ApiKey[],
|
||||
searchQuery: '',
|
||||
hasFetched: false,
|
||||
loadApiKeys: loadApiKeysMock,
|
||||
deleteApiKey: deleteApiKeyMock,
|
||||
setSearchQuery: setSearchQueryMock,
|
||||
migrateApiKey: migrateApiKeyMock,
|
||||
migrateAll: migrateAllMock,
|
||||
apiKeysCount: 0,
|
||||
timeZone: 'utc',
|
||||
includeExpired: false,
|
||||
includeExpiredDisabled: false,
|
||||
toggleIncludeExpired: toggleIncludeExpiredMock,
|
||||
canCreate: true,
|
||||
migrationResult: undefined,
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
const { rerender } = render(
|
||||
<TestProvider>
|
||||
<ApiKeysPageUnconnected {...props} />
|
||||
</TestProvider>
|
||||
);
|
||||
return {
|
||||
rerender: (element: JSX.Element) => rerender(<TestProvider>{element}</TestProvider>),
|
||||
props,
|
||||
loadApiKeysMock,
|
||||
setSearchQueryMock,
|
||||
deleteApiKeyMock,
|
||||
addApiKeyMock,
|
||||
toggleIncludeExpiredMock,
|
||||
};
|
||||
};
|
||||
|
||||
describe('ApiKeysPage', () => {
|
||||
silenceConsoleOutput();
|
||||
describe('when mounted', () => {
|
||||
it('then it should call loadApiKeys', () => {
|
||||
const { loadApiKeysMock } = setup({});
|
||||
expect(loadApiKeysMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when loading', () => {
|
||||
it('then should show Loading message', () => {
|
||||
setup({ hasFetched: false });
|
||||
expect(screen.getByText(/loading \.\.\./i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there are API keys', () => {
|
||||
it('then it should render API keys table', async () => {
|
||||
const apiKeys = [
|
||||
{ id: 1, name: 'First', role: OrgRole.Admin, secondsToLive: 60, expiration: '2021-01-01' },
|
||||
{ id: 2, name: 'Second', role: OrgRole.Editor, secondsToLive: 60, expiration: '2021-01-02' },
|
||||
{ id: 3, name: 'Third', role: OrgRole.Viewer, secondsToLive: 0, expiration: undefined },
|
||||
];
|
||||
setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true });
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('row').length).toBe(4);
|
||||
expect(screen.getByRole('row', { name: /first admin 2021-01-01 00:00:00/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('row', { name: /second editor 2021-01-02 00:00:00/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('row', { name: /third viewer no expiration date/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a user toggles the Show expired toggle', () => {
|
||||
it('then it should dispatch toggleIncludeExpired', async () => {
|
||||
const apiKeys = getMultipleMockKeys(3);
|
||||
const { toggleIncludeExpiredMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true });
|
||||
|
||||
await toggleShowExpired();
|
||||
expect(toggleIncludeExpiredMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a user searches for an API key', () => {
|
||||
it('then it should dispatch setSearchQuery with correct parameters', async () => {
|
||||
const apiKeys = getMultipleMockKeys(3);
|
||||
const { setSearchQueryMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true });
|
||||
|
||||
setSearchQueryMock.mockClear();
|
||||
expect(screen.getByPlaceholderText(/search keys/i)).toBeInTheDocument();
|
||||
await userEvent.type(screen.getByPlaceholderText(/search keys/i), 'First');
|
||||
expect(setSearchQueryMock).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a user deletes an API key', () => {
|
||||
it('then it should dispatch deleteApi with correct parameters', async () => {
|
||||
const apiKeys = [
|
||||
{ id: 1, name: 'First', role: OrgRole.Admin, secondsToLive: 60, expiration: '2021-01-01' },
|
||||
{ id: 2, name: 'Second', role: OrgRole.Editor, secondsToLive: 60, expiration: '2021-01-02' },
|
||||
{ id: 3, name: 'Third', role: OrgRole.Viewer, secondsToLive: 0, expiration: undefined },
|
||||
];
|
||||
const { deleteApiKeyMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true });
|
||||
const firstRow = screen.getByRole('row', { name: /first admin 2021-01-01 00:00:00/i });
|
||||
const secondRow = screen.getByRole('row', { name: /second editor 2021-01-02 00:00:00/i });
|
||||
|
||||
deleteApiKeyMock.mockClear();
|
||||
expect(within(firstRow).getByLabelText('Delete API key')).toBeInTheDocument();
|
||||
await userEvent.click(within(firstRow).getByLabelText('Delete API key'));
|
||||
|
||||
expect(within(firstRow).getByRole('button', { name: /delete$/i })).toBeInTheDocument();
|
||||
await userEvent.click(within(firstRow).getByRole('button', { name: /delete$/i }));
|
||||
expect(deleteApiKeyMock).toHaveBeenCalledTimes(1);
|
||||
expect(deleteApiKeyMock).toHaveBeenCalledWith(1);
|
||||
|
||||
await toggleShowExpired();
|
||||
|
||||
deleteApiKeyMock.mockClear();
|
||||
expect(within(secondRow).getByLabelText('Delete API key')).toBeInTheDocument();
|
||||
await userEvent.click(within(secondRow).getByLabelText('Delete API key'));
|
||||
expect(within(secondRow).getByRole('button', { name: /delete$/i })).toBeInTheDocument();
|
||||
await userEvent.click(within(secondRow).getByRole('button', { name: /delete$/i }), {
|
||||
pointerEventsCheck: PointerEventsCheckLevel.Never,
|
||||
});
|
||||
expect(deleteApiKeyMock).toHaveBeenCalledTimes(1);
|
||||
expect(deleteApiKeyMock).toHaveBeenCalledWith(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function toggleShowExpired() {
|
||||
expect(screen.queryByLabelText(/include expired keys/i)).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByLabelText(/include expired keys/i));
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
import { PureComponent } from 'react';
|
||||
import * as React from 'react';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
|
||||
// Utils
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { t } from '@grafana/i18n/internal';
|
||||
import { InlineField, InlineSwitch, Modal, Button, EmptyState } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { getTimeZone } from 'app/features/profile/state/selectors';
|
||||
import { AccessControlAction, ApiKey, ApikeyMigrationResult, StoreState } from 'app/types';
|
||||
|
||||
import { ApiKeysActionBar } from './ApiKeysActionBar';
|
||||
import { ApiKeysTable } from './ApiKeysTable';
|
||||
import { MigrateToServiceAccountsCard } from './MigrateToServiceAccountsCard';
|
||||
import { deleteApiKey, migrateApiKey, migrateAll, loadApiKeys, toggleIncludeExpired } from './state/actions';
|
||||
import { setSearchQuery } from './state/reducers';
|
||||
import { getApiKeys, getApiKeysCount, getIncludeExpired, getIncludeExpiredDisabled } from './state/selectors';
|
||||
|
||||
function mapStateToProps(state: StoreState) {
|
||||
const canCreate = contextSrv.hasPermission(AccessControlAction.ActionAPIKeysCreate);
|
||||
return {
|
||||
apiKeys: getApiKeys(state.apiKeys),
|
||||
searchQuery: state.apiKeys.searchQuery,
|
||||
apiKeysCount: getApiKeysCount(state.apiKeys),
|
||||
hasFetched: state.apiKeys.hasFetched,
|
||||
timeZone: getTimeZone(state.user),
|
||||
includeExpired: getIncludeExpired(state.apiKeys),
|
||||
includeExpiredDisabled: getIncludeExpiredDisabled(state.apiKeys),
|
||||
canCreate: canCreate,
|
||||
migrationResult: state.apiKeys.migrationResult,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultPageProps = {
|
||||
navId: 'apikeys',
|
||||
};
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loadApiKeys,
|
||||
deleteApiKey,
|
||||
migrateApiKey,
|
||||
migrateAll,
|
||||
setSearchQuery,
|
||||
toggleIncludeExpired,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
interface OwnProps {}
|
||||
|
||||
export type Props = OwnProps & ConnectedProps<typeof connector>;
|
||||
|
||||
interface State {
|
||||
showMigrationResult: boolean;
|
||||
}
|
||||
|
||||
export class ApiKeysPageUnconnected extends PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
showMigrationResult: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchApiKeys();
|
||||
}
|
||||
|
||||
async fetchApiKeys() {
|
||||
await this.props.loadApiKeys();
|
||||
}
|
||||
|
||||
onDeleteApiKey = (key: ApiKey) => {
|
||||
this.props.deleteApiKey(key.id!);
|
||||
};
|
||||
|
||||
onMigrateApiKey = (key: ApiKey) => {
|
||||
this.props.migrateApiKey(key.id!);
|
||||
};
|
||||
|
||||
onSearchQueryChange = (value: string) => {
|
||||
this.props.setSearchQuery(value);
|
||||
};
|
||||
|
||||
onIncludeExpiredChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
|
||||
this.props.toggleIncludeExpired();
|
||||
};
|
||||
|
||||
onMigrateApiKeys = async () => {
|
||||
try {
|
||||
await this.props.migrateAll();
|
||||
this.setState({
|
||||
showMigrationResult: true,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
dismissModal = async () => {
|
||||
this.setState({ showMigrationResult: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
hasFetched,
|
||||
apiKeysCount,
|
||||
apiKeys,
|
||||
searchQuery,
|
||||
timeZone,
|
||||
includeExpired,
|
||||
includeExpiredDisabled,
|
||||
canCreate,
|
||||
migrationResult,
|
||||
} = this.props;
|
||||
|
||||
const showTable = apiKeysCount > 0;
|
||||
return (
|
||||
<Page {...defaultPageProps}>
|
||||
<Page.Contents isLoading={!hasFetched}>
|
||||
<MigrateToServiceAccountsCard onMigrate={this.onMigrateApiKeys} apikeysCount={apiKeysCount} />
|
||||
{showTable ? (
|
||||
<ApiKeysActionBar
|
||||
searchQuery={searchQuery}
|
||||
disabled={!canCreate}
|
||||
onSearchChange={this.onSearchQueryChange}
|
||||
/>
|
||||
) : null}
|
||||
<InlineField
|
||||
disabled={includeExpiredDisabled}
|
||||
label={t('api-keys.api-keys-page-unconnected.label-include-expired-keys', 'Include expired keys')}
|
||||
>
|
||||
<InlineSwitch id="showExpired" value={includeExpired} onChange={this.onIncludeExpiredChange} />
|
||||
</InlineField>
|
||||
{apiKeys.length > 0 ? (
|
||||
<ApiKeysTable
|
||||
apiKeys={apiKeys}
|
||||
timeZone={timeZone}
|
||||
onMigrate={this.onMigrateApiKey}
|
||||
onDelete={this.onDeleteApiKey}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState variant="not-found" message={t('api-keys.empty-state.message', 'No API keys found')} />
|
||||
)}
|
||||
</Page.Contents>
|
||||
{migrationResult && (
|
||||
<MigrationSummary
|
||||
visible={this.state.showMigrationResult}
|
||||
data={migrationResult}
|
||||
onDismiss={this.dismissModal}
|
||||
/>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
export type MigrationSummaryProps = {
|
||||
visible: boolean;
|
||||
data: ApikeyMigrationResult;
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
const styles: { [key: string]: React.CSSProperties } = {
|
||||
migrationSummary: {
|
||||
padding: '20px',
|
||||
},
|
||||
infoText: {
|
||||
color: '#007bff',
|
||||
},
|
||||
summaryDetails: {
|
||||
marginTop: '20px',
|
||||
},
|
||||
summaryParagraph: {
|
||||
margin: '10px 0',
|
||||
},
|
||||
};
|
||||
|
||||
export const MigrationSummary: React.FC<MigrationSummaryProps> = ({ visible, data, onDismiss }) => {
|
||||
return (
|
||||
<Modal
|
||||
title={t('api-keys.migration-summary.title-migration-summary', 'Migration summary')}
|
||||
isOpen={visible}
|
||||
closeOnBackdropClick={true}
|
||||
onDismiss={onDismiss}
|
||||
>
|
||||
{data.failedApikeyIDs.length === 0 && (
|
||||
<div style={styles.migrationSummary}>
|
||||
<p>
|
||||
<Trans i18nKey="api-keys.migration-summary.migration-successful">Migration Successful!</Trans>
|
||||
</p>
|
||||
<p>
|
||||
<Trans i18nKey="api-keys.migration-summary.total" values={{ total: data.total }}>
|
||||
<strong>Total: </strong>
|
||||
{'{{total}}'}
|
||||
</Trans>
|
||||
</p>
|
||||
<p>
|
||||
<Trans i18nKey="api-keys.migration-summary.migrated" values={{ migrated: data.migrated }}>
|
||||
<strong>Migrated: </strong>
|
||||
{'{{migrated}}'}
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{data.failedApikeyIDs.length !== 0 && (
|
||||
<div style={styles.migrationSummary}>
|
||||
<p>
|
||||
<Trans i18nKey="api-keys.migration-summary.migration-complete">
|
||||
Migration complete! Please note, while there might be a few API keys flagged as `failed migrations`, rest
|
||||
assured, all of your API keys are fully functional and operational. Please try again or contact support.
|
||||
</Trans>
|
||||
</p>
|
||||
<hr />
|
||||
<p>
|
||||
<Trans i18nKey="api-keys.migration-summary.total" values={{ total: data.total }}>
|
||||
<strong>Total: </strong>
|
||||
{'{{total}}'}
|
||||
</Trans>
|
||||
</p>
|
||||
<p>
|
||||
<Trans i18nKey="api-keys.migration-summary.migrated" values={{ migrated: data.migrated }}>
|
||||
<strong>Migrated: </strong>
|
||||
{'{{migrated}}'}
|
||||
</Trans>
|
||||
</p>
|
||||
<p>
|
||||
<Trans i18nKey="api-keys.migration-summary.failed" values={{ failed: data.failed }}>
|
||||
<strong>Failed: </strong>
|
||||
{'{{failed}}'}
|
||||
</Trans>
|
||||
</p>
|
||||
<p>
|
||||
<Trans i18nKey="api-keys.migration-summary.failed-ids" values={{ ids: data.failedApikeyIDs.join(', ') }}>
|
||||
<strong>Failed api key IDs: </strong>
|
||||
{'{{ids}}'}
|
||||
</Trans>
|
||||
</p>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="api-keys.migration-summary.failed-details"
|
||||
values={{ details: data.failedDetails.join(', ') }}
|
||||
>
|
||||
<strong>Failed details: </strong>
|
||||
{'{{details}}'}
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Modal.ButtonRow>
|
||||
<Button variant="secondary" onClick={onDismiss}>
|
||||
<Trans i18nKey="api-keys.migration-summary.close">Close</Trans>
|
||||
</Button>
|
||||
</Modal.ButtonRow>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const ApiKeysPage = connector(ApiKeysPageUnconnected);
|
||||
export default ApiKeysPage;
|
||||
@@ -1,112 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data';
|
||||
import { Trans, useTranslate } from '@grafana/i18n';
|
||||
import { Button, DeleteButton, Icon, Stack, Tooltip, useTheme2 } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { ApiKey } from '../../types';
|
||||
|
||||
interface Props {
|
||||
apiKeys: ApiKey[];
|
||||
timeZone: TimeZone;
|
||||
onDelete: (apiKey: ApiKey) => void;
|
||||
onMigrate: (apiKey: ApiKey) => void;
|
||||
}
|
||||
|
||||
export const ApiKeysTable = ({ apiKeys, timeZone, onDelete, onMigrate }: Props) => {
|
||||
const theme = useTheme2();
|
||||
const { t } = useTranslate();
|
||||
const styles = getStyles(theme);
|
||||
|
||||
return (
|
||||
<table className="filter-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<Trans i18nKey="api-keys.api-keys-table.name">Name</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="api-keys.api-keys-table.role">Role</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="api-keys.api-keys-table.expires">Expires</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="api-keys.api-keys-table.last-used-at">Last used at</Trans>
|
||||
</th>
|
||||
<th style={{ width: '34px' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
{apiKeys.length > 0 ? (
|
||||
<tbody>
|
||||
{apiKeys.map((key) => {
|
||||
const isExpired = Boolean(key.expiration && Date.now() > new Date(key.expiration).getTime());
|
||||
return (
|
||||
<tr key={key.id} className={styles.tableRow(isExpired)}>
|
||||
<td>{key.name}</td>
|
||||
<td>{key.role}</td>
|
||||
<td>
|
||||
{formatDate(key.expiration, timeZone)}
|
||||
{isExpired && (
|
||||
<span className={styles.tooltipContainer}>
|
||||
<Tooltip
|
||||
content={t(
|
||||
'api-keys.api-keys-table.content-this-api-key-has-expired',
|
||||
'This API key has expired.'
|
||||
)}
|
||||
>
|
||||
<Icon name="exclamation-triangle" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{formatLastUsedAtDate(timeZone, key.lastUsedAt)}</td>
|
||||
<td>
|
||||
<Stack justifyContent="flex-end">
|
||||
<Button size="sm" onClick={() => onMigrate(key)}>
|
||||
<Trans i18nKey="api-keys.api-keys-table.migrate-to-service-account">
|
||||
Migrate to service account
|
||||
</Trans>
|
||||
</Button>
|
||||
<DeleteButton
|
||||
aria-label={t('api-keys.api-keys-table.aria-label-delete-api-key', 'Delete API key')}
|
||||
size="sm"
|
||||
onConfirm={() => onDelete(key)}
|
||||
disabled={!contextSrv.hasPermissionInMetadata(AccessControlAction.ActionAPIKeysDelete, key)}
|
||||
/>
|
||||
</Stack>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
) : null}
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
function formatLastUsedAtDate(timeZone: TimeZone, lastUsedAt?: string): string {
|
||||
if (!lastUsedAt) {
|
||||
return 'Never';
|
||||
}
|
||||
return dateTimeFormat(lastUsedAt, { timeZone });
|
||||
}
|
||||
|
||||
function formatDate(expiration: string | undefined, timeZone: TimeZone): string {
|
||||
if (!expiration) {
|
||||
return 'No expiration date';
|
||||
}
|
||||
return dateTimeFormat(expiration, { timeZone });
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
tableRow: (isExpired: boolean) =>
|
||||
css({
|
||||
color: isExpired ? theme.colors.text.secondary : theme.colors.text.primary,
|
||||
}),
|
||||
tooltipContainer: css({
|
||||
marginLeft: theme.spacing(1),
|
||||
}),
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans, useTranslate } from '@grafana/i18n';
|
||||
import { Alert, Button, ConfirmModal, useStyles2 } from '@grafana/ui';
|
||||
|
||||
interface Props {
|
||||
onMigrate: () => void;
|
||||
apikeysCount: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const MigrateToServiceAccountsCard = ({ onMigrate, apikeysCount, disabled }: Props): JSX.Element => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const styles = useStyles2(getStyles);
|
||||
const { t } = useTranslate();
|
||||
const docsLink = (
|
||||
<a
|
||||
className="external-link"
|
||||
href="https://grafana.com/docs/grafana/latest/administration/service-accounts/migrate-api-keys/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Trans i18nKey="api-keys.migrate-to-service-accounts-card.docs-link.about-migration">
|
||||
Find out more about the migration here.
|
||||
</Trans>
|
||||
</a>
|
||||
);
|
||||
const migrationBoxDesc = (
|
||||
<span>
|
||||
<Trans i18nKey="api-keys.migrate-to-service-accounts-card.migration-box-desc.migrating">
|
||||
Migrating all API keys will hide the API keys tab.
|
||||
</Trans>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{apikeysCount > 0 && (
|
||||
<Alert
|
||||
title={t(
|
||||
'api-keys.migrate-to-service-accounts-card.title-switch-service-accounts',
|
||||
'Switch from API keys to service accounts'
|
||||
)}
|
||||
severity="warning"
|
||||
>
|
||||
<div className={styles.text}>
|
||||
<Trans
|
||||
i18nKey="api-keys.migrate-to-service-accounts-card.body-switch-service-accounts"
|
||||
components={{ docsLink }}
|
||||
>
|
||||
API keys are deprecated and will be removed from Grafana on Jan 31, 2025. 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 />'}
|
||||
</Trans>
|
||||
</div>
|
||||
<div className={styles.actionRow}>
|
||||
<Button className={styles.actionButton} onClick={() => setIsModalOpen(true)}>
|
||||
<Trans i18nKey="api-keys.migrate-to-service-accounts-card.migrate-all-service-accounts">
|
||||
Migrate all service accounts
|
||||
</Trans>
|
||||
</Button>
|
||||
<ConfirmModal
|
||||
title={t('api-keys.migrate-to-service-accounts-card.modal-title', 'Migrate API keys to service accounts')}
|
||||
isOpen={isModalOpen}
|
||||
body={migrationBoxDesc}
|
||||
confirmText={'Yes, migrate now'}
|
||||
onConfirm={onMigrate}
|
||||
onDismiss={() => setIsModalOpen(false)}
|
||||
confirmVariant="primary"
|
||||
confirmButtonVariant="primary"
|
||||
/>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
{apikeysCount === 0 && (
|
||||
<>
|
||||
<Alert
|
||||
title={t('api-keys.migrate-to-service-accounts-card.title-no-api-keys-found', 'No API keys found')}
|
||||
severity="warning"
|
||||
>
|
||||
<div className={styles.text}>
|
||||
<Trans i18nKey="api-keys.migrate-to-service-accounts-card.body-no-api-keys-found">
|
||||
No API keys were found. If you reload the browser, this page will not be available anymore.
|
||||
</Trans>
|
||||
</div>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => ({
|
||||
text: css({
|
||||
marginBottom: theme.spacing(2),
|
||||
}),
|
||||
actionRow: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}),
|
||||
actionButton: css({
|
||||
marginRight: theme.spacing(2),
|
||||
}),
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { ApiKey, OrgRole } from 'app/types';
|
||||
|
||||
export const getMultipleMockKeys = (numberOfKeys: number): ApiKey[] => {
|
||||
const keys: ApiKey[] = [];
|
||||
|
||||
for (let i = 1; i <= numberOfKeys; i++) {
|
||||
keys.push({
|
||||
id: i,
|
||||
name: `test-${i}`,
|
||||
role: OrgRole.Viewer,
|
||||
secondsToLive: 100,
|
||||
expiration: '2019-06-04',
|
||||
});
|
||||
}
|
||||
|
||||
return keys;
|
||||
};
|
||||
|
||||
export const getMockKey = (): ApiKey => {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'test',
|
||||
role: OrgRole.Admin,
|
||||
secondsToLive: 200,
|
||||
expiration: '2019-06-04',
|
||||
};
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
import { ThunkResult } from 'app/types';
|
||||
|
||||
import { apiKeysLoaded, includeExpiredToggled, isFetching, setMigrationResult } from './reducers';
|
||||
|
||||
export function loadApiKeys(): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
dispatch(isFetching());
|
||||
const [keys, keysIncludingExpired] = await Promise.all([
|
||||
getBackendSrv().get('/api/auth/keys?includeExpired=false&accesscontrol=true'),
|
||||
getBackendSrv().get('/api/auth/keys?includeExpired=true&accesscontrol=true'),
|
||||
]);
|
||||
dispatch(apiKeysLoaded({ keys, keysIncludingExpired }));
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteApiKey(id: number): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
getBackendSrv()
|
||||
.delete(`/api/auth/keys/${id}`)
|
||||
.then(() => dispatch(loadApiKeys()));
|
||||
};
|
||||
}
|
||||
|
||||
export function migrateApiKey(id: number): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
await getBackendSrv().post(`/api/serviceaccounts/migrate/${id}`);
|
||||
} finally {
|
||||
dispatch(loadApiKeys());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function migrateAll(): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const payload = await getBackendSrv().post('/api/serviceaccounts/migrate');
|
||||
dispatch(setMigrationResult(payload));
|
||||
} finally {
|
||||
dispatch(loadApiKeys());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleIncludeExpired(): ThunkResult<void> {
|
||||
return (dispatch) => {
|
||||
dispatch(includeExpiredToggled());
|
||||
};
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { reducerTester } from '../../../../test/core/redux/reducerTester';
|
||||
import { ApiKeysState } from '../../../types';
|
||||
import { getMultipleMockKeys } from '../__mocks__/apiKeysMock';
|
||||
|
||||
import {
|
||||
apiKeysLoaded,
|
||||
apiKeysReducer,
|
||||
includeExpiredToggled,
|
||||
initialApiKeysState,
|
||||
isFetching,
|
||||
setSearchQuery,
|
||||
} from './reducers';
|
||||
|
||||
describe('API Keys reducer', () => {
|
||||
it('should set keys', () => {
|
||||
reducerTester<ApiKeysState>()
|
||||
.givenReducer(apiKeysReducer, { ...initialApiKeysState })
|
||||
.whenActionIsDispatched(
|
||||
apiKeysLoaded({ keys: getMultipleMockKeys(4), keysIncludingExpired: getMultipleMockKeys(6) })
|
||||
)
|
||||
.thenStateShouldEqual({
|
||||
...initialApiKeysState,
|
||||
keys: getMultipleMockKeys(4),
|
||||
keysIncludingExpired: getMultipleMockKeys(6),
|
||||
hasFetched: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set search query', () => {
|
||||
reducerTester<ApiKeysState>()
|
||||
.givenReducer(apiKeysReducer, { ...initialApiKeysState })
|
||||
.whenActionIsDispatched(setSearchQuery('test query'))
|
||||
.thenStateShouldEqual({
|
||||
...initialApiKeysState,
|
||||
searchQuery: 'test query',
|
||||
});
|
||||
});
|
||||
|
||||
it('should toggle the includeExpired state', () => {
|
||||
reducerTester<ApiKeysState>()
|
||||
.givenReducer(apiKeysReducer, { ...initialApiKeysState })
|
||||
.whenActionIsDispatched(includeExpiredToggled())
|
||||
.thenStateShouldEqual({
|
||||
...initialApiKeysState,
|
||||
includeExpired: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set state when fetching', () => {
|
||||
reducerTester<ApiKeysState>()
|
||||
.givenReducer(apiKeysReducer, { ...initialApiKeysState })
|
||||
.whenActionIsDispatched(isFetching())
|
||||
.thenStateShouldEqual({
|
||||
...initialApiKeysState,
|
||||
hasFetched: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { ApiKeysState } from 'app/types';
|
||||
|
||||
export const initialApiKeysState: ApiKeysState = {
|
||||
hasFetched: false,
|
||||
includeExpired: false,
|
||||
keys: [],
|
||||
keysIncludingExpired: [],
|
||||
searchQuery: '',
|
||||
migrationResult: {
|
||||
total: 0,
|
||||
migrated: 0,
|
||||
failed: 0,
|
||||
failedApikeyIDs: [0],
|
||||
failedDetails: [],
|
||||
},
|
||||
};
|
||||
|
||||
const apiKeysSlice = createSlice({
|
||||
name: 'apiKeys',
|
||||
initialState: initialApiKeysState,
|
||||
reducers: {
|
||||
apiKeysLoaded: (state, action): ApiKeysState => {
|
||||
const { keys, keysIncludingExpired } = action.payload;
|
||||
const includeExpired =
|
||||
action.payload.keys.length === 0 && action.payload.keysIncludingExpired.length > 0
|
||||
? true
|
||||
: state.includeExpired;
|
||||
return { ...state, hasFetched: true, keys, keysIncludingExpired, includeExpired };
|
||||
},
|
||||
setSearchQuery: (state, action): ApiKeysState => {
|
||||
return { ...state, searchQuery: action.payload };
|
||||
},
|
||||
includeExpiredToggled: (state): ApiKeysState => {
|
||||
return { ...state, includeExpired: !state.includeExpired };
|
||||
},
|
||||
isFetching: (state): ApiKeysState => {
|
||||
return { ...state, hasFetched: false };
|
||||
},
|
||||
setMigrationResult: (state, action): ApiKeysState => {
|
||||
return { ...state, migrationResult: action.payload };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { apiKeysLoaded, includeExpiredToggled, isFetching, setSearchQuery, setMigrationResult } =
|
||||
apiKeysSlice.actions;
|
||||
|
||||
export const apiKeysReducer = apiKeysSlice.reducer;
|
||||
|
||||
export default {
|
||||
apiKeys: apiKeysReducer,
|
||||
};
|
||||
@@ -1,142 +0,0 @@
|
||||
import { ApiKeysState } from 'app/types';
|
||||
|
||||
import { getMultipleMockKeys } from '../__mocks__/apiKeysMock';
|
||||
|
||||
import { getApiKeys, getApiKeysCount, getIncludeExpired, getIncludeExpiredDisabled } from './selectors';
|
||||
|
||||
describe('API Keys selectors', () => {
|
||||
const mockKeys = getMultipleMockKeys(5);
|
||||
const mockKeysIncludingExpired = getMultipleMockKeys(8);
|
||||
|
||||
describe('getApiKeysCount', () => {
|
||||
it('returns the correct count when includeExpired is false', () => {
|
||||
const mockState: ApiKeysState = {
|
||||
keys: mockKeys,
|
||||
keysIncludingExpired: mockKeysIncludingExpired,
|
||||
searchQuery: '',
|
||||
hasFetched: true,
|
||||
includeExpired: false,
|
||||
};
|
||||
const keyCount = getApiKeysCount(mockState);
|
||||
expect(keyCount).toBe(5);
|
||||
});
|
||||
|
||||
it('returns the correct count when includeExpired is true', () => {
|
||||
const mockState: ApiKeysState = {
|
||||
keys: mockKeys,
|
||||
keysIncludingExpired: mockKeysIncludingExpired,
|
||||
searchQuery: '',
|
||||
hasFetched: true,
|
||||
includeExpired: true,
|
||||
};
|
||||
const keyCount = getApiKeysCount(mockState);
|
||||
expect(keyCount).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getApiKeys', () => {
|
||||
describe('when includeExpired is false', () => {
|
||||
it('should return all keys if no search query', () => {
|
||||
const mockState: ApiKeysState = {
|
||||
keys: mockKeys,
|
||||
keysIncludingExpired: mockKeysIncludingExpired,
|
||||
searchQuery: '',
|
||||
hasFetched: true,
|
||||
includeExpired: false,
|
||||
};
|
||||
const keys = getApiKeys(mockState);
|
||||
expect(keys).toEqual(mockKeys);
|
||||
});
|
||||
|
||||
it('should filter keys if search query exists', () => {
|
||||
const mockState: ApiKeysState = {
|
||||
keys: mockKeys,
|
||||
keysIncludingExpired: mockKeysIncludingExpired,
|
||||
searchQuery: '5',
|
||||
hasFetched: true,
|
||||
includeExpired: false,
|
||||
};
|
||||
const keys = getApiKeys(mockState);
|
||||
expect(keys.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when includeExpired is true', () => {
|
||||
it('should return all keys if no search query', () => {
|
||||
const mockState: ApiKeysState = {
|
||||
keys: mockKeys,
|
||||
keysIncludingExpired: mockKeysIncludingExpired,
|
||||
searchQuery: '',
|
||||
hasFetched: true,
|
||||
includeExpired: true,
|
||||
};
|
||||
const keys = getApiKeys(mockState);
|
||||
expect(keys).toEqual(mockKeysIncludingExpired);
|
||||
});
|
||||
|
||||
it('should filter keys if search query exists', () => {
|
||||
const mockState: ApiKeysState = {
|
||||
keys: mockKeys,
|
||||
keysIncludingExpired: mockKeysIncludingExpired,
|
||||
searchQuery: '5',
|
||||
hasFetched: true,
|
||||
includeExpired: true,
|
||||
};
|
||||
const keys = getApiKeys(mockState);
|
||||
expect(keys.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIncludeExpired', () => {
|
||||
it('returns true if includeExpired is true', () => {
|
||||
const mockState: ApiKeysState = {
|
||||
keys: mockKeys,
|
||||
keysIncludingExpired: mockKeysIncludingExpired,
|
||||
searchQuery: '',
|
||||
hasFetched: true,
|
||||
includeExpired: true,
|
||||
};
|
||||
const includeExpired = getIncludeExpired(mockState);
|
||||
expect(includeExpired).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false if includeExpired is false', () => {
|
||||
const mockState: ApiKeysState = {
|
||||
keys: mockKeys,
|
||||
keysIncludingExpired: mockKeysIncludingExpired,
|
||||
searchQuery: '',
|
||||
hasFetched: true,
|
||||
includeExpired: false,
|
||||
};
|
||||
const includeExpired = getIncludeExpired(mockState);
|
||||
expect(includeExpired).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIncludeExpiredDisabled', () => {
|
||||
it('returns true if there are no active keys but there are expired keys', () => {
|
||||
const mockState: ApiKeysState = {
|
||||
keys: [],
|
||||
keysIncludingExpired: mockKeysIncludingExpired,
|
||||
searchQuery: '',
|
||||
hasFetched: true,
|
||||
includeExpired: true,
|
||||
};
|
||||
const includeExpiredDisabled = getIncludeExpiredDisabled(mockState);
|
||||
expect(includeExpiredDisabled).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false otherwise', () => {
|
||||
const mockState: ApiKeysState = {
|
||||
keys: mockKeys,
|
||||
keysIncludingExpired: mockKeysIncludingExpired,
|
||||
searchQuery: '',
|
||||
hasFetched: true,
|
||||
includeExpired: false,
|
||||
};
|
||||
const includeExpiredDisabled = getIncludeExpired(mockState);
|
||||
expect(includeExpiredDisabled).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { ApiKeysState } from 'app/types';
|
||||
|
||||
export const getApiKeysCount = (state: ApiKeysState) =>
|
||||
state.includeExpired ? state.keysIncludingExpired.length : state.keys.length;
|
||||
|
||||
export const getApiKeys = (state: ApiKeysState) => {
|
||||
const regex = RegExp(state.searchQuery, 'i');
|
||||
const keysToFilter = state.includeExpired ? state.keysIncludingExpired : state.keys;
|
||||
|
||||
return keysToFilter.filter((key) => {
|
||||
return regex.test(key.name) || regex.test(key.role);
|
||||
});
|
||||
};
|
||||
|
||||
export const getIncludeExpired = (state: ApiKeysState) => state.includeExpired;
|
||||
|
||||
export const getIncludeExpiredDisabled = (state: ApiKeysState) =>
|
||||
state.keys.length === 0 && state.keysIncludingExpired.length > 0;
|
||||
@@ -248,13 +248,6 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
() => import(/* webpackChunkName: "UserInvitePage" */ 'app/features/org/UserInvitePage')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/org/apikeys',
|
||||
roles: () => contextSrv.evaluatePermission([AccessControlAction.ActionAPIKeysRead]),
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "ApiKeysPage" */ 'app/features/api-keys/ApiKeysPage')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/org/serviceaccounts',
|
||||
roles: () =>
|
||||
|
||||
@@ -2964,51 +2964,6 @@
|
||||
"title-query-result": "Query result"
|
||||
}
|
||||
},
|
||||
"api-keys": {
|
||||
"api-keys-action-bar": {
|
||||
"placeholder-search-keys": "Search keys"
|
||||
},
|
||||
"api-keys-page-unconnected": {
|
||||
"label-include-expired-keys": "Include expired keys"
|
||||
},
|
||||
"api-keys-table": {
|
||||
"aria-label-delete-api-key": "Delete API key",
|
||||
"content-this-api-key-has-expired": "This API key has expired.",
|
||||
"expires": "Expires",
|
||||
"last-used-at": "Last used at",
|
||||
"migrate-to-service-account": "Migrate to service account",
|
||||
"name": "Name",
|
||||
"role": "Role"
|
||||
},
|
||||
"empty-state": {
|
||||
"message": "No API keys found"
|
||||
},
|
||||
"migrate-to-service-accounts-card": {
|
||||
"body-no-api-keys-found": "No API keys were found. If you reload the browser, this page will not be available anymore.",
|
||||
"body-switch-service-accounts": "API keys are deprecated and will be removed from Grafana on Jan 31, 2025. 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 />",
|
||||
"docs-link": {
|
||||
"about-migration": "Find out more about the migration here."
|
||||
},
|
||||
"migrate-all-service-accounts": "Migrate all service accounts",
|
||||
"migration-box-desc": {
|
||||
"migrating": "Migrating all API keys will hide the API keys tab."
|
||||
},
|
||||
"modal-title": "Migrate API keys to service accounts",
|
||||
"title-no-api-keys-found": "No API keys found",
|
||||
"title-switch-service-accounts": "Switch from API keys to service accounts"
|
||||
},
|
||||
"migration-summary": {
|
||||
"close": "Close",
|
||||
"failed": "<0>Failed: </0>{{failed}}",
|
||||
"failed-details": "<0>Failed details: </0>{{details}}",
|
||||
"failed-ids": "<0>Failed api key IDs: </0>{{ids}}",
|
||||
"migrated": "<0>Migrated: </0>{{migrated}}",
|
||||
"migration-complete": "Migration complete! Please note, while there might be a few API keys flagged as `failed migrations`, rest assured, all of your API keys are fully functional and operational. Please try again or contact support.",
|
||||
"migration-successful": "Migration Successful!",
|
||||
"title-migration-summary": "Migration summary",
|
||||
"total": "<0>Total: </0>{{total}}"
|
||||
}
|
||||
},
|
||||
"app-chrome": {
|
||||
"skip-content-button": "Skip to main content",
|
||||
"top-bar": {
|
||||
@@ -8162,10 +8117,6 @@
|
||||
"subtitle": "See recently deleted alert rules",
|
||||
"title": "Recently deleted"
|
||||
},
|
||||
"api-keys": {
|
||||
"subtitle": "Manage and create API keys that are used to interact with Grafana HTTP APIs",
|
||||
"title": "API keys"
|
||||
},
|
||||
"application": {
|
||||
"title": "Application"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user