]> source.dussan.org Git - sonarqube.git/commitdiff
Rewrite users page to TS and React
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>
Mon, 6 Nov 2017 13:18:22 +0000 (14:18 +0100)
committerGrégoire Aubert <gregoire.aubert@sonarsource.com>
Fri, 24 Nov 2017 16:22:33 +0000 (17:22 +0100)
51 files changed:
server/sonar-web/package.json
server/sonar-web/src/main/js/api/components.ts
server/sonar-web/src/main/js/api/user-tokens.ts
server/sonar-web/src/main/js/api/users.ts
server/sonar-web/src/main/js/app/styles/init/misc.css
server/sonar-web/src/main/js/apps/account/components/Password.js
server/sonar-web/src/main/js/apps/account/tokens-view.js
server/sonar-web/src/main/js/apps/issues/utils.js
server/sonar-web/src/main/js/apps/tutorials/onboarding/TokenStep.js
server/sonar-web/src/main/js/apps/users/Header.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/Search.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/UsersApp.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/UsersAppContainer.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/UsersList.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/components/PasswordForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/components/TokensFormNewToken.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/components/UserActions.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/components/UserForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/components/UserGroups.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/components/UserScmAccountInput.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/components/UserScmAccounts.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/components/UsersAppContainer.js [deleted file]
server/sonar-web/src/main/js/apps/users/components/UsersAppContainerOld.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.js [deleted file]
server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js [deleted file]
server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js [deleted file]
server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.js [deleted file]
server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchOption-test.js [deleted file]
server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchValue-test.js [deleted file]
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.js.snap [deleted file]
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap [deleted file]
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap [deleted file]
server/sonar-web/src/main/js/apps/users/routes.ts
server/sonar-web/src/main/js/apps/users/tokens-view.js
server/sonar-web/src/main/js/apps/users/utils.ts [new file with mode: 0644]
server/sonar-web/src/main/js/components/common/DeferredSpinner.tsx
server/sonar-web/src/main/js/components/controls/ActionsDropdown.tsx
server/sonar-web/src/main/js/components/icons-components/BulletListIcon.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.js
server/sonar-web/src/main/js/components/ui/buttons.tsx
server/sonar-web/yarn.lock
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index febc889e9b3d08be147a0b8b2f436a7652d17b53..640c3f00eab62d8f30bb5d748d57cbab7bba9228 100644 (file)
@@ -44,6 +44,7 @@
   },
   "devDependencies": {
     "@types/classnames": "2.2.3",
+    "@types/clipboard": "1.5.35",
     "@types/d3-array": "1.2.1",
     "@types/d3-scale": "1.0.10",
     "@types/enzyme": "3.1.1",
index 2d03c4f0db44cb983e03af012f81d57bdf0d5999..6448333071fda81d97246252272e4be59ea87e47 100644 (file)
@@ -154,12 +154,6 @@ export function getMyProjects(data: RequestData): Promise<any> {
   return getJSON(url, data);
 }
 
-export interface Paging {
-  pageIndex: number;
-  pageSize: number;
-  total: number;
-}
-
 export interface Component {
   organization: string;
   id: string;
index 2b98e0407cd04f65c01289aa079cf405af9f92fe..8fa8b24157560e35441162a1db8a05147e84a28e 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { getJSON, postJSON, post, RequestData } from '../helpers/request';
+import { getJSON, postJSON, post } from '../helpers/request';
 import throwGlobalError from '../app/utils/throwGlobalError';
 
+export interface UserToken {
+  name: string;
+  createdAt: string;
+}
+
 /**
  * List tokens for given user login
  */
-export function getTokens(login: string): Promise<any> {
-  return getJSON('/api/user_tokens/search', { login }).then(r => r.userTokens);
+export function getTokens(login: string): Promise<UserToken[]> {
+  return getJSON('/api/user_tokens/search', { login }).then(r => r.userTokens, throwGlobalError);
 }
 
 /**
  * Generate a user token
  */
-export function generateToken(
-  tokenName: string,
-  userLogin?: string
-): Promise<{ name: string; token: string }> {
-  const data: RequestData = { name: tokenName };
-  if (userLogin) {
-    data.login = userLogin;
-  }
+export function generateToken(data: {
+  name: string;
+  login?: string;
+}): Promise<{ login: string; name: string; token: string }> {
   return postJSON('/api/user_tokens/generate', data).catch(throwGlobalError);
 }
 
 /**
  * Revoke a user token
  */
-export function revokeToken(tokenName: string, userLogin?: string): Promise<void | Response> {
-  const data: RequestData = { name: tokenName };
-  if (userLogin) {
-    data.login = userLogin;
-  }
+export function revokeToken(data: { name: string; login?: string }): Promise<void | Response> {
   return post('/api/user_tokens/revoke', data).catch(throwGlobalError);
 }
index 2fb8213e13976200e41a1623c36aeb0ff4699860..f78343b59695a75f078f85bd40e65219d01d57b5 100644 (file)
@@ -17,8 +17,9 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { getJSON, post, RequestData } from '../helpers/request';
+import { getJSON, post, postJSON, RequestData } from '../helpers/request';
 import throwGlobalError from '../app/utils/throwGlobalError';
+import { Paging } from '../app/types';
 
 export interface IdentityProvider {
   backgroundColor: string;
@@ -27,19 +28,29 @@ export interface IdentityProvider {
   name: string;
 }
 
+export interface User {
+  login: string;
+  name: string;
+  active: boolean;
+  email?: string;
+  scmAccounts: string[];
+  groups?: string[];
+  tokensCount?: number;
+  local: boolean;
+  externalIdentity?: string;
+  externalProvider?: string;
+  avatar?: string;
+}
+
 export function getCurrentUser(): Promise<any> {
   return getJSON('/api/users/current');
 }
 
-export function changePassword(
-  login: string,
-  password: string,
-  previousPassword?: string
-): Promise<void> {
-  const data: RequestData = { login, password };
-  if (previousPassword != null) {
-    data.previousPassword = previousPassword;
-  }
+export function changePassword(data: {
+  login: string;
+  password: string;
+  previousPassword?: string;
+}): Promise<void> {
   return post('/api/users/change_password', data);
 }
 
@@ -52,15 +63,40 @@ export function getUserGroups(login: string, organization?: string): Promise<any
 }
 
 export function getIdentityProviders(): Promise<{ identityProviders: IdentityProvider[] }> {
-  return getJSON('/api/users/identity_providers');
+  return getJSON('/api/users/identity_providers').catch(throwGlobalError);
 }
 
-export function searchUsers(query: string, pageSize?: number): Promise<any> {
-  const data: RequestData = { q: query };
-  if (pageSize != null) {
-    data.ps = pageSize;
-  }
-  return getJSON('/api/users/search', data);
+export function searchUsers(data: {
+  p?: number;
+  ps?: number;
+  q?: string;
+}): Promise<{ paging: Paging; users: User[] }> {
+  data.q = data.q || undefined;
+  return getJSON('/api/users/search', data).catch(throwGlobalError);
+}
+
+export function createUser(data: {
+  email?: string;
+  local?: boolean;
+  login: string;
+  name: string;
+  password?: string;
+  scmAccount: string[];
+}): Promise<void | Response> {
+  return post('/api/users/create', data);
+}
+
+export function updateUser(data: {
+  email?: string;
+  login: string;
+  name?: string;
+  scmAccount: string[];
+}): Promise<User> {
+  return postJSON('/api/users/update', data);
+}
+
+export function deactivateUser(data: { login: string }): Promise<User> {
+  return postJSON('/api/users/deactivate', data).catch(throwGlobalError);
 }
 
 export function skipOnboarding(): Promise<void | Response> {
index bdb9435384c638b729a3c9487d6c472990fdb2c8..09aa7c6a906522cc0b11d1fef0d7f12d3cf7271f 100644 (file)
@@ -316,6 +316,10 @@ td.big-spacer-top {
   cursor: not-allowed;
 }
 
+.no-select {
+  user-select: none;
+}
+
 .no-outline,
 .no-outline:focus {
   outline: none;
index f90aedc34cd073616290b7ec3df63705f00412db..dec082bc69bfb2ffc76981b7e5908f257fe5fbbb 100644 (file)
@@ -27,44 +27,44 @@ export default class Password extends Component {
     errors: null
   };
 
-  handleSuccessfulChange() {
-    this.refs.oldPassword.value = '';
-    this.refs.password.value = '';
-    this.refs.passwordConfirmation.value = '';
+  handleSuccessfulChange = () => {
+    this.oldPassword.value = '';
+    this.password.value = '';
+    this.passwordConfirmation.value = '';
     this.setState({ success: true, errors: null });
-  }
+  };
 
-  handleFailedChange(e) {
+  handleFailedChange = e => {
     e.response.json().then(r => {
-      this.refs.oldPassword.focus();
+      this.oldPassword.focus();
       this.setErrors(r.errors.map(e => e.msg));
     });
-  }
+  };
 
-  setErrors(errors) {
+  setErrors = errors => {
     this.setState({
       success: false,
       errors
     });
-  }
+  };
 
-  handleChangePassword(e) {
+  handleChangePassword = e => {
     e.preventDefault();
 
     const { user } = this.props;
-    const oldPassword = this.refs.oldPassword.value;
-    const password = this.refs.password.value;
-    const passwordConfirmation = this.refs.passwordConfirmation.value;
+    const previousPassword = this.oldPassword.value;
+    const password = this.password.value;
+    const passwordConfirmation = this.passwordConfirmation.value;
 
     if (password !== passwordConfirmation) {
-      this.refs.password.focus();
+      this.password.focus();
       this.setErrors([translate('user.password_doesnt_match_confirmation')]);
     } else {
-      changePassword(user.login, password, oldPassword)
-        .then(this.handleSuccessfulChange.bind(this))
-        .catch(this.handleFailedChange.bind(this));
+      changePassword({ login: user.login, password, previousPassword })
+        .then(this.handleSuccessfulChange)
+        .catch(this.handleFailedChange);
     }
-  }
+  };
 
   render() {
     const { success, errors } = this.state;
@@ -73,7 +73,7 @@ export default class Password extends Component {
       <section>
         <h2 className="spacer-bottom">{translate('my_profile.password.title')}</h2>
 
-        <form onSubmit={this.handleChangePassword.bind(this)}>
+        <form onSubmit={this.handleChangePassword}>
           {success && (
             <div className="alert alert-success">{translate('my_profile.password.changed')}</div>
           )}
@@ -91,7 +91,7 @@ export default class Password extends Component {
               <em className="mandatory">*</em>
             </label>
             <input
-              ref="oldPassword"
+              ref={elem => (this.oldPassword = elem)}
               autoComplete="off"
               id="old_password"
               name="old_password"
@@ -105,7 +105,7 @@ export default class Password extends Component {
               <em className="mandatory">*</em>
             </label>
             <input
-              ref="password"
+              ref={elem => (this.password = elem)}
               autoComplete="off"
               id="password"
               name="password"
@@ -119,7 +119,7 @@ export default class Password extends Component {
               <em className="mandatory">*</em>
             </label>
             <input
-              ref="passwordConfirmation"
+              ref={elem => (this.passwordConfirmation = elem)}
               autoComplete="off"
               id="password_confirmation"
               name="password_confirmation"
index e63f1692804f9e2c3ae0754be67ef6d247237e20..68c74f3d6c8bbbdbc42db8a432a463fa8e19b749 100644 (file)
@@ -53,7 +53,7 @@ export default Marionette.ItemView.extend({
     this.errors = [];
     this.newToken = null;
     const tokenName = this.$('.js-generate-token-form input').val();
-    generateToken(tokenName, this.model.id).then(
+    generateToken({ name: tokenName, login: this.model.id }).then(
       response => {
         this.newToken = response;
         this.requestTokens();
@@ -68,7 +68,10 @@ export default Marionette.ItemView.extend({
     const token = this.tokens.find(token => token.name === `${tokenName}`);
     if (token) {
       if (token.deleting) {
-        revokeToken(tokenName, this.model.id).then(this.requestTokens.bind(this), () => {});
+        revokeToken({ name: tokenName, login: this.model.id }).then(
+          () => this.requestTokens(),
+          () => {}
+        );
       } else {
         token.deleting = true;
         this.render();
index e0532e1e8142926186d63dd9c323df5d3307499b..a044e880696db7109373dda80a7ee58cbef5437c 100644 (file)
@@ -230,7 +230,7 @@ export const searchAssignees = (query /*: string */, organization /*: ?string */
           value: user.login
         }))
       )
-    : searchUsers(query, 50).then(response =>
+    : searchUsers({ q: query }).then(response =>
         response.users.map(user => ({
           // TODO this WS returns no avatar
           avatar: user.avatar,
index dd72bd238e858984eac2b6a8474826b315f1f3b3..4a5609c3d9b4d07e68b0fc3de6432c1cf0e17d7d 100644 (file)
@@ -95,7 +95,7 @@ export default class TokenStep extends React.PureComponent {
     const { tokenName } = this.state;
     if (tokenName) {
       this.setState({ loading: true });
-      generateToken(tokenName).then(
+      generateToken({ name: tokenName }).then(
         ({ token }) => {
           if (this.mounted) {
             this.setState({ loading: false, token });
@@ -114,7 +114,7 @@ export default class TokenStep extends React.PureComponent {
     const { tokenName } = this.state;
     if (tokenName) {
       this.setState({ loading: true });
-      revokeToken(tokenName).then(
+      revokeToken({ name: tokenName }).then(
         () => {
           if (this.mounted) {
             this.setState({ loading: false, token: undefined, tokenName: undefined });
diff --git a/server/sonar-web/src/main/js/apps/users/Header.tsx b/server/sonar-web/src/main/js/apps/users/Header.tsx
new file mode 100644 (file)
index 0000000..60eeb83
--- /dev/null
@@ -0,0 +1,59 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import UserForm from './components/UserForm';
+import DeferredSpinner from '../../components/common/DeferredSpinner';
+import { translate } from '../../helpers/l10n';
+
+interface Props {
+  loading: boolean;
+  onUpdateUsers: () => void;
+}
+
+interface State {
+  openUserForm: boolean;
+}
+
+export default class Header extends React.PureComponent<Props, State> {
+  state: State = { openUserForm: false };
+
+  handleOpenUserForm = () => this.setState({ openUserForm: true });
+  handleCloseUserForm = () => this.setState({ openUserForm: false });
+
+  render() {
+    return (
+      <header id="users-header" className="page-header">
+        <h1 className="page-title">{translate('users.page')}</h1>
+        <DeferredSpinner loading={this.props.loading} />
+
+        <div className="page-actions">
+          <button id="users-create" onClick={this.handleOpenUserForm}>
+            {translate('users.create_user')}
+          </button>
+        </div>
+
+        <p className="page-description">{translate('users.page.description')}</p>
+        {this.state.openUserForm && (
+          <UserForm onClose={this.handleCloseUserForm} onUpdateUsers={this.props.onUpdateUsers} />
+        )}
+      </header>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/users/Search.tsx b/server/sonar-web/src/main/js/apps/users/Search.tsx
new file mode 100644 (file)
index 0000000..42f3684
--- /dev/null
@@ -0,0 +1,49 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { Query } from './utils';
+import SearchBox from '../../components/controls/SearchBox';
+import { translate } from '../../helpers/l10n';
+
+interface Props {
+  query: Query;
+  updateQuery: (newQuery: Partial<Query>) => void;
+}
+
+export default class Search extends React.PureComponent<Props> {
+  handleSearch = (search: string) => {
+    this.props.updateQuery({ search });
+  };
+
+  render() {
+    const { query } = this.props;
+
+    return (
+      <div id="users-search" className="panel panel-vertical bordered-bottom spacer-bottom">
+        <SearchBox
+          minLength={2}
+          onChange={this.handleSearch}
+          placeholder={translate('search.search_by_login_or_name')}
+          value={query.search}
+        />
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/users/UsersApp.tsx b/server/sonar-web/src/main/js/apps/users/UsersApp.tsx
new file mode 100644 (file)
index 0000000..24c638c
--- /dev/null
@@ -0,0 +1,142 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import * as PropTypes from 'prop-types';
+import Helmet from 'react-helmet';
+import { Location } from 'history';
+import Header from './Header';
+import ListFooter from '../../components/controls/ListFooter';
+import Search from './Search';
+import UsersList from './UsersList';
+import { getIdentityProviders, IdentityProvider, searchUsers, User } from '../../api/users';
+import { Paging } from '../../app/types';
+import { translate } from '../../helpers/l10n';
+import { parseQuery, Query, serializeQuery } from './utils';
+
+interface Props {
+  currentUser: { isLoggedIn: boolean; login?: string };
+  location: Location;
+  organizationsEnabled: boolean;
+}
+
+interface State {
+  identityProviders: IdentityProvider[];
+  loading: boolean;
+  paging?: Paging;
+  users: User[];
+}
+
+export default class UsersApp extends React.PureComponent<Props, State> {
+  mounted: boolean;
+
+  static contextTypes = {
+    router: PropTypes.object.isRequired
+  };
+
+  state: State = { identityProviders: [], loading: true, users: [] };
+
+  componentDidMount() {
+    this.mounted = true;
+    this.fetchIdentityProviders();
+    this.fetchUsers();
+  }
+
+  componentWillReceiveProps(nextProps: Props) {
+    if (nextProps.location.query.search !== this.props.location.query.search) {
+      this.fetchUsers(nextProps);
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  finishLoading = () => {
+    if (this.mounted) {
+      this.setState({ loading: false });
+    }
+  };
+
+  fetchIdentityProviders = () =>
+    getIdentityProviders().then(
+      ({ identityProviders }) => {
+        if (this.mounted) {
+          this.setState({ identityProviders });
+        }
+      },
+      () => {}
+    );
+
+  fetchUsers = ({ location } = this.props) => {
+    this.setState({ loading: true });
+    searchUsers({ q: parseQuery(location.query).search }).then(({ paging, users }) => {
+      if (this.mounted) {
+        this.setState({ loading: false, paging, users });
+      }
+    }, this.finishLoading);
+  };
+
+  fetchMoreUsers = () => {
+    const { paging } = this.state;
+    if (paging) {
+      this.setState({ loading: true });
+      searchUsers({
+        p: paging.pageIndex + 1,
+        q: parseQuery(this.props.location.query).search
+      }).then(({ paging, users }) => {
+        if (this.mounted) {
+          this.setState(state => ({ loading: false, users: [...state.users, ...users], paging }));
+        }
+      }, this.finishLoading);
+    }
+  };
+
+  updateQuery = (newQuery: Partial<Query>) => {
+    const query = serializeQuery({ ...parseQuery(this.props.location.query), ...newQuery });
+    this.context.router.push({ ...this.props.location, query });
+  };
+
+  render() {
+    const query = parseQuery(this.props.location.query);
+    const { loading, paging, users } = this.state;
+    return (
+      <div id="users-page" className="page page-limited">
+        <Helmet title={translate('users.page')} />
+        <Header loading={loading} onUpdateUsers={this.fetchUsers} />
+        <Search query={query} updateQuery={this.updateQuery} />
+        <UsersList
+          currentUser={this.props.currentUser}
+          identityProviders={this.state.identityProviders}
+          onUpdateUsers={this.fetchUsers}
+          organizationsEnabled={this.props.organizationsEnabled}
+          users={users}
+        />
+        {paging !== undefined && (
+          <ListFooter
+            count={users.length}
+            total={paging.total}
+            ready={!loading}
+            loadMore={this.fetchMoreUsers}
+          />
+        )}
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/users/UsersAppContainer.tsx b/server/sonar-web/src/main/js/apps/users/UsersAppContainer.tsx
new file mode 100644 (file)
index 0000000..b5c1473
--- /dev/null
@@ -0,0 +1,39 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { connect } from 'react-redux';
+import { Location } from 'history';
+import UsersApp from './UsersApp';
+import { areThereCustomOrganizations, getCurrentUser } from '../../store/rootReducer';
+
+interface OwnProps {
+  location: Location;
+}
+
+interface StateToProps {
+  currentUser: { isLoggedIn: boolean; login?: string };
+  organizationsEnabled: boolean;
+}
+
+const mapStateToProps = (state: any) => ({
+  currentUser: getCurrentUser(state),
+  organizationsEnabled: areThereCustomOrganizations(state)
+});
+
+export default connect<StateToProps, {}, OwnProps>(mapStateToProps)(UsersApp);
diff --git a/server/sonar-web/src/main/js/apps/users/UsersList.tsx b/server/sonar-web/src/main/js/apps/users/UsersList.tsx
new file mode 100644 (file)
index 0000000..a81a944
--- /dev/null
@@ -0,0 +1,68 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import UserListItem from './components/UserListItem';
+import { IdentityProvider, User } from '../../api/users';
+import { translate } from '../../helpers/l10n';
+
+interface Props {
+  currentUser: { isLoggedIn: boolean; login?: string };
+  identityProviders: IdentityProvider[];
+  onUpdateUsers: () => void;
+  organizationsEnabled: boolean;
+  users: User[];
+}
+
+export default function UsersList({
+  currentUser,
+  identityProviders,
+  onUpdateUsers,
+  organizationsEnabled,
+  users
+}: Props) {
+  return (
+    <table id="users-list" className="data zebra">
+      <thead>
+        <tr>
+          <th />
+          <th className="nowrap" />
+          <th className="nowrap">{translate('my_profile.scm_accounts')}</th>
+          {!organizationsEnabled && <th className="nowrap">{translate('my_profile.groups')}</th>}
+          <th className="nowrap">{translate('users.tokens')}</th>
+          <th className="nowrap">&nbsp;</th>
+        </tr>
+      </thead>
+      <tbody>
+        {users.map(user => (
+          <UserListItem
+            identityProvider={identityProviders.find(
+              provider => user.externalProvider === provider.key
+            )}
+            isCurrentUser={currentUser.isLoggedIn && currentUser.login === user.login}
+            key={user.login}
+            onUpdateUsers={onUpdateUsers}
+            organizationsEnabled={organizationsEnabled}
+            user={user}
+          />
+        ))}
+      </tbody>
+    </table>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx b/server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx
new file mode 100644 (file)
index 0000000..c9776ac
--- /dev/null
@@ -0,0 +1,95 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import Modal from '../../../components/controls/Modal';
+import { deactivateUser, User } from '../../../api/users';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+
+export interface Props {
+  onClose: () => void;
+  onUpdateUsers: () => void;
+  user: User;
+}
+
+interface State {
+  submitting: boolean;
+}
+
+export default class DeactivateForm extends React.PureComponent<Props, State> {
+  mounted: boolean;
+  state: State = { submitting: false };
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  handleCancelClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+    event.preventDefault();
+    this.props.onClose();
+  };
+
+  handleDeactivate = (event: React.SyntheticEvent<HTMLFormElement>) => {
+    event.preventDefault();
+    this.setState({ submitting: true });
+    deactivateUser({ login: this.props.user.login }).then(
+      () => {
+        this.props.onUpdateUsers();
+        this.props.onClose();
+      },
+      () => {
+        if (this.mounted) {
+          this.setState({ submitting: false });
+        }
+      }
+    );
+  };
+
+  render() {
+    const { user } = this.props;
+    const { submitting } = this.state;
+
+    const header = translate('users.deactivate_user');
+    return (
+      <Modal contentLabel={header} onRequestClose={this.props.onClose}>
+        <form id="deactivate-user-form" onSubmit={this.handleDeactivate} autoComplete="off">
+          <header className="modal-head">
+            <h2>{header}</h2>
+          </header>
+          <div className="modal-body">
+            {translateWithParameters('users.deactivate_user.confirmation', user.name, user.login)}
+          </div>
+          <footer className="modal-foot">
+            {submitting && <i className="spinner spacer-right" />}
+            <button className="js-confirm button-red" disabled={submitting} type="submit">
+              {translate('users.deactivate')}
+            </button>
+            <a className="js-modal-close" href="#" onClick={this.handleCancelClick}>
+              {translate('cancel')}
+            </a>
+          </footer>
+        </form>
+      </Modal>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx b/server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx
new file mode 100644 (file)
index 0000000..c7d616b
--- /dev/null
@@ -0,0 +1,97 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import * as escapeHtml from 'escape-html';
+import Modal from '../../../components/controls/Modal';
+import SelectList from '../../../components/SelectList';
+import { User } from '../../../api/users';
+import { translate } from '../../../helpers/l10n';
+import { getBaseUrl } from '../../../helpers/urls';
+
+interface Props {
+  onClose: () => void;
+  onUpdateUsers: () => void;
+  user: User;
+}
+
+export default class GroupsForm extends React.PureComponent<Props> {
+  container: HTMLDivElement | null;
+
+  handleCloseClick = (event: React.SyntheticEvent<HTMLElement>) => {
+    event.preventDefault();
+    this.handleClose();
+  };
+
+  handleClose = () => {
+    this.props.onUpdateUsers();
+    this.props.onClose();
+  };
+
+  renderSelectList = () => {
+    const searchUrl = `${getBaseUrl()}/api/users/groups?ps=100&login=${encodeURIComponent(
+      this.props.user.login
+    )}`;
+
+    new (SelectList as any)({
+      el: this.container,
+      width: '100%',
+      readOnly: false,
+      focusSearch: false,
+      dangerouslyUnescapedHtmlFormat: (item: { name: string; description: string }) =>
+        `${escapeHtml(item.name)}<br><span class="note">${escapeHtml(item.description)}</span>`,
+      queryParam: 'q',
+      searchUrl,
+      selectUrl: getBaseUrl() + '/api/user_groups/add_user',
+      deselectUrl: getBaseUrl() + '/api/user_groups/remove_user',
+      extra: { login: this.props.user.login },
+      selectParameter: 'id',
+      selectParameterValue: 'id',
+      parse(r: any) {
+        this.more = false;
+        return r.groups;
+      }
+    });
+  };
+
+  render() {
+    const header = translate('users.update_groups');
+
+    return (
+      <Modal
+        contentLabel={header}
+        onAfterOpen={this.renderSelectList}
+        onRequestClose={this.handleClose}>
+        <div className="modal-head">
+          <h2>{header}</h2>
+        </div>
+
+        <div className="modal-body">
+          <div id="user-groups" ref={node => (this.container = node)} />
+        </div>
+
+        <footer className="modal-foot">
+          <a className="js-modal-close" href="#" onClick={this.handleCloseClick}>
+            {translate('Done')}
+          </a>
+        </footer>
+      </Modal>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/PasswordForm.tsx b/server/sonar-web/src/main/js/apps/users/components/PasswordForm.tsx
new file mode 100644 (file)
index 0000000..b6864aa
--- /dev/null
@@ -0,0 +1,182 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import Modal from '../../../components/controls/Modal';
+import addGlobalSuccessMessage from '../../../app/utils/addGlobalSuccessMessage';
+import throwGlobalError from '../../../app/utils/throwGlobalError';
+import { parseError } from '../../../helpers/request';
+import { changePassword, User } from '../../../api/users';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+  isCurrentUser: boolean;
+  user: User;
+  onClose: () => void;
+}
+
+interface State {
+  confirmPassword: string;
+  error?: string;
+  newPassword: string;
+  oldPassword: string;
+  submitting: boolean;
+}
+
+export default class PasswordForm extends React.PureComponent<Props, State> {
+  mounted: boolean;
+  state: State = {
+    confirmPassword: '',
+    newPassword: '',
+    oldPassword: '',
+    submitting: false
+  };
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  handleError = (error: { response: Response }) => {
+    if (!this.mounted || error.response.status !== 400) {
+      return throwGlobalError(error);
+    } else {
+      return parseError(error).then(
+        errorMsg => this.setState({ error: errorMsg, submitting: false }),
+        throwGlobalError
+      );
+    }
+  };
+
+  handleConfirmPasswordChange = (event: React.SyntheticEvent<HTMLInputElement>) =>
+    this.setState({ confirmPassword: event.currentTarget.value });
+  handleNewPasswordChange = (event: React.SyntheticEvent<HTMLInputElement>) =>
+    this.setState({ newPassword: event.currentTarget.value });
+  handleOldPasswordChange = (event: React.SyntheticEvent<HTMLInputElement>) =>
+    this.setState({ oldPassword: event.currentTarget.value });
+
+  handleCancelClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+    event.preventDefault();
+    this.props.onClose();
+  };
+
+  handleChangePassword = (event: React.SyntheticEvent<HTMLFormElement>) => {
+    event.preventDefault();
+    if (
+      this.state.newPassword.length > 0 &&
+      this.state.newPassword === this.state.confirmPassword
+    ) {
+      this.setState({ submitting: true });
+      changePassword({
+        login: this.props.user.login,
+        password: this.state.newPassword,
+        previousPassword: this.state.oldPassword
+      }).then(() => {
+        addGlobalSuccessMessage(translate('my_profile.password.changed'));
+        this.props.onClose();
+      }, this.handleError);
+    }
+  };
+
+  render() {
+    const { error, submitting, newPassword, confirmPassword } = this.state;
+
+    const header = translate('my_profile.password.title');
+    return (
+      <Modal contentLabel={header} onRequestClose={this.props.onClose}>
+        <form id="user-password-form" onSubmit={this.handleChangePassword} autoComplete="off">
+          <header className="modal-head">
+            <h2>{header}</h2>
+          </header>
+          <div className="modal-body">
+            {error && <p className="alert alert-danger">{error}</p>}
+            {this.props.isCurrentUser && (
+              <div className="modal-field">
+                <label htmlFor="old-user-password">
+                  {translate('my_profile.password.old')}
+                  <em className="mandatory">*</em>
+                </label>
+                {/* keep this fake field to hack browser autofill */}
+                <input name="old-password-fake" type="password" className="hidden" />
+                <input
+                  id="old-user-password"
+                  name="old-password"
+                  type="password"
+                  maxLength={50}
+                  onChange={this.handleOldPasswordChange}
+                  required={true}
+                  value={this.state.oldPassword}
+                />
+              </div>
+            )}
+            <div className="modal-field">
+              <label htmlFor="user-password">
+                {translate('my_profile.password.new')}
+                <em className="mandatory">*</em>
+              </label>
+              {/* keep this fake field to hack browser autofill */}
+              <input name="password-fake" type="password" className="hidden" />
+              <input
+                id="user-password"
+                name="password"
+                type="password"
+                maxLength={50}
+                onChange={this.handleNewPasswordChange}
+                required={true}
+                value={this.state.newPassword}
+              />
+            </div>
+            <div className="modal-field">
+              <label htmlFor="confirm-user-password">
+                {translate('my_profile.password.confirm')}
+                <em className="mandatory">*</em>
+              </label>
+              {/* keep this fake field to hack browser autofill */}
+              <input name="confirm-password-fake" type="password" className="hidden" />
+              <input
+                id="confirm-user-password"
+                name="confirm-password"
+                type="password"
+                maxLength={50}
+                onChange={this.handleConfirmPasswordChange}
+                required={true}
+                value={this.state.confirmPassword}
+              />
+            </div>
+          </div>
+          <footer className="modal-foot">
+            {submitting && <i className="spinner spacer-right" />}
+            <button
+              className="js-confirm"
+              disabled={submitting || !newPassword || newPassword !== confirmPassword}
+              type="submit">
+              {translate('change_verb')}
+            </button>
+            <a className="js-modal-close" href="#" onClick={this.handleCancelClick}>
+              {translate('cancel')}
+            </a>
+          </footer>
+        </form>
+      </Modal>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx b/server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx
new file mode 100644 (file)
index 0000000..4d723aa
--- /dev/null
@@ -0,0 +1,202 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import Modal from '../../../components/controls/Modal';
+import TokensFormItem from './TokensFormItem';
+import TokensFormNewToken from './TokensFormNewToken';
+import DeferredSpinner from '../../../components/common/DeferredSpinner';
+import { User } from '../../../api/users';
+import { getTokens, generateToken, UserToken } from '../../../api/user-tokens';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+  user: User;
+  onClose: () => void;
+  onUpdateUsers: () => void;
+}
+
+interface State {
+  generating: boolean;
+  hasChanged: boolean;
+  loading: boolean;
+  newToken?: { name: string; token: string };
+  newTokenName: string;
+  tokens: UserToken[];
+}
+
+export default class TokensForm extends React.PureComponent<Props, State> {
+  mounted: boolean;
+  state: State = {
+    generating: false,
+    hasChanged: false,
+    loading: true,
+    newTokenName: '',
+    tokens: []
+  };
+
+  componentDidMount() {
+    this.mounted = true;
+    this.fetchTokens();
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  fetchTokens = ({ user } = this.props) => {
+    this.setState({ loading: true });
+    getTokens(user.login).then(
+      tokens => {
+        if (this.mounted) {
+          this.setState({ loading: false, tokens });
+        }
+      },
+      () => {
+        if (this.mounted) {
+          this.setState({ loading: false });
+        }
+      }
+    );
+  };
+
+  handleCloseClick = (evt: React.SyntheticEvent<HTMLAnchorElement>) => {
+    evt.preventDefault();
+    this.handleClose();
+  };
+
+  handleClose = () => {
+    if (this.state.hasChanged) {
+      this.props.onUpdateUsers();
+    }
+    this.props.onClose();
+  };
+
+  handleGenerateToken = (evt: React.SyntheticEvent<HTMLFormElement>) => {
+    evt.preventDefault();
+    if (this.state.newTokenName.length > 0) {
+      this.setState({ generating: true });
+      generateToken({ name: this.state.newTokenName, login: this.props.user.login }).then(
+        newToken => {
+          if (this.mounted) {
+            this.fetchTokens();
+            this.setState({ generating: false, hasChanged: true, newToken, newTokenName: '' });
+          }
+        },
+        () => {
+          if (this.mounted) {
+            this.setState({ generating: false });
+          }
+        }
+      );
+    }
+  };
+
+  handleRevokeToken = () => {
+    this.setState({ hasChanged: true });
+    this.fetchTokens();
+  };
+
+  handleNewTokenChange = (evt: React.SyntheticEvent<HTMLInputElement>) =>
+    this.setState({ newTokenName: evt.currentTarget.value });
+
+  renderItems() {
+    const { tokens } = this.state;
+    if (tokens.length <= 0) {
+      return (
+        <tr>
+          <td colSpan={3} className="note">
+            {translate('users.no_tokens')}
+          </td>
+        </tr>
+      );
+    }
+    return tokens.map(token => (
+      <TokensFormItem
+        key={token.name}
+        token={token}
+        onRevokeToken={this.handleRevokeToken}
+        user={this.props.user}
+      />
+    ));
+  }
+
+  render() {
+    const { generating, loading, newToken, newTokenName, tokens } = this.state;
+    const header = translate('users.tokens');
+    const customSpinner = (
+      <tr>
+        <td>
+          <i className="spinner" />
+        </td>
+      </tr>
+    );
+    return (
+      <Modal contentLabel={header} onRequestClose={this.handleClose}>
+        <header className="modal-head">
+          <h2>{header}</h2>
+        </header>
+        <div className="modal-body modal-container">
+          <h3 className="spacer-bottom">{translate('users.generate_tokens')}</h3>
+          <form id="generate-token-form" onSubmit={this.handleGenerateToken} autoComplete="off">
+            <input
+              className="spacer-right"
+              type="text"
+              maxLength={100}
+              onChange={this.handleNewTokenChange}
+              placeholder={translate('users.enter_token_name')}
+              required={true}
+              value={newTokenName}
+            />
+            <button
+              className="js-generate-token"
+              disabled={generating || newTokenName.length <= 0}
+              type="submit">
+              {translate('users.generate')}
+            </button>
+          </form>
+
+          {newToken && <TokensFormNewToken token={newToken} />}
+
+          <table className="data zebra big-spacer-top ">
+            <thead>
+              <tr>
+                <th>{translate('name')}</th>
+                <th className="text-right">{translate('created')}</th>
+                <th />
+              </tr>
+            </thead>
+            <tbody>
+              <DeferredSpinner
+                customSpinner={customSpinner}
+                loading={loading && tokens.length <= 0}>
+                {this.renderItems()}
+              </DeferredSpinner>
+            </tbody>
+          </table>
+        </div>
+        <footer className="modal-foot">
+          <a className="js-modal-close" href="#" onClick={this.handleCloseClick}>
+            {translate('Done')}
+          </a>
+        </footer>
+      </Modal>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx b/server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx
new file mode 100644 (file)
index 0000000..f94a65c
--- /dev/null
@@ -0,0 +1,97 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import Tooltip from '../../../components/controls/Tooltip';
+import DateFormatter from '../../../components/intl/DateFormatter';
+import DeferredSpinner from '../../../components/common/DeferredSpinner';
+import { User } from '../../../api/users';
+import { revokeToken, UserToken } from '../../../api/user-tokens';
+import { limitComponentName } from '../../../helpers/path';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+  token: UserToken;
+  user: User;
+  onRevokeToken: () => void;
+}
+
+interface State {
+  deleting: boolean;
+  loading: boolean;
+}
+
+export default class TokensFormItem extends React.PureComponent<Props, State> {
+  mounted: boolean;
+  state: State = { deleting: false, loading: false };
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  handleRevoke = () => {
+    if (this.state.deleting) {
+      this.setState({ loading: true });
+      revokeToken({ login: this.props.user.login, name: this.props.token.name }).then(
+        this.props.onRevokeToken,
+        () => {
+          if (this.mounted) {
+            this.setState({ loading: false, deleting: false });
+          }
+        }
+      );
+    } else {
+      this.setState({ deleting: true });
+    }
+  };
+
+  render() {
+    const { token } = this.props;
+    const { loading } = this.state;
+    return (
+      <tr>
+        <td>
+          <Tooltip overlay={token.name}>
+            <span>{limitComponentName(token.name)}</span>
+          </Tooltip>
+        </td>
+        <td className="thin nowrap text-right">
+          <DateFormatter date={token.createdAt} long={true} />
+        </td>
+        <td className="thin nowrap text-right">
+          <DeferredSpinner loading={loading}>
+            <i className="spinner-placeholder " />
+          </DeferredSpinner>
+          <button
+            className="button-red input-small spacer-left"
+            onClick={this.handleRevoke}
+            disabled={loading}>
+            {this.state.deleting
+              ? translate('users.tokens.sure')
+              : translate('users.tokens.revoke')}
+          </button>
+        </td>
+      </tr>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/TokensFormNewToken.tsx b/server/sonar-web/src/main/js/apps/users/components/TokensFormNewToken.tsx
new file mode 100644 (file)
index 0000000..2c39611
--- /dev/null
@@ -0,0 +1,101 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import * as Clipboard from 'clipboard';
+import Tooltip from '../../../components/controls/Tooltip';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+
+interface Props {
+  token: { name: string; token: string };
+}
+
+interface State {
+  tooltipShown: boolean;
+}
+
+export default class TokensFormNewToken extends React.PureComponent<Props, State> {
+  clipboard: Clipboard;
+  copyButton: HTMLButtonElement | null;
+  mounted: boolean;
+  state: State = { tooltipShown: false };
+
+  componentDidMount() {
+    this.mounted = true;
+    if (this.copyButton) {
+      this.clipboard = new Clipboard(this.copyButton);
+      this.clipboard.on('success', this.showTooltip);
+    }
+  }
+
+  componentDidUpdate() {
+    this.clipboard.destroy();
+    if (this.copyButton) {
+      this.clipboard = new Clipboard(this.copyButton);
+      this.clipboard.on('success', this.showTooltip);
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+    this.clipboard.destroy();
+  }
+
+  showTooltip = () => {
+    if (this.mounted) {
+      this.setState({ tooltipShown: true });
+      setTimeout(() => {
+        if (this.mounted) {
+          this.setState({ tooltipShown: false });
+        }
+      }, 1000);
+    }
+  };
+
+  render() {
+    const { name, token } = this.props.token;
+    const button = (
+      <button
+        className="js-copy-to-clipboard no-select"
+        data-clipboard-text={token}
+        ref={node => (this.copyButton = node)}>
+        {translate('copy')}
+      </button>
+    );
+    return (
+      <div className="panel panel-white big-spacer-top">
+        <p className="alert alert-warning">
+          {translateWithParameters('users.tokens.new_token_created', name)}
+        </p>
+        {this.state.tooltipShown ? (
+          <Tooltip
+            defaultVisible={true}
+            placement="bottom"
+            overlay={translate('users.tokens.copied')}
+            trigger="manual">
+            {button}
+          </Tooltip>
+        ) : (
+          button
+        )}
+        <code className="big-spacer-left text-success">{token}</code>
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx b/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx
new file mode 100644 (file)
index 0000000..9c5f64f
--- /dev/null
@@ -0,0 +1,113 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import ActionsDropdown, {
+  ActionsDropdownItem,
+  ActionsDropdownDivider
+} from '../../../components/controls/ActionsDropdown';
+import DeactivateForm from './DeactivateForm';
+import PasswordForm from './PasswordForm';
+import UserForm from './UserForm';
+import { User } from '../../../api/users';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+  isCurrentUser: boolean;
+  onUpdateUsers: () => void;
+  user: User;
+}
+
+interface State {
+  openForm?: string;
+}
+
+export default class UserActions extends React.PureComponent<Props, State> {
+  state: State = {};
+
+  handleOpenDeactivateForm = () => this.setState({ openForm: 'deactivate' });
+  handleOpenPasswordForm = () => this.setState({ openForm: 'password' });
+  handleOpenUpdateForm = () => this.setState({ openForm: 'update' });
+  handleCloseForm = () => this.setState({ openForm: undefined });
+
+  renderActions = () => {
+    const { user } = this.props;
+    return (
+      <ActionsDropdown key="actions" menuClassName="dropdown-menu-right">
+        <ActionsDropdownItem className="js-user-update" onClick={this.handleOpenUpdateForm}>
+          {translate('update_details')}
+        </ActionsDropdownItem>
+        {user.local && (
+          <ActionsDropdownItem
+            className="js-user-change-password"
+            onClick={this.handleOpenPasswordForm}>
+            {translate('my_profile.password.title')}
+          </ActionsDropdownItem>
+        )}
+        <ActionsDropdownDivider />
+        <ActionsDropdownItem
+          className="js-user-deactivate"
+          destructive={true}
+          onClick={this.handleOpenDeactivateForm}>
+          {translate('users.deactivate')}
+        </ActionsDropdownItem>
+      </ActionsDropdown>
+    );
+  };
+
+  render() {
+    const { openForm } = this.state;
+    const { isCurrentUser, onUpdateUsers, user } = this.props;
+
+    if (openForm === 'deactivate') {
+      return [
+        this.renderActions(),
+        <DeactivateForm
+          key="form"
+          onClose={this.handleCloseForm}
+          onUpdateUsers={onUpdateUsers}
+          user={user}
+        />
+      ];
+    }
+    if (openForm === 'password') {
+      return [
+        this.renderActions(),
+        <PasswordForm
+          isCurrentUser={isCurrentUser}
+          key="form"
+          onClose={this.handleCloseForm}
+          user={user}
+        />
+      ];
+    }
+    if (openForm === 'update') {
+      return [
+        this.renderActions(),
+        <UserForm
+          key="form"
+          onClose={this.handleCloseForm}
+          onUpdateUsers={onUpdateUsers}
+          user={user}
+        />
+      ];
+    }
+    return this.renderActions();
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx b/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx
new file mode 100644 (file)
index 0000000..292d13f
--- /dev/null
@@ -0,0 +1,270 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { uniq } from 'lodash';
+import Modal from '../../../components/controls/Modal';
+import UserScmAccountInput from './UserScmAccountInput';
+import throwGlobalError from '../../../app/utils/throwGlobalError';
+import { parseError } from '../../../helpers/request';
+import { createUser, updateUser, User } from '../../../api/users';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+
+export interface Props {
+  user?: User;
+  onClose: () => void;
+  onUpdateUsers: () => void;
+}
+
+interface State {
+  email: string;
+  error?: string;
+  login: string;
+  name: string;
+  password: string;
+  scmAccounts: string[];
+  submitting: boolean;
+}
+
+export default class UserForm extends React.PureComponent<Props, State> {
+  mounted: boolean;
+
+  constructor(props: Props) {
+    super(props);
+    const { user } = props;
+    if (user) {
+      this.state = {
+        email: user.email || '',
+        login: user.login,
+        name: user.name,
+        password: '',
+        scmAccounts: user.scmAccounts,
+        submitting: false
+      };
+    } else {
+      this.state = {
+        email: '',
+        login: '',
+        name: '',
+        password: '',
+        scmAccounts: [],
+        submitting: false
+      };
+    }
+  }
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  handleError = (error: { response: Response }) => {
+    if (!this.mounted || ![400, 500].includes(error.response.status)) {
+      return throwGlobalError(error);
+    } else {
+      return parseError(error).then(
+        errorMsg => this.setState({ error: errorMsg, submitting: false }),
+        throwGlobalError
+      );
+    }
+  };
+
+  handleEmailChange = (event: React.SyntheticEvent<HTMLInputElement>) =>
+    this.setState({ email: event.currentTarget.value });
+  handleLoginChange = (event: React.SyntheticEvent<HTMLInputElement>) =>
+    this.setState({ login: event.currentTarget.value });
+  handleNameChange = (event: React.SyntheticEvent<HTMLInputElement>) =>
+    this.setState({ name: event.currentTarget.value });
+  handlePasswordChange = (event: React.SyntheticEvent<HTMLInputElement>) =>
+    this.setState({ password: event.currentTarget.value });
+
+  handleCancelClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+    event.preventDefault();
+    this.props.onClose();
+  };
+
+  handleCreateUser = (event: React.SyntheticEvent<HTMLFormElement>) => {
+    event.preventDefault();
+    this.setState({ submitting: true });
+    createUser({
+      email: this.state.email || undefined,
+      login: this.state.login,
+      name: this.state.name,
+      password: this.state.password,
+      scmAccount: uniq(this.state.scmAccounts)
+    }).then(() => {
+      this.props.onUpdateUsers();
+      this.props.onClose();
+    }, this.handleError);
+  };
+
+  handleUpdateUser = (event: React.SyntheticEvent<HTMLFormElement>) => {
+    event.preventDefault();
+    this.setState({ submitting: true });
+    updateUser({
+      email: this.state.email || undefined,
+      login: this.state.login,
+      name: this.state.name,
+      scmAccount: uniq(this.state.scmAccounts)
+    }).then(() => {
+      this.props.onUpdateUsers();
+      this.props.onClose();
+    }, this.handleError);
+  };
+
+  handleAddScmAccount = (evt: React.SyntheticEvent<HTMLButtonElement>) => {
+    evt.preventDefault();
+    this.setState(({ scmAccounts }) => ({ scmAccounts: scmAccounts.concat('') }));
+  };
+
+  handleUpdateScmAccount = (idx: number, scmAccount: string) =>
+    this.setState(({ scmAccounts: oldScmAccounts }) => {
+      const scmAccounts = oldScmAccounts.slice();
+      scmAccounts[idx] = scmAccount;
+      return { scmAccounts };
+    });
+
+  handleRemoveScmAccount = (idx: number) =>
+    this.setState(({ scmAccounts }) => ({
+      scmAccounts: scmAccounts.slice(0, idx).concat(scmAccounts.slice(idx + 1))
+    }));
+
+  render() {
+    const { user } = this.props;
+    const { error, submitting } = this.state;
+
+    const header = user ? translate('users.update_user') : translate('users.create_user');
+    return (
+      <Modal contentLabel={header} onRequestClose={this.props.onClose}>
+        <form
+          id="user-form"
+          onSubmit={this.props.user ? this.handleUpdateUser : this.handleCreateUser}
+          autoComplete="off">
+          <header className="modal-head">
+            <h2>{header}</h2>
+          </header>
+
+          <div className="modal-body">
+            {error && <p className="alert alert-danger">{error}</p>}
+
+            {!user && (
+              <div className="modal-field">
+                <label htmlFor="create-user-login">
+                  {translate('login')}
+                  <em className="mandatory">*</em>
+                </label>
+                {/* keep this fake field to hack browser autofill */}
+                <input name="login-fake" type="text" className="hidden" />
+                <input
+                  id="create-user-login"
+                  name="login"
+                  type="text"
+                  minLength={3}
+                  maxLength={255}
+                  onChange={this.handleLoginChange}
+                  required={true}
+                  value={this.state.login}
+                />
+                <p className="note">{translateWithParameters('users.minimum_x_characters', 3)}</p>
+              </div>
+            )}
+            <div className="modal-field">
+              <label htmlFor="create-user-name">
+                {translate('name')}
+                <em className="mandatory">*</em>
+              </label>
+              {/* keep this fake field to hack browser autofill */}
+              <input name="name-fake" type="text" className="hidden" />
+              <input
+                id="create-user-name"
+                name="name"
+                type="text"
+                maxLength={200}
+                onChange={this.handleNameChange}
+                required={true}
+                value={this.state.name}
+              />
+            </div>
+            <div className="modal-field">
+              <label htmlFor="create-user-email">{translate('users.email')}</label>
+              {/* keep this fake field to hack browser autofill */}
+              <input name="email-fake" type="email" className="hidden" />
+              <input
+                id="create-user-email"
+                name="email"
+                type="email"
+                maxLength={100}
+                onChange={this.handleEmailChange}
+                value={this.state.email}
+              />
+            </div>
+            {!user && (
+              <div className="modal-field">
+                <label htmlFor="create-user-password">
+                  {translate('password')}
+                  <em className="mandatory">*</em>
+                </label>
+                {/* keep this fake field to hack browser autofill */}
+                <input name="password-fake" type="password" className="hidden" />
+                <input
+                  id="create-user-password"
+                  name="password"
+                  type="password"
+                  maxLength={50}
+                  onChange={this.handlePasswordChange}
+                  required={true}
+                  value={this.state.password}
+                />
+              </div>
+            )}
+            <div className="modal-field">
+              <label>{translate('my_profile.scm_accounts')}</label>
+              {this.state.scmAccounts.map((scm, idx) => (
+                <UserScmAccountInput
+                  idx={idx}
+                  key={idx}
+                  onChange={this.handleUpdateScmAccount}
+                  onRemove={this.handleRemoveScmAccount}
+                  scmAccount={scm}
+                />
+              ))}
+              <div className="spacer-bottom">
+                <button onClick={this.handleAddScmAccount}>{translate('add_verb')}</button>
+              </div>
+              <p className="note">{translate('user.login_or_email_used_as_scm_account')}</p>
+            </div>
+          </div>
+
+          <footer className="modal-foot">
+            {submitting && <i className="spinner spacer-right" />}
+            <button className="js-confirm" disabled={submitting} type="submit">
+              {user ? translate('update_verb') : translate('create')}
+            </button>
+            <a className="js-modal-close" href="#" onClick={this.handleCancelClick}>
+              {translate('cancel')}
+            </a>
+          </footer>
+        </form>
+      </Modal>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/UserGroups.tsx b/server/sonar-web/src/main/js/apps/users/components/UserGroups.tsx
new file mode 100644 (file)
index 0000000..ee00341
--- /dev/null
@@ -0,0 +1,95 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import BulletListIcon from '../../../components/icons-components/BulletListIcon';
+import GroupsForm from './GroupsForm';
+import { User } from '../../../api/users';
+import { ButtonIcon } from '../../../components/ui/buttons';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+
+interface Props {
+  groups: string[];
+  onUpdateUsers: () => void;
+  user: User;
+}
+
+interface State {
+  openForm: boolean;
+  showMore: boolean;
+}
+
+const GROUPS_LIMIT = 3;
+
+export default class UserGroups extends React.PureComponent<Props, State> {
+  state: State = { openForm: false, showMore: false };
+
+  handleOpenForm = () => this.setState({ openForm: true });
+  handleCloseForm = () => this.setState({ openForm: false });
+
+  toggleShowMore = (evt: React.SyntheticEvent<HTMLAnchorElement>) => {
+    evt.preventDefault();
+    this.setState(state => ({ showMore: !state.showMore }));
+  };
+
+  render() {
+    const { groups } = this.props;
+    const limit = groups.length > GROUPS_LIMIT ? GROUPS_LIMIT - 1 : GROUPS_LIMIT;
+    return (
+      <ul>
+        {groups.slice(0, limit).map(group => (
+          <li key={group} className="little-spacer-bottom">
+            {group}
+          </li>
+        ))}
+        {groups.length > GROUPS_LIMIT &&
+          this.state.showMore &&
+          groups.slice(limit).map(group => (
+            <li key={group} className="little-spacer-bottom">
+              {group}
+            </li>
+          ))}
+        <li className="little-spacer-bottom">
+          {groups.length > GROUPS_LIMIT &&
+            !this.state.showMore && (
+              <a
+                className="js-user-more-groups spacer-right"
+                href="#"
+                onClick={this.toggleShowMore}>
+                {translateWithParameters('more_x', groups.length - limit)}
+              </a>
+            )}
+          <ButtonIcon
+            className="js-user-groups button-small"
+            onClick={this.handleOpenForm}
+            tooltip={translate('users.update_groups')}>
+            <BulletListIcon />
+          </ButtonIcon>
+        </li>
+        {this.state.openForm && (
+          <GroupsForm
+            onClose={this.handleCloseForm}
+            onUpdateUsers={this.props.onUpdateUsers}
+            user={this.props.user}
+          />
+        )}
+      </ul>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx b/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx
new file mode 100644 (file)
index 0000000..f6ebe46
--- /dev/null
@@ -0,0 +1,93 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import Avatar from '../../../components/ui/Avatar';
+import BulletListIcon from '../../../components/icons-components/BulletListIcon';
+import { ButtonIcon } from '../../../components/ui/buttons';
+import TokensForm from './TokensForm';
+import UserActions from './UserActions';
+import UserGroups from './UserGroups';
+import UserListItemIdentity from './UserListItemIdentity';
+import UserScmAccounts from './UserScmAccounts';
+import { IdentityProvider, User } from '../../../api/users';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+  identityProvider?: IdentityProvider;
+  isCurrentUser: boolean;
+  onUpdateUsers: () => void;
+  organizationsEnabled: boolean;
+  user: User;
+}
+
+interface State {
+  openTokenForm: boolean;
+}
+
+export default class UserListItem extends React.PureComponent<Props, State> {
+  state: State = { openTokenForm: false };
+
+  handleOpenTokensForm = () => this.setState({ openTokenForm: true });
+  handleCloseTokensForm = () => this.setState({ openTokenForm: false });
+
+  render() {
+    const { identityProvider, onUpdateUsers, organizationsEnabled, user } = this.props;
+
+    return (
+      <tr>
+        <td className="thin nowrap">
+          <Avatar hash={user.avatar} name={user.name} size={36} />
+        </td>
+        <UserListItemIdentity identityProvider={identityProvider} user={user} />
+        <td>
+          <UserScmAccounts scmAccounts={user.scmAccounts || []} />
+        </td>
+        {!organizationsEnabled && (
+          <td>
+            <UserGroups groups={user.groups || []} user={user} onUpdateUsers={onUpdateUsers} />
+          </td>
+        )}
+        <td>
+          {user.tokensCount}
+          <ButtonIcon
+            className="js-user-tokens spacer-left button-small"
+            onClick={this.handleOpenTokensForm}
+            tooltip={translate('users.update_tokens')}>
+            <BulletListIcon />
+          </ButtonIcon>
+        </td>
+        <td className="thin nowrap text-right">
+          <UserActions
+            isCurrentUser={this.props.isCurrentUser}
+            onUpdateUsers={onUpdateUsers}
+            user={user}
+          />
+        </td>
+        {this.state.openTokenForm && (
+          <TokensForm
+            user={user}
+            onClose={this.handleCloseTokensForm}
+            onUpdateUsers={onUpdateUsers}
+          />
+        )}
+      </tr>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx b/server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx
new file mode 100644 (file)
index 0000000..f3bcc9a
--- /dev/null
@@ -0,0 +1,71 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { IdentityProvider, User } from '../../../api/users';
+import { getBaseUrl } from '../../../helpers/urls';
+
+interface Props {
+  identityProvider?: IdentityProvider;
+  user: User;
+}
+
+export default function UserListItemIdentity({ identityProvider, user }: Props) {
+  return (
+    <td>
+      <div>
+        <strong className="js-user-name">{user.name}</strong>
+        <span className="js-user-login note little-spacer-left">{user.login}</span>
+      </div>
+      {user.email && <div className="js-user-email little-spacer-top">{user.email}</div>}
+      {!user.local &&
+        user.externalProvider !== 'sonarqube' && (
+          <ExternalProvider identityProvider={identityProvider} user={user} />
+        )}
+    </td>
+  );
+}
+
+export function ExternalProvider({ identityProvider, user }: Props) {
+  if (!identityProvider) {
+    return (
+      <div className="js-user-identity-provider little-spacer-top">
+        <span>
+          {user.externalProvider}: {user.externalIdentity}
+        </span>
+      </div>
+    );
+  }
+
+  return (
+    <div className="js-user-identity-provider little-spacer-top">
+      <div
+        className="identity-provider"
+        style={{ 'background-color': identityProvider.backgroundColor }}>
+        <img
+          alt={identityProvider.name}
+          src={getBaseUrl() + identityProvider.iconPath}
+          width="14"
+          height="14"
+        />
+        {user.externalIdentity}
+      </div>
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/UserScmAccountInput.tsx b/server/sonar-web/src/main/js/apps/users/components/UserScmAccountInput.tsx
new file mode 100644 (file)
index 0000000..55a54a7
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { DeleteButton } from '../../../components/ui/buttons';
+
+export interface Props {
+  idx: number;
+  scmAccount: string;
+  onChange: (idx: number, scmAccount: string) => void;
+  onRemove: (idx: number) => void;
+}
+
+export default class UserScmAccountInput extends React.PureComponent<Props> {
+  handleChange = (event: React.SyntheticEvent<HTMLInputElement>) =>
+    this.props.onChange(this.props.idx, event.currentTarget.value);
+  handleRemove = () => this.props.onRemove(this.props.idx);
+
+  render() {
+    return (
+      <div>
+        <input
+          maxLength={255}
+          onChange={this.handleChange}
+          type="text"
+          value={this.props.scmAccount}
+        />
+        <DeleteButton onClick={this.handleRemove} />
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/UserScmAccounts.tsx b/server/sonar-web/src/main/js/apps/users/components/UserScmAccounts.tsx
new file mode 100644 (file)
index 0000000..a5c4334
--- /dev/null
@@ -0,0 +1,68 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { translateWithParameters } from '../../../helpers/l10n';
+
+interface Props {
+  scmAccounts: string[];
+}
+
+interface State {
+  showMore: boolean;
+}
+
+const SCM_LIMIT = 3;
+
+export default class UserScmAccounts extends React.PureComponent<Props, State> {
+  state: State = { showMore: false };
+
+  toggleShowMore = (evt: React.SyntheticEvent<HTMLAnchorElement>) => {
+    evt.preventDefault();
+    this.setState(state => ({ showMore: !state.showMore }));
+  };
+
+  render() {
+    const { scmAccounts } = this.props;
+    const limit = scmAccounts.length > SCM_LIMIT ? SCM_LIMIT - 1 : SCM_LIMIT;
+    return (
+      <ul>
+        {scmAccounts.slice(0, limit).map((scmAccount, idx) => (
+          <li key={idx} className="little-spacer-bottom">
+            {scmAccount}
+          </li>
+        ))}
+        {scmAccounts.length > SCM_LIMIT &&
+          (this.state.showMore ? (
+            scmAccounts.slice(limit).map((scmAccount, idx) => (
+              <li key={idx + limit} className="little-spacer-bottom">
+                {scmAccount}
+              </li>
+            ))
+          ) : (
+            <li className="little-spacer-bottom">
+              <a className="js-user-more-scm" href="#" onClick={this.toggleShowMore}>
+                {translateWithParameters('more_x', scmAccounts.length - limit)}
+              </a>
+            </li>
+          ))}
+      </ul>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersAppContainer.js b/server/sonar-web/src/main/js/apps/users/components/UsersAppContainer.js
deleted file mode 100644 (file)
index 3e6bbd2..0000000
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import React from 'react';
-import PropTypes from 'prop-types';
-import Helmet from 'react-helmet';
-import { connect } from 'react-redux';
-import init from '../init';
-import { getCurrentUser } from '../../../store/rootReducer';
-import { translate } from '../../../helpers/l10n';
-// import styles to have the `.button-icon` styles
-import '../../../components/ui/buttons.css';
-import '../../../components/controls/SearchBox.css';
-
-class UsersAppContainer extends React.PureComponent {
-  static propTypes = {
-    currentUser: PropTypes.object.isRequired
-  };
-
-  componentDidMount() {
-    init(this.refs.container, this.props.currentUser);
-  }
-
-  render() {
-    return (
-      <div>
-        <Helmet title={translate('users.page')} />
-        <div ref="container" />
-      </div>
-    );
-  }
-}
-
-const mapStateToProps = state => ({
-  currentUser: getCurrentUser(state)
-});
-
-export default connect(mapStateToProps)(UsersAppContainer);
diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersAppContainerOld.js b/server/sonar-web/src/main/js/apps/users/components/UsersAppContainerOld.js
new file mode 100644 (file)
index 0000000..3e6bbd2
--- /dev/null
@@ -0,0 +1,54 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import React from 'react';
+import PropTypes from 'prop-types';
+import Helmet from 'react-helmet';
+import { connect } from 'react-redux';
+import init from '../init';
+import { getCurrentUser } from '../../../store/rootReducer';
+import { translate } from '../../../helpers/l10n';
+// import styles to have the `.button-icon` styles
+import '../../../components/ui/buttons.css';
+import '../../../components/controls/SearchBox.css';
+
+class UsersAppContainer extends React.PureComponent {
+  static propTypes = {
+    currentUser: PropTypes.object.isRequired
+  };
+
+  componentDidMount() {
+    init(this.refs.container, this.props.currentUser);
+  }
+
+  render() {
+    return (
+      <div>
+        <Helmet title={translate('users.page')} />
+        <div ref="container" />
+      </div>
+    );
+  }
+}
+
+const mapStateToProps = state => ({
+  currentUser: getCurrentUser(state)
+});
+
+export default connect(mapStateToProps)(UsersAppContainer);
diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.js b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.js
deleted file mode 100644 (file)
index b22877a..0000000
+++ /dev/null
@@ -1,131 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-//@flow
-import React from 'react';
-import { debounce } from 'lodash';
-import Select from '../../../components/controls/Select';
-import { translate, translateWithParameters } from '../../../helpers/l10n';
-import UsersSelectSearchOption from './UsersSelectSearchOption';
-import UsersSelectSearchValue from './UsersSelectSearchValue';
-
-/*::
-export type Option = {
-  login: string,
-  name: string,
-  email?: string,
-  avatar?: string,
-  groupCount?: number
-};
-*/
-
-/*::
-type Props = {
-  autoFocus?: boolean,
-  excludedUsers: Array<string>,
-  handleValueChange: Option => void,
-  searchUsers: (string, number) => Promise<*>,
-  selectedUser?: Option
-};
-*/
-
-/*::
-type State = {
-  isLoading: boolean,
-  search: string,
-  searchResult: Array<Option>
-};
-*/
-
-const LIST_SIZE = 10;
-
-export default class UsersSelectSearch extends React.PureComponent {
-  /*:: mounted: boolean; */
-  /*:: props: Props; */
-  /*:: state: State; */
-
-  constructor(props /*: Props */) {
-    super(props);
-    this.handleSearch = debounce(this.handleSearch, 250);
-    this.state = { searchResult: [], isLoading: false, search: '' };
-  }
-  componentDidMount() {
-    this.mounted = true;
-    this.handleSearch(this.state.search);
-  }
-
-  componentWillReceiveProps(nextProps /*: Props */) {
-    if (this.props.excludedUsers !== nextProps.excludedUsers) {
-      this.handleSearch(this.state.search);
-    }
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  filterSearchResult = ({ users } /*: { users: Array<Option> } */) =>
-    users.filter(user => !this.props.excludedUsers.includes(user.login)).slice(0, LIST_SIZE);
-
-  handleSearch = (search /*: string */) => {
-    this.props
-      .searchUsers(search, Math.min(this.props.excludedUsers.length + LIST_SIZE, 500))
-      .then(this.filterSearchResult)
-      .then(searchResult => {
-        if (this.mounted) {
-          this.setState({ isLoading: false, searchResult });
-        }
-      });
-  };
-
-  handleInputChange = (search /*: string */) => {
-    if (search == null || search.length === 1) {
-      this.setState({ search });
-    } else {
-      this.setState({ isLoading: true, search });
-      this.handleSearch(search);
-    }
-  };
-
-  render() {
-    const noResult =
-      this.state.search.length === 1
-        ? translateWithParameters('select2.tooShort', 2)
-        : translate('no_results');
-    return (
-      <Select
-        autofocus={this.props.autoFocus}
-        className="Select-big"
-        options={this.state.searchResult}
-        isLoading={this.state.isLoading}
-        optionComponent={UsersSelectSearchOption}
-        valueComponent={UsersSelectSearchValue}
-        onChange={this.props.handleValueChange}
-        onInputChange={this.handleInputChange}
-        value={this.props.selectedUser}
-        placeholder=""
-        noResultsText={noResult}
-        labelKey="name"
-        valueKey="login"
-        clearable={false}
-        searchable={true}
-      />
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.tsx b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.tsx
new file mode 100644 (file)
index 0000000..bbcffa1
--- /dev/null
@@ -0,0 +1,185 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { debounce } from 'lodash';
+import Avatar from '../../../components/ui/Avatar';
+import Select from '../../../components/controls/Select';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+
+interface Option {
+  login: string;
+  name: string;
+  email?: string;
+  avatar?: string;
+}
+
+interface Props {
+  autoFocus?: boolean;
+  excludedUsers: string[];
+  handleValueChange: (option: Option) => void;
+  searchUsers: (query: string, ps: number) => Promise<{ users: Option[] }>;
+  selectedUser?: Option;
+}
+
+interface State {
+  isLoading: boolean;
+  search: string;
+  searchResult: Option[];
+}
+
+const LIST_SIZE = 10;
+const AVATAR_SIZE = 16;
+
+export default class UsersSelectSearch extends React.PureComponent<Props, State> {
+  mounted: boolean;
+
+  constructor(props: Props) {
+    super(props);
+    this.handleSearch = debounce(this.handleSearch, 250);
+    this.state = { searchResult: [], isLoading: false, search: '' };
+  }
+  componentDidMount() {
+    this.mounted = true;
+    this.handleSearch(this.state.search);
+  }
+
+  componentWillReceiveProps(nextProps: Props) {
+    if (this.props.excludedUsers !== nextProps.excludedUsers) {
+      this.handleSearch(this.state.search);
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  filterSearchResult = ({ users }: { users: Option[] }) =>
+    users.filter(user => !this.props.excludedUsers.includes(user.login)).slice(0, LIST_SIZE);
+
+  handleSearch = (search: string) => {
+    this.props
+      .searchUsers(search, Math.min(this.props.excludedUsers.length + LIST_SIZE, 500))
+      .then(this.filterSearchResult)
+      .then(searchResult => {
+        if (this.mounted) {
+          this.setState({ isLoading: false, searchResult });
+        }
+      });
+  };
+
+  handleInputChange = (search: string) => {
+    if (search == null || search.length === 1) {
+      this.setState({ search });
+    } else {
+      this.setState({ isLoading: true, search });
+      this.handleSearch(search);
+    }
+  };
+
+  render() {
+    const noResult =
+      this.state.search.length === 1
+        ? translateWithParameters('select2.tooShort', 2)
+        : translate('no_results');
+    return (
+      <Select
+        autofocus={this.props.autoFocus}
+        className="Select-big"
+        options={this.state.searchResult}
+        isLoading={this.state.isLoading}
+        optionComponent={UsersSelectSearchOption}
+        valueComponent={UsersSelectSearchValue}
+        onChange={this.props.handleValueChange}
+        onInputChange={this.handleInputChange}
+        value={this.props.selectedUser}
+        placeholder=""
+        noResultsText={noResult}
+        labelKey="name"
+        valueKey="login"
+        clearable={false}
+        searchable={true}
+      />
+    );
+  }
+}
+
+interface OptionProps {
+  children?: React.ReactNode;
+  className?: string;
+  isFocused?: boolean;
+  onFocus: (option: Option, evt: React.MouseEvent<HTMLDivElement>) => void;
+  onSelect: (option: Option, evt: React.MouseEvent<HTMLDivElement>) => void;
+  option: Option;
+}
+
+export class UsersSelectSearchOption extends React.PureComponent<OptionProps> {
+  handleMouseDown = (evt: React.MouseEvent<HTMLDivElement>) => {
+    evt.preventDefault();
+    evt.stopPropagation();
+    this.props.onSelect(this.props.option, evt);
+  };
+
+  handleMouseEnter = (evt: React.MouseEvent<HTMLDivElement>) => {
+    this.props.onFocus(this.props.option, evt);
+  };
+
+  handleMouseMove = (evt: React.MouseEvent<HTMLDivElement>) => {
+    if (this.props.isFocused) {
+      return;
+    }
+    this.props.onFocus(this.props.option, evt);
+  };
+
+  render() {
+    const { option } = this.props;
+    return (
+      <div
+        className={this.props.className}
+        onMouseDown={this.handleMouseDown}
+        onMouseEnter={this.handleMouseEnter}
+        onMouseMove={this.handleMouseMove}
+        title={option.name}>
+        <Avatar hash={option.avatar} name={option.name} size={AVATAR_SIZE} />
+        <strong className="spacer-left">{this.props.children}</strong>
+        <span className="note little-spacer-left">{option.login}</span>
+      </div>
+    );
+  }
+}
+
+interface ValueProps {
+  value?: Option;
+  children?: React.ReactNode;
+}
+
+export function UsersSelectSearchValue({ children, value }: ValueProps) {
+  return (
+    <div className="Select-value" title={value ? value.name : ''}>
+      {value &&
+        value.login && (
+          <div className="Select-value-label">
+            <Avatar hash={value.avatar} name={value.name} size={AVATAR_SIZE} />
+            <strong className="spacer-left">{children}</strong>
+            <span className="note little-spacer-left">{value.login}</span>
+          </div>
+        )}
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js
deleted file mode 100644 (file)
index 3fecdcc..0000000
+++ /dev/null
@@ -1,73 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-//@flow
-import React from 'react';
-import Avatar from '../../../components/ui/Avatar';
-/*:: import type { Option } from './UsersSelectSearch'; */
-
-/*::
-type Props = {
-  option: Option,
-  children?: Element | Text,
-  className?: string,
-  isFocused?: boolean,
-  onFocus: (Option, MouseEvent) => void,
-  onSelect: (Option, MouseEvent) => void
-};
-*/
-
-const AVATAR_SIZE /*: number */ = 16;
-
-export default class UsersSelectSearchOption extends React.PureComponent {
-  /*:: props: Props; */
-
-  handleMouseDown = (event /*: MouseEvent */) => {
-    event.preventDefault();
-    event.stopPropagation();
-    this.props.onSelect(this.props.option, event);
-  };
-
-  handleMouseEnter = (event /*: MouseEvent */) => {
-    this.props.onFocus(this.props.option, event);
-  };
-
-  handleMouseMove = (event /*: MouseEvent */) => {
-    if (this.props.isFocused) {
-      return;
-    }
-    this.props.onFocus(this.props.option, event);
-  };
-
-  render() {
-    const user = this.props.option;
-    return (
-      <div
-        className={this.props.className}
-        onMouseDown={this.handleMouseDown}
-        onMouseEnter={this.handleMouseEnter}
-        onMouseMove={this.handleMouseMove}
-        title={user.name}>
-        <Avatar hash={user.avatar} name={user.name} size={AVATAR_SIZE} />
-        <strong className="spacer-left">{this.props.children}</strong>
-        <span className="note little-spacer-left">{user.login}</span>
-      </div>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js
deleted file mode 100644 (file)
index 347d935..0000000
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-//@flow
-import React from 'react';
-import Avatar from '../../../components/ui/Avatar';
-/*:: import type { Option } from './UsersSelectSearch'; */
-
-/*::
-type Props = {
-  value: Option,
-  children?: Element | Text
-};
-*/
-
-const AVATAR_SIZE /*: number */ = 16;
-
-export default class UsersSelectSearchValue extends React.PureComponent {
-  /*:: props: Props; */
-
-  render() {
-    const user = this.props.value;
-    return (
-      <div className="Select-value" title={user ? user.name : ''}>
-        {user &&
-          user.login && (
-            <div className="Select-value-label">
-              <Avatar hash={user.avatar} name={user.name} size={AVATAR_SIZE} />
-              <strong className="spacer-left">{this.props.children}</strong>
-              <span className="note little-spacer-left">{user.login}</span>
-            </div>
-          )}
-      </div>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.js b/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.js
deleted file mode 100644 (file)
index cfc0481..0000000
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import React from 'react';
-import { shallow } from 'enzyme';
-import UsersSelectSearch from '../UsersSelectSearch';
-
-const selectedUser = {
-  login: 'admin',
-  name: 'Administrator',
-  avatar: '7daf6c79d4802916d83f6266e24850af'
-};
-const users = [
-  { login: 'admin', name: 'Administrator', email: 'admin@admin.ch' },
-  { login: 'test', name: 'Tester', email: 'tester@testing.ch' },
-  { login: 'foo', name: 'Foo Bar', email: 'foo@bar.ch' }
-];
-const excludedUsers = ['admin'];
-const onSearch = jest.fn(() => {
-  return Promise.resolve(users);
-});
-const onChange = jest.fn();
-
-it('should render correctly', () => {
-  const wrapper = shallow(
-    <UsersSelectSearch
-      selectedUser={selectedUser}
-      excludedUsers={excludedUsers}
-      isLoading={false}
-      handleValueChange={onChange}
-      searchUsers={onSearch}
-    />
-  );
-  expect(wrapper).toMatchSnapshot();
-  const searchResult = wrapper.instance().filterSearchResult({ users });
-  expect(searchResult).toMatchSnapshot();
-  expect(wrapper.setState({ searchResult })).toMatchSnapshot();
-});
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.tsx b/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.tsx
new file mode 100644 (file)
index 0000000..f69e831
--- /dev/null
@@ -0,0 +1,96 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import UsersSelectSearch, {
+  UsersSelectSearchOption,
+  UsersSelectSearchValue
+} from '../UsersSelectSearch';
+
+const selectedUser = {
+  login: 'admin',
+  name: 'Administrator',
+  avatar: '7daf6c79d4802916d83f6266e24850af'
+};
+const users = [
+  { login: 'admin', name: 'Administrator', email: 'admin@admin.ch' },
+  { login: 'test', name: 'Tester', email: 'tester@testing.ch' },
+  { login: 'foo', name: 'Foo Bar', email: 'foo@bar.ch' }
+];
+const excludedUsers = ['admin'];
+
+describe('UsersSelectSearch', () => {
+  it('should render correctly', () => {
+    const onSearch = jest.fn(() => Promise.resolve(users));
+    const wrapper = shallow(
+      <UsersSelectSearch
+        selectedUser={selectedUser}
+        excludedUsers={excludedUsers}
+        handleValueChange={jest.fn()}
+        searchUsers={onSearch}
+      />
+    );
+    expect(wrapper).toMatchSnapshot();
+    const searchResult = (wrapper.instance() as UsersSelectSearch).filterSearchResult({ users });
+    expect(searchResult).toMatchSnapshot();
+    expect(wrapper.setState({ searchResult })).toMatchSnapshot();
+  });
+});
+
+describe('UsersSelectSearchOption', () => {
+  it('should render correctly without all parameters', () => {
+    const wrapper = shallow(
+      <UsersSelectSearchOption option={selectedUser} onFocus={jest.fn()} onSelect={jest.fn()}>
+        {selectedUser.name}
+      </UsersSelectSearchOption>
+    );
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render correctly with email instead of hash', () => {
+    const wrapper = shallow(
+      <UsersSelectSearchOption option={users[0]} onFocus={jest.fn()} onSelect={jest.fn()}>
+        {users[0].name}
+      </UsersSelectSearchOption>
+    );
+    expect(wrapper).toMatchSnapshot();
+  });
+});
+
+describe('UsersSelectSearchValue', () => {
+  it('should render correctly with a user', () => {
+    const wrapper = shallow(
+      <UsersSelectSearchValue value={selectedUser}>{selectedUser.name}</UsersSelectSearchValue>
+    );
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render correctly with email instead of hash', () => {
+    const wrapper = shallow(
+      <UsersSelectSearchValue value={users[0]}>{users[0].name}</UsersSelectSearchValue>
+    );
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render correctly without value', () => {
+    const wrapper = shallow(<UsersSelectSearchValue />);
+    expect(wrapper).toMatchSnapshot();
+  });
+});
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchOption-test.js b/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchOption-test.js
deleted file mode 100644 (file)
index 3023f73..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import React from 'react';
-import { shallow } from 'enzyme';
-import UsersSelectSearchOption from '../UsersSelectSearchOption';
-
-const user = {
-  login: 'admin',
-  name: 'Administrator',
-  avatar: '7daf6c79d4802916d83f6266e24850af'
-};
-
-const user2 = {
-  login: 'admin',
-  name: 'Administrator',
-  email: 'admin@admin.ch'
-};
-
-it('should render correctly without all parameters', () => {
-  const wrapper = shallow(
-    <UsersSelectSearchOption option={user}>{user.name}</UsersSelectSearchOption>
-  );
-  expect(wrapper).toMatchSnapshot();
-});
-
-it('should render correctly with email instead of hash', () => {
-  const wrapper = shallow(
-    <UsersSelectSearchOption option={user2}>{user.name}</UsersSelectSearchOption>
-  );
-  expect(wrapper).toMatchSnapshot();
-});
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchValue-test.js b/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchValue-test.js
deleted file mode 100644 (file)
index 357365a..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import React from 'react';
-import { shallow } from 'enzyme';
-import UsersSelectSearchValue from '../UsersSelectSearchValue';
-
-const user = {
-  login: 'admin',
-  name: 'Administrator',
-  avatar: '7daf6c79d4802916d83f6266e24850af'
-};
-
-const user2 = {
-  login: 'admin',
-  name: 'Administrator',
-  email: 'admin@admin.ch'
-};
-
-it('should render correctly with a user', () => {
-  const wrapper = shallow(
-    <UsersSelectSearchValue value={user}>{user.name}</UsersSelectSearchValue>
-  );
-  expect(wrapper).toMatchSnapshot();
-});
-
-it('should render correctly with email instead of hash', () => {
-  const wrapper = shallow(
-    <UsersSelectSearchValue value={user2}>{user2.name}</UsersSelectSearchValue>
-  );
-  expect(wrapper).toMatchSnapshot();
-});
-
-it('should render correctly without value', () => {
-  const wrapper = shallow(<UsersSelectSearchValue />);
-  expect(wrapper).toMatchSnapshot();
-});
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.js.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.js.snap
deleted file mode 100644 (file)
index 49fe664..0000000
+++ /dev/null
@@ -1,79 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render correctly 1`] = `
-<Select
-  className="Select-big"
-  clearable={false}
-  isLoading={false}
-  labelKey="name"
-  noResultsText="no_results"
-  onChange={[Function]}
-  onInputChange={[Function]}
-  optionComponent={[Function]}
-  options={Array []}
-  placeholder=""
-  searchable={true}
-  value={
-    Object {
-      "avatar": "7daf6c79d4802916d83f6266e24850af",
-      "login": "admin",
-      "name": "Administrator",
-    }
-  }
-  valueComponent={[Function]}
-  valueKey="login"
-/>
-`;
-
-exports[`should render correctly 2`] = `
-Array [
-  Object {
-    "email": "tester@testing.ch",
-    "login": "test",
-    "name": "Tester",
-  },
-  Object {
-    "email": "foo@bar.ch",
-    "login": "foo",
-    "name": "Foo Bar",
-  },
-]
-`;
-
-exports[`should render correctly 3`] = `
-<Select
-  className="Select-big"
-  clearable={false}
-  isLoading={false}
-  labelKey="name"
-  noResultsText="no_results"
-  onChange={[Function]}
-  onInputChange={[Function]}
-  optionComponent={[Function]}
-  options={
-    Array [
-      Object {
-        "email": "tester@testing.ch",
-        "login": "test",
-        "name": "Tester",
-      },
-      Object {
-        "email": "foo@bar.ch",
-        "login": "foo",
-        "name": "Foo Bar",
-      },
-    ]
-  }
-  placeholder=""
-  searchable={true}
-  value={
-    Object {
-      "avatar": "7daf6c79d4802916d83f6266e24850af",
-      "login": "admin",
-      "name": "Administrator",
-    }
-  }
-  valueComponent={[Function]}
-  valueKey="login"
-/>
-`;
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.tsx.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.tsx.snap
new file mode 100644 (file)
index 0000000..f79a49c
--- /dev/null
@@ -0,0 +1,188 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`UsersSelectSearch should render correctly 1`] = `
+<Select
+  className="Select-big"
+  clearable={false}
+  isLoading={false}
+  labelKey="name"
+  noResultsText="no_results"
+  onChange={[Function]}
+  onInputChange={[Function]}
+  optionComponent={[Function]}
+  options={Array []}
+  placeholder=""
+  searchable={true}
+  value={
+    Object {
+      "avatar": "7daf6c79d4802916d83f6266e24850af",
+      "login": "admin",
+      "name": "Administrator",
+    }
+  }
+  valueComponent={[Function]}
+  valueKey="login"
+/>
+`;
+
+exports[`UsersSelectSearch should render correctly 2`] = `
+Array [
+  Object {
+    "email": "tester@testing.ch",
+    "login": "test",
+    "name": "Tester",
+  },
+  Object {
+    "email": "foo@bar.ch",
+    "login": "foo",
+    "name": "Foo Bar",
+  },
+]
+`;
+
+exports[`UsersSelectSearch should render correctly 3`] = `
+<Select
+  className="Select-big"
+  clearable={false}
+  isLoading={false}
+  labelKey="name"
+  noResultsText="no_results"
+  onChange={[Function]}
+  onInputChange={[Function]}
+  optionComponent={[Function]}
+  options={
+    Array [
+      Object {
+        "email": "tester@testing.ch",
+        "login": "test",
+        "name": "Tester",
+      },
+      Object {
+        "email": "foo@bar.ch",
+        "login": "foo",
+        "name": "Foo Bar",
+      },
+    ]
+  }
+  placeholder=""
+  searchable={true}
+  value={
+    Object {
+      "avatar": "7daf6c79d4802916d83f6266e24850af",
+      "login": "admin",
+      "name": "Administrator",
+    }
+  }
+  valueComponent={[Function]}
+  valueKey="login"
+/>
+`;
+
+exports[`UsersSelectSearchOption should render correctly with email instead of hash 1`] = `
+<div
+  onMouseDown={[Function]}
+  onMouseEnter={[Function]}
+  onMouseMove={[Function]}
+  title="Administrator"
+>
+  <Connect(Avatar)
+    name="Administrator"
+    size={16}
+  />
+  <strong
+    className="spacer-left"
+  >
+    Administrator
+  </strong>
+  <span
+    className="note little-spacer-left"
+  >
+    admin
+  </span>
+</div>
+`;
+
+exports[`UsersSelectSearchOption should render correctly without all parameters 1`] = `
+<div
+  onMouseDown={[Function]}
+  onMouseEnter={[Function]}
+  onMouseMove={[Function]}
+  title="Administrator"
+>
+  <Connect(Avatar)
+    hash="7daf6c79d4802916d83f6266e24850af"
+    name="Administrator"
+    size={16}
+  />
+  <strong
+    className="spacer-left"
+  >
+    Administrator
+  </strong>
+  <span
+    className="note little-spacer-left"
+  >
+    admin
+  </span>
+</div>
+`;
+
+exports[`UsersSelectSearchValue should render correctly with a user 1`] = `
+<div
+  className="Select-value"
+  title="Administrator"
+>
+  <div
+    className="Select-value-label"
+  >
+    <Connect(Avatar)
+      hash="7daf6c79d4802916d83f6266e24850af"
+      name="Administrator"
+      size={16}
+    />
+    <strong
+      className="spacer-left"
+    >
+      Administrator
+    </strong>
+    <span
+      className="note little-spacer-left"
+    >
+      admin
+    </span>
+  </div>
+</div>
+`;
+
+exports[`UsersSelectSearchValue should render correctly with email instead of hash 1`] = `
+<div
+  className="Select-value"
+  title="Administrator"
+>
+  <div
+    className="Select-value-label"
+  >
+    <Connect(Avatar)
+      name="Administrator"
+      size={16}
+    />
+    <strong
+      className="spacer-left"
+    >
+      Administrator
+    </strong>
+    <span
+      className="note little-spacer-left"
+    >
+      admin
+    </span>
+  </div>
+</div>
+`;
+
+exports[`UsersSelectSearchValue should render correctly without value 1`] = `
+<div
+  className="Select-value"
+  title=""
+/>
+`;
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap
deleted file mode 100644 (file)
index e75246d..0000000
+++ /dev/null
@@ -1,50 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render correctly with email instead of hash 1`] = `
-<div
-  onMouseDown={[Function]}
-  onMouseEnter={[Function]}
-  onMouseMove={[Function]}
-  title="Administrator"
->
-  <Connect(Avatar)
-    name="Administrator"
-    size={16}
-  />
-  <strong
-    className="spacer-left"
-  >
-    Administrator
-  </strong>
-  <span
-    className="note little-spacer-left"
-  >
-    admin
-  </span>
-</div>
-`;
-
-exports[`should render correctly without all parameters 1`] = `
-<div
-  onMouseDown={[Function]}
-  onMouseEnter={[Function]}
-  onMouseMove={[Function]}
-  title="Administrator"
->
-  <Connect(Avatar)
-    hash="7daf6c79d4802916d83f6266e24850af"
-    name="Administrator"
-    size={16}
-  />
-  <strong
-    className="spacer-left"
-  >
-    Administrator
-  </strong>
-  <span
-    className="note little-spacer-left"
-  >
-    admin
-  </span>
-</div>
-`;
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap
deleted file mode 100644 (file)
index 1aad730..0000000
+++ /dev/null
@@ -1,61 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render correctly with a user 1`] = `
-<div
-  className="Select-value"
-  title="Administrator"
->
-  <div
-    className="Select-value-label"
-  >
-    <Connect(Avatar)
-      hash="7daf6c79d4802916d83f6266e24850af"
-      name="Administrator"
-      size={16}
-    />
-    <strong
-      className="spacer-left"
-    >
-      Administrator
-    </strong>
-    <span
-      className="note little-spacer-left"
-    >
-      admin
-    </span>
-  </div>
-</div>
-`;
-
-exports[`should render correctly with email instead of hash 1`] = `
-<div
-  className="Select-value"
-  title="Administrator"
->
-  <div
-    className="Select-value-label"
-  >
-    <Connect(Avatar)
-      name="Administrator"
-      size={16}
-    />
-    <strong
-      className="spacer-left"
-    >
-      Administrator
-    </strong>
-    <span
-      className="note little-spacer-left"
-    >
-      admin
-    </span>
-  </div>
-</div>
-`;
-
-exports[`should render correctly without value 1`] = `
-<div
-  className="Select-value"
-  title=""
-/>
-`;
index 88751d8b8039fb88752f007adc6d797a7de07eca..a56f4adf1c18eb0d8c19fcd08e060e99814932f7 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { RouterState, IndexRouteProps } from 'react-router';
+import { RouterState, IndexRouteProps, RouteComponent } from 'react-router';
 
 const routes = [
   {
     getIndexRoute(_: RouterState, callback: (err: any, route: IndexRouteProps) => any) {
-      import('./components/UsersAppContainer').then(i => callback(null, { component: i.default }));
+      import('./UsersAppContainer').then(i => callback(null, { component: i.default }));
+    }
+  },
+  {
+    path: 'old',
+    getComponent(_: RouterState, callback: (err: any, component: RouteComponent) => any) {
+      import('./components/UsersAppContainerOld').then(i => callback(null, i.default));
     }
   }
 ];
index fb9434142c0bf932850dc0ca15533d3a3cc97aeb..9cd7a95b5a8a3af1f283ce5d27e0e25d313b7de3 100644 (file)
@@ -55,7 +55,7 @@ export default Modal.extend({
     this.errors = [];
     this.newToken = null;
     const tokenName = this.$('.js-generate-token-form input').val();
-    generateToken(tokenName, this.model.id).then(
+    generateToken({ name: tokenName, login: this.model.id }).then(
       response => {
         this.newToken = response;
         this.requestTokens();
@@ -70,7 +70,10 @@ export default Modal.extend({
     const token = this.tokens.find(t => t.name === `${tokenName}`);
     if (token) {
       if (token.deleting) {
-        revokeToken(tokenName, this.model.id).then(this.requestTokens.bind(this), () => {});
+        revokeToken({ name: tokenName, login: this.model.id }).then(
+          this.requestTokens.bind(this),
+          () => {}
+        );
       } else {
         token.deleting = true;
         this.render();
diff --git a/server/sonar-web/src/main/js/apps/users/utils.ts b/server/sonar-web/src/main/js/apps/users/utils.ts
new file mode 100644 (file)
index 0000000..0cac3aa
--- /dev/null
@@ -0,0 +1,35 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { memoize } from 'lodash';
+import { cleanQuery, parseAsString, RawQuery, serializeString } from '../../helpers/query';
+
+export interface Query {
+  search: string;
+}
+
+export const parseQuery = memoize((urlQuery: RawQuery): Query => ({
+  search: parseAsString(urlQuery['search'])
+}));
+
+export const serializeQuery = memoize((query: Query): RawQuery =>
+  cleanQuery({
+    search: query.search ? serializeString(query.search) : undefined
+  })
+);
index 5c434d380d849cbd5d0b2da3131c6810dd0eff64..1954b9aa2a72b910420240de9dd13679ce4c5f08 100644 (file)
@@ -21,7 +21,7 @@ import * as React from 'react';
 import * as classNames from 'classnames';
 
 interface Props {
-  children?: JSX.Element;
+  children?: JSX.Element | JSX.Element[];
   className?: string;
   loading?: boolean;
   customSpinner?: JSX.Element;
index c23f62d0f942ecabbc0700b88b4963128f4495c3..a21b0f533a4f8d2868448d3dddecd0918f67365c 100644 (file)
@@ -26,6 +26,7 @@ import SettingsIcon from '../icons-components/SettingsIcon';
 interface Props {
   className?: string;
   children: React.ReactNode;
+  menuClassName?: string;
   menuPosition?: 'left' | 'right';
   small?: boolean;
   toggleClassName?: string;
@@ -43,7 +44,7 @@ export default function ActionsDropdown({ menuPosition = 'right', ...props }: Pr
         <i className="icon-dropdown little-spacer-left" />
       </button>
       <ul
-        className={classNames('dropdown-menu', {
+        className={classNames('dropdown-menu', props.menuClassName, {
           'dropdown-menu-right': menuPosition === 'right'
         })}>
         {props.children}
diff --git a/server/sonar-web/src/main/js/components/icons-components/BulletListIcon.tsx b/server/sonar-web/src/main/js/components/icons-components/BulletListIcon.tsx
new file mode 100644 (file)
index 0000000..87a06d9
--- /dev/null
@@ -0,0 +1,39 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { IconProps } from './types';
+
+export default function BulletListIcon({ className, fill = 'currentColor', size = 16 }: IconProps) {
+  return (
+    <svg
+      className={className}
+      width={size}
+      height={size}
+      viewBox="0 0 16 16"
+      version="1.1"
+      xmlnsXlink="http://www.w3.org/1999/xlink"
+      xmlSpace="preserve">
+      <path
+        style={{ fill }}
+        d="M2.968 11.274v1.51q0 0.102-0.075 0.177t-0.177 0.075h-1.51q-0.102 0-0.177-0.075t-0.075-0.177v-1.51q0-0.102 0.075-0.177t0.177-0.075h1.51q0.102 0 0.177 0.075t0.075 0.177zM2.968 8.255v1.51q0 0.102-0.075 0.177t-0.177 0.075h-1.51q-0.102 0-0.177-0.075t-0.075-0.177v-1.51q0-0.102 0.075-0.177t0.177-0.075h1.51q0.102 0 0.177 0.075t0.075 0.177zM2.968 5.235v1.51q0 0.102-0.075 0.177t-0.177 0.075h-1.51q-0.102 0-0.177-0.075t-0.075-0.177v-1.51q0-0.102 0.075-0.177t0.177-0.075h1.51q0.102 0 0.177 0.075t0.075 0.177zM15.045 11.274v1.51q0 0.102-0.075 0.177t-0.177 0.075h-10.568q-0.102 0-0.177-0.075t-0.075-0.177v-1.51q0-0.102 0.075-0.177t0.177-0.075h10.568q0.102 0 0.177 0.075t0.075 0.177zM2.968 2.216v1.51q0 0.102-0.075 0.177t-0.177 0.075h-1.51q-0.102 0-0.177-0.075t-0.075-0.177v-1.51q0-0.102 0.075-0.177t0.177-0.075h1.51q0.102 0 0.177 0.075t0.075 0.177zM15.045 8.255v1.51q0 0.102-0.075 0.177t-0.177 0.075h-10.568q-0.102 0-0.177-0.075t-0.075-0.177v-1.51q0-0.102 0.075-0.177t0.177-0.075h10.568q0.102 0 0.177 0.075t0.075 0.177zM15.045 5.235v1.51q0 0.102-0.075 0.177t-0.177 0.075h-10.568q-0.102 0-0.177-0.075t-0.075-0.177v-1.51q0-0.102 0.075-0.177t0.177-0.075h10.568q0.102 0 0.177 0.075t0.075 0.177zM15.045 2.216v1.51q0 0.102-0.075 0.177t-0.177 0.075h-10.568q-0.102 0-0.177-0.075t-0.075-0.177v-1.51q0-0.102 0.075-0.177t0.177-0.075h10.568q0.102 0 0.177 0.075t0.075 0.177z"
+      />
+    </svg>
+  );
+}
index d25651d6c6c48639dcc7850c829374d7974db074..506531e2783c478f549177d7670ef4cc3b70e068 100644 (file)
@@ -91,9 +91,8 @@ export default class SetAssigneePopup extends React.PureComponent {
     }).then(this.handleSearchResult, this.props.onFail);
   };
 
-  searchUsers = (query /*: string */) => {
-    searchUsers(query, LIST_SIZE).then(this.handleSearchResult, this.props.onFail);
-  };
+  searchUsers = (query /*: string */) =>
+    searchUsers({ q: query, ps: LIST_SIZE }).then(this.handleSearchResult, this.props.onFail);
 
   handleSearchResult = (data /*: Object */) => {
     this.setState({
index 0e65602d0127a733d875329340ee858684b79c51..e73bdba9ca5fd8d6d56f0055877f82086c55c75a 100644 (file)
@@ -21,14 +21,16 @@ import * as React from 'react';
 import * as classNames from 'classnames';
 import * as theme from '../../app/theme';
 import ClearIcon from '../icons-components/ClearIcon';
-import './buttons.css';
 import EditIcon from '../icons-components/EditIcon';
+import Tooltip from '../controls/Tooltip';
+import './buttons.css';
 
 interface ButtonIconProps {
   children: React.ReactNode;
   className?: string;
   color?: string;
   onClick?: () => void;
+  tooltip?: string;
   [x: string]: any;
 }
 
@@ -43,8 +45,8 @@ export class ButtonIcon extends React.PureComponent<ButtonIconProps> {
   };
 
   render() {
-    const { children, className, color = theme.darkBlue, onClick, ...props } = this.props;
-    return (
+    const { children, className, color = theme.darkBlue, onClick, tooltip, ...props } = this.props;
+    const buttonComponent = (
       <button
         className={classNames(className, 'button-icon')}
         onClick={this.handleClick}
@@ -53,6 +55,14 @@ export class ButtonIcon extends React.PureComponent<ButtonIconProps> {
         {children}
       </button>
     );
+    if (tooltip) {
+      return (
+        <Tooltip overlay={tooltip} mouseEnterDelay={0.4}>
+          {buttonComponent}
+        </Tooltip>
+      );
+    }
+    return buttonComponent;
   }
 }
 
index 7777a06fcf6df2517b193b53693dba60a2f87efc..f441b8359667dc55f22d030a0f3bb6fe8d7cd4c5 100644 (file)
   version "2.2.3"
   resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.3.tgz#3f0ff6873da793870e20a260cada55982f38a9e5"
 
+"@types/clipboard@1.5.35":
+  version "1.5.35"
+  resolved "https://registry.yarnpkg.com/@types/clipboard/-/clipboard-1.5.35.tgz#bceb67a7e0aad3070468929b95a2d626405db464"
+
 "@types/d3-array@1.2.1":
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-1.2.1.tgz#e489605208d46a1c9d980d2e5772fa9c75d9ec65"
index 7a0f19e1d94a48171523c555a27ea2e8f4b1f9ef..cf5916587df7217982290f6321e9abd58a36806b 100644 (file)
@@ -91,6 +91,7 @@ members=Members
 min=Min
 minor=Minor
 more=More
+more_x={0} more
 more_actions=More Actions
 my_favorite=My Favorite
 my_favorites=My Favorites