浏览代码

Merge pull request #13488 from grafana/org-users-to-react

Org users to react
Torkel Ödegaard 7 年之前
父节点
当前提交
38637f056f
共有 39 个文件被更改,包括 1860 次插入440 次删除
  1. 23 0
      public/app/core/components/OrgActionBar/OrgActionBar.test.tsx
  2. 38 0
      public/app/core/components/OrgActionBar/OrgActionBar.tsx
  3. 2 3
      public/app/core/components/OrgActionBar/__snapshots__/OrgActionBar.test.tsx.snap
  4. 0 23
      public/app/features/datasources/DataSourcesActionBar.test.tsx
  5. 0 62
      public/app/features/datasources/DataSourcesActionBar.tsx
  6. 3 0
      public/app/features/datasources/DataSourcesListPage.test.tsx
  7. 38 7
      public/app/features/datasources/DataSourcesListPage.tsx
  8. 0 42
      public/app/features/datasources/__snapshots__/DataSourcesActionBar.test.tsx.snap
  9. 11 1
      public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap
  10. 0 2
      public/app/features/org/all.ts
  11. 0 87
      public/app/features/org/org_users_ctrl.ts
  12. 0 105
      public/app/features/org/partials/orgUsers.html
  13. 0 31
      public/app/features/plugins/PluginActionBar.test.tsx
  14. 0 62
      public/app/features/plugins/PluginActionBar.tsx
  15. 3 0
      public/app/features/plugins/PluginListPage.test.tsx
  16. 23 7
      public/app/features/plugins/PluginListPage.tsx
  17. 12 1
      public/app/features/plugins/__snapshots__/PluginListPage.test.tsx.snap
  18. 1 1
      public/app/features/plugins/state/actions.ts
  19. 32 0
      public/app/features/users/InviteesTable.test.tsx
  20. 64 0
      public/app/features/users/InviteesTable.tsx
  21. 51 0
      public/app/features/users/UsersActionBar.test.tsx
  22. 80 0
      public/app/features/users/UsersActionBar.tsx
  23. 55 0
      public/app/features/users/UsersListPage.test.tsx
  24. 125 0
      public/app/features/users/UsersListPage.tsx
  25. 33 0
      public/app/features/users/UsersTable.test.tsx
  26. 67 0
      public/app/features/users/UsersTable.tsx
  27. 56 0
      public/app/features/users/__mocks__/userMocks.ts
  28. 318 0
      public/app/features/users/__snapshots__/InviteesTable.test.tsx.snap
  29. 141 0
      public/app/features/users/__snapshots__/UsersActionBar.test.tsx.snap
  30. 21 0
      public/app/features/users/__snapshots__/UsersListPage.test.tsx.snap
  31. 444 0
      public/app/features/users/__snapshots__/UsersTable.test.tsx.snap
  32. 79 0
      public/app/features/users/state/actions.ts
  33. 32 0
      public/app/features/users/state/reducers.ts
  34. 18 0
      public/app/features/users/state/selectors.ts
  35. 5 3
      public/app/routes/routes.ts
  36. 2 0
      public/app/store/configureStore.ts
  37. 7 2
      public/app/types/index.ts
  38. 39 1
      public/app/types/user.ts
  39. 37 0
      public/app/types/users.ts

+ 23 - 0
public/app/core/components/OrgActionBar/OrgActionBar.test.tsx

@@ -0,0 +1,23 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import OrgActionBar, { Props } from './OrgActionBar';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    searchQuery: '',
+    setSearchQuery: jest.fn(),
+    linkButton: { href: 'some/url', title: 'test' },
+  };
+
+  Object.assign(props, propOverrides);
+
+  return shallow(<OrgActionBar {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 38 - 0
public/app/core/components/OrgActionBar/OrgActionBar.tsx

@@ -0,0 +1,38 @@
+import React, { PureComponent } from 'react';
+import LayoutSelector, { LayoutMode } from '../LayoutSelector/LayoutSelector';
+
+export interface Props {
+  searchQuery: string;
+  layoutMode?: LayoutMode;
+  setLayoutMode?: (mode: LayoutMode) => {};
+  setSearchQuery: (value: string) => {};
+  linkButton: { href: string; title: string };
+}
+
+export default class OrgActionBar extends PureComponent<Props> {
+  render() {
+    const { searchQuery, layoutMode, setLayoutMode, linkButton, setSearchQuery } = this.props;
+
+    return (
+      <div className="page-action-bar">
+        <div className="gf-form gf-form--grow">
+          <label className="gf-form--has-input-icon">
+            <input
+              type="text"
+              className="gf-form-input width-20"
+              value={searchQuery}
+              onChange={event => setSearchQuery(event.target.value)}
+              placeholder="Filter by name or type"
+            />
+            <i className="gf-form-input-icon fa fa-search" />
+          </label>
+          <LayoutSelector mode={layoutMode} onLayoutModeChanged={(mode: LayoutMode) => setLayoutMode(mode)} />
+        </div>
+        <div className="page-action-bar__spacer" />
+        <a className="btn btn-success" href={linkButton.href} target="_blank">
+          {linkButton.title}
+        </a>
+      </div>
+    );
+  }
+}

+ 2 - 3
public/app/features/plugins/__snapshots__/PluginActionBar.test.tsx.snap → public/app/core/components/OrgActionBar/__snapshots__/OrgActionBar.test.tsx.snap

@@ -22,7 +22,6 @@ exports[`Render should render component 1`] = `
       />
     </label>
     <LayoutSelector
-      mode="grid"
       onLayoutModeChanged={[Function]}
     />
   </div>
@@ -31,10 +30,10 @@ exports[`Render should render component 1`] = `
   />
   <a
     className="btn btn-success"
-    href="https://grafana.com/plugins?utm_source=grafana_plugin_list"
+    href="some/url"
     target="_blank"
   >
-    Find more plugins on Grafana.com
+    test
   </a>
 </div>
 `;

+ 0 - 23
public/app/features/datasources/DataSourcesActionBar.test.tsx

@@ -1,23 +0,0 @@
-import React from 'react';
-import { shallow } from 'enzyme';
-import { DataSourcesActionBar, Props } from './DataSourcesActionBar';
-import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
-
-const setup = (propOverrides?: object) => {
-  const props: Props = {
-    layoutMode: LayoutModes.Grid,
-    searchQuery: '',
-    setDataSourcesLayoutMode: jest.fn(),
-    setDataSourcesSearchQuery: jest.fn(),
-  };
-
-  return shallow(<DataSourcesActionBar {...props} />);
-};
-
-describe('Render', () => {
-  it('should render component', () => {
-    const wrapper = setup();
-
-    expect(wrapper).toMatchSnapshot();
-  });
-});

+ 0 - 62
public/app/features/datasources/DataSourcesActionBar.tsx

@@ -1,62 +0,0 @@
-import React, { PureComponent } from 'react';
-import { connect } from 'react-redux';
-import LayoutSelector, { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
-import { setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/actions';
-import { getDataSourcesLayoutMode, getDataSourcesSearchQuery } from './state/selectors';
-
-export interface Props {
-  searchQuery: string;
-  layoutMode: LayoutMode;
-  setDataSourcesLayoutMode: typeof setDataSourcesLayoutMode;
-  setDataSourcesSearchQuery: typeof setDataSourcesSearchQuery;
-}
-
-export class DataSourcesActionBar extends PureComponent<Props> {
-  onSearchQueryChange = event => {
-    this.props.setDataSourcesSearchQuery(event.target.value);
-  };
-
-  render() {
-    const { searchQuery, layoutMode, setDataSourcesLayoutMode } = this.props;
-
-    return (
-      <div className="page-action-bar">
-        <div className="gf-form gf-form--grow">
-          <label className="gf-form--has-input-icon">
-            <input
-              type="text"
-              className="gf-form-input width-20"
-              value={searchQuery}
-              onChange={this.onSearchQueryChange}
-              placeholder="Filter by name or type"
-            />
-            <i className="gf-form-input-icon fa fa-search" />
-          </label>
-          <LayoutSelector
-            mode={layoutMode}
-            onLayoutModeChanged={(mode: LayoutMode) => setDataSourcesLayoutMode(mode)}
-          />
-        </div>
-        <div className="page-action-bar__spacer" />
-        <a className="page-header__cta btn btn-success" href="datasources/new">
-          <i className="fa fa-plus" />
-          Add data source
-        </a>
-      </div>
-    );
-  }
-}
-
-function mapStateToProps(state) {
-  return {
-    searchQuery: getDataSourcesSearchQuery(state.dataSources),
-    layoutMode: getDataSourcesLayoutMode(state.dataSources),
-  };
-}
-
-const mapDispatchToProps = {
-  setDataSourcesLayoutMode,
-  setDataSourcesSearchQuery,
-};
-
-export default connect(mapStateToProps, mapDispatchToProps)(DataSourcesActionBar);

+ 3 - 0
public/app/features/datasources/DataSourcesListPage.test.tsx

@@ -12,6 +12,9 @@ const setup = (propOverrides?: object) => {
     loadDataSources: jest.fn(),
     navModel: {} as NavModel,
     dataSourcesCount: 0,
+    searchQuery: '',
+    setDataSourcesSearchQuery: jest.fn(),
+    setDataSourcesLayoutMode: jest.fn(),
   };
 
   Object.assign(props, propOverrides);

+ 38 - 7
public/app/features/datasources/DataSourcesListPage.tsx

@@ -2,21 +2,29 @@ import React, { PureComponent } from 'react';
 import { connect } from 'react-redux';
 import { hot } from 'react-hot-loader';
 import PageHeader from '../../core/components/PageHeader/PageHeader';
-import DataSourcesActionBar from './DataSourcesActionBar';
+import OrgActionBar from '../../core/components/OrgActionBar/OrgActionBar';
+import EmptyListCTA from '../../core/components/EmptyListCTA/EmptyListCTA';
 import DataSourcesList from './DataSourcesList';
-import { loadDataSources } from './state/actions';
-import { getDataSources, getDataSourcesCount, getDataSourcesLayoutMode } from './state/selectors';
-import { getNavModel } from '../../core/selectors/navModel';
 import { DataSource, NavModel } from 'app/types';
 import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
-import EmptyListCTA from '../../core/components/EmptyListCTA/EmptyListCTA';
+import { loadDataSources, setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/actions';
+import { getNavModel } from '../../core/selectors/navModel';
+import {
+  getDataSources,
+  getDataSourcesCount,
+  getDataSourcesLayoutMode,
+  getDataSourcesSearchQuery,
+} from './state/selectors';
 
 export interface Props {
   navModel: NavModel;
   dataSources: DataSource[];
   dataSourcesCount: number;
   layoutMode: LayoutMode;
+  searchQuery: string;
   loadDataSources: typeof loadDataSources;
+  setDataSourcesLayoutMode: typeof setDataSourcesLayoutMode;
+  setDataSourcesSearchQuery: typeof setDataSourcesSearchQuery;
 }
 
 const emptyListModel = {
@@ -40,7 +48,20 @@ export class DataSourcesListPage extends PureComponent<Props> {
   }
 
   render() {
-    const { dataSources, dataSourcesCount, navModel, layoutMode } = this.props;
+    const {
+      dataSources,
+      dataSourcesCount,
+      navModel,
+      layoutMode,
+      searchQuery,
+      setDataSourcesSearchQuery,
+      setDataSourcesLayoutMode,
+    } = this.props;
+
+    const linkButton = {
+      href: 'datasources/new',
+      title: 'Add data source',
+    };
 
     return (
       <div>
@@ -50,7 +71,14 @@ export class DataSourcesListPage extends PureComponent<Props> {
             <EmptyListCTA model={emptyListModel} />
           ) : (
             [
-              <DataSourcesActionBar key="action-bar" />,
+              <OrgActionBar
+                layoutMode={layoutMode}
+                searchQuery={searchQuery}
+                setLayoutMode={mode => setDataSourcesLayoutMode(mode)}
+                setSearchQuery={query => setDataSourcesSearchQuery(query)}
+                linkButton={linkButton}
+                key="action-bar"
+              />,
               <DataSourcesList dataSources={dataSources} layoutMode={layoutMode} key="list" />,
             ]
           )}
@@ -66,11 +94,14 @@ function mapStateToProps(state) {
     dataSources: getDataSources(state.dataSources),
     layoutMode: getDataSourcesLayoutMode(state.dataSources),
     dataSourcesCount: getDataSourcesCount(state.dataSources),
+    searchQuery: getDataSourcesSearchQuery(state.dataSources),
   };
 }
 
 const mapDispatchToProps = {
   loadDataSources,
+  setDataSourcesSearchQuery,
+  setDataSourcesLayoutMode,
 };
 
 export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourcesListPage));

+ 0 - 42
public/app/features/datasources/__snapshots__/DataSourcesActionBar.test.tsx.snap

@@ -1,42 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Render should render component 1`] = `
-<div
-  className="page-action-bar"
->
-  <div
-    className="gf-form gf-form--grow"
-  >
-    <label
-      className="gf-form--has-input-icon"
-    >
-      <input
-        className="gf-form-input width-20"
-        onChange={[Function]}
-        placeholder="Filter by name or type"
-        type="text"
-        value=""
-      />
-      <i
-        className="gf-form-input-icon fa fa-search"
-      />
-    </label>
-    <LayoutSelector
-      mode="grid"
-      onLayoutModeChanged={[Function]}
-    />
-  </div>
-  <div
-    className="page-action-bar__spacer"
-  />
-  <a
-    className="page-header__cta btn btn-success"
-    href="datasources/new"
-  >
-    <i
-      className="fa fa-plus"
-    />
-    Add data source
-  </a>
-</div>
-`;

+ 11 - 1
public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap

@@ -8,8 +8,18 @@ exports[`Render should render action bar and datasources 1`] = `
   <div
     className="page-container page-body"
   >
-    <Connect(DataSourcesActionBar)
+    <OrgActionBar
       key="action-bar"
+      layoutMode="grid"
+      linkButton={
+        Object {
+          "href": "datasources/new",
+          "title": "Add data source",
+        }
+      }
+      searchQuery=""
+      setLayoutMode={[Function]}
+      setSearchQuery={[Function]}
     />
     <DataSourcesList
       dataSources={

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

@@ -1,6 +1,4 @@
-import './org_users_ctrl';
 import './profile_ctrl';
-import './org_users_ctrl';
 import './select_org_ctrl';
 import './change_password_ctrl';
 import './new_org_ctrl';

+ 0 - 87
public/app/features/org/org_users_ctrl.ts

@@ -1,87 +0,0 @@
-import config from 'app/core/config';
-import coreModule from 'app/core/core_module';
-import Remarkable from 'remarkable';
-import _ from 'lodash';
-
-export class OrgUsersCtrl {
-  unfiltered: any;
-  users: any;
-  pendingInvites: any;
-  editor: any;
-  navModel: any;
-  externalUserMngLinkUrl: string;
-  externalUserMngLinkName: string;
-  externalUserMngInfo: string;
-  canInvite: boolean;
-  searchQuery: string;
-  showInvites: boolean;
-
-  /** @ngInject */
-  constructor(private $scope, private backendSrv, navModelSrv, $sce) {
-    this.navModel = navModelSrv.getNav('cfg', 'users', 0);
-
-    this.get();
-    this.externalUserMngLinkUrl = config.externalUserMngLinkUrl;
-    this.externalUserMngLinkName = config.externalUserMngLinkName;
-    this.canInvite = !config.disableLoginForm && !config.externalUserMngLinkName;
-
-    // render external user management info markdown
-    if (config.externalUserMngInfo) {
-      this.externalUserMngInfo = new Remarkable({
-        linkTarget: '__blank',
-      }).render(config.externalUserMngInfo);
-    }
-  }
-
-  get() {
-    this.backendSrv.get('/api/org/users').then(users => {
-      this.users = users;
-      this.unfiltered = users;
-    });
-    this.backendSrv.get('/api/org/invites').then(pendingInvites => {
-      this.pendingInvites = pendingInvites;
-    });
-  }
-
-  onQueryUpdated() {
-    const regex = new RegExp(this.searchQuery, 'ig');
-    this.users = _.filter(this.unfiltered, item => {
-      return regex.test(item.email) || regex.test(item.login);
-    });
-  }
-
-  updateOrgUser(user) {
-    this.backendSrv.patch('/api/org/users/' + user.userId, user);
-  }
-
-  removeUser(user) {
-    this.$scope.appEvent('confirm-modal', {
-      title: 'Delete',
-      text: 'Are you sure you want to delete user ' + user.login + '?',
-      yesText: 'Delete',
-      icon: 'fa-warning',
-      onConfirm: () => {
-        this.removeUserConfirmed(user);
-      },
-    });
-  }
-
-  removeUserConfirmed(user) {
-    this.backendSrv.delete('/api/org/users/' + user.userId).then(this.get.bind(this));
-  }
-
-  revokeInvite(invite, evt) {
-    evt.stopPropagation();
-    this.backendSrv.patch('/api/org/invites/' + invite.code + '/revoke').then(this.get.bind(this));
-  }
-
-  copyInviteToClipboard(evt) {
-    evt.stopPropagation();
-  }
-
-  getInviteUrl(invite) {
-    return invite.url;
-  }
-}
-
-coreModule.controller('OrgUsersCtrl', OrgUsersCtrl);

+ 0 - 105
public/app/features/org/partials/orgUsers.html

@@ -1,105 +0,0 @@
-<page-header model="ctrl.navModel"></page-header>
-
-<div class="page-container page-body">
-  <div class="page-action-bar">
-    <label class="gf-form gf-form--has-input-icon">
-      <input type="text" class="gf-form-input width-20" ng-model="ctrl.searchQuery" ng-change="ctrl.onQueryUpdated()" placeholder="Filter by username or email" />
-      <i class="gf-form-input-icon fa fa-search"></i>
-    </label>
-
-    <div ng-if="ctrl.pendingInvites.length" style="margin-left: 1rem">
-      <button class="btn toggle-btn active" ng-if="!ctrl.showInvites">
-        Users
-      </button><button class="btn toggle-btn" ng-if="!ctrl.showInvites" ng-click="ctrl.showInvites = true">
-        Pending Invites ({{ctrl.pendingInvites.length}})
-      </button>
-      <button class="btn toggle-btn" ng-if="ctrl.showInvites" ng-click="ctrl.showInvites = false">
-        Users
-      </button><button class="btn toggle-btn active" ng-if="ctrl.showInvites">
-        Pending Invites ({{ctrl.pendingInvites.length}})
-      </button>
-    </div>
-
-    <div class="page-action-bar__spacer"></div>
-
-    <a class="btn btn-success" href="org/users/invite" ng-show="ctrl.canInvite">
-      <i class="fa fa-plus"></i>
-      <span>Invite</span>
-    </a>
-
-    <a class="btn btn-success" ng-href="{{ctrl.externalUserMngLinkUrl}}" target="_blank" ng-if="ctrl.externalUserMngLinkUrl">
-      <i class="fa fa-external-link-square"></i>
-      {{ctrl.externalUserMngLinkName}}
-    </a>
-  </div>
-
-  <div class="grafana-info-box" ng-if="ctrl.externalUserMngInfo">
-    <span ng-bind-html="ctrl.externalUserMngInfo"></span>
-  </div>
-
-  <div ng-hide="ctrl.showInvites">
-    <table class="filter-table form-inline">
-      <thead>
-        <tr>
-          <th></th>
-          <th>Login</th>
-          <th>Email</th>
-          <th>
-            Seen
-            <tip>Time since user was seen using Grafana</tip>
-          </th>
-          <th>Role</th>
-          <th style="width: 34px;"></th>
-        </tr>
-      </thead>
-      <tr ng-repeat="user in ctrl.users">
-        <td class="width-4 text-center">
-          <img class="filter-table__avatar" ng-src="{{user.avatarUrl}}"></img>
-        </td>
-        <td>{{user.login}}</td>
-        <td><span class="ellipsis">{{user.email}}</span></td>
-        <td>{{user.lastSeenAtAge}}</td>
-        <td>
-          <div class="gf-form-select-wrapper width-12">
-            <select type="text" ng-model="user.role" class="gf-form-input" ng-options="f for f in ['Viewer', 'Editor', 'Admin']" ng-change="ctrl.updateOrgUser(user)">
-            </select>
-          </div>
-        </td>
-        <td>
-          <a ng-click="ctrl.removeUser(user)" class="btn btn-danger btn-mini">
-            <i class="fa fa-remove"></i>
-          </a>
-        </td>
-      </tr>
-    </table>
-  </div>
-
-  <div ng-if="ctrl.showInvites">
-    <table class="filter-table form-inline">
-      <thead>
-        <tr>
-          <th>Email</th>
-          <th>Name</th>
-          <th></th>
-          <th style="width: 34px;"></th>
-        </tr>
-      </thead>
-      <tr ng-repeat="invite in ctrl.pendingInvites">
-        <td>{{invite.email}}</td>
-        <td>{{invite.name}}</td>
-        <td class="text-right">
-          <button class="btn btn-inverse btn-mini" clipboard-button="ctrl.getInviteUrl(invite)" ng-click="ctrl.copyInviteToClipboard($event)">
-            <i class="fa fa-clipboard"></i> Copy Invite
-          </button>
-          &nbsp;
-        </td>
-        <td>
-          <button class="btn btn-danger btn-mini" ng-click="ctrl.revokeInvite(invite, $event)">
-            <i class="fa fa-remove"></i>
-          </button>
-        </td>
-      </tr>
-    </table>
-  </div>
-</div>
-

+ 0 - 31
public/app/features/plugins/PluginActionBar.test.tsx

@@ -1,31 +0,0 @@
-import React from 'react';
-import { shallow } from 'enzyme';
-import { PluginActionBar, Props } from './PluginActionBar';
-import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
-
-const setup = (propOverrides?: object) => {
-  const props: Props = {
-    searchQuery: '',
-    layoutMode: LayoutModes.Grid,
-    setLayoutMode: jest.fn(),
-    setPluginsSearchQuery: jest.fn(),
-  };
-
-  Object.assign(props, propOverrides);
-
-  const wrapper = shallow(<PluginActionBar {...props} />);
-  const instance = wrapper.instance() as PluginActionBar;
-
-  return {
-    wrapper,
-    instance,
-  };
-};
-
-describe('Render', () => {
-  it('should render component', () => {
-    const { wrapper } = setup();
-
-    expect(wrapper).toMatchSnapshot();
-  });
-});

+ 0 - 62
public/app/features/plugins/PluginActionBar.tsx

@@ -1,62 +0,0 @@
-import React, { PureComponent } from 'react';
-import { connect } from 'react-redux';
-import LayoutSelector, { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
-import { setLayoutMode, setPluginsSearchQuery } from './state/actions';
-import { getPluginsSearchQuery, getLayoutMode } from './state/selectors';
-
-export interface Props {
-  searchQuery: string;
-  layoutMode: LayoutMode;
-  setLayoutMode: typeof setLayoutMode;
-  setPluginsSearchQuery: typeof setPluginsSearchQuery;
-}
-
-export class PluginActionBar extends PureComponent<Props> {
-  onSearchQueryChange = event => {
-    this.props.setPluginsSearchQuery(event.target.value);
-  };
-
-  render() {
-    const { searchQuery, layoutMode, setLayoutMode } = this.props;
-
-    return (
-      <div className="page-action-bar">
-        <div className="gf-form gf-form--grow">
-          <label className="gf-form--has-input-icon">
-            <input
-              type="text"
-              className="gf-form-input width-20"
-              value={searchQuery}
-              onChange={this.onSearchQueryChange}
-              placeholder="Filter by name or type"
-            />
-            <i className="gf-form-input-icon fa fa-search" />
-          </label>
-          <LayoutSelector mode={layoutMode} onLayoutModeChanged={(mode: LayoutMode) => setLayoutMode(mode)} />
-        </div>
-        <div className="page-action-bar__spacer" />
-        <a
-          className="btn btn-success"
-          href="https://grafana.com/plugins?utm_source=grafana_plugin_list"
-          target="_blank"
-        >
-          Find more plugins on Grafana.com
-        </a>
-      </div>
-    );
-  }
-}
-
-function mapStateToProps(state) {
-  return {
-    searchQuery: getPluginsSearchQuery(state.plugins),
-    layoutMode: getLayoutMode(state.plugins),
-  };
-}
-
-const mapDispatchToProps = {
-  setPluginsSearchQuery,
-  setLayoutMode,
-};
-
-export default connect(mapStateToProps, mapDispatchToProps)(PluginActionBar);

+ 3 - 0
public/app/features/plugins/PluginListPage.test.tsx

@@ -8,6 +8,9 @@ const setup = (propOverrides?: object) => {
   const props: Props = {
     navModel: {} as NavModel,
     plugins: [] as Plugin[],
+    searchQuery: '',
+    setPluginsSearchQuery: jest.fn(),
+    setPluginsLayoutMode: jest.fn(),
     layoutMode: LayoutModes.Grid,
     loadPlugins: jest.fn(),
   };

+ 23 - 7
public/app/features/plugins/PluginListPage.tsx

@@ -1,20 +1,23 @@
 import React, { PureComponent } from 'react';
 import { hot } from 'react-hot-loader';
 import { connect } from 'react-redux';
-import PageHeader from '../../core/components/PageHeader/PageHeader';
-import PluginActionBar from './PluginActionBar';
+import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import OrgActionBar from 'app/core/components/OrgActionBar/OrgActionBar';
 import PluginList from './PluginList';
-import { NavModel, Plugin } from '../../types';
-import { loadPlugins } from './state/actions';
+import { NavModel, Plugin } from 'app/types';
+import { loadPlugins, setPluginsLayoutMode, setPluginsSearchQuery } from './state/actions';
 import { getNavModel } from '../../core/selectors/navModel';
-import { getLayoutMode, getPlugins } from './state/selectors';
+import { getLayoutMode, getPlugins, getPluginsSearchQuery } from './state/selectors';
 import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
 
 export interface Props {
   navModel: NavModel;
   plugins: Plugin[];
   layoutMode: LayoutMode;
+  searchQuery: string;
   loadPlugins: typeof loadPlugins;
+  setPluginsLayoutMode: typeof setPluginsLayoutMode;
+  setPluginsSearchQuery: typeof setPluginsSearchQuery;
 }
 
 export class PluginListPage extends PureComponent<Props> {
@@ -27,13 +30,23 @@ export class PluginListPage extends PureComponent<Props> {
   }
 
   render() {
-    const { navModel, plugins, layoutMode } = this.props;
+    const { navModel, plugins, layoutMode, setPluginsLayoutMode, setPluginsSearchQuery, searchQuery } = this.props;
 
+    const linkButton = {
+      href: 'https://grafana.com/plugins?utm_source=grafana_plugin_list',
+      title: 'Find more plugins on Grafana.com',
+    };
     return (
       <div>
         <PageHeader model={navModel} />
         <div className="page-container page-body">
-          <PluginActionBar />
+          <OrgActionBar
+            searchQuery={searchQuery}
+            layoutMode={layoutMode}
+            setLayoutMode={mode => setPluginsLayoutMode(mode)}
+            setSearchQuery={query => setPluginsSearchQuery(query)}
+            linkButton={linkButton}
+          />
           {plugins && <PluginList plugins={plugins} layoutMode={layoutMode} />}
         </div>
       </div>
@@ -46,11 +59,14 @@ function mapStateToProps(state) {
     navModel: getNavModel(state.navIndex, 'plugins'),
     plugins: getPlugins(state.plugins),
     layoutMode: getLayoutMode(state.plugins),
+    searchQuery: getPluginsSearchQuery(state.plugins),
   };
 }
 
 const mapDispatchToProps = {
   loadPlugins,
+  setPluginsLayoutMode,
+  setPluginsSearchQuery,
 };
 
 export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(PluginListPage));

+ 12 - 1
public/app/features/plugins/__snapshots__/PluginListPage.test.tsx.snap

@@ -8,7 +8,18 @@ exports[`Render should render component 1`] = `
   <div
     className="page-container page-body"
   >
-    <Connect(PluginActionBar) />
+    <OrgActionBar
+      layoutMode="grid"
+      linkButton={
+        Object {
+          "href": "https://grafana.com/plugins?utm_source=grafana_plugin_list",
+          "title": "Find more plugins on Grafana.com",
+        }
+      }
+      searchQuery=""
+      setLayoutMode={[Function]}
+      setSearchQuery={[Function]}
+    />
     <PluginList
       layoutMode="grid"
       plugins={Array []}

+ 1 - 1
public/app/features/plugins/state/actions.ts

@@ -24,7 +24,7 @@ export interface SetLayoutModeAction {
   payload: LayoutMode;
 }
 
-export const setLayoutMode = (mode: LayoutMode): SetLayoutModeAction => ({
+export const setPluginsLayoutMode = (mode: LayoutMode): SetLayoutModeAction => ({
   type: ActionTypes.SetLayoutMode,
   payload: mode,
 });

+ 32 - 0
public/app/features/users/InviteesTable.test.tsx

@@ -0,0 +1,32 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import InviteesTable, { Props } from './InviteesTable';
+import { Invitee } from 'app/types';
+import { getMockInvitees } from './__mocks__/userMocks';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    invitees: [] as Invitee[],
+    revokeInvite: jest.fn(),
+  };
+
+  Object.assign(props, propOverrides);
+
+  return shallow(<InviteesTable {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render invitees', () => {
+    const wrapper = setup({
+      invitees: getMockInvitees(5),
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 64 - 0
public/app/features/users/InviteesTable.tsx

@@ -0,0 +1,64 @@
+import React, { createRef, PureComponent } from 'react';
+import { Invitee } from 'app/types';
+
+export interface Props {
+  invitees: Invitee[];
+  revokeInvite: (code: string) => void;
+}
+
+export default class InviteesTable extends PureComponent<Props> {
+  private copyUrlRef = createRef<HTMLTextAreaElement>();
+
+  copyToClipboard = () => {
+    const node = this.copyUrlRef.current;
+
+    if (node) {
+      node.select();
+      document.execCommand('copy');
+    }
+  };
+
+  render() {
+    const { invitees, revokeInvite } = this.props;
+
+    return (
+      <table className="filter-table form-inline">
+        <thead>
+          <tr>
+            <th>Email</th>
+            <th>Name</th>
+            <th />
+            <th style={{ width: '34px' }} />
+          </tr>
+        </thead>
+        <tbody>
+          {invitees.map((invitee, index) => {
+            return (
+              <tr key={`${invitee.id}-${index}`}>
+                <td>{invitee.email}</td>
+                <td>{invitee.name}</td>
+                <td className="text-right">
+                  <button className="btn btn-inverse btn-mini" onClick={this.copyToClipboard}>
+                    <textarea
+                      readOnly={true}
+                      value={invitee.url}
+                      style={{ position: 'absolute', right: -1000 }}
+                      ref={this.copyUrlRef}
+                    />
+                    <i className="fa fa-clipboard" /> Copy Invite
+                  </button>
+                  &nbsp;
+                </td>
+                <td>
+                  <button className="btn btn-danger btn-mini" onClick={() => revokeInvite(invitee.code)}>
+                    <i className="fa fa-remove" />
+                  </button>
+                </td>
+              </tr>
+            );
+          })}
+        </tbody>
+      </table>
+    );
+  }
+}

+ 51 - 0
public/app/features/users/UsersActionBar.test.tsx

@@ -0,0 +1,51 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { UsersActionBar, Props } from './UsersActionBar';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    searchQuery: '',
+    setUsersSearchQuery: jest.fn(),
+    showInvites: jest.fn(),
+    pendingInvitesCount: 0,
+    canInvite: false,
+    externalUserMngLinkUrl: '',
+    externalUserMngLinkName: '',
+  };
+
+  Object.assign(props, propOverrides);
+
+  return shallow(<UsersActionBar {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render pending invites button', () => {
+    const wrapper = setup({
+      pendingInvitesCount: 5,
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should show invite button', () => {
+    const wrapper = setup({
+      canInvite: true,
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should show external user management button', () => {
+    const wrapper = setup({
+      externalUserMngLinkUrl: 'some/url',
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 80 - 0
public/app/features/users/UsersActionBar.tsx

@@ -0,0 +1,80 @@
+import React, { PureComponent } from 'react';
+import { connect } from 'react-redux';
+import { setUsersSearchQuery } from './state/actions';
+import { getInviteesCount, getUsersSearchQuery } from './state/selectors';
+
+export interface Props {
+  searchQuery: string;
+  setUsersSearchQuery: typeof setUsersSearchQuery;
+  showInvites: () => void;
+  pendingInvitesCount: number;
+  canInvite: boolean;
+  externalUserMngLinkUrl: string;
+  externalUserMngLinkName: string;
+}
+
+export class UsersActionBar extends PureComponent<Props> {
+  render() {
+    const {
+      canInvite,
+      externalUserMngLinkName,
+      externalUserMngLinkUrl,
+      searchQuery,
+      pendingInvitesCount,
+      setUsersSearchQuery,
+      showInvites,
+    } = this.props;
+
+    return (
+      <div className="page-action-bar">
+        <div className="gf-form gf-form--grow">
+          <label className="gf-form--has-input-icon">
+            <input
+              type="text"
+              className="gf-form-input width-20"
+              value={searchQuery}
+              onChange={event => setUsersSearchQuery(event.target.value)}
+              placeholder="Filter by name or type"
+            />
+            <i className="gf-form-input-icon fa fa-search" />
+          </label>
+
+          <div className="page-action-bar__spacer" />
+          {pendingInvitesCount > 0 && (
+            <button className="btn btn-inverse" onClick={showInvites}>
+              Pending Invites ({pendingInvitesCount})
+            </button>
+          )}
+          {canInvite && (
+            <a className="btn btn-success" href="org/users/invite">
+              <i className="fa fa-plus" />
+              <span>Invite</span>
+            </a>
+          )}
+          {externalUserMngLinkUrl && (
+            <a className="btn btn-success" href={externalUserMngLinkUrl} target="_blank">
+              <i className="fa fa-external-link-square" />
+              {externalUserMngLinkName}
+            </a>
+          )}
+        </div>
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    searchQuery: getUsersSearchQuery(state.users),
+    pendingInvitesCount: getInviteesCount(state.users),
+    externalUserMngLinkName: state.users.externalUserMngLinkName,
+    externalUserMngLinkUrl: state.users.externalUserMngLinkUrl,
+    canInvite: state.users.canInvite,
+  };
+}
+
+const mapDispatchToProps = {
+  setUsersSearchQuery,
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(UsersActionBar);

+ 55 - 0
public/app/features/users/UsersListPage.test.tsx

@@ -0,0 +1,55 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { UsersListPage, Props } from './UsersListPage';
+import { Invitee, NavModel, OrgUser } from 'app/types';
+import { getMockUser } from './__mocks__/userMocks';
+import appEvents from '../../core/app_events';
+
+jest.mock('../../core/app_events', () => ({
+  emit: jest.fn(),
+}));
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    navModel: {} as NavModel,
+    users: [] as OrgUser[],
+    invitees: [] as Invitee[],
+    searchQuery: '',
+    externalUserMngInfo: '',
+    revokeInvite: jest.fn(),
+    loadInvitees: jest.fn(),
+    loadUsers: jest.fn(),
+    updateUser: jest.fn(),
+    removeUser: jest.fn(),
+    setUsersSearchQuery: jest.fn(),
+  };
+
+  Object.assign(props, propOverrides);
+
+  const wrapper = shallow(<UsersListPage {...props} />);
+  const instance = wrapper.instance() as UsersListPage;
+
+  return {
+    wrapper,
+    instance,
+  };
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const { wrapper } = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});
+
+describe('Functions', () => {
+  it('should emit show remove user modal', () => {
+    const { instance } = setup();
+    const mockUser = getMockUser();
+
+    instance.onRemoveUser(mockUser);
+
+    expect(appEvents.emit).toHaveBeenCalled();
+  });
+});

+ 125 - 0
public/app/features/users/UsersListPage.tsx

@@ -0,0 +1,125 @@
+import React, { PureComponent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import UsersActionBar from './UsersActionBar';
+import UsersTable from 'app/features/users/UsersTable';
+import InviteesTable from './InviteesTable';
+import { Invitee, NavModel, OrgUser } from 'app/types';
+import appEvents from 'app/core/app_events';
+import { loadUsers, loadInvitees, revokeInvite, setUsersSearchQuery, updateUser, removeUser } from './state/actions';
+import { getNavModel } from '../../core/selectors/navModel';
+import { getInvitees, getUsers, getUsersSearchQuery } from './state/selectors';
+
+export interface Props {
+  navModel: NavModel;
+  invitees: Invitee[];
+  users: OrgUser[];
+  searchQuery: string;
+  externalUserMngInfo: string;
+  loadUsers: typeof loadUsers;
+  loadInvitees: typeof loadInvitees;
+  setUsersSearchQuery: typeof setUsersSearchQuery;
+  updateUser: typeof updateUser;
+  removeUser: typeof removeUser;
+  revokeInvite: typeof revokeInvite;
+}
+
+export interface State {
+  showInvites: boolean;
+}
+
+export class UsersListPage extends PureComponent<Props, State> {
+  state = {
+    showInvites: false,
+  };
+
+  componentDidMount() {
+    this.fetchUsers();
+    this.fetchInvitees();
+  }
+
+  async fetchUsers() {
+    return await this.props.loadUsers();
+  }
+
+  async fetchInvitees() {
+    return await this.props.loadInvitees();
+  }
+
+  onRoleChange = (role, user) => {
+    const updatedUser = { ...user, role: role };
+
+    this.props.updateUser(updatedUser);
+  };
+
+  onRemoveUser = user => {
+    appEvents.emit('confirm-modal', {
+      title: 'Delete',
+      text: 'Are you sure you want to delete user ' + user.login + '?',
+      yesText: 'Delete',
+      icon: 'fa-warning',
+      onConfirm: () => {
+        this.props.removeUser(user.userId);
+      },
+    });
+  };
+
+  onRevokeInvite = code => {
+    this.props.revokeInvite(code);
+  };
+
+  showInvites = () => {
+    this.setState(prevState => ({
+      showInvites: !prevState.showInvites,
+    }));
+  };
+
+  render() {
+    const { externalUserMngInfo, invitees, navModel, users } = this.props;
+
+    return (
+      <div>
+        <PageHeader model={navModel} />
+        <div className="page-container page-body">
+          <UsersActionBar showInvites={this.showInvites} />
+          {externalUserMngInfo && (
+            <div className="grafana-info-box">
+              <span>{externalUserMngInfo}</span>
+            </div>
+          )}
+          {this.state.showInvites ? (
+            <InviteesTable invitees={invitees} revokeInvite={code => this.onRevokeInvite(code)} />
+          ) : (
+            <UsersTable
+              users={users}
+              onRoleChange={(role, user) => this.onRoleChange(role, user)}
+              onRemoveUser={user => this.onRemoveUser(user)}
+            />
+          )}
+        </div>
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    navModel: getNavModel(state.navIndex, 'users'),
+    users: getUsers(state.users),
+    searchQuery: getUsersSearchQuery(state.users),
+    invitees: getInvitees(state.users),
+    externalUserMngInfo: state.users.externalUserMngInfo,
+  };
+}
+
+const mapDispatchToProps = {
+  loadUsers,
+  loadInvitees,
+  setUsersSearchQuery,
+  updateUser,
+  removeUser,
+  revokeInvite,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(UsersListPage));

+ 33 - 0
public/app/features/users/UsersTable.test.tsx

@@ -0,0 +1,33 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import UsersTable, { Props } from './UsersTable';
+import { OrgUser } from 'app/types';
+import { getMockUsers } from './__mocks__/userMocks';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    users: [] as OrgUser[],
+    onRoleChange: jest.fn(),
+    onRemoveUser: jest.fn(),
+  };
+
+  Object.assign(props, propOverrides);
+
+  return shallow(<UsersTable {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render users table', () => {
+    const wrapper = setup({
+      users: getMockUsers(5),
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 67 - 0
public/app/features/users/UsersTable.tsx

@@ -0,0 +1,67 @@
+import React, { SFC } from 'react';
+import { OrgUser } from 'app/types';
+
+export interface Props {
+  users: OrgUser[];
+  onRoleChange: (role: string, user: OrgUser) => void;
+  onRemoveUser: (user: OrgUser) => void;
+}
+
+const UsersTable: SFC<Props> = props => {
+  const { users, onRoleChange, onRemoveUser } = props;
+
+  return (
+    <table className="filter-table form-inline">
+      <thead>
+        <tr>
+          <th />
+          <th>Login</th>
+          <th>Email</th>
+          <th>Seen</th>
+          <th>Role</th>
+          <th style={{ width: '34px' }} />
+        </tr>
+      </thead>
+      <tbody>
+        {users.map((user, index) => {
+          return (
+            <tr key={`${user.userId}-${index}`}>
+              <td className="width-4 text-center">
+                <img className="filter-table__avatar" src={user.avatarUrl} />
+              </td>
+              <td>{user.login}</td>
+              <td>
+                <span className="ellipsis">{user.email}</span>
+              </td>
+              <td>{user.lastSeenAtAge}</td>
+              <td>
+                <div className="gf-form-select-wrapper width-12">
+                  <select
+                    value={user.role}
+                    className="gf-form-input"
+                    onChange={event => onRoleChange(event.target.value, user)}
+                  >
+                    {['Viewer', 'Editor', 'Admin'].map((option, index) => {
+                      return (
+                        <option value={option} key={`${option}-${index}`}>
+                          {option}
+                        </option>
+                      );
+                    })}
+                  </select>
+                </div>
+              </td>
+              <td>
+                <div onClick={() => onRemoveUser(user)} className="btn btn-danger btn-mini">
+                  <i className="fa fa-remove" />
+                </div>
+              </td>
+            </tr>
+          );
+        })}
+      </tbody>
+    </table>
+  );
+};
+
+export default UsersTable;

+ 56 - 0
public/app/features/users/__mocks__/userMocks.ts

@@ -0,0 +1,56 @@
+export const getMockUsers = (amount: number) => {
+  const users = [];
+
+  for (let i = 0; i <= amount; i++) {
+    users.push({
+      avatarUrl: 'url/to/avatar',
+      email: `user-${i}@test.com`,
+      lastSeenAt: '2018-10-01',
+      lastSeenAtAge: '',
+      login: `user-${i}`,
+      orgId: 1,
+      role: 'Admin',
+      userId: i,
+    });
+  }
+
+  return users;
+};
+
+export const getMockUser = () => {
+  return {
+    avatarUrl: 'url/to/avatar',
+    email: `user@test.com`,
+    lastSeenAt: '2018-10-01',
+    lastSeenAtAge: '',
+    login: `user`,
+    orgId: 1,
+    role: 'Admin',
+    userId: 2,
+  };
+};
+
+export const getMockInvitees = (amount: number) => {
+  const invitees = [];
+
+  for (let i = 0; i <= amount; i++) {
+    invitees.push({
+      code: `asdfasdfsadf-${i}`,
+      createdOn: '2018-10-02',
+      email: `invitee-${i}@test.com`,
+      emailSent: true,
+      emailSentOn: '2018-10-02',
+      id: i,
+      invitedByEmail: 'admin@grafana.com',
+      invitedByLogin: 'admin',
+      invitedByName: 'admin',
+      name: `invitee-${i}`,
+      orgId: 1,
+      role: 'viewer',
+      status: 'not accepted',
+      url: `localhost/invite/$${i}`,
+    });
+  }
+
+  return invitees;
+};

+ 318 - 0
public/app/features/users/__snapshots__/InviteesTable.test.tsx.snap

@@ -0,0 +1,318 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<table
+  className="filter-table form-inline"
+>
+  <thead>
+    <tr>
+      <th>
+        Email
+      </th>
+      <th>
+        Name
+      </th>
+      <th />
+      <th
+        style={
+          Object {
+            "width": "34px",
+          }
+        }
+      />
+    </tr>
+  </thead>
+  <tbody />
+</table>
+`;
+
+exports[`Render should render invitees 1`] = `
+<table
+  className="filter-table form-inline"
+>
+  <thead>
+    <tr>
+      <th>
+        Email
+      </th>
+      <th>
+        Name
+      </th>
+      <th />
+      <th
+        style={
+          Object {
+            "width": "34px",
+          }
+        }
+      />
+    </tr>
+  </thead>
+  <tbody>
+    <tr
+      key="0-0"
+    >
+      <td>
+        invitee-0@test.com
+      </td>
+      <td>
+        invitee-0
+      </td>
+      <td
+        className="text-right"
+      >
+        <button
+          className="btn btn-inverse btn-mini"
+          onClick={[Function]}
+        >
+          <textarea
+            readOnly={true}
+            style={
+              Object {
+                "position": "absolute",
+                "right": -1000,
+              }
+            }
+            value="localhost/invite/$0"
+          />
+          <i
+            className="fa fa-clipboard"
+          />
+           Copy Invite
+        </button>
+         
+      </td>
+      <td>
+        <button
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </button>
+      </td>
+    </tr>
+    <tr
+      key="1-1"
+    >
+      <td>
+        invitee-1@test.com
+      </td>
+      <td>
+        invitee-1
+      </td>
+      <td
+        className="text-right"
+      >
+        <button
+          className="btn btn-inverse btn-mini"
+          onClick={[Function]}
+        >
+          <textarea
+            readOnly={true}
+            style={
+              Object {
+                "position": "absolute",
+                "right": -1000,
+              }
+            }
+            value="localhost/invite/$1"
+          />
+          <i
+            className="fa fa-clipboard"
+          />
+           Copy Invite
+        </button>
+         
+      </td>
+      <td>
+        <button
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </button>
+      </td>
+    </tr>
+    <tr
+      key="2-2"
+    >
+      <td>
+        invitee-2@test.com
+      </td>
+      <td>
+        invitee-2
+      </td>
+      <td
+        className="text-right"
+      >
+        <button
+          className="btn btn-inverse btn-mini"
+          onClick={[Function]}
+        >
+          <textarea
+            readOnly={true}
+            style={
+              Object {
+                "position": "absolute",
+                "right": -1000,
+              }
+            }
+            value="localhost/invite/$2"
+          />
+          <i
+            className="fa fa-clipboard"
+          />
+           Copy Invite
+        </button>
+         
+      </td>
+      <td>
+        <button
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </button>
+      </td>
+    </tr>
+    <tr
+      key="3-3"
+    >
+      <td>
+        invitee-3@test.com
+      </td>
+      <td>
+        invitee-3
+      </td>
+      <td
+        className="text-right"
+      >
+        <button
+          className="btn btn-inverse btn-mini"
+          onClick={[Function]}
+        >
+          <textarea
+            readOnly={true}
+            style={
+              Object {
+                "position": "absolute",
+                "right": -1000,
+              }
+            }
+            value="localhost/invite/$3"
+          />
+          <i
+            className="fa fa-clipboard"
+          />
+           Copy Invite
+        </button>
+         
+      </td>
+      <td>
+        <button
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </button>
+      </td>
+    </tr>
+    <tr
+      key="4-4"
+    >
+      <td>
+        invitee-4@test.com
+      </td>
+      <td>
+        invitee-4
+      </td>
+      <td
+        className="text-right"
+      >
+        <button
+          className="btn btn-inverse btn-mini"
+          onClick={[Function]}
+        >
+          <textarea
+            readOnly={true}
+            style={
+              Object {
+                "position": "absolute",
+                "right": -1000,
+              }
+            }
+            value="localhost/invite/$4"
+          />
+          <i
+            className="fa fa-clipboard"
+          />
+           Copy Invite
+        </button>
+         
+      </td>
+      <td>
+        <button
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </button>
+      </td>
+    </tr>
+    <tr
+      key="5-5"
+    >
+      <td>
+        invitee-5@test.com
+      </td>
+      <td>
+        invitee-5
+      </td>
+      <td
+        className="text-right"
+      >
+        <button
+          className="btn btn-inverse btn-mini"
+          onClick={[Function]}
+        >
+          <textarea
+            readOnly={true}
+            style={
+              Object {
+                "position": "absolute",
+                "right": -1000,
+              }
+            }
+            value="localhost/invite/$5"
+          />
+          <i
+            className="fa fa-clipboard"
+          />
+           Copy Invite
+        </button>
+         
+      </td>
+      <td>
+        <button
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </button>
+      </td>
+    </tr>
+  </tbody>
+</table>
+`;

+ 141 - 0
public/app/features/users/__snapshots__/UsersActionBar.test.tsx.snap

@@ -0,0 +1,141 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div
+  className="page-action-bar"
+>
+  <div
+    className="gf-form gf-form--grow"
+  >
+    <label
+      className="gf-form--has-input-icon"
+    >
+      <input
+        className="gf-form-input width-20"
+        onChange={[Function]}
+        placeholder="Filter by name or type"
+        type="text"
+        value=""
+      />
+      <i
+        className="gf-form-input-icon fa fa-search"
+      />
+    </label>
+    <div
+      className="page-action-bar__spacer"
+    />
+  </div>
+</div>
+`;
+
+exports[`Render should render pending invites button 1`] = `
+<div
+  className="page-action-bar"
+>
+  <div
+    className="gf-form gf-form--grow"
+  >
+    <label
+      className="gf-form--has-input-icon"
+    >
+      <input
+        className="gf-form-input width-20"
+        onChange={[Function]}
+        placeholder="Filter by name or type"
+        type="text"
+        value=""
+      />
+      <i
+        className="gf-form-input-icon fa fa-search"
+      />
+    </label>
+    <div
+      className="page-action-bar__spacer"
+    />
+    <button
+      className="btn btn-inverse"
+      onClick={[MockFunction]}
+    >
+      Pending Invites (
+      5
+      )
+    </button>
+  </div>
+</div>
+`;
+
+exports[`Render should show external user management button 1`] = `
+<div
+  className="page-action-bar"
+>
+  <div
+    className="gf-form gf-form--grow"
+  >
+    <label
+      className="gf-form--has-input-icon"
+    >
+      <input
+        className="gf-form-input width-20"
+        onChange={[Function]}
+        placeholder="Filter by name or type"
+        type="text"
+        value=""
+      />
+      <i
+        className="gf-form-input-icon fa fa-search"
+      />
+    </label>
+    <div
+      className="page-action-bar__spacer"
+    />
+    <a
+      className="btn btn-success"
+      href="some/url"
+      target="_blank"
+    >
+      <i
+        className="fa fa-external-link-square"
+      />
+    </a>
+  </div>
+</div>
+`;
+
+exports[`Render should show invite button 1`] = `
+<div
+  className="page-action-bar"
+>
+  <div
+    className="gf-form gf-form--grow"
+  >
+    <label
+      className="gf-form--has-input-icon"
+    >
+      <input
+        className="gf-form-input width-20"
+        onChange={[Function]}
+        placeholder="Filter by name or type"
+        type="text"
+        value=""
+      />
+      <i
+        className="gf-form-input-icon fa fa-search"
+      />
+    </label>
+    <div
+      className="page-action-bar__spacer"
+    />
+    <a
+      className="btn btn-success"
+      href="org/users/invite"
+    >
+      <i
+        className="fa fa-plus"
+      />
+      <span>
+        Invite
+      </span>
+    </a>
+  </div>
+</div>
+`;

+ 21 - 0
public/app/features/users/__snapshots__/UsersListPage.test.tsx.snap

@@ -0,0 +1,21 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div>
+  <PageHeader
+    model={Object {}}
+  />
+  <div
+    className="page-container page-body"
+  >
+    <Connect(UsersActionBar)
+      showInvites={[Function]}
+    />
+    <UsersTable
+      onRemoveUser={[Function]}
+      onRoleChange={[Function]}
+      users={Array []}
+    />
+  </div>
+</div>
+`;

+ 444 - 0
public/app/features/users/__snapshots__/UsersTable.test.tsx.snap

@@ -0,0 +1,444 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<table
+  className="filter-table form-inline"
+>
+  <thead>
+    <tr>
+      <th />
+      <th>
+        Login
+      </th>
+      <th>
+        Email
+      </th>
+      <th>
+        Seen
+      </th>
+      <th>
+        Role
+      </th>
+      <th
+        style={
+          Object {
+            "width": "34px",
+          }
+        }
+      />
+    </tr>
+  </thead>
+  <tbody />
+</table>
+`;
+
+exports[`Render should render users table 1`] = `
+<table
+  className="filter-table form-inline"
+>
+  <thead>
+    <tr>
+      <th />
+      <th>
+        Login
+      </th>
+      <th>
+        Email
+      </th>
+      <th>
+        Seen
+      </th>
+      <th>
+        Role
+      </th>
+      <th
+        style={
+          Object {
+            "width": "34px",
+          }
+        }
+      />
+    </tr>
+  </thead>
+  <tbody>
+    <tr
+      key="0-0"
+    >
+      <td
+        className="width-4 text-center"
+      >
+        <img
+          className="filter-table__avatar"
+          src="url/to/avatar"
+        />
+      </td>
+      <td>
+        user-0
+      </td>
+      <td>
+        <span
+          className="ellipsis"
+        >
+          user-0@test.com
+        </span>
+      </td>
+      <td />
+      <td>
+        <div
+          className="gf-form-select-wrapper width-12"
+        >
+          <select
+            className="gf-form-input"
+            onChange={[Function]}
+            value="Admin"
+          >
+            <option
+              key="Viewer-0"
+              value="Viewer"
+            >
+              Viewer
+            </option>
+            <option
+              key="Editor-1"
+              value="Editor"
+            >
+              Editor
+            </option>
+            <option
+              key="Admin-2"
+              value="Admin"
+            >
+              Admin
+            </option>
+          </select>
+        </div>
+      </td>
+      <td>
+        <div
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </div>
+      </td>
+    </tr>
+    <tr
+      key="1-1"
+    >
+      <td
+        className="width-4 text-center"
+      >
+        <img
+          className="filter-table__avatar"
+          src="url/to/avatar"
+        />
+      </td>
+      <td>
+        user-1
+      </td>
+      <td>
+        <span
+          className="ellipsis"
+        >
+          user-1@test.com
+        </span>
+      </td>
+      <td />
+      <td>
+        <div
+          className="gf-form-select-wrapper width-12"
+        >
+          <select
+            className="gf-form-input"
+            onChange={[Function]}
+            value="Admin"
+          >
+            <option
+              key="Viewer-0"
+              value="Viewer"
+            >
+              Viewer
+            </option>
+            <option
+              key="Editor-1"
+              value="Editor"
+            >
+              Editor
+            </option>
+            <option
+              key="Admin-2"
+              value="Admin"
+            >
+              Admin
+            </option>
+          </select>
+        </div>
+      </td>
+      <td>
+        <div
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </div>
+      </td>
+    </tr>
+    <tr
+      key="2-2"
+    >
+      <td
+        className="width-4 text-center"
+      >
+        <img
+          className="filter-table__avatar"
+          src="url/to/avatar"
+        />
+      </td>
+      <td>
+        user-2
+      </td>
+      <td>
+        <span
+          className="ellipsis"
+        >
+          user-2@test.com
+        </span>
+      </td>
+      <td />
+      <td>
+        <div
+          className="gf-form-select-wrapper width-12"
+        >
+          <select
+            className="gf-form-input"
+            onChange={[Function]}
+            value="Admin"
+          >
+            <option
+              key="Viewer-0"
+              value="Viewer"
+            >
+              Viewer
+            </option>
+            <option
+              key="Editor-1"
+              value="Editor"
+            >
+              Editor
+            </option>
+            <option
+              key="Admin-2"
+              value="Admin"
+            >
+              Admin
+            </option>
+          </select>
+        </div>
+      </td>
+      <td>
+        <div
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </div>
+      </td>
+    </tr>
+    <tr
+      key="3-3"
+    >
+      <td
+        className="width-4 text-center"
+      >
+        <img
+          className="filter-table__avatar"
+          src="url/to/avatar"
+        />
+      </td>
+      <td>
+        user-3
+      </td>
+      <td>
+        <span
+          className="ellipsis"
+        >
+          user-3@test.com
+        </span>
+      </td>
+      <td />
+      <td>
+        <div
+          className="gf-form-select-wrapper width-12"
+        >
+          <select
+            className="gf-form-input"
+            onChange={[Function]}
+            value="Admin"
+          >
+            <option
+              key="Viewer-0"
+              value="Viewer"
+            >
+              Viewer
+            </option>
+            <option
+              key="Editor-1"
+              value="Editor"
+            >
+              Editor
+            </option>
+            <option
+              key="Admin-2"
+              value="Admin"
+            >
+              Admin
+            </option>
+          </select>
+        </div>
+      </td>
+      <td>
+        <div
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </div>
+      </td>
+    </tr>
+    <tr
+      key="4-4"
+    >
+      <td
+        className="width-4 text-center"
+      >
+        <img
+          className="filter-table__avatar"
+          src="url/to/avatar"
+        />
+      </td>
+      <td>
+        user-4
+      </td>
+      <td>
+        <span
+          className="ellipsis"
+        >
+          user-4@test.com
+        </span>
+      </td>
+      <td />
+      <td>
+        <div
+          className="gf-form-select-wrapper width-12"
+        >
+          <select
+            className="gf-form-input"
+            onChange={[Function]}
+            value="Admin"
+          >
+            <option
+              key="Viewer-0"
+              value="Viewer"
+            >
+              Viewer
+            </option>
+            <option
+              key="Editor-1"
+              value="Editor"
+            >
+              Editor
+            </option>
+            <option
+              key="Admin-2"
+              value="Admin"
+            >
+              Admin
+            </option>
+          </select>
+        </div>
+      </td>
+      <td>
+        <div
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </div>
+      </td>
+    </tr>
+    <tr
+      key="5-5"
+    >
+      <td
+        className="width-4 text-center"
+      >
+        <img
+          className="filter-table__avatar"
+          src="url/to/avatar"
+        />
+      </td>
+      <td>
+        user-5
+      </td>
+      <td>
+        <span
+          className="ellipsis"
+        >
+          user-5@test.com
+        </span>
+      </td>
+      <td />
+      <td>
+        <div
+          className="gf-form-select-wrapper width-12"
+        >
+          <select
+            className="gf-form-input"
+            onChange={[Function]}
+            value="Admin"
+          >
+            <option
+              key="Viewer-0"
+              value="Viewer"
+            >
+              Viewer
+            </option>
+            <option
+              key="Editor-1"
+              value="Editor"
+            >
+              Editor
+            </option>
+            <option
+              key="Admin-2"
+              value="Admin"
+            >
+              Admin
+            </option>
+          </select>
+        </div>
+      </td>
+      <td>
+        <div
+          className="btn btn-danger btn-mini"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-remove"
+          />
+        </div>
+      </td>
+    </tr>
+  </tbody>
+</table>
+`;

+ 79 - 0
public/app/features/users/state/actions.ts

@@ -0,0 +1,79 @@
+import { ThunkAction } from 'redux-thunk';
+import { StoreState } from '../../../types';
+import { getBackendSrv } from '../../../core/services/backend_srv';
+import { Invitee, OrgUser } from 'app/types';
+
+export enum ActionTypes {
+  LoadUsers = 'LOAD_USERS',
+  LoadInvitees = 'LOAD_INVITEES',
+  SetUsersSearchQuery = 'SET_USERS_SEARCH_QUERY',
+}
+
+export interface LoadUsersAction {
+  type: ActionTypes.LoadUsers;
+  payload: OrgUser[];
+}
+
+export interface LoadInviteesAction {
+  type: ActionTypes.LoadInvitees;
+  payload: Invitee[];
+}
+
+export interface SetUsersSearchQueryAction {
+  type: ActionTypes.SetUsersSearchQuery;
+  payload: string;
+}
+
+const usersLoaded = (users: OrgUser[]): LoadUsersAction => ({
+  type: ActionTypes.LoadUsers,
+  payload: users,
+});
+
+const inviteesLoaded = (invitees: Invitee[]): LoadInviteesAction => ({
+  type: ActionTypes.LoadInvitees,
+  payload: invitees,
+});
+
+export const setUsersSearchQuery = (query: string): SetUsersSearchQueryAction => ({
+  type: ActionTypes.SetUsersSearchQuery,
+  payload: query,
+});
+
+export type Action = LoadUsersAction | SetUsersSearchQueryAction | LoadInviteesAction;
+
+type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
+
+export function loadUsers(): ThunkResult<void> {
+  return async dispatch => {
+    const users = await getBackendSrv().get('/api/org/users');
+    dispatch(usersLoaded(users));
+  };
+}
+
+export function loadInvitees(): ThunkResult<void> {
+  return async dispatch => {
+    const invitees = await getBackendSrv().get('/api/org/invites');
+    dispatch(inviteesLoaded(invitees));
+  };
+}
+
+export function updateUser(user: OrgUser): ThunkResult<void> {
+  return async dispatch => {
+    await getBackendSrv().patch(`/api/org/users/${user.userId}`, user);
+    dispatch(loadUsers());
+  };
+}
+
+export function removeUser(userId: number): ThunkResult<void> {
+  return async dispatch => {
+    await getBackendSrv().delete(`/api/org/users/${userId}`);
+    dispatch(loadUsers());
+  };
+}
+
+export function revokeInvite(code: string): ThunkResult<void> {
+  return async dispatch => {
+    await getBackendSrv().patch(`/api/org/invites/${code}/revoke`, {});
+    dispatch(loadInvitees());
+  };
+}

+ 32 - 0
public/app/features/users/state/reducers.ts

@@ -0,0 +1,32 @@
+import { Invitee, OrgUser, UsersState } from 'app/types';
+import { Action, ActionTypes } from './actions';
+import config from '../../../core/config';
+
+export const initialState: UsersState = {
+  invitees: [] as Invitee[],
+  users: [] as OrgUser[],
+  searchQuery: '',
+  canInvite: !config.disableLoginForm && !config.externalUserMngLinkName,
+  externalUserMngInfo: config.externalUserMngInfo,
+  externalUserMngLinkName: config.externalUserMngLinkName,
+  externalUserMngLinkUrl: config.externalUserMngLinkUrl,
+};
+
+export const usersReducer = (state = initialState, action: Action): UsersState => {
+  switch (action.type) {
+    case ActionTypes.LoadUsers:
+      return { ...state, users: action.payload };
+
+    case ActionTypes.LoadInvitees:
+      return { ...state, invitees: action.payload };
+
+    case ActionTypes.SetUsersSearchQuery:
+      return { ...state, searchQuery: action.payload };
+  }
+
+  return state;
+};
+
+export default {
+  users: usersReducer,
+};

+ 18 - 0
public/app/features/users/state/selectors.ts

@@ -0,0 +1,18 @@
+export const getUsers = state => {
+  const regex = new RegExp(state.searchQuery, 'i');
+
+  return state.users.filter(user => {
+    return regex.test(user.login) || regex.test(user.email);
+  });
+};
+
+export const getInvitees = state => {
+  const regex = new RegExp(state.searchQuery, 'i');
+
+  return state.invitees.filter(invitee => {
+    return regex.test(invitee.name) || regex.test(invitee.email);
+  });
+};
+
+export const getInviteesCount = state => state.invitees.length;
+export const getUsersSearchQuery = state => state.searchQuery;

+ 5 - 3
public/app/routes/routes.ts

@@ -10,6 +10,7 @@ import PluginListPage from 'app/features/plugins/PluginListPage';
 import FolderSettingsPage from 'app/features/folders/FolderSettingsPage';
 import FolderPermissions from 'app/features/folders/FolderPermissions';
 import DataSourcesListPage from 'app/features/datasources/DataSourcesListPage';
+import UsersListPage from 'app/features/users/UsersListPage';
 
 /** @ngInject */
 export function setupAngularRoutes($routeProvider, $locationProvider) {
@@ -133,9 +134,10 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
       controller: 'NewOrgCtrl',
     })
     .when('/org/users', {
-      templateUrl: 'public/app/features/org/partials/orgUsers.html',
-      controller: 'OrgUsersCtrl',
-      controllerAs: 'ctrl',
+      template: '<react-container />',
+      resolve: {
+        component: () => UsersListPage,
+      },
     })
     .when('/org/users/invite', {
       templateUrl: 'public/app/features/org/partials/invite.html',

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

@@ -9,6 +9,7 @@ import foldersReducers from 'app/features/folders/state/reducers';
 import dashboardReducers from 'app/features/dashboard/state/reducers';
 import pluginReducers from 'app/features/plugins/state/reducers';
 import dataSourcesReducers from 'app/features/datasources/state/reducers';
+import usersReducers from 'app/features/users/state/reducers';
 
 const rootReducer = combineReducers({
   ...sharedReducers,
@@ -19,6 +20,7 @@ const rootReducer = combineReducers({
   ...dashboardReducers,
   ...pluginReducers,
   ...dataSourcesReducers,
+  ...usersReducers,
 });
 
 export let store;

+ 7 - 2
public/app/types/index.ts

@@ -6,7 +6,7 @@ import { FolderDTO, FolderState, FolderInfo } from './folders';
 import { DashboardState } from './dashboard';
 import { DashboardAcl, OrgRole, PermissionLevel } from './acl';
 import { ApiKey, ApiKeysState, NewApiKey } from './apiKeys';
-import { User } from './user';
+import { Invitee, OrgUser, User, UsersState } from './user';
 import { DataSource, DataSourcesState } from './datasources';
 import { PluginMeta, Plugin, PluginsState } from './plugins';
 
@@ -38,10 +38,13 @@ export {
   ApiKey,
   ApiKeysState,
   NewApiKey,
-  User,
   Plugin,
   PluginsState,
   DataSourcesState,
+  Invitee,
+  OrgUser,
+  User,
+  UsersState,
 };
 
 export interface StoreState {
@@ -52,4 +55,6 @@ export interface StoreState {
   team: TeamState;
   folder: FolderState;
   dashboard: DashboardState;
+  dataSources: DataSourcesState;
+  users: UsersState;
 }

+ 39 - 1
public/app/types/user.ts

@@ -1,6 +1,44 @@
-export interface User {
+export interface OrgUser {
+  avatarUrl: string;
+  email: string;
+  lastSeenAt: string;
+  lastSeenAtAge: string;
+  login: string;
+  orgId: number;
+  role: string;
+  userId: number;
+}
+
+export interface User {
   id: number;
   label: string;
   avatarUrl: string;
   login: string;
 }
+
+export interface Invitee {
+  code: string;
+  createdOn: string;
+  email: string;
+  emailSent: boolean;
+  emailSentOn: string;
+  id: number;
+  invitedByEmail: string;
+  invitedByLogin: string;
+  invitedByName: string;
+  name: string;
+  orgId: number;
+  role: string;
+  status: string;
+  url: string;
+}
+
+export interface UsersState {
+  users: OrgUser[];
+  invitees: Invitee[];
+  searchQuery: string;
+  canInvite: boolean;
+  externalUserMngLinkUrl: string;
+  externalUserMngLinkName: string;
+  externalUserMngInfo: string;
+}

+ 37 - 0
public/app/types/users.ts

@@ -0,0 +1,37 @@
+export interface Invitee {
+  code: string;
+  createdOn: string;
+  email: string;
+  emailSent: boolean;
+  emailSentOn: string;
+  id: number;
+  invitedByEmail: string;
+  invitedByLogin: string;
+  invitedByName: string;
+  name: string;
+  orgId: number;
+  role: string;
+  status: string;
+  url: string;
+}
+
+export interface User {
+  avatarUrl: string;
+  email: string;
+  lastSeenAt: string;
+  lastSeenAtAge: string;
+  login: string;
+  orgId: number;
+  role: string;
+  userId: number;
+}
+
+export interface UsersState {
+  users: User[];
+  invitees: Invitee[];
+  searchQuery: string;
+  canInvite: boolean;
+  externalUserMngLinkUrl: string;
+  externalUserMngLinkName: string;
+  externalUserMngInfo: string;
+}