Browse Source

SONAR-10695 Prompt admin to enter a license on new instance

tags/7.5
Grégoire Aubert 6 years ago
parent
commit
cbf8cf43e9

+ 26
- 0
server/sonar-web/src/main/js/api/marketplace.ts View File

@@ -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<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<{

+ 13
- 45
server/sonar-web/src/main/js/app/components/GlobalContainer.tsx View 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>
);
}

+ 148
- 0
server/sonar-web/src/main/js/app/components/StartupModal.tsx View File

@@ -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
);

+ 137
- 0
server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx View File

@@ -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>
);
}

+ 13
- 32
server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx View 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);

+ 56
- 0
server/sonar-web/src/main/js/apps/marketplace/components/LicensePromptModal.tsx View File

@@ -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>
);
}

Loading…
Cancel
Save