From: Grégoire Aubert Date: Fri, 25 May 2018 15:32:53 +0000 (+0200) Subject: SONAR-10695 Prompt admin to enter a license on new instance X-Git-Tag: 7.5~1040 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=cbf8cf43e9b606681e63454dbfd83d2472c837c6;p=sonarqube.git SONAR-10695 Prompt admin to enter a license on new instance --- diff --git a/server/sonar-web/src/main/js/api/marketplace.ts b/server/sonar-web/src/main/js/api/marketplace.ts index 238efa8b3a3..adbb4442343 100644 --- a/server/sonar-web/src/main/js/api/marketplace.ts +++ b/server/sonar-web/src/main/js/api/marketplace.ts @@ -20,6 +20,23 @@ 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 { return getJSON('/api/editions/status'); } +export function showLicense(): Promise { + 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<{ diff --git a/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx b/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx index 3b81367b34f..e61540bd5a8 100644 --- a/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx +++ b/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx @@ -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 { - 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 ( - - {({ suggestions }) => ( +export default function GlobalContainer(props: Props) { + // it is important to pass `location` down to `GlobalNav` to trigger render on url change + return ( + + {({ suggestions }) => ( +
- + - {this.props.children} + {props.children}
- )} -
- ); - } + + )} +
+ ); } 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 index 00000000000..f71ddd87bad --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/StartupModal.tsx @@ -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 { + 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 && } + {modal === ModalKey.onboarding && } + + ); + } +} + +const mapStateToProps = (state: any): StateProps => ({ + canAdmin: getAppState(state).canAdmin, + currentEdition: getAppState(state).edition, + currentUser: getCurrentUser(state) +}); + +const mapDispatchToProps: DispatchProps = { skipOnboarding }; + +export default connect(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 index 00000000000..dee4a3bcd29 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx @@ -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).mockClear(); + (hasMessage as jest.Mock).mockClear(); + (get as jest.Mock).mockClear(); + (save as jest.Mock).mockClear(); + (showLicense as jest.Mock).mockClear(); + (toShortNotSoISOString as jest.Mock).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).mockReturnValueOnce(false); + await shouldNotHaveModals(getWrapper()); + + (showLicense as jest.Mock).mockResolvedValueOnce({ edition: 'enterprise' }); + await shouldNotHaveModals(getWrapper()); + + (get as jest.Mock).mockReturnValueOnce('date'); + (differenceInDays as jest.Mock).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).mockReturnValueOnce('date'); + (differenceInDays as jest.Mock).mockReturnValueOnce(1); + await shouldDisplayLicense(getWrapper()); + + (showLicense as jest.Mock).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).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( + +
+ + ); +} diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx index dc3d5127587..062a97a4445 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx @@ -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; } +type Props = StateProps & OwnProps; + interface State { - helpOpen: boolean; onboardingTutorialTooltip: boolean; } class GlobalNav extends React.PureComponent { 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 { } } - 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 { - + )} - - {this.props.isOnboardingTutorialOpen && ( - - )} ); } @@ -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(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 index 00000000000..d8b5a037696 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/marketplace/components/LicensePromptModal.tsx @@ -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 ( + +
+

{header}

+
+
+ + {translate('license.prompt.link')} + + ) + }} + /> +
+ +
+ ); +}