]> source.dussan.org Git - sonarqube.git/commitdiff
Reuse react based tokens form in account space (#2954)
authorStas Vilchik <stas.vilchik@sonarsource.com>
Thu, 11 Jan 2018 12:41:50 +0000 (13:41 +0100)
committerGitHub <noreply@github.com>
Thu, 11 Jan 2018 12:41:50 +0000 (13:41 +0100)
24 files changed:
server/sonar-server/src/main/java/org/sonar/server/usertoken/ws/GenerateAction.java
server/sonar-server/src/main/java/org/sonar/server/usertoken/ws/SearchAction.java
server/sonar-server/src/main/resources/org/sonar/server/usertoken/ws/generate-example.json
server/sonar-server/src/test/java/org/sonar/server/usertoken/ws/GenerateActionTest.java
server/sonar-web/src/main/js/api/user-tokens.ts
server/sonar-web/src/main/js/app/types.ts
server/sonar-web/src/main/js/apps/account/components/Security.js
server/sonar-web/src/main/js/apps/account/components/Tokens.js [deleted file]
server/sonar-web/src/main/js/apps/account/components/Tokens.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/components/__tests__/Tokens-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/components/__tests__/__snapshots__/Tokens-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/templates/account-tokens.hbs [deleted file]
server/sonar-web/src/main/js/apps/account/tokens-view.js [deleted file]
server/sonar-web/src/main/js/apps/users/UsersApp.tsx
server/sonar-web/src/main/js/apps/users/UsersList.tsx
server/sonar-web/src/main/js/apps/users/__tests__/UsersList.tsx
server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersApp-test.tsx.snap
server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersList.tsx.snap
server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx
server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx
server/sonar-web/src/main/js/apps/users/components/TokensFormModal.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx
server/sonar-web/src/main/js/apps/users/components/__tests__/UserListItem-test.tsx
sonar-ws/src/main/protobuf/ws-user_tokens.proto

index 4479a6e55fe778bff075485aa66c371a1f2e0aaf..7f880d4cf1399f8fc9c2f7872031fb6abb6774f0 100644 (file)
@@ -35,12 +35,13 @@ import org.sonarqube.ws.UserTokens;
 import org.sonarqube.ws.UserTokens.GenerateWsResponse;
 
 import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR;
+import static org.sonar.api.utils.DateUtils.formatDateTime;
 import static org.sonar.server.user.AbstractUserSession.insufficientPrivilegesException;
-import static org.sonar.server.ws.WsUtils.checkRequest;
-import static org.sonar.server.ws.WsUtils.writeProtobuf;
 import static org.sonar.server.usertoken.ws.UserTokensWsParameters.ACTION_GENERATE;
 import static org.sonar.server.usertoken.ws.UserTokensWsParameters.PARAM_LOGIN;
 import static org.sonar.server.usertoken.ws.UserTokensWsParameters.PARAM_NAME;
+import static org.sonar.server.ws.WsUtils.checkRequest;
+import static org.sonar.server.ws.WsUtils.writeProtobuf;
 
 public class GenerateAction implements UserTokensWsAction {
   private static final int MAX_TOKEN_NAME_LENGTH = 100;
@@ -151,6 +152,7 @@ public class GenerateAction implements UserTokensWsAction {
     return UserTokens.GenerateWsResponse.newBuilder()
       .setLogin(userTokenDto.getLogin())
       .setName(userTokenDto.getName())
+      .setCreatedAt(formatDateTime(userTokenDto.getCreatedAt()))
       .setToken(token)
       .build();
   }
index b486f7dc5ed66cf7780872a25d4de5cb87032230..4ead6902c207c13a3a4d30e0607fce7fad16ecf6 100644 (file)
@@ -19,7 +19,6 @@
  */
 package org.sonar.server.usertoken.ws;
 
-import java.util.Date;
 import java.util.List;
 import org.sonar.api.server.ws.Request;
 import org.sonar.api.server.ws.Response;
@@ -31,10 +30,10 @@ import org.sonar.server.user.UserSession;
 import org.sonarqube.ws.UserTokens.SearchWsResponse;
 
 import static org.sonar.api.utils.DateUtils.formatDateTime;
-import static org.sonar.server.ws.WsUtils.checkFound;
-import static org.sonar.server.ws.WsUtils.writeProtobuf;
 import static org.sonar.server.usertoken.ws.UserTokensWsParameters.ACTION_SEARCH;
 import static org.sonar.server.usertoken.ws.UserTokensWsParameters.PARAM_LOGIN;
+import static org.sonar.server.ws.WsUtils.checkFound;
+import static org.sonar.server.ws.WsUtils.writeProtobuf;
 
 public class SearchAction implements UserTokensWsAction {
   private final DbClient dbClient;
@@ -88,7 +87,7 @@ public class SearchAction implements UserTokensWsAction {
       userTokenBuilder
         .clear()
         .setName(userTokenDto.getName())
-        .setCreatedAt(formatDateTime(new Date(userTokenDto.getCreatedAt())));
+        .setCreatedAt(formatDateTime(userTokenDto.getCreatedAt()));
       searchWsResponse.addUserTokens(userTokenBuilder);
     }
 
index 16d5236762c2342ba8a845d4a4b246309e464c04..cbf4aacb362bbe4591b62969566a6a1c9bf12add 100644 (file)
@@ -1,5 +1,6 @@
 {
   "login": "grace.hopper",
   "name": "Third Party Application",
+  "createdAt": "2018-01-10T14:06:05+0100",
   "token": "123456789"
 }
index a639da33bb6777559e434df7962605ad090ace7c..d30470869f6bd1ccaf2f4f327ee4256c38141e93 100644 (file)
@@ -83,7 +83,7 @@ public class GenerateActionTest {
       .setParam(PARAM_NAME, TOKEN_NAME)
       .execute().getInput();
 
-    assertJson(response).isSimilarTo(getClass().getResource("generate-example.json"));
+    assertJson(response).ignoreFields("createdAt").isSimilarTo(getClass().getResource("generate-example.json"));
   }
 
   @Test
@@ -93,6 +93,7 @@ public class GenerateActionTest {
     GenerateWsResponse response = newRequest(null, TOKEN_NAME);
 
     assertThat(response.getLogin()).isEqualTo(GRACE_HOPPER);
+    assertThat(response.getCreatedAt()).isNotEmpty();
   }
 
   @Test
index 0d7f6a95ace2ca670e7f8c75f58b08b065970383..bbe02d173118a5e7a0d552e2482c00c242fc204c 100644 (file)
@@ -45,26 +45,24 @@ export interface UserToken {
   createdAt: string;
 }
 
-/**
- * List tokens for given user login
- */
+/** List tokens for given user login */
 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(data: {
+export interface NewToken {
+  createdAt: string;
+  login: string;
   name: string;
-  login?: string;
-}): Promise<{ login: string; name: string; token: string }> {
+  token: string;
+}
+
+/** Generate a user token */
+export function generateToken(data: { name: string; login?: string }): Promise<NewToken> {
   return postJSON('/api/user_tokens/generate', data).catch(throwGlobalError);
 }
 
-/**
- * Revoke a user token
- */
+/** Revoke a user token */
 export function revokeToken(data: { name: string; login?: string }): Promise<void | Response> {
   return post('/api/user_tokens/revoke', data).catch(throwGlobalError);
 }
index 69a81b64d0f26dd132564a071e348dc5beda84cc..228038e736e40e91417c19636b786c5a54695c80 100644 (file)
@@ -184,6 +184,7 @@ export interface LoggedInUser extends CurrentUser {
   email?: string;
   homepage?: HomePage;
   isLoggedIn: true;
+  login: string;
   name: string;
 }
 
index 2063cc2cc8d7cc8570dd677a7266b5555c84aece..af77c5eff8d0697d56b71aa1b4240245bbeea216 100644 (file)
@@ -31,7 +31,7 @@ function Security(props) {
   return (
     <div className="account-body account-container">
       <Helmet title={translate('my_account.security')} />
-      <Tokens user={user} />
+      <Tokens login={user.login} />
       {user.local && <Password user={user} />}
     </div>
   );
diff --git a/server/sonar-web/src/main/js/apps/account/components/Tokens.js b/server/sonar-web/src/main/js/apps/account/components/Tokens.js
deleted file mode 100644 (file)
index fc3e859..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2018 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 Backbone from 'backbone';
-import React, { Component } from 'react';
-import TokensView from '../tokens-view';
-
-export default class Tokens extends Component {
-  componentDidMount() {
-    this.renderView();
-  }
-
-  componentWillUnmount() {
-    this.destroyView();
-  }
-
-  destroyView() {
-    if (this.destroyView) {
-      this.tokensView.destroy();
-    }
-  }
-
-  renderView() {
-    const account = new Backbone.Model({
-      id: this.props.user.login
-    });
-
-    this.tokensView = new TokensView({
-      el: this.refs.container,
-      model: account
-    }).render();
-  }
-
-  render() {
-    return <div ref="container" />;
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/account/components/Tokens.tsx b/server/sonar-web/src/main/js/apps/account/components/Tokens.tsx
new file mode 100644 (file)
index 0000000..db6a74a
--- /dev/null
@@ -0,0 +1,41 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 TokenForm from '../../users/components/TokensForm';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+  login: string;
+}
+
+export default function Tokens({ login }: Props) {
+  return (
+    <div className="boxed-group">
+      <h2>{translate('users.tokens')}</h2>
+      <div className="boxed-group-inner">
+        <div className="big-spacer-bottom big-spacer-right markdown">
+          {translate('my_account.tokens_description')}
+        </div>
+
+        <TokenForm login={login} />
+      </div>
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/account/components/__tests__/Tokens-test.tsx b/server/sonar-web/src/main/js/apps/account/components/__tests__/Tokens-test.tsx
new file mode 100644 (file)
index 0000000..3b82e5f
--- /dev/null
@@ -0,0 +1,26 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 Tokens from '../Tokens';
+
+it('renders', () => {
+  expect(shallow(<Tokens login="user" />)).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/apps/account/components/__tests__/__snapshots__/Tokens-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/components/__tests__/__snapshots__/Tokens-test.tsx.snap
new file mode 100644 (file)
index 0000000..f495a2f
--- /dev/null
@@ -0,0 +1,23 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders 1`] = `
+<div
+  className="boxed-group"
+>
+  <h2>
+    users.tokens
+  </h2>
+  <div
+    className="boxed-group-inner"
+  >
+    <div
+      className="big-spacer-bottom big-spacer-right markdown"
+    >
+      my_account.tokens_description
+    </div>
+    <TokensForm
+      login="user"
+    />
+  </div>
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/apps/account/templates/account-tokens.hbs b/server/sonar-web/src/main/js/apps/account/templates/account-tokens.hbs
deleted file mode 100644 (file)
index 65bbf91..0000000
+++ /dev/null
@@ -1,81 +0,0 @@
-<div class="boxed-group">
-  <h2>{{t 'users.tokens'}}</h2>
-
-<div class="boxed-group-inner">
-  <div class="big-spacer-bottom big-spacer-right markdown">
-    <p>{{t 'my_account.tokens_description'}}</p>
-  </div>
-
-  {{#notNull tokens}}
-    <table class="data">
-      <thead>
-      <tr>
-        <th>{{t 'name'}}</th>
-        <th class="text-right">{{t 'created'}}</th>
-        <th>&nbsp;</th>
-      </tr>
-      </thead>
-      <tbody>
-      {{#each tokens}}
-        <tr>
-          <td>
-            <div title="{{name}}">
-              {{limitString name}}
-            </div>
-          </td>
-          <td class="thin nowrap text-right">
-            {{d createdAt}}
-          </td>
-          <td class="thin nowrap text-right">
-            <div class="big-spacer-left">
-              <form class="js-revoke-token-form" data-token="{{name}}">
-                {{#if deleting}}
-                  <button class="button-red active input-small">{{t 'users.tokens.sure'}}</button>
-                {{else}}
-                  <button class="button-red input-small">{{t 'users.tokens.revoke'}}</button>
-                {{/if}}
-              </form>
-            </div>
-          </td>
-        </tr>
-      {{else}}
-        <tr>
-          <td colspan="3">
-            <span class="note">{{t 'users.no_tokens'}}</span>
-          </td>
-        </tr>
-      {{/each}}
-      </tbody>
-    </table>
-  {{/notNull}}
-
-  {{#each errors}}
-    <div class="alert alert-danger">{{msg}}</div>
-  {{/each}}
-
-  <form class="js-generate-token-form spacer-top panel bg-muted">
-    <label>{{t 'users.generate_new_token'}}:</label>
-    <input type="text" required maxlength="100" placeholder="{{t 'users.enter_token_name'}}">
-    <button>{{t 'users.generate'}}</button>
-  </form>
-
-  {{#if newToken}}
-    <div class="panel panel-white big-spacer-top">
-      <div class="alert alert-warning">
-        {{tp 'users.tokens.new_token_created' newToken.name}}
-      </div>
-
-      <table class="data">
-        <tr>
-          <td class="thin">
-            <button class="js-copy-to-clipboard" data-clipboard-text="{{newToken.token}}">{{t 'copy'}}</button>
-          </td>
-          <td class="nowrap">
-            <div class="monospaced text-success">{{newToken.token}}</div>
-          </td>
-        </tr>
-      </table>
-    </div>
-  {{/if}}
-</div>
-</div>
diff --git a/server/sonar-web/src/main/js/apps/account/tokens-view.js b/server/sonar-web/src/main/js/apps/account/tokens-view.js
deleted file mode 100644 (file)
index 76f3cc2..0000000
+++ /dev/null
@@ -1,108 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2018 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 $ from 'jquery';
-import Marionette from 'backbone.marionette';
-import Clipboard from 'clipboard';
-import Template from './templates/account-tokens.hbs';
-import { getTokens, generateToken, revokeToken } from '../../api/user-tokens';
-import { translate } from '../../helpers/l10n';
-
-export default Marionette.ItemView.extend({
-  template: Template,
-
-  events() {
-    return {
-      'submit .js-generate-token-form': 'onGenerateTokenFormSubmit',
-      'submit .js-revoke-token-form': 'onRevokeTokenFormSubmit'
-    };
-  },
-
-  initialize() {
-    this.tokens = null;
-    this.newToken = null;
-    this.errors = [];
-    this.requestTokens();
-  },
-
-  requestTokens() {
-    return getTokens(this.model.id).then(tokens => {
-      this.tokens = tokens;
-      this.render();
-    });
-  },
-
-  onGenerateTokenFormSubmit(e) {
-    e.preventDefault();
-    this.errors = [];
-    this.newToken = null;
-    const tokenName = this.$('.js-generate-token-form input').val();
-    generateToken({ name: tokenName, login: this.model.id }).then(
-      response => {
-        this.newToken = response;
-        this.requestTokens();
-      },
-      () => {}
-    );
-  },
-
-  onRevokeTokenFormSubmit(e) {
-    e.preventDefault();
-    const tokenName = $(e.currentTarget).data('token');
-    const token = this.tokens.find(token => token.name === `${tokenName}`);
-    if (token) {
-      if (token.deleting) {
-        revokeToken({ name: tokenName, login: this.model.id }).then(
-          () => this.requestTokens(),
-          () => {}
-        );
-      } else {
-        token.deleting = true;
-        this.render();
-      }
-    }
-  },
-
-  onRender() {
-    const copyButton = this.$('.js-copy-to-clipboard');
-    if (copyButton.length) {
-      const clipboard = new Clipboard(copyButton.get(0));
-      clipboard.on('success', () => {
-        copyButton
-          .tooltip({
-            title: translate('users.tokens.copied'),
-            placement: 'bottom',
-            trigger: 'manual'
-          })
-          .tooltip('show');
-        setTimeout(() => copyButton.tooltip('hide'), 1000);
-      });
-    }
-    this.newToken = null;
-  },
-
-  serializeData() {
-    return {
-      ...Marionette.ItemView.prototype.serializeData.apply(this, arguments),
-      tokens: this.tokens,
-      newToken: this.newToken,
-      errors: this.errors
-    };
-  }
-});
index 24c638ca7b628355627f1f3935950581406949eb..45a3f77e7cdbb49d5d794663af1c90f45852837d 100644 (file)
@@ -113,6 +113,12 @@ export default class UsersApp extends React.PureComponent<Props, State> {
     this.context.router.push({ ...this.props.location, query });
   };
 
+  updateTokensCount = (login: string, tokensCount: number) => {
+    this.setState(state => ({
+      users: state.users.map(user => (user.login === login ? { ...user, tokensCount } : user))
+    }));
+  };
+
   render() {
     const query = parseQuery(this.props.location.query);
     const { loading, paging, users } = this.state;
@@ -126,6 +132,7 @@ export default class UsersApp extends React.PureComponent<Props, State> {
           identityProviders={this.state.identityProviders}
           onUpdateUsers={this.fetchUsers}
           organizationsEnabled={this.props.organizationsEnabled}
+          updateTokensCount={this.updateTokensCount}
           users={users}
         />
         {paging !== undefined && (
index 9609acd0a9b28381e416472fdaff12d4607df948..8938ae00e04ccfc7feacd3c47e0b886e5d8c0f26 100644 (file)
@@ -27,6 +27,7 @@ interface Props {
   identityProviders: IdentityProvider[];
   onUpdateUsers: () => void;
   organizationsEnabled: boolean;
+  updateTokensCount: (login: string, tokensCount: number) => void;
   users: User[];
 }
 
@@ -35,6 +36,7 @@ export default function UsersList({
   identityProviders,
   onUpdateUsers,
   organizationsEnabled,
+  updateTokensCount,
   users
 }: Props) {
   return (
@@ -60,6 +62,7 @@ export default function UsersList({
               key={user.login}
               onUpdateUsers={onUpdateUsers}
               organizationsEnabled={organizationsEnabled}
+              updateTokensCount={updateTokensCount}
               user={user}
             />
           ))}
index aa3ed1b718ac7e1443ab108cc5f6ad2b76dc853f..2a3d301e27596db41442db54f099e8c794f33464 100644 (file)
@@ -63,6 +63,7 @@ function getWrapper(props = {}) {
       ]}
       onUpdateUsers={jest.fn()}
       organizationsEnabled={true}
+      updateTokensCount={jest.fn()}
       users={users}
       {...props}
     />
index 8aff49f07eda4b678a3155eeda76aee5aabbc027..c955a2eef06ec375dc8d547219bd894593019b87 100644 (file)
@@ -32,6 +32,7 @@ exports[`should render correctly 1`] = `
     identityProviders={Array []}
     onUpdateUsers={[Function]}
     organizationsEnabled={true}
+    updateTokensCount={[Function]}
     users={Array []}
   />
 </div>
@@ -78,6 +79,7 @@ exports[`should render correctly 2`] = `
     }
     onUpdateUsers={[Function]}
     organizationsEnabled={true}
+    updateTokensCount={[Function]}
     users={
       Array [
         Object {
index 64c13cf48e9db06e8c589069ab9cb730727e8478..5fc532a3be4bf951bb7165a2306a23e3817be527 100644 (file)
@@ -37,6 +37,7 @@ exports[`should render correctly 1`] = `
         key="luke"
         onUpdateUsers={[Function]}
         organizationsEnabled={true}
+        updateTokensCount={[Function]}
         user={
           Object {
             "active": true,
@@ -52,6 +53,7 @@ exports[`should render correctly 1`] = `
         key="obi"
         onUpdateUsers={[Function]}
         organizationsEnabled={true}
+        updateTokensCount={[Function]}
         user={
           Object {
             "active": true,
index 4d723aa723ff2cb9c85b4d1bc1fb12d3dc782fed..2f27df08c7752e3fca1530d8b8451432be58bbdb 100644 (file)
  * 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;
+  login: string;
+  updateTokensCount?: (login: string, tokensCount: number) => void;
 }
 
 interface State {
   generating: boolean;
-  hasChanged: boolean;
   loading: boolean;
   newToken?: { name: string; token: string };
   newTokenName: string;
@@ -45,7 +41,6 @@ export default class TokensForm extends React.PureComponent<Props, State> {
   mounted: boolean;
   state: State = {
     generating: false,
-    hasChanged: false,
     loading: true,
     newTokenName: '',
     tokens: []
@@ -60,9 +55,9 @@ export default class TokensForm extends React.PureComponent<Props, State> {
     this.mounted = false;
   }
 
-  fetchTokens = ({ user } = this.props) => {
+  fetchTokens = () => {
     this.setState({ loading: true });
-    getTokens(user.login).then(
+    getTokens(this.props.login).then(
       tokens => {
         if (this.mounted) {
           this.setState({ loading: false, tokens });
@@ -76,27 +71,26 @@ export default class TokensForm extends React.PureComponent<Props, State> {
     );
   };
 
-  handleCloseClick = (evt: React.SyntheticEvent<HTMLAnchorElement>) => {
-    evt.preventDefault();
-    this.handleClose();
-  };
-
-  handleClose = () => {
-    if (this.state.hasChanged) {
-      this.props.onUpdateUsers();
+  updateTokensCount = () => {
+    if (this.props.updateTokensCount) {
+      this.props.updateTokensCount(this.props.login, this.state.tokens.length);
     }
-    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(
+      generateToken({ name: this.state.newTokenName, login: this.props.login }).then(
         newToken => {
           if (this.mounted) {
-            this.fetchTokens();
-            this.setState({ generating: false, hasChanged: true, newToken, newTokenName: '' });
+            this.setState(state => {
+              const tokens = [
+                ...state.tokens,
+                { name: newToken.name, createdAt: newToken.createdAt }
+              ];
+              return { generating: false, newToken, newTokenName: '', tokens };
+            }, this.updateTokensCount);
           }
         },
         () => {
@@ -108,9 +102,13 @@ export default class TokensForm extends React.PureComponent<Props, State> {
     }
   };
 
-  handleRevokeToken = () => {
-    this.setState({ hasChanged: true });
-    this.fetchTokens();
+  handleRevokeToken = (revokedToken: UserToken) => {
+    this.setState(
+      state => ({
+        tokens: state.tokens.filter(token => token.name !== revokedToken.name)
+      }),
+      this.updateTokensCount
+    );
   };
 
   handleNewTokenChange = (evt: React.SyntheticEvent<HTMLInputElement>) =>
@@ -130,16 +128,15 @@ export default class TokensForm extends React.PureComponent<Props, State> {
     return tokens.map(token => (
       <TokensFormItem
         key={token.name}
+        login={this.props.login}
         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>
@@ -148,55 +145,43 @@ export default class TokensForm extends React.PureComponent<Props, State> {
       </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>
+      <>
+        <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} />}
+        {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>
+        <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>
+      </>
     );
   }
 }
index f94a65caf8c3697b206674da3d4ee3f05f120d8d..e1426619ba9cfdf0255ee7021642d9c470cddc97 100644 (file)
@@ -21,15 +21,14 @@ 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 {
+  login: string;
+  onRevokeToken: (token: UserToken) => void;
   token: UserToken;
-  user: User;
-  onRevokeToken: () => void;
 }
 
 interface State {
@@ -52,8 +51,8 @@ export default class TokensFormItem extends React.PureComponent<Props, State> {
   handleRevoke = () => {
     if (this.state.deleting) {
       this.setState({ loading: true });
-      revokeToken({ login: this.props.user.login, name: this.props.token.name }).then(
-        this.props.onRevokeToken,
+      revokeToken({ login: this.props.login, name: this.props.token.name }).then(
+        () => this.props.onRevokeToken(this.props.token),
         () => {
           if (this.mounted) {
             this.setState({ loading: false, deleting: false });
diff --git a/server/sonar-web/src/main/js/apps/users/components/TokensFormModal.tsx b/server/sonar-web/src/main/js/apps/users/components/TokensFormModal.tsx
new file mode 100644 (file)
index 0000000..3befffc
--- /dev/null
@@ -0,0 +1,78 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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.
+ */
+/*
+ * 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 TokensForm from './TokensForm';
+import { User } from '../../../api/users';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+  user: User;
+  onClose: () => void;
+  updateTokensCount: (login: string, tokensCount: number) => void;
+}
+
+export default class TokensFormModal extends React.PureComponent<Props> {
+  handleCloseClick = (evt: React.SyntheticEvent<HTMLAnchorElement>) => {
+    evt.preventDefault();
+    this.props.onClose();
+  };
+
+  render() {
+    const header = translate('users.tokens');
+    return (
+      <Modal contentLabel={header} onRequestClose={this.props.onClose}>
+        <header className="modal-head">
+          <h2>{header}</h2>
+        </header>
+        <div className="modal-body modal-container">
+          <TokensForm
+            login={this.props.user.login}
+            updateTokensCount={this.props.updateTokensCount}
+          />
+        </div>
+        <footer className="modal-foot">
+          <a className="js-modal-close" href="#" onClick={this.handleCloseClick}>
+            {translate('Done')}
+          </a>
+        </footer>
+      </Modal>
+    );
+  }
+}
index f6ebe464f4c1f6f829fda22215a3b9394f459ab3..a5ed91dac8e9fb3fafe241e6315d07bf8654e0f2 100644 (file)
@@ -21,7 +21,7 @@ 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 TokensFormModal from './TokensFormModal';
 import UserActions from './UserActions';
 import UserGroups from './UserGroups';
 import UserListItemIdentity from './UserListItemIdentity';
@@ -34,6 +34,7 @@ interface Props {
   isCurrentUser: boolean;
   onUpdateUsers: () => void;
   organizationsEnabled: boolean;
+  updateTokensCount: (login: string, tokensCount: number) => void;
   user: User;
 }
 
@@ -81,10 +82,10 @@ export default class UserListItem extends React.PureComponent<Props, State> {
           />
         </td>
         {this.state.openTokenForm && (
-          <TokensForm
+          <TokensFormModal
             user={user}
             onClose={this.handleCloseTokensForm}
-            onUpdateUsers={onUpdateUsers}
+            updateTokensCount={this.props.updateTokensCount}
           />
         )}
       </tr>
index 632020a9068d1d9d19cf9f28ce6d76f684c0d80f..7482e1f239a96121d1cd8ff48f672c0293bd8aa5 100644 (file)
@@ -45,7 +45,7 @@ it('should display a change password button', () => {
 it('should open the correct forms', () => {
   const wrapper = getWrapper();
   click(wrapper.find('.js-user-tokens'));
-  expect(wrapper.find('TokensForm').exists()).toBeTruthy();
+  expect(wrapper.find('TokensFormModal').exists()).toBeTruthy();
 });
 
 function getWrapper(props = {}) {
@@ -54,6 +54,7 @@ function getWrapper(props = {}) {
       isCurrentUser={false}
       onUpdateUsers={jest.fn()}
       organizationsEnabled={false}
+      updateTokensCount={jest.fn()}
       user={user}
       {...props}
     />
index e51cd80174c8d5f65d1081a5570834e80bd1320f..d25e2af36bbe87b61eaa9b4fc60375e766afe8c0 100644 (file)
@@ -29,6 +29,7 @@ message GenerateWsResponse {
   optional string login = 1;
   optional string name = 2;
   optional string token = 3;
+  optional string createdAt = 4;
 }
 
 // WS api/user_tokens/search