Переглянути джерело

Merge pull request #13444 from grafana/13411-react-api-key

13411 react api key
Torkel Ödegaard 7 роки тому
батько
коміт
c0996e7a39
25 змінених файлів з 1086 додано та 144 видалено
  1. 2 1
      public/app/core/components/PermissionList/AddPermission.tsx
  2. 1 7
      public/app/core/components/Picker/UserPicker.tsx
  3. 25 0
      public/app/features/api-keys/ApiKeysAddedModal.test.tsx
  4. 46 0
      public/app/features/api-keys/ApiKeysAddedModal.tsx
  5. 73 0
      public/app/features/api-keys/ApiKeysPage.test.tsx
  6. 222 0
      public/app/features/api-keys/ApiKeysPage.tsx
  7. 22 0
      public/app/features/api-keys/__mocks__/apiKeysMock.ts
  8. 78 0
      public/app/features/api-keys/__snapshots__/ApiKeysAddedModal.test.tsx.snap
  9. 435 0
      public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap
  10. 56 0
      public/app/features/api-keys/state/actions.ts
  11. 31 0
      public/app/features/api-keys/state/reducers.test.ts
  12. 21 0
      public/app/features/api-keys/state/reducers.ts
  13. 25 0
      public/app/features/api-keys/state/selectors.test.ts
  14. 9 0
      public/app/features/api-keys/state/selectors.ts
  15. 0 1
      public/app/features/org/all.ts
  16. 0 44
      public/app/features/org/org_api_keys_ctrl.ts
  17. 0 37
      public/app/features/org/partials/apikeyModal.html
  18. 0 49
      public/app/features/org/partials/orgApiKeys.html
  19. 2 2
      public/app/features/teams/TeamMembers.tsx
  20. 1 1
      public/app/features/teams/__mocks__/teamMocks.ts
  21. 6 2
      public/app/routes/routes.ts
  22. 2 0
      public/app/store/configureStore.ts
  23. 17 0
      public/app/types/apiKeys.ts
  24. 6 0
      public/app/types/index.ts
  25. 6 0
      public/app/types/user.ts

+ 2 - 1
public/app/core/components/PermissionList/AddPermission.tsx

@@ -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,

+ 1 - 7
public/app/core/components/Picker/UserPicker.tsx

@@ -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 - 0
public/app/features/api-keys/ApiKeysAddedModal.test.tsx

@@ -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 - 0
public/app/features/api-keys/ApiKeysAddedModal.tsx

@@ -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 - 0
public/app/features/api-keys/ApiKeysPage.test.tsx

@@ -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 - 0
public/app/features/api-keys/ApiKeysPage.tsx

@@ -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 - 0
public/app/features/api-keys/__mocks__/apiKeysMock.ts

@@ -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,
+  };
+};

+ 78 - 0
public/app/features/api-keys/__snapshots__/ApiKeysAddedModal.test.tsx.snap

@@ -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>
+`;

+ 435 - 0
public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap

@@ -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 - 0
public/app/features/api-keys/state/actions.ts

@@ -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 - 0
public/app/features/api-keys/state/reducers.test.ts

@@ -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 - 0
public/app/features/api-keys/state/reducers.ts

@@ -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 - 0
public/app/features/api-keys/state/selectors.test.ts

@@ -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 - 0
public/app/features/api-keys/state/selectors.ts

@@ -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);
+  });
+};

+ 0 - 1
public/app/features/org/all.ts

@@ -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';

+ 0 - 44
public/app/features/org/org_api_keys_ctrl.ts

@@ -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);

+ 0 - 37
public/app/features/org/partials/apikeyModal.html

@@ -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>
-

+ 0 - 49
public/app/features/org/partials/orgApiKeys.html

@@ -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>
-
-
-

+ 2 - 2
public/app/features/teams/TeamMembers.tsx

@@ -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 - 1
public/app/features/teams/__mocks__/teamMocks.ts

@@ -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[] = [];

+ 6 - 2
public/app/routes/routes.ts

@@ -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 />',

+ 2 - 0
public/app/store/configureStore.ts

@@ -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 - 0
public/app/types/apiKeys.ts

@@ -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 - 0
public/app/types/index.ts

@@ -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 - 0
public/app/types/user.ts

@@ -0,0 +1,6 @@
+export interface User {
+  id: number;
+  label: string;
+  avatarUrl: string;
+  login: string;
+}