Selaa lähdekoodia

invites table

Peter Holmberg 7 vuotta sitten
vanhempi
commit
3c8820ab55

+ 2 - 9
public/app/core/components/OrgActionBar/OrgActionBar.tsx

@@ -4,19 +4,14 @@ import LayoutSelector, { LayoutMode } from '../LayoutSelector/LayoutSelector';
 export interface Props {
   searchQuery: string;
   layoutMode?: LayoutMode;
-  showLayoutMode: boolean;
   setLayoutMode?: (mode: LayoutMode) => {};
   setSearchQuery: (value: string) => {};
   linkButton: { href: string; title: string };
 }
 
 export default class OrgActionBar extends PureComponent<Props> {
-  static defaultProps = {
-    showLayoutMode: true,
-  };
-
   render() {
-    const { searchQuery, layoutMode, setLayoutMode, linkButton, setSearchQuery, showLayoutMode } = this.props;
+    const { searchQuery, layoutMode, setLayoutMode, linkButton, setSearchQuery } = this.props;
 
     return (
       <div className="page-action-bar">
@@ -31,9 +26,7 @@ export default class OrgActionBar extends PureComponent<Props> {
             />
             <i className="gf-form-input-icon fa fa-search" />
           </label>
-          {showLayoutMode && (
-            <LayoutSelector mode={layoutMode} onLayoutModeChanged={(mode: LayoutMode) => setLayoutMode(mode)} />
-          )}
+          <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">

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

@@ -0,0 +1,59 @@
+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 copyRef = createRef<HTMLTextAreaElement>();
+
+  copyToClipboard = () => {
+    const node = this.copyRef.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={{ display: 'none' }} ref={this.copyRef} />
+                    <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>
+    );
+  }
+}

+ 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';
+
+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);

+ 53 - 22
public/app/features/users/UsersListPage.tsx

@@ -1,34 +1,52 @@
 import React, { PureComponent } from 'react';
 import { hot } from 'react-hot-loader';
 import { connect } from 'react-redux';
-import OrgActionBar from 'app/core/components/OrgActionBar/OrgActionBar';
 import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import UsersActionBar from './UsersActionBar';
 import UsersTable from 'app/features/users/UsersTable';
-import { NavModel, User } from 'app/types';
+import InviteesTable from './InviteesTable';
+import { Invitee, NavModel, User } from 'app/types';
 import appEvents from 'app/core/app_events';
-import { loadUsers, setUsersSearchQuery, updateUser, removeUser } from './state/actions';
+import { loadUsers, loadInvitees, revokeInvite, setUsersSearchQuery, updateUser, removeUser } from './state/actions';
 import { getNavModel } from '../../core/selectors/navModel';
-import { getUsers, getUsersSearchQuery } from './state/selectors';
+import { getInvitees, getUsers, getUsersSearchQuery } from './state/selectors';
 
 export interface Props {
   navModel: NavModel;
+  invitees: Invitee[];
   users: User[];
   searchQuery: string;
+  externalUserMngInfo: string;
   loadUsers: typeof loadUsers;
+  loadInvitees: typeof loadInvitees;
   setUsersSearchQuery: typeof setUsersSearchQuery;
   updateUser: typeof updateUser;
   removeUser: typeof removeUser;
+  revokeInvite: typeof revokeInvite;
 }
 
-export class UsersListPage extends PureComponent<Props> {
+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 };
 
@@ -47,29 +65,38 @@ export class UsersListPage extends PureComponent<Props> {
     });
   };
 
-  render() {
-    const { navModel, searchQuery, setUsersSearchQuery, users } = this.props;
+  onRevokeInvite = code => {
+    this.props.revokeInvite(code);
+  };
 
-    const linkButton = {
-      href: '/org/users/add',
-      title: 'Add user',
-    };
+  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">
-          <OrgActionBar
-            searchQuery={searchQuery}
-            showLayoutMode={false}
-            setSearchQuery={setUsersSearchQuery}
-            linkButton={linkButton}
-          />
-          <UsersTable
-            users={users}
-            onRoleChange={(role, user) => this.onRoleChange(role, user)}
-            onRemoveUser={user => this.onRemoveUser(user)}
-          />
+          <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>
     );
@@ -81,14 +108,18 @@ function mapStateToProps(state) {
     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));

+ 50 - 52
public/app/features/users/UsersTable.tsx

@@ -11,58 +11,56 @@ const UsersTable: SFC<Props> = props => {
   const { users, onRoleChange, onRemoveUser } = props;
 
   return (
-    <div>
-      <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>
-    </div>
+    <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>
   );
 };
 

+ 27 - 2
public/app/features/users/state/actions.ts

@@ -1,10 +1,11 @@
 import { ThunkAction } from 'redux-thunk';
 import { StoreState } from '../../../types';
 import { getBackendSrv } from '../../../core/services/backend_srv';
-import { User } from 'app/types';
+import { Invitee, User } from 'app/types';
 
 export enum ActionTypes {
   LoadUsers = 'LOAD_USERS',
+  LoadInvitees = 'LOAD_INVITEES',
   SetUsersSearchQuery = 'SET_USERS_SEARCH_QUERY',
 }
 
@@ -13,6 +14,11 @@ export interface LoadUsersAction {
   payload: User[];
 }
 
+export interface LoadInviteesAction {
+  type: ActionTypes.LoadInvitees;
+  payload: Invitee[];
+}
+
 export interface SetUsersSearchQueryAction {
   type: ActionTypes.SetUsersSearchQuery;
   payload: string;
@@ -23,12 +29,17 @@ const usersLoaded = (users: User[]): LoadUsersAction => ({
   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;
+export type Action = LoadUsersAction | SetUsersSearchQueryAction | LoadInviteesAction;
 
 type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
 
@@ -39,6 +50,13 @@ export function loadUsers(): ThunkResult<void> {
   };
 }
 
+export function loadInvitees(): ThunkResult<void> {
+  return async dispatch => {
+    const invitees = await getBackendSrv().get('/api/org/invites');
+    dispatch(inviteesLoaded(invitees));
+  };
+}
+
 export function updateUser(user: User): ThunkResult<void> {
   return async dispatch => {
     await getBackendSrv().patch(`/api/org/users/${user.userId}`, user);
@@ -52,3 +70,10 @@ export function removeUser(userId: number): ThunkResult<void> {
     dispatch(loadUsers());
   };
 }
+
+export function revokeInvite(code: string): ThunkResult<void> {
+  return async dispatch => {
+    await getBackendSrv().patch(`/api/org/invites/${code}/revoke`, {});
+    dispatch(loadInvitees());
+  };
+}

+ 14 - 2
public/app/features/users/state/reducers.ts

@@ -1,13 +1,25 @@
-import { User, UsersState } from 'app/types';
+import { Invitee, User, UsersState } from 'app/types';
 import { Action, ActionTypes } from './actions';
+import config from '../../../core/config';
 
-export const initialState: UsersState = { users: [] as User[], searchQuery: '' };
+export const initialState: UsersState = {
+  invitees: [] as Invitee[],
+  users: [] as User[],
+  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 };
   }

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

@@ -6,4 +6,13 @@ export const getUsers = state => {
   });
 };
 
+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;

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

@@ -7,7 +7,7 @@ import { DashboardState } from './dashboard';
 import { DashboardAcl, OrgRole, PermissionLevel } from './acl';
 import { DataSource, DataSourcesState } from './datasources';
 import { PluginMeta, Plugin, PluginsState } from './plugins';
-import { User, UsersState } from './users';
+import { Invitee, User, UsersState } from './users';
 
 export {
   Team,
@@ -37,6 +37,7 @@ export {
   Plugin,
   PluginsState,
   DataSourcesState,
+  Invitee,
   User,
   UsersState,
 };

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

@@ -1,3 +1,20 @@
+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;
@@ -11,5 +28,10 @@ export interface User {
 
 export interface UsersState {
   users: User[];
+  invitees: Invitee[];
   searchQuery: string;
+  canInvite: boolean;
+  externalUserMngLinkUrl: string;
+  externalUserMngLinkName: string;
+  externalUserMngInfo: string;
 }