]> source.dussan.org Git - sonarqube.git/commitdiff
pre-validate exising token during onboarding
authorStas Vilchik <stas.vilchik@sonarsource.com>
Wed, 18 Oct 2017 12:35:54 +0000 (14:35 +0200)
committerStas Vilchik <stas.vilchik@sonarsource.com>
Thu, 19 Oct 2017 09:07:32 +0000 (11:07 +0200)
server/sonar-web/src/main/js/apps/tutorials/onboarding/Onboarding.js
server/sonar-web/src/main/js/apps/tutorials/onboarding/TokenStep.js
server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/TokenStep-test.js
server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/Onboarding-test.js.snap
server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/TokenStep-test.js.snap
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index c5ec51485110994a72c7838a9fc64dc8d8d8dc15..ad825f28a364bf402f0105e7cf0581ef56495b7a 100644 (file)
@@ -190,6 +190,7 @@ export default class Onboarding extends React.PureComponent {
           )}
 
           <TokenStep
+            currentUser={this.props.currentUser}
             finished={this.state.token != null}
             onContinue={this.handleTokenDone}
             onOpen={this.handleTokenOpen}
index abcdb98b84ed5d798434b80bd556b97f2bdab961..1bbaa9df1b4da7821db95432c162427507d255b7 100644 (file)
 import React from 'react';
 import classNames from 'classnames';
 import Step from './Step';
+import { getTokens, generateToken, revokeToken } from '../../../api/user-tokens';
+import AlertErrorIcon from '../../../components/icons-components/AlertErrorIcon';
 import CloseIcon from '../../../components/icons-components/CloseIcon';
-import { generateToken, revokeToken } from '../../../api/user-tokens';
 import { translate } from '../../../helpers/l10n';
 
 /*::
 type Props = {|
+  currentUser: { login: string },
   finished: boolean,
   open: boolean,
   onContinue: (token: string) => void,
@@ -37,7 +39,8 @@ type Props = {|
 
 /*::
 type State = {
-  existingToken:string,
+  canUseExisting: boolean,
+  existingToken: string,
   loading: boolean,
   selection: string,
   tokenName?: string,
@@ -49,6 +52,7 @@ export default class TokenStep extends React.PureComponent {
   /*:: mounted: boolean; */
   /*:: props: Props; */
   state /*: State */ = {
+    canUseExisting: false,
     existingToken: '',
     loading: false,
     selection: 'generate'
@@ -56,6 +60,14 @@ export default class TokenStep extends React.PureComponent {
 
   componentDidMount() {
     this.mounted = true;
+    getTokens(this.props.currentUser.login).then(
+      tokens => {
+        if (this.mounted) {
+          this.setState({ canUseExisting: tokens.length > 0 });
+        }
+      },
+      () => {}
+    );
   }
 
   componentWillUnmount() {
@@ -65,6 +77,15 @@ export default class TokenStep extends React.PureComponent {
   getToken = () =>
     this.state.selection === 'generate' ? this.state.token : this.state.existingToken;
 
+  canContinue = () => {
+    const { existingToken, selection, token } = this.state;
+    const validExistingToken = existingToken.match(/^[a-z0-9]+$/) != null;
+    return (
+      (selection === 'generate' && token != null) ||
+      (selection === 'use-existing' && existingToken && validExistingToken)
+    );
+  };
+
   handleTokenNameChange = (event /*: { target: HTMLInputElement } */) => {
     this.setState({ tokenName: event.target.value });
   };
@@ -133,17 +154,21 @@ export default class TokenStep extends React.PureComponent {
 
   renderGenerateOption = () => (
     <div>
-      <a
-        className="js-new link-base-color link-no-underline"
-        href="#"
-        onClick={this.handleGenerateClick}>
-        <i
-          className={classNames('icon-radio', 'spacer-right', {
-            'is-checked': this.state.selection === 'generate'
-          })}
-        />
-        {translate('onboading.token.generate_token')}
-      </a>
+      {this.state.canUseExisting ? (
+        <a
+          className="js-new link-base-color link-no-underline"
+          href="#"
+          onClick={this.handleGenerateClick}>
+          <i
+            className={classNames('icon-radio', 'spacer-right', {
+              'is-checked': this.state.selection === 'generate'
+            })}
+          />
+          {translate('onboading.token.generate_token')}
+        </a>
+      ) : (
+        translate('onboading.token.generate_token')
+      )}
       {this.state.selection === 'generate' && (
         <div className="big-spacer-top">
           <form onSubmit={this.handleTokenGenerate}>
@@ -169,37 +194,48 @@ export default class TokenStep extends React.PureComponent {
     </div>
   );
 
-  renderUseExistingOption = () => (
-    <div className="big-spacer-top">
-      <a
-        className="js-new link-base-color link-no-underline"
-        href="#"
-        onClick={this.handleUseExistingClick}>
-        <i
-          className={classNames('icon-radio', 'spacer-right', {
-            'is-checked': this.state.selection === 'use-existing'
-          })}
-        />
-        {translate('onboarding.token.use_existing_token')}
-      </a>
-      {this.state.selection === 'use-existing' && (
-        <div className="big-spacer-top">
-          <input
-            autoFocus={true}
-            className="input-large spacer-right text-middle"
-            onChange={this.handleExisingTokenChange}
-            placeholder={translate('onboarding.token.use_existing_token.placeholder')}
-            required={true}
-            type="text"
-            value={this.state.existingToken}
+  renderUseExistingOption = () => {
+    const { existingToken } = this.state;
+    const validInput = !existingToken || existingToken.match(/^[a-z0-9]+$/) != null;
+
+    return (
+      <div className="big-spacer-top">
+        <a
+          className="js-new link-base-color link-no-underline"
+          href="#"
+          onClick={this.handleUseExistingClick}>
+          <i
+            className={classNames('icon-radio', 'spacer-right', {
+              'is-checked': this.state.selection === 'use-existing'
+            })}
           />
-        </div>
-      )}
-    </div>
-  );
+          {translate('onboarding.token.use_existing_token')}
+        </a>
+        {this.state.selection === 'use-existing' && (
+          <div className="big-spacer-top">
+            <input
+              autoFocus={true}
+              className="input-large spacer-right text-middle"
+              onChange={this.handleExisingTokenChange}
+              placeholder={translate('onboarding.token.use_existing_token.placeholder')}
+              required={true}
+              type="text"
+              value={this.state.existingToken}
+            />
+            {!validInput && (
+              <span className="text-danger">
+                <AlertErrorIcon className="little-spacer-right text-text-top" />
+                {translate('onboarding.token.invalid_format')}
+              </span>
+            )}
+          </div>
+        )}
+      </div>
+    );
+  };
 
   renderForm = () => {
-    const { existingToken, loading, selection, token, tokenName } = this.state;
+    const { canUseExisting, loading, token, tokenName } = this.state;
 
     return (
       <div className="boxed-group-inner">
@@ -221,14 +257,13 @@ export default class TokenStep extends React.PureComponent {
         ) : (
           <div>
             {this.renderGenerateOption()}
-            {this.renderUseExistingOption()}
+            {canUseExisting && this.renderUseExistingOption()}
           </div>
         )}
 
         <div className="note big-spacer-top width-50">{translate('onboarding.token.text')}</div>
 
-        {((selection === 'generate' && token != null) ||
-          (selection === 'use-existing' && existingToken)) && (
+        {this.canContinue() && (
           <div className="big-spacer-top">
             <button className="js-continue" onClick={this.handleContinueClick}>
               {translate('continue')}
index 2c2f07d5b75f2dac565b2df62491890d4f8a9fc4..200a90300528575ca10edfcce1a965b66a042865 100644 (file)
@@ -24,13 +24,17 @@ import TokenStep from '../TokenStep';
 import { change, click, doAsync, submit } from '../../../../helpers/testUtils';
 
 jest.mock('../../../../api/user-tokens', () => ({
+  getTokens: () => Promise.resolve([{ name: 'foo' }]),
   generateToken: () => Promise.resolve({ token: 'abcd1234' }),
   revokeToken: () => Promise.resolve()
 }));
 
-it('generates token', () => {
+const currentUser = { login: 'user' };
+
+it('generates token', async () => {
   const wrapper = mount(
     <TokenStep
+      currentUser={currentUser}
       finished={false}
       open={true}
       onContinue={jest.fn()}
@@ -38,6 +42,7 @@ it('generates token', () => {
       stepNumber={1}
     />
   );
+  await new Promise(setImmediate);
   expect(wrapper).toMatchSnapshot();
   change(wrapper.find('input'), 'my token');
   submit(wrapper.find('form'));
@@ -45,9 +50,10 @@ it('generates token', () => {
   return doAsync(() => expect(wrapper).toMatchSnapshot());
 });
 
-it('revokes token', () => {
+it('revokes token', async () => {
   const wrapper = mount(
     <TokenStep
+      currentUser={currentUser}
       finished={false}
       open={true}
       onContinue={jest.fn()}
@@ -55,6 +61,7 @@ it('revokes token', () => {
       stepNumber={1}
     />
   );
+  await new Promise(setImmediate);
   wrapper.setState({ token: 'abcd1234', tokenName: 'my token' });
   expect(wrapper).toMatchSnapshot();
   submit(wrapper.find('form'));
@@ -62,10 +69,11 @@ it('revokes token', () => {
   return doAsync(() => expect(wrapper).toMatchSnapshot());
 });
 
-it('continues', () => {
+it('continues', async () => {
   const onContinue = jest.fn();
   const wrapper = mount(
     <TokenStep
+      currentUser={currentUser}
       finished={false}
       open={true}
       onContinue={onContinue}
@@ -73,15 +81,17 @@ it('continues', () => {
       stepNumber={1}
     />
   );
+  await new Promise(setImmediate);
   wrapper.setState({ token: 'abcd1234', tokenName: 'my token' });
   click(wrapper.find('.js-continue'));
   expect(onContinue).toBeCalledWith('abcd1234');
 });
 
-it('uses existing token', () => {
+it('uses existing token', async () => {
   const onContinue = jest.fn();
   const wrapper = mount(
     <TokenStep
+      currentUser={currentUser}
       finished={false}
       open={true}
       onContinue={onContinue}
@@ -89,6 +99,7 @@ it('uses existing token', () => {
       stepNumber={1}
     />
   );
+  await new Promise(setImmediate);
   wrapper.setState({ existingToken: 'abcd1234', selection: 'use-existing' });
   click(wrapper.find('.js-continue'));
   expect(onContinue).toBeCalledWith('abcd1234');
index 3fed8ff970892abebf2df85ff3abfa38ee6673c8..55409e8625065674e3b5fd5ce537cd34097ac6ed 100644 (file)
@@ -43,6 +43,12 @@ exports[`guides for on-premise 1`] = `
       </div>
     </header>
     <TokenStep
+      currentUser={
+        Object {
+          "isLoggedIn": true,
+          "login": "admin",
+        }
+      }
       finished={false}
       onContinue={[Function]}
       onOpen={[Function]}
@@ -103,6 +109,12 @@ exports[`guides for on-premise 2`] = `
       </div>
     </header>
     <TokenStep
+      currentUser={
+        Object {
+          "isLoggedIn": true,
+          "login": "admin",
+        }
+      }
       finished={true}
       onContinue={[Function]}
       onOpen={[Function]}
@@ -177,6 +189,12 @@ exports[`guides for sonarcloud 1`] = `
       stepNumber={1}
     />
     <TokenStep
+      currentUser={
+        Object {
+          "isLoggedIn": true,
+          "login": "admin",
+        }
+      }
       finished={false}
       onContinue={[Function]}
       onOpen={[Function]}
@@ -250,6 +268,12 @@ exports[`guides for sonarcloud 2`] = `
       stepNumber={1}
     />
     <TokenStep
+      currentUser={
+        Object {
+          "isLoggedIn": true,
+          "login": "admin",
+        }
+      }
       finished={false}
       onContinue={[Function]}
       onOpen={[Function]}
@@ -324,6 +348,12 @@ exports[`guides for sonarcloud 3`] = `
       stepNumber={1}
     />
     <TokenStep
+      currentUser={
+        Object {
+          "isLoggedIn": true,
+          "login": "admin",
+        }
+      }
       finished={true}
       onContinue={[Function]}
       onOpen={[Function]}
index 38b83ffa20a21b01beea55dad15424cef25838b6..5afe9b488f1ff767b1e85badc763656b09c47f77 100644 (file)
@@ -2,6 +2,11 @@
 
 exports[`generates token 1`] = `
 <TokenStep
+  currentUser={
+    Object {
+      "login": "user",
+    }
+  }
   finished={false}
   onContinue={[Function]}
   onOpen={[Function]}
@@ -99,6 +104,11 @@ exports[`generates token 1`] = `
 
 exports[`generates token 2`] = `
 <TokenStep
+  currentUser={
+    Object {
+      "login": "user",
+    }
+  }
   finished={false}
   onContinue={[Function]}
   onOpen={[Function]}
@@ -193,6 +203,11 @@ exports[`generates token 2`] = `
 
 exports[`generates token 3`] = `
 <TokenStep
+  currentUser={
+    Object {
+      "login": "user",
+    }
+  }
   finished={false}
   onContinue={[Function]}
   onOpen={[Function]}
@@ -285,6 +300,11 @@ exports[`generates token 3`] = `
 
 exports[`revokes token 1`] = `
 <TokenStep
+  currentUser={
+    Object {
+      "login": "user",
+    }
+  }
   finished={false}
   onContinue={[Function]}
   onOpen={[Function]}
@@ -377,6 +397,11 @@ exports[`revokes token 1`] = `
 
 exports[`revokes token 2`] = `
 <TokenStep
+  currentUser={
+    Object {
+      "login": "user",
+    }
+  }
   finished={false}
   onContinue={[Function]}
   onOpen={[Function]}
@@ -451,6 +476,11 @@ exports[`revokes token 2`] = `
 
 exports[`revokes token 3`] = `
 <TokenStep
+  currentUser={
+    Object {
+      "login": "user",
+    }
+  }
   finished={false}
   onContinue={[Function]}
   onOpen={[Function]}
index a7f2bbad4058406eb1c09e66e33972510e5c0183..a6f52214b65311074e543a25caed4886476225eb 100644 (file)
@@ -2432,6 +2432,7 @@ onboading.token.generate_token=Generate a token
 onboading.token.generate_token.placeholder=Enter a name for your token
 onboarding.token.use_existing_token=Use existing token
 onboarding.token.use_existing_token.placeholder=Enter your existing token
+onboarding.token.invalid_format=The token you have entered has invalid format.
 
 onboarding.organization.header=Choose an organization for your project
 onboarding.organization.text=Organizations are where your projects belong. You can add your team members to your organization later to allow them to contribute to your projects.