]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-10695 Prompt admin to enter a license on new instance
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>
Fri, 25 May 2018 15:32:53 +0000 (17:32 +0200)
committerSonarTech <sonartech@sonarsource.com>
Tue, 12 Jun 2018 18:20:58 +0000 (20:20 +0200)
server/sonar-web/src/main/js/api/marketplace.ts
server/sonar-web/src/main/js/app/components/GlobalContainer.tsx
server/sonar-web/src/main/js/app/components/StartupModal.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx
server/sonar-web/src/main/js/apps/marketplace/components/LicensePromptModal.tsx [new file with mode: 0644]

index 238efa8b3a3d6258962f5312110ea8816d40bf58..adbb4442343b68705d04fdca2c64f71cf8929e92 100644 (file)
 import { getJSON, postJSON } from '../helpers/request';
 import throwGlobalError from '../app/utils/throwGlobalError';
 
+export interface License {
+  contactEmail: string;
+  edition: string;
+  expiresAt: string;
+  invalidInstalledPlugins: string[];
+  isExpired: boolean;
+  isOfficialDistribution: boolean;
+  isSupported: boolean;
+  isValidServerId: boolean;
+  loc: number;
+  maxLoc: number;
+  plugins: string[];
+  remainingLocThreshold: number;
+  serverId: string;
+  type: string;
+}
+
 export interface EditionStatus {
   currentEditionKey?: string;
 }
@@ -28,6 +45,15 @@ export function getEditionStatus(): Promise<EditionStatus> {
   return getJSON('/api/editions/status');
 }
 
+export function showLicense(): Promise<License> {
+  return getJSON('/api/editions/show_license').catch((e: { response: Response }) => {
+    if (e.response && e.response.status === 404) {
+      return Promise.resolve(undefined);
+    }
+    return throwGlobalError(e);
+  });
+}
+
 export function getLicensePreview(data: {
   license: string;
 }): Promise<{
index 3b81367b34febfd755c89420aa14ac2ee4ccedaf..e61540bd5a8e4814e9cb4cbd893a97bb821e9ee1 100644 (file)
@@ -18,8 +18,8 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import * as PropTypes from 'prop-types';
 import GlobalNav from './nav/global/GlobalNav';
+import StartupModal from './StartupModal';
 import GlobalFooterContainer from './GlobalFooterContainer';
 import GlobalMessagesContainer from './GlobalMessagesContainer';
 import SuggestionsProvider from './embed-docs-modal/SuggestionsProvider';
@@ -30,58 +30,26 @@ interface Props {
   location: { pathname: string };
 }
 
-interface State {
-  isOnboardingTutorialOpen: boolean;
-}
-
-export default class GlobalContainer extends React.PureComponent<Props, State> {
-  static childContextTypes = {
-    closeOnboardingTutorial: PropTypes.func,
-    openOnboardingTutorial: PropTypes.func
-  };
-
-  constructor(props: Props) {
-    super(props);
-    this.state = { isOnboardingTutorialOpen: false };
-  }
-
-  getChildContext() {
-    return {
-      closeOnboardingTutorial: this.closeOnboardingTutorial,
-      openOnboardingTutorial: this.openOnboardingTutorial
-    };
-  }
-
-  openOnboardingTutorial = () => this.setState({ isOnboardingTutorialOpen: true });
-
-  closeOnboardingTutorial = () => this.setState({ isOnboardingTutorialOpen: false });
-
-  render() {
-    // it is important to pass `location` down to `GlobalNav` to trigger render on url change
-
-    return (
-      <SuggestionsProvider>
-        {({ suggestions }) => (
+export default function GlobalContainer(props: Props) {
+  // it is important to pass `location` down to `GlobalNav` to trigger render on url change
+  return (
+    <SuggestionsProvider>
+      {({ suggestions }) => (
+        <StartupModal>
           <div className="global-container">
             <div className="page-wrapper" id="container">
               <div className="page-container">
                 <Workspace>
-                  <GlobalNav
-                    closeOnboardingTutorial={this.closeOnboardingTutorial}
-                    isOnboardingTutorialOpen={this.state.isOnboardingTutorialOpen}
-                    location={this.props.location}
-                    openOnboardingTutorial={this.openOnboardingTutorial}
-                    suggestions={suggestions}
-                  />
+                  <GlobalNav location={props.location} suggestions={suggestions} />
                   <GlobalMessagesContainer />
-                  {this.props.children}
+                  {props.children}
                 </Workspace>
               </div>
             </div>
             <GlobalFooterContainer />
           </div>
-        )}
-      </SuggestionsProvider>
-    );
-  }
+        </StartupModal>
+      )}
+    </SuggestionsProvider>
+  );
 }
diff --git a/server/sonar-web/src/main/js/app/components/StartupModal.tsx b/server/sonar-web/src/main/js/app/components/StartupModal.tsx
new file mode 100644 (file)
index 0000000..f71ddd8
--- /dev/null
@@ -0,0 +1,148 @@
+/*
+ * 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 * as PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import OnboardingModal from '../../apps/tutorials/onboarding/OnboardingModal';
+import LicensePromptModal from '../../apps/marketplace/components/LicensePromptModal';
+import { showLicense } from '../../api/marketplace';
+import { differenceInDays, parseDate, toShortNotSoISOString } from '../../helpers/dates';
+import { hasMessage } from '../../helpers/l10n';
+import { save, get } from '../../helpers/storage';
+import { getCurrentUser, getAppState } from '../../store/rootReducer';
+import { skipOnboarding } from '../../store/users/actions';
+import { CurrentUser, isLoggedIn } from '../types';
+
+interface StateProps {
+  canAdmin: boolean;
+  currentEdition: string;
+  currentUser: CurrentUser;
+}
+
+interface DispatchProps {
+  skipOnboarding: () => void;
+}
+
+interface OwnProps {
+  children?: React.ReactNode;
+}
+
+type Props = StateProps & DispatchProps & OwnProps;
+
+enum ModalKey {
+  license,
+  onboarding
+}
+
+interface State {
+  modal?: ModalKey;
+}
+
+const LICENSE_PROMPT = 'sonarqube.license.prompt';
+
+export class StartupModal extends React.PureComponent<Props, State> {
+  static childContextTypes = {
+    closeOnboardingTutorial: PropTypes.func,
+    openOnboardingTutorial: PropTypes.func
+  };
+
+  state: State = {};
+
+  getChildContext() {
+    return {
+      closeOnboardingTutorial: this.closeOnboarding,
+      openOnboardingTutorial: this.openOnboarding
+    };
+  }
+
+  componentDidMount() {
+    this.tryAutoOpenLicense().catch(this.tryAutoOpenOnboarding);
+  }
+
+  closeOnboarding = () => {
+    this.setState(state => ({
+      modal: state.modal === ModalKey.onboarding ? undefined : state.modal
+    }));
+    this.props.skipOnboarding();
+  };
+
+  closeLicense = () => {
+    this.setState(state => ({
+      modal: state.modal === ModalKey.license ? undefined : state.modal
+    }));
+  };
+
+  openOnboarding = () => {
+    this.setState({ modal: ModalKey.onboarding });
+  };
+
+  tryAutoOpenLicense = () => {
+    const { canAdmin, currentEdition, currentUser } = this.props;
+    const hasLicenseManager = hasMessage('license.prompt.title');
+    if (
+      currentEdition !== 'community' &&
+      isLoggedIn(currentUser) &&
+      canAdmin &&
+      hasLicenseManager
+    ) {
+      const lastPrompt = get(LICENSE_PROMPT, currentUser.login);
+      if (!lastPrompt || differenceInDays(new Date(), parseDate(lastPrompt)) >= 1) {
+        return showLicense().then(license => {
+          if (!license || license.edition !== currentEdition) {
+            save(LICENSE_PROMPT, toShortNotSoISOString(new Date()), currentUser.login);
+            this.setState({ modal: ModalKey.license });
+            return Promise.resolve();
+          }
+          return Promise.reject('License exists');
+        });
+      }
+    }
+    return Promise.reject('No license prompt');
+  };
+
+  tryAutoOpenOnboarding = () => {
+    if (this.props.currentUser.showOnboardingTutorial) {
+      this.openOnboarding();
+    }
+  };
+
+  render() {
+    const { modal } = this.state;
+    return (
+      <>
+        {this.props.children}
+        {modal === ModalKey.license && <LicensePromptModal onClose={this.closeLicense} />}
+        {modal === ModalKey.onboarding && <OnboardingModal onFinish={this.closeOnboarding} />}
+      </>
+    );
+  }
+}
+
+const mapStateToProps = (state: any): StateProps => ({
+  canAdmin: getAppState(state).canAdmin,
+  currentEdition: getAppState(state).edition,
+  currentUser: getCurrentUser(state)
+});
+
+const mapDispatchToProps: DispatchProps = { skipOnboarding };
+
+export default connect<StateProps, DispatchProps, OwnProps>(mapStateToProps, mapDispatchToProps)(
+  StartupModal
+);
diff --git a/server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx b/server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx
new file mode 100644 (file)
index 0000000..dee4a3b
--- /dev/null
@@ -0,0 +1,137 @@
+/*
+ * 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, ShallowWrapper } from 'enzyme';
+import { StartupModal } from '../StartupModal';
+import { showLicense } from '../../../api/marketplace';
+import { save, get } from '../../../helpers/storage';
+import { hasMessage } from '../../../helpers/l10n';
+import { waitAndUpdate } from '../../../helpers/testUtils';
+import { differenceInDays, toShortNotSoISOString } from '../../../helpers/dates';
+import { LoggedInUser } from '../../types';
+
+jest.mock('../../../api/marketplace', () => ({
+  showLicense: jest.fn().mockResolvedValue(undefined)
+}));
+
+jest.mock('../../../helpers/storage', () => ({
+  get: jest.fn(),
+  save: jest.fn()
+}));
+
+jest.mock('../../../helpers/l10n', () => ({
+  hasMessage: jest.fn().mockReturnValue(true)
+}));
+
+jest.mock('../../../helpers/dates', () => ({
+  differenceInDays: jest.fn().mockReturnValue(1),
+  parseDate: jest.fn().mockReturnValue('parsed-date'),
+  toShortNotSoISOString: jest.fn().mockReturnValue('short-not-iso-date')
+}));
+
+const LOGGED_IN_USER: LoggedInUser = {
+  isLoggedIn: true,
+  login: 'luke',
+  name: 'Skywalker',
+  showOnboardingTutorial: false
+};
+
+beforeEach(() => {
+  (differenceInDays as jest.Mock<any>).mockClear();
+  (hasMessage as jest.Mock<any>).mockClear();
+  (get as jest.Mock<any>).mockClear();
+  (save as jest.Mock<any>).mockClear();
+  (showLicense as jest.Mock<any>).mockClear();
+  (toShortNotSoISOString as jest.Mock<any>).mockClear();
+});
+
+it('should render only the children', async () => {
+  const wrapper = getWrapper({ currentEdition: 'community' });
+  await shouldNotHaveModals(wrapper);
+  expect(showLicense).toHaveBeenCalledTimes(0);
+  expect(wrapper.find('div').exists()).toBeTruthy();
+
+  await shouldNotHaveModals(getWrapper({ canAdmin: false }));
+
+  (hasMessage as jest.Mock<any>).mockReturnValueOnce(false);
+  await shouldNotHaveModals(getWrapper());
+
+  (showLicense as jest.Mock<any>).mockResolvedValueOnce({ edition: 'enterprise' });
+  await shouldNotHaveModals(getWrapper());
+
+  (get as jest.Mock<any>).mockReturnValueOnce('date');
+  (differenceInDays as jest.Mock<any>).mockReturnValueOnce(0);
+  await shouldNotHaveModals(getWrapper());
+});
+
+it('should render license prompt', async () => {
+  await shouldDisplayLicense(getWrapper());
+  expect(save).toHaveBeenCalledWith('sonarqube.license.prompt', 'short-not-iso-date', 'luke');
+
+  (get as jest.Mock<any>).mockReturnValueOnce('date');
+  (differenceInDays as jest.Mock<any>).mockReturnValueOnce(1);
+  await shouldDisplayLicense(getWrapper());
+
+  (showLicense as jest.Mock<any>).mockResolvedValueOnce({ edition: 'developer' });
+  await shouldDisplayLicense(getWrapper());
+});
+
+it('should render onboarding modal', async () => {
+  await shouldDisplayOnboarding(
+    getWrapper({
+      canAdmin: false,
+      currentUser: { ...LOGGED_IN_USER, showOnboardingTutorial: true }
+    })
+  );
+
+  (showLicense as jest.Mock<any>).mockResolvedValueOnce({ edition: 'enterprise' });
+  await shouldDisplayOnboarding(
+    getWrapper({ currentUser: { ...LOGGED_IN_USER, showOnboardingTutorial: true } })
+  );
+});
+
+async function shouldNotHaveModals(wrapper: ShallowWrapper) {
+  await waitAndUpdate(wrapper);
+  expect(wrapper.find('LicensePromptModal').exists()).toBeFalsy();
+  expect(wrapper.find('OnboardingModal').exists()).toBeFalsy();
+}
+
+async function shouldDisplayOnboarding(wrapper: ShallowWrapper) {
+  await waitAndUpdate(wrapper);
+  expect(wrapper.find('OnboardingModal').exists()).toBeTruthy();
+}
+
+async function shouldDisplayLicense(wrapper: ShallowWrapper) {
+  await waitAndUpdate(wrapper);
+  expect(wrapper.find('LicensePromptModal').exists()).toBeTruthy();
+}
+
+function getWrapper(props = {}) {
+  return shallow(
+    <StartupModal
+      canAdmin={true}
+      currentEdition="enterprise"
+      currentUser={LOGGED_IN_USER}
+      skipOnboarding={jest.fn()}
+      {...props}>
+      <div />
+    </StartupModal>
+  );
+}
index dc3d5127587b1383e8119ee9d69a7f53bcc85c34..062a97a44454ec7abdaf66fe60bdfc592d48cf17 100644 (file)
@@ -18,6 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import * as PropTypes from 'prop-types';
 import { connect } from 'react-redux';
 import GlobalNavBranding from './GlobalNavBranding';
 import GlobalNavMenu from './GlobalNavMenu';
@@ -27,13 +28,11 @@ import Search from '../../search/Search';
 import EmbedDocsPopupHelper from '../../embed-docs-modal/EmbedDocsPopupHelper';
 import * as theme from '../../../theme';
 import { isLoggedIn, CurrentUser, AppState } from '../../../types';
-import OnboardingModal from '../../../../apps/tutorials/onboarding/OnboardingModal';
 import NavBar from '../../../../components/nav/NavBar';
 import Tooltip from '../../../../components/controls/Tooltip';
 import { lazyLoad } from '../../../../components/lazyLoad';
 import { translate } from '../../../../helpers/l10n';
 import { getCurrentUser, getAppState } from '../../../../store/rootReducer';
-import { skipOnboarding } from '../../../../store/users/actions';
 import { SuggestionLink } from '../../embed-docs-modal/SuggestionsProvider';
 import { isSonarCloud } from '../../../../helpers/system';
 import './GlobalNav.css';
@@ -45,32 +44,26 @@ interface StateProps {
   currentUser: CurrentUser;
 }
 
-interface DispatchProps {
-  skipOnboarding: () => void;
-}
-
-interface Props extends StateProps, DispatchProps {
-  closeOnboardingTutorial: () => void;
-  isOnboardingTutorialOpen: boolean;
+interface OwnProps {
   location: { pathname: string };
-  openOnboardingTutorial: () => void;
   suggestions: Array<SuggestionLink>;
 }
 
+type Props = StateProps & OwnProps;
+
 interface State {
-  helpOpen: boolean;
   onboardingTutorialTooltip: boolean;
 }
 
 class GlobalNav extends React.PureComponent<Props, State> {
   interval?: number;
-  state: State = { helpOpen: false, onboardingTutorialTooltip: false };
 
-  componentDidMount() {
-    if (this.props.currentUser.showOnboardingTutorial) {
-      this.openOnboardingTutorial();
-    }
-  }
+  static contextTypes = {
+    closeOnboardingTutorial: PropTypes.func,
+    openOnboardingTutorial: PropTypes.func
+  };
+
+  state: State = { onboardingTutorialTooltip: false };
 
   componentWillUnmount() {
     if (this.interval) {
@@ -78,15 +71,9 @@ class GlobalNav extends React.PureComponent<Props, State> {
     }
   }
 
-  openOnboardingTutorial = () => {
-    this.setState({ helpOpen: false });
-    this.props.openOnboardingTutorial();
-  };
-
   closeOnboardingTutorial = () => {
     this.setState({ onboardingTutorialTooltip: true });
-    this.props.skipOnboarding();
-    this.props.closeOnboardingTutorial();
+    this.context.closeOnboardingTutorial();
     this.interval = window.setInterval(() => {
       this.setState({ onboardingTutorialTooltip: false });
     }, 3000);
@@ -113,15 +100,11 @@ class GlobalNav extends React.PureComponent<Props, State> {
               <Tooltip
                 overlay={translate('tutorials.follow_later')}
                 visible={this.state.onboardingTutorialTooltip}>
-                <GlobalNavPlus openOnboardingTutorial={this.openOnboardingTutorial} />
+                <GlobalNavPlus openOnboardingTutorial={this.context.openOnboardingTutorial} />
               </Tooltip>
             )}
           <GlobalNavUserContainer {...this.props} />
         </ul>
-
-        {this.props.isOnboardingTutorialOpen && (
-          <OnboardingModal onFinish={this.closeOnboardingTutorial} />
-        )}
       </NavBar>
     );
   }
@@ -132,6 +115,4 @@ const mapStateToProps = (state: any): StateProps => ({
   appState: getAppState(state)
 });
 
-const mapDispatchToProps: DispatchProps = { skipOnboarding };
-
-export default connect(mapStateToProps, mapDispatchToProps)(GlobalNav);
+export default connect<StateProps, {}, OwnProps>(mapStateToProps)(GlobalNav);
diff --git a/server/sonar-web/src/main/js/apps/marketplace/components/LicensePromptModal.tsx b/server/sonar-web/src/main/js/apps/marketplace/components/LicensePromptModal.tsx
new file mode 100644 (file)
index 0000000..d8b5a03
--- /dev/null
@@ -0,0 +1,56 @@
+/*
+ * 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 { FormattedMessage } from 'react-intl';
+import { Link } from 'react-router';
+import Modal from '../../../components/controls/Modal';
+import { translate } from '../../../helpers/l10n';
+import { ResetButtonLink } from '../../../components/ui/buttons';
+
+interface Props {
+  onClose: () => void;
+}
+
+export default function LicensePromptModal({ onClose }: Props) {
+  const header = translate('license.prompt.title');
+  return (
+    <Modal contentLabel={header} onRequestClose={onClose}>
+      <header className="modal-head">
+        <h2>{header}</h2>
+      </header>
+      <div className="modal-body">
+        <FormattedMessage
+          defaultMessage={translate('license.prompt.description')}
+          id={'license.prompt.description'}
+          values={{
+            url: (
+              <Link onClick={onClose} to="/admin/extension/license/app">
+                {translate('license.prompt.link')}
+              </Link>
+            )
+          }}
+        />
+      </div>
+      <footer className="modal-foot">
+        <ResetButtonLink onClick={onClose}>{translate('cancel')}</ResetButtonLink>
+      </footer>
+    </Modal>
+  );
+}