mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge pull request #13444 from grafana/13411-react-api-key
13411 react api key
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import React, { Component } from 'react';
|
||||
import { UserPicker, User } from 'app/core/components/Picker/UserPicker';
|
||||
import { UserPicker } from 'app/core/components/Picker/UserPicker';
|
||||
import { TeamPicker, Team } from 'app/core/components/Picker/TeamPicker';
|
||||
import DescriptionPicker, { OptionWithDescription } from 'app/core/components/Picker/DescriptionPicker';
|
||||
import { User } from 'app/types';
|
||||
import {
|
||||
dashboardPermissionLevels,
|
||||
dashboardAclTargets,
|
||||
|
@@ -3,6 +3,7 @@ import Select from 'react-select';
|
||||
import PickerOption from './PickerOption';
|
||||
import { debounce } from 'lodash';
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
import { User } from 'app/types';
|
||||
|
||||
export interface Props {
|
||||
onSelected: (user: User) => void;
|
||||
@@ -14,13 +15,6 @@ export interface State {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
label: string;
|
||||
avatarUrl: string;
|
||||
login: string;
|
||||
}
|
||||
|
||||
export class UserPicker extends Component<Props, State> {
|
||||
debouncedSearch: any;
|
||||
|
||||
|
25
public/app/features/api-keys/ApiKeysAddedModal.test.tsx
Normal file
25
public/app/features/api-keys/ApiKeysAddedModal.test.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { ApiKeysAddedModal, Props } from './ApiKeysAddedModal';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
apiKey: 'api key test',
|
||||
rootPath: 'test/path',
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
const wrapper = shallow(<ApiKeysAddedModal {...props} />);
|
||||
|
||||
return {
|
||||
wrapper,
|
||||
};
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
const { wrapper } = setup();
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
46
public/app/features/api-keys/ApiKeysAddedModal.tsx
Normal file
46
public/app/features/api-keys/ApiKeysAddedModal.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface Props {
|
||||
apiKey: string;
|
||||
rootPath: string;
|
||||
}
|
||||
|
||||
export const ApiKeysAddedModal = (props: Props) => {
|
||||
return (
|
||||
<div className="modal-body">
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-header-title">
|
||||
<i className="fa fa-key" />
|
||||
<span className="p-l-1">API Key Created</span>
|
||||
</h2>
|
||||
|
||||
<a className="modal-header-close" ng-click="dismiss();">
|
||||
<i className="fa fa-remove" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="modal-content">
|
||||
<div className="gf-form-group">
|
||||
<div className="gf-form">
|
||||
<span className="gf-form-label">Key</span>
|
||||
<span className="gf-form-label">{props.apiKey}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grafana-info-box" style={{ border: 0 }}>
|
||||
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.
|
||||
<br />
|
||||
<br />
|
||||
You can authenticate request using the Authorization HTTP header, example:
|
||||
<br />
|
||||
<br />
|
||||
<pre className="small">
|
||||
curl -H "Authorization: Bearer {props.apiKey}" {props.rootPath}/api/dashboards/home
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeysAddedModal;
|
73
public/app/features/api-keys/ApiKeysPage.test.tsx
Normal file
73
public/app/features/api-keys/ApiKeysPage.test.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { Props, ApiKeysPage } from './ApiKeysPage';
|
||||
import { NavModel, ApiKey } from 'app/types';
|
||||
import { getMultipleMockKeys, getMockKey } from './__mocks__/apiKeysMock';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props: Props = {
|
||||
navModel: {} as NavModel,
|
||||
apiKeys: [] as ApiKey[],
|
||||
searchQuery: '',
|
||||
loadApiKeys: jest.fn(),
|
||||
deleteApiKey: jest.fn(),
|
||||
setSearchQuery: jest.fn(),
|
||||
addApiKey: jest.fn(),
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
const wrapper = shallow(<ApiKeysPage {...props} />);
|
||||
const instance = wrapper.instance() as ApiKeysPage;
|
||||
|
||||
return {
|
||||
wrapper,
|
||||
instance,
|
||||
};
|
||||
};
|
||||
|
||||
describe('Render', () => {
|
||||
it('should render component', () => {
|
||||
const { wrapper } = setup();
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render API keys table', () => {
|
||||
const { wrapper } = setup({
|
||||
apiKeys: getMultipleMockKeys(5),
|
||||
});
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Life cycle', () => {
|
||||
it('should call loadApiKeys', () => {
|
||||
const { instance } = setup();
|
||||
|
||||
instance.componentDidMount();
|
||||
|
||||
expect(instance.props.loadApiKeys).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Functions', () => {
|
||||
describe('Delete team', () => {
|
||||
it('should call delete team', () => {
|
||||
const { instance } = setup();
|
||||
instance.onDeleteApiKey(getMockKey());
|
||||
expect(instance.props.deleteApiKey).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('on search query change', () => {
|
||||
it('should call setSearchQuery', () => {
|
||||
const { instance } = setup();
|
||||
const mockEvent = { target: { value: 'test' } };
|
||||
|
||||
instance.onSearchQueryChange(mockEvent);
|
||||
|
||||
expect(instance.props.setSearchQuery).toHaveBeenCalledWith('test');
|
||||
});
|
||||
});
|
||||
});
|
222
public/app/features/api-keys/ApiKeysPage.tsx
Normal file
222
public/app/features/api-keys/ApiKeysPage.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import ReactDOMServer from 'react-dom/server';
|
||||
import { connect } from 'react-redux';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { NavModel, ApiKey, NewApiKey, OrgRole } from 'app/types';
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
import { getApiKeys } from './state/selectors';
|
||||
import { loadApiKeys, deleteApiKey, setSearchQuery, addApiKey } from './state/actions';
|
||||
import PageHeader from 'app/core/components/PageHeader/PageHeader';
|
||||
import SlideDown from 'app/core/components/Animations/SlideDown';
|
||||
import ApiKeysAddedModal from './ApiKeysAddedModal';
|
||||
import config from 'app/core/config';
|
||||
import appEvents from 'app/core/app_events';
|
||||
|
||||
export interface Props {
|
||||
navModel: NavModel;
|
||||
apiKeys: ApiKey[];
|
||||
searchQuery: string;
|
||||
loadApiKeys: typeof loadApiKeys;
|
||||
deleteApiKey: typeof deleteApiKey;
|
||||
setSearchQuery: typeof setSearchQuery;
|
||||
addApiKey: typeof addApiKey;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
isAdding: boolean;
|
||||
newApiKey: NewApiKey;
|
||||
}
|
||||
|
||||
enum ApiKeyStateProps {
|
||||
Name = 'name',
|
||||
Role = 'role',
|
||||
}
|
||||
|
||||
const initialApiKeyState = {
|
||||
name: '',
|
||||
role: OrgRole.Viewer,
|
||||
};
|
||||
|
||||
export class ApiKeysPage extends PureComponent<Props, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { isAdding: false, newApiKey: initialApiKeyState };
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchApiKeys();
|
||||
}
|
||||
|
||||
async fetchApiKeys() {
|
||||
await this.props.loadApiKeys();
|
||||
}
|
||||
|
||||
onDeleteApiKey(key: ApiKey) {
|
||||
this.props.deleteApiKey(key.id);
|
||||
}
|
||||
|
||||
onSearchQueryChange = evt => {
|
||||
this.props.setSearchQuery(evt.target.value);
|
||||
};
|
||||
|
||||
onToggleAdding = () => {
|
||||
this.setState({ isAdding: !this.state.isAdding });
|
||||
};
|
||||
|
||||
onAddApiKey = async evt => {
|
||||
evt.preventDefault();
|
||||
|
||||
const openModal = (apiKey: string) => {
|
||||
const rootPath = window.location.origin + config.appSubUrl;
|
||||
const modalTemplate = ReactDOMServer.renderToString(<ApiKeysAddedModal apiKey={apiKey} rootPath={rootPath} />);
|
||||
|
||||
appEvents.emit('show-modal', {
|
||||
templateHtml: modalTemplate,
|
||||
});
|
||||
};
|
||||
|
||||
this.props.addApiKey(this.state.newApiKey, openModal);
|
||||
this.setState((prevState: State) => {
|
||||
return {
|
||||
...prevState,
|
||||
newApiKey: initialApiKeyState,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
onApiKeyStateUpdate = (evt, prop: string) => {
|
||||
const value = evt.currentTarget.value;
|
||||
this.setState((prevState: State) => {
|
||||
const newApiKey = {
|
||||
...prevState.newApiKey,
|
||||
};
|
||||
newApiKey[prop] = value;
|
||||
|
||||
return {
|
||||
...prevState,
|
||||
newApiKey: newApiKey,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { newApiKey, isAdding } = this.state;
|
||||
const { navModel, apiKeys, searchQuery } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader model={navModel} />
|
||||
<div className="page-container page-body">
|
||||
<div className="page-action-bar">
|
||||
<div className="gf-form gf-form--grow">
|
||||
<label className="gf-form--has-input-icon gf-form--grow">
|
||||
<input
|
||||
type="text"
|
||||
className="gf-form-input"
|
||||
placeholder="Search keys"
|
||||
value={searchQuery}
|
||||
onChange={this.onSearchQueryChange}
|
||||
/>
|
||||
<i className="gf-form-input-icon fa fa-search" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="page-action-bar__spacer" />
|
||||
<button className="btn btn-success pull-right" onClick={this.onToggleAdding} disabled={isAdding}>
|
||||
<i className="fa fa-plus" /> Add API Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SlideDown in={isAdding}>
|
||||
<div className="cta-form">
|
||||
<button className="cta-form__close btn btn-transparent" onClick={this.onToggleAdding}>
|
||||
<i className="fa fa-close" />
|
||||
</button>
|
||||
<h5>Add API Key</h5>
|
||||
<form className="gf-form-group" onSubmit={this.onAddApiKey}>
|
||||
<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={newApiKey.name}
|
||||
placeholder="Name"
|
||||
onChange={evt => this.onApiKeyStateUpdate(evt, ApiKeyStateProps.Name)}
|
||||
/>
|
||||
</div>
|
||||
<div className="gf-form">
|
||||
<span className="gf-form-label">Role</span>
|
||||
<span className="gf-form-select-wrapper">
|
||||
<select
|
||||
className="gf-form-input gf-size-auto"
|
||||
value={newApiKey.role}
|
||||
onChange={evt => this.onApiKeyStateUpdate(evt, ApiKeyStateProps.Role)}
|
||||
>
|
||||
{Object.keys(OrgRole).map(role => {
|
||||
return (
|
||||
<option key={role} label={role} value={role}>
|
||||
{role}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
<div className="gf-form">
|
||||
<button className="btn gf-form-btn btn-success">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</SlideDown>
|
||||
|
||||
<h3 className="page-heading">Existing Keys</h3>
|
||||
<table className="filter-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th style={{ width: '34px' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
{apiKeys.length > 0 ? (
|
||||
<tbody>
|
||||
{apiKeys.map(key => {
|
||||
return (
|
||||
<tr key={key.id}>
|
||||
<td>{key.name}</td>
|
||||
<td>{key.role}</td>
|
||||
<td>
|
||||
<a onClick={() => this.onDeleteApiKey(key)} className="btn btn-danger btn-mini">
|
||||
<i className="fa fa-remove" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
) : null}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
navModel: getNavModel(state.navIndex, 'apikeys'),
|
||||
apiKeys: getApiKeys(state.apiKeys),
|
||||
searchQuery: state.apiKeys.searchQuery,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loadApiKeys,
|
||||
deleteApiKey,
|
||||
setSearchQuery,
|
||||
addApiKey,
|
||||
};
|
||||
|
||||
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(ApiKeysPage));
|
22
public/app/features/api-keys/__mocks__/apiKeysMock.ts
Normal file
22
public/app/features/api-keys/__mocks__/apiKeysMock.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
return keys;
|
||||
};
|
||||
|
||||
export const getMockKey = (): ApiKey => {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'test',
|
||||
role: OrgRole.Admin,
|
||||
};
|
||||
};
|
@@ -0,0 +1,78 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<div
|
||||
className="modal-body"
|
||||
>
|
||||
<div
|
||||
className="modal-header"
|
||||
>
|
||||
<h2
|
||||
className="modal-header-title"
|
||||
>
|
||||
<i
|
||||
className="fa fa-key"
|
||||
/>
|
||||
<span
|
||||
className="p-l-1"
|
||||
>
|
||||
API Key Created
|
||||
</span>
|
||||
</h2>
|
||||
<a
|
||||
className="modal-header-close"
|
||||
ng-click="dismiss();"
|
||||
>
|
||||
<i
|
||||
className="fa fa-remove"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
className="modal-content"
|
||||
>
|
||||
<div
|
||||
className="gf-form-group"
|
||||
>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<span
|
||||
className="gf-form-label"
|
||||
>
|
||||
Key
|
||||
</span>
|
||||
<span
|
||||
className="gf-form-label"
|
||||
>
|
||||
api key test
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="grafana-info-box"
|
||||
style={
|
||||
Object {
|
||||
"border": 0,
|
||||
}
|
||||
}
|
||||
>
|
||||
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.
|
||||
<br />
|
||||
<br />
|
||||
You can authenticate request using the Authorization HTTP header, example:
|
||||
<br />
|
||||
<br />
|
||||
<pre
|
||||
className="small"
|
||||
>
|
||||
curl -H "Authorization: Bearer
|
||||
api key test
|
||||
"
|
||||
test/path
|
||||
/api/dashboards/home
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
@@ -0,0 +1,435 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Render should render API keys table 1`] = `
|
||||
<div>
|
||||
<PageHeader
|
||||
model={Object {}}
|
||||
/>
|
||||
<div
|
||||
className="page-container page-body"
|
||||
>
|
||||
<div
|
||||
className="page-action-bar"
|
||||
>
|
||||
<div
|
||||
className="gf-form gf-form--grow"
|
||||
>
|
||||
<label
|
||||
className="gf-form--has-input-icon gf-form--grow"
|
||||
>
|
||||
<input
|
||||
className="gf-form-input"
|
||||
onChange={[Function]}
|
||||
placeholder="Search keys"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<i
|
||||
className="gf-form-input-icon fa fa-search"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
className="page-action-bar__spacer"
|
||||
/>
|
||||
<button
|
||||
className="btn btn-success pull-right"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-plus"
|
||||
/>
|
||||
Add API Key
|
||||
</button>
|
||||
</div>
|
||||
<Component
|
||||
in={false}
|
||||
>
|
||||
<div
|
||||
className="cta-form"
|
||||
>
|
||||
<button
|
||||
className="cta-form__close btn btn-transparent"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-close"
|
||||
/>
|
||||
</button>
|
||||
<h5>
|
||||
Add API Key
|
||||
</h5>
|
||||
<form
|
||||
className="gf-form-group"
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<div
|
||||
className="gf-form-inline"
|
||||
>
|
||||
<div
|
||||
className="gf-form max-width-21"
|
||||
>
|
||||
<span
|
||||
className="gf-form-label"
|
||||
>
|
||||
Key name
|
||||
</span>
|
||||
<input
|
||||
className="gf-form-input"
|
||||
onChange={[Function]}
|
||||
placeholder="Name"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<span
|
||||
className="gf-form-label"
|
||||
>
|
||||
Role
|
||||
</span>
|
||||
<span
|
||||
className="gf-form-select-wrapper"
|
||||
>
|
||||
<select
|
||||
className="gf-form-input gf-size-auto"
|
||||
onChange={[Function]}
|
||||
value="Viewer"
|
||||
>
|
||||
<option
|
||||
key="Viewer"
|
||||
label="Viewer"
|
||||
value="Viewer"
|
||||
>
|
||||
Viewer
|
||||
</option>
|
||||
<option
|
||||
key="Editor"
|
||||
label="Editor"
|
||||
value="Editor"
|
||||
>
|
||||
Editor
|
||||
</option>
|
||||
<option
|
||||
key="Admin"
|
||||
label="Admin"
|
||||
value="Admin"
|
||||
>
|
||||
Admin
|
||||
</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<button
|
||||
className="btn gf-form-btn btn-success"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Component>
|
||||
<h3
|
||||
className="page-heading"
|
||||
>
|
||||
Existing Keys
|
||||
</h3>
|
||||
<table
|
||||
className="filter-table"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
Name
|
||||
</th>
|
||||
<th>
|
||||
Role
|
||||
</th>
|
||||
<th
|
||||
style={
|
||||
Object {
|
||||
"width": "34px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
key="1"
|
||||
>
|
||||
<td>
|
||||
test-1
|
||||
</td>
|
||||
<td>
|
||||
Viewer
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
className="btn btn-danger btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-remove"
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="2"
|
||||
>
|
||||
<td>
|
||||
test-2
|
||||
</td>
|
||||
<td>
|
||||
Viewer
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
className="btn btn-danger btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-remove"
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="3"
|
||||
>
|
||||
<td>
|
||||
test-3
|
||||
</td>
|
||||
<td>
|
||||
Viewer
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
className="btn btn-danger btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-remove"
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="4"
|
||||
>
|
||||
<td>
|
||||
test-4
|
||||
</td>
|
||||
<td>
|
||||
Viewer
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
className="btn btn-danger btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-remove"
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="5"
|
||||
>
|
||||
<td>
|
||||
test-5
|
||||
</td>
|
||||
<td>
|
||||
Viewer
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
className="btn btn-danger btn-mini"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-remove"
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Render should render component 1`] = `
|
||||
<div>
|
||||
<PageHeader
|
||||
model={Object {}}
|
||||
/>
|
||||
<div
|
||||
className="page-container page-body"
|
||||
>
|
||||
<div
|
||||
className="page-action-bar"
|
||||
>
|
||||
<div
|
||||
className="gf-form gf-form--grow"
|
||||
>
|
||||
<label
|
||||
className="gf-form--has-input-icon gf-form--grow"
|
||||
>
|
||||
<input
|
||||
className="gf-form-input"
|
||||
onChange={[Function]}
|
||||
placeholder="Search keys"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<i
|
||||
className="gf-form-input-icon fa fa-search"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
className="page-action-bar__spacer"
|
||||
/>
|
||||
<button
|
||||
className="btn btn-success pull-right"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-plus"
|
||||
/>
|
||||
Add API Key
|
||||
</button>
|
||||
</div>
|
||||
<Component
|
||||
in={false}
|
||||
>
|
||||
<div
|
||||
className="cta-form"
|
||||
>
|
||||
<button
|
||||
className="cta-form__close btn btn-transparent"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<i
|
||||
className="fa fa-close"
|
||||
/>
|
||||
</button>
|
||||
<h5>
|
||||
Add API Key
|
||||
</h5>
|
||||
<form
|
||||
className="gf-form-group"
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<div
|
||||
className="gf-form-inline"
|
||||
>
|
||||
<div
|
||||
className="gf-form max-width-21"
|
||||
>
|
||||
<span
|
||||
className="gf-form-label"
|
||||
>
|
||||
Key name
|
||||
</span>
|
||||
<input
|
||||
className="gf-form-input"
|
||||
onChange={[Function]}
|
||||
placeholder="Name"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<span
|
||||
className="gf-form-label"
|
||||
>
|
||||
Role
|
||||
</span>
|
||||
<span
|
||||
className="gf-form-select-wrapper"
|
||||
>
|
||||
<select
|
||||
className="gf-form-input gf-size-auto"
|
||||
onChange={[Function]}
|
||||
value="Viewer"
|
||||
>
|
||||
<option
|
||||
key="Viewer"
|
||||
label="Viewer"
|
||||
value="Viewer"
|
||||
>
|
||||
Viewer
|
||||
</option>
|
||||
<option
|
||||
key="Editor"
|
||||
label="Editor"
|
||||
value="Editor"
|
||||
>
|
||||
Editor
|
||||
</option>
|
||||
<option
|
||||
key="Admin"
|
||||
label="Admin"
|
||||
value="Admin"
|
||||
>
|
||||
Admin
|
||||
</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form"
|
||||
>
|
||||
<button
|
||||
className="btn gf-form-btn btn-success"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Component>
|
||||
<h3
|
||||
className="page-heading"
|
||||
>
|
||||
Existing Keys
|
||||
</h3>
|
||||
<table
|
||||
className="filter-table"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
Name
|
||||
</th>
|
||||
<th>
|
||||
Role
|
||||
</th>
|
||||
<th
|
||||
style={
|
||||
Object {
|
||||
"width": "34px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
56
public/app/features/api-keys/state/actions.ts
Normal file
56
public/app/features/api-keys/state/actions.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ThunkAction } from 'redux-thunk';
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
import { StoreState, ApiKey } from 'app/types';
|
||||
|
||||
export enum ActionTypes {
|
||||
LoadApiKeys = 'LOAD_API_KEYS',
|
||||
SetApiKeysSearchQuery = 'SET_API_KEYS_SEARCH_QUERY',
|
||||
}
|
||||
|
||||
export interface LoadApiKeysAction {
|
||||
type: ActionTypes.LoadApiKeys;
|
||||
payload: ApiKey[];
|
||||
}
|
||||
|
||||
export interface SetSearchQueryAction {
|
||||
type: ActionTypes.SetApiKeysSearchQuery;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export type Action = LoadApiKeysAction | SetSearchQueryAction;
|
||||
|
||||
type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
|
||||
|
||||
const apiKeysLoaded = (apiKeys: ApiKey[]): LoadApiKeysAction => ({
|
||||
type: ActionTypes.LoadApiKeys,
|
||||
payload: apiKeys,
|
||||
});
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
||||
export function loadApiKeys(): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
const response = await getBackendSrv().get('/api/auth/keys');
|
||||
dispatch(apiKeysLoaded(response));
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteApiKey(id: number): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
getBackendSrv()
|
||||
.delete('/api/auth/keys/' + id)
|
||||
.then(dispatch(loadApiKeys()));
|
||||
};
|
||||
}
|
||||
|
||||
export const setSearchQuery = (searchQuery: string): SetSearchQueryAction => ({
|
||||
type: ActionTypes.SetApiKeysSearchQuery,
|
||||
payload: searchQuery,
|
||||
});
|
31
public/app/features/api-keys/state/reducers.test.ts
Normal file
31
public/app/features/api-keys/state/reducers.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Action, ActionTypes } from './actions';
|
||||
import { initialApiKeysState, apiKeysReducer } from './reducers';
|
||||
import { getMultipleMockKeys } from '../__mocks__/apiKeysMock';
|
||||
|
||||
describe('API Keys reducer', () => {
|
||||
it('should set keys', () => {
|
||||
const payload = getMultipleMockKeys(4);
|
||||
|
||||
const action: Action = {
|
||||
type: ActionTypes.LoadApiKeys,
|
||||
payload,
|
||||
};
|
||||
|
||||
const result = apiKeysReducer(initialApiKeysState, action);
|
||||
|
||||
expect(result.keys).toEqual(payload);
|
||||
});
|
||||
|
||||
it('should set search query', () => {
|
||||
const payload = 'test query';
|
||||
|
||||
const action: Action = {
|
||||
type: ActionTypes.SetApiKeysSearchQuery,
|
||||
payload,
|
||||
};
|
||||
|
||||
const result = apiKeysReducer(initialApiKeysState, action);
|
||||
|
||||
expect(result.searchQuery).toEqual('test query');
|
||||
});
|
||||
});
|
21
public/app/features/api-keys/state/reducers.ts
Normal file
21
public/app/features/api-keys/state/reducers.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ApiKeysState } from 'app/types';
|
||||
import { Action, ActionTypes } from './actions';
|
||||
|
||||
export const initialApiKeysState: ApiKeysState = {
|
||||
keys: [],
|
||||
searchQuery: '',
|
||||
};
|
||||
|
||||
export const apiKeysReducer = (state = initialApiKeysState, action: Action): ApiKeysState => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.LoadApiKeys:
|
||||
return { ...state, keys: action.payload };
|
||||
case ActionTypes.SetApiKeysSearchQuery:
|
||||
return { ...state, searchQuery: action.payload };
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
export default {
|
||||
apiKeys: apiKeysReducer,
|
||||
};
|
25
public/app/features/api-keys/state/selectors.test.ts
Normal file
25
public/app/features/api-keys/state/selectors.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { getApiKeys } from './selectors';
|
||||
import { getMultipleMockKeys } from '../__mocks__/apiKeysMock';
|
||||
import { ApiKeysState } from 'app/types';
|
||||
|
||||
describe('API Keys selectors', () => {
|
||||
describe('Get API Keys', () => {
|
||||
const mockKeys = getMultipleMockKeys(5);
|
||||
|
||||
it('should return all keys if no search query', () => {
|
||||
const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '' };
|
||||
|
||||
const keys = getApiKeys(mockState);
|
||||
|
||||
expect(keys).toEqual(mockKeys);
|
||||
});
|
||||
|
||||
it('should filter keys if search query exists', () => {
|
||||
const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '5' };
|
||||
|
||||
const keys = getApiKeys(mockState);
|
||||
|
||||
expect(keys.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
9
public/app/features/api-keys/state/selectors.ts
Normal file
9
public/app/features/api-keys/state/selectors.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ApiKeysState } from 'app/types';
|
||||
|
||||
export const getApiKeys = (state: ApiKeysState) => {
|
||||
const regex = RegExp(state.searchQuery, 'i');
|
||||
|
||||
return state.keys.filter(key => {
|
||||
return regex.test(key.name) || regex.test(key.role);
|
||||
});
|
||||
};
|
@@ -6,6 +6,5 @@ import './change_password_ctrl';
|
||||
import './new_org_ctrl';
|
||||
import './user_invite_ctrl';
|
||||
import './create_team_ctrl';
|
||||
import './org_api_keys_ctrl';
|
||||
import './org_details_ctrl';
|
||||
import './prefs_control';
|
||||
|
@@ -1,44 +0,0 @@
|
||||
import angular from 'angular';
|
||||
|
||||
export class OrgApiKeysCtrl {
|
||||
/** @ngInject */
|
||||
constructor($scope, $http, backendSrv, navModelSrv) {
|
||||
$scope.navModel = navModelSrv.getNav('cfg', 'apikeys', 0);
|
||||
|
||||
$scope.roleTypes = ['Viewer', 'Editor', 'Admin'];
|
||||
$scope.token = { role: 'Viewer' };
|
||||
|
||||
$scope.init = () => {
|
||||
$scope.getTokens();
|
||||
};
|
||||
|
||||
$scope.getTokens = () => {
|
||||
backendSrv.get('/api/auth/keys').then(tokens => {
|
||||
$scope.tokens = tokens;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.removeToken = id => {
|
||||
backendSrv.delete('/api/auth/keys/' + id).then($scope.getTokens);
|
||||
};
|
||||
|
||||
$scope.addToken = () => {
|
||||
backendSrv.post('/api/auth/keys', $scope.token).then(result => {
|
||||
const modalScope = $scope.$new(true);
|
||||
modalScope.key = result.key;
|
||||
modalScope.rootPath = window.location.origin + $scope.$root.appSubUrl;
|
||||
|
||||
$scope.appEvent('show-modal', {
|
||||
src: 'public/app/features/org/partials/apikeyModal.html',
|
||||
scope: modalScope,
|
||||
});
|
||||
|
||||
$scope.getTokens();
|
||||
});
|
||||
};
|
||||
|
||||
$scope.init();
|
||||
}
|
||||
}
|
||||
|
||||
angular.module('grafana.controllers').controller('OrgApiKeysCtrl', OrgApiKeysCtrl);
|
@@ -1,37 +0,0 @@
|
||||
<div class="modal-body">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-header-title">
|
||||
<i class="fa fa-key"></i>
|
||||
<span class="p-l-1">API Key Created</span>
|
||||
</h2>
|
||||
|
||||
<a class="modal-header-close" ng-click="dismiss();">
|
||||
<i class="fa fa-remove"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="modal-content">
|
||||
|
||||
<div class="gf-form-group">
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label">Key</span>
|
||||
<span class="gf-form-label">{{key}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grafana-info-box" style="border: 0;">
|
||||
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.
|
||||
<br>
|
||||
<br>
|
||||
You can authenticate request using the Authorization HTTP header, example:
|
||||
<br>
|
||||
<br>
|
||||
<pre class="small">
|
||||
curl -H "Authorization: Bearer {{key}}" {{rootPath}}/api/dashboards/home
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
@@ -1,49 +0,0 @@
|
||||
<page-header model="navModel"></page-header>
|
||||
|
||||
<div class="page-container page-body">
|
||||
|
||||
<h3 class="page-heading">Add new</h3>
|
||||
|
||||
<form name="addTokenForm" class="gf-form-group">
|
||||
<div class="gf-form-inline">
|
||||
<div class="gf-form max-width-21">
|
||||
<span class="gf-form-label">Key name</span>
|
||||
<input type="text" class="gf-form-input" ng-model='token.name' placeholder="Name"></input>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<span class="gf-form-label">Role</span>
|
||||
<span class="gf-form-select-wrapper">
|
||||
<select class="gf-form-input gf-size-auto" ng-model="token.role" ng-options="r for r in roleTypes"></select>
|
||||
</span>
|
||||
</div>
|
||||
<div class="gf-form">
|
||||
<button class="btn gf-form-btn btn-success" ng-click="addToken()">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h3 class="page-heading">Existing Keys</h3>
|
||||
<table class="filter-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th style="width: 34px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="t in tokens">
|
||||
<td>{{t.name}}</td>
|
||||
<td>{{t.role}}</td>
|
||||
<td>
|
||||
<a ng-click="removeToken(t.id)" class="btn btn-danger btn-mini">
|
||||
<i class="fa fa-remove"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
|
@@ -1,10 +1,10 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import SlideDown from 'app/core/components/Animations/SlideDown';
|
||||
import { UserPicker, User } from 'app/core/components/Picker/UserPicker';
|
||||
import { UserPicker } from 'app/core/components/Picker/UserPicker';
|
||||
import DeleteButton from 'app/core/components/DeleteButton/DeleteButton';
|
||||
import { TagBadge } from 'app/core/components/TagFilter/TagBadge';
|
||||
import { TeamMember } from '../../types';
|
||||
import { TeamMember, User } from 'app/types';
|
||||
import { loadTeamMembers, addTeamMember, removeTeamMember, setSearchMemberQuery } from './state/actions';
|
||||
import { getSearchMemberQuery, getTeamMembers } from './state/selectors';
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Team, TeamGroup, TeamMember } from '../../../types';
|
||||
import { Team, TeamGroup, TeamMember } from 'app/types';
|
||||
|
||||
export const getMultipleMockTeams = (numberOfTeams: number): Team[] => {
|
||||
const teams: Team[] = [];
|
||||
|
@@ -5,6 +5,7 @@ import ServerStats from 'app/features/admin/ServerStats';
|
||||
import AlertRuleList from 'app/features/alerting/AlertRuleList';
|
||||
import TeamPages from 'app/features/teams/TeamPages';
|
||||
import TeamList from 'app/features/teams/TeamList';
|
||||
import ApiKeys from 'app/features/api-keys/ApiKeysPage';
|
||||
import PluginListPage from 'app/features/plugins/PluginListPage';
|
||||
import FolderSettingsPage from 'app/features/folders/FolderSettingsPage';
|
||||
import FolderPermissions from 'app/features/folders/FolderPermissions';
|
||||
@@ -139,8 +140,11 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
|
||||
controllerAs: 'ctrl',
|
||||
})
|
||||
.when('/org/apikeys', {
|
||||
templateUrl: 'public/app/features/org/partials/orgApiKeys.html',
|
||||
controller: 'OrgApiKeysCtrl',
|
||||
template: '<react-container />',
|
||||
resolve: {
|
||||
roles: () => ['Editor', 'Admin'],
|
||||
component: () => ApiKeys,
|
||||
},
|
||||
})
|
||||
.when('/org/teams', {
|
||||
template: '<react-container />',
|
||||
|
@@ -4,6 +4,7 @@ import { createLogger } from 'redux-logger';
|
||||
import sharedReducers from 'app/core/reducers';
|
||||
import alertingReducers from 'app/features/alerting/state/reducers';
|
||||
import teamsReducers from 'app/features/teams/state/reducers';
|
||||
import apiKeysReducers from 'app/features/api-keys/state/reducers';
|
||||
import foldersReducers from 'app/features/folders/state/reducers';
|
||||
import dashboardReducers from 'app/features/dashboard/state/reducers';
|
||||
import pluginReducers from 'app/features/plugins/state/reducers';
|
||||
@@ -12,6 +13,7 @@ const rootReducer = combineReducers({
|
||||
...sharedReducers,
|
||||
...alertingReducers,
|
||||
...teamsReducers,
|
||||
...apiKeysReducers,
|
||||
...foldersReducers,
|
||||
...dashboardReducers,
|
||||
...pluginReducers,
|
||||
|
17
public/app/types/apiKeys.ts
Normal file
17
public/app/types/apiKeys.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { OrgRole } from './acl';
|
||||
|
||||
export interface ApiKey {
|
||||
id: number;
|
||||
name: string;
|
||||
role: OrgRole;
|
||||
}
|
||||
|
||||
export interface NewApiKey {
|
||||
name: string;
|
||||
role: OrgRole;
|
||||
}
|
||||
|
||||
export interface ApiKeysState {
|
||||
keys: ApiKey[];
|
||||
searchQuery: string;
|
||||
}
|
@@ -6,6 +6,8 @@ import { FolderDTO, FolderState, FolderInfo } from './folders';
|
||||
import { DashboardState } from './dashboard';
|
||||
import { DashboardAcl, OrgRole, PermissionLevel } from './acl';
|
||||
import { DataSource } from './datasources';
|
||||
import { ApiKey, ApiKeysState, NewApiKey } from './apiKeys';
|
||||
import { User } from './user';
|
||||
import { PluginMeta, Plugin, PluginsState } from './plugins';
|
||||
|
||||
export {
|
||||
@@ -33,6 +35,10 @@ export {
|
||||
PermissionLevel,
|
||||
DataSource,
|
||||
PluginMeta,
|
||||
ApiKey,
|
||||
ApiKeysState,
|
||||
NewApiKey,
|
||||
User,
|
||||
Plugin,
|
||||
PluginsState,
|
||||
};
|
||||
|
6
public/app/types/user.ts
Normal file
6
public/app/types/user.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
label: string;
|
||||
avatarUrl: string;
|
||||
login: string;
|
||||
}
|
Reference in New Issue
Block a user