diff options
author | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2018-06-29 16:33:30 +0200 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2018-07-11 20:21:21 +0200 |
commit | 477cfbd296c49853604563e24fc2c9987b4f751e (patch) | |
tree | 02346fa12e2f500fd0bf8b0e712e99a242d61609 | |
parent | 65950a3720eab9c67e2d99ca6e7973de13a54bcb (diff) | |
download | sonarqube-477cfbd296c49853604563e24fc2c9987b4f751e.tar.gz sonarqube-477cfbd296c49853604563e24fc2c9987b4f751e.zip |
SONAR-10945 QP and QG pages should only be visible only to members of paid organizations
* SONAR-10968 Warn user about project privacy after billing upgrade
* SONAR-10949 Show QG, QP, members and rules only when the user has correct access
* SONAR-10959 Do not display Rules, QP and QG pages for non members of paid organizations
* SONAR-10961 Do not display Members page for non member of private organizations
* Remove rule permalink in issues page for non members of paid orgs
* Do not display QP, QG on project overview page
40 files changed, 704 insertions, 465 deletions
diff --git a/server/sonar-docs/src/pages/organizations/organization-and-project-privacy.md b/server/sonar-docs/src/pages/organizations/organization-and-project-privacy.md new file mode 100644 index 00000000000..639f9ce1685 --- /dev/null +++ b/server/sonar-docs/src/pages/organizations/organization-and-project-privacy.md @@ -0,0 +1,6 @@ +--- +title: Organization and Project Privacy +scope: sonarcloud +--- + +## TODO diff --git a/server/sonar-web/src/main/js/api/organizations.ts b/server/sonar-web/src/main/js/api/organizations.ts index b793d7412c3..95918a609b3 100644 --- a/server/sonar-web/src/main/js/api/organizations.ts +++ b/server/sonar-web/src/main/js/api/organizations.ts @@ -19,33 +19,15 @@ */ import { getJSON, post, postJSON, RequestData } from '../helpers/request'; import throwGlobalError from '../app/utils/throwGlobalError'; -import { Paging } from '../app/types'; +import { LightOrganization, Paging } from '../app/types'; -interface GetOrganizationsParameters { +export function getOrganizations(data: { organizations?: string; member?: boolean; -} - -interface GetOrganizationsResponse { - organizations: Array<{ - avatar?: string; - description?: string; - guarded: boolean; - isAdmin: boolean; - key: string; - name: string; - url?: string; - }>; - paging: { - pageIndex: number; - pageSize: number; - total: number; - }; -} - -export function getOrganizations( - data: GetOrganizationsParameters -): Promise<GetOrganizationsResponse> { +}): Promise<{ + organizations: LightOrganization[]; + paging: Paging; +}> { return getJSON('/api/organizations/search', data); } diff --git a/server/sonar-web/src/main/js/app/components/extensions/OrganizationPageExtension.js b/server/sonar-web/src/main/js/app/components/extensions/OrganizationPageExtension.js index dc9980c1c1f..f93fc31fba3 100644 --- a/server/sonar-web/src/main/js/app/components/extensions/OrganizationPageExtension.js +++ b/server/sonar-web/src/main/js/app/components/extensions/OrganizationPageExtension.js @@ -24,7 +24,7 @@ import ExtensionContainer from './ExtensionContainer'; import ExtensionNotFound from './ExtensionNotFound'; import { getOrganizationByKey } from '../../../store/rootReducer'; import { fetchOrganization } from '../../../apps/organizations/actions'; -/*:: import type { Organization } from '../../../store/organizations/duck'; */ +/*:: import type { Organization } from '../../../app/types'; */ /*:: type Props = { diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavHeader.tsx b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavHeader.tsx index 325d8e0b613..150c9c50923 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavHeader.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavHeader.tsx @@ -105,8 +105,7 @@ function renderBreadcrumbs(breadcrumbs: Breadcrumb[]) { } const mapStateToProps = (state: any, ownProps: OwnProps): StateProps => ({ - organization: - ownProps.component.organization && getOrganizationByKey(state, ownProps.component.organization), + organization: getOrganizationByKey(state, ownProps.component.organization), shouldOrganizationBeDisplayed: areThereCustomOrganizations(state) }); diff --git a/server/sonar-web/src/main/js/app/types.ts b/server/sonar-web/src/main/js/app/types.ts index 97168176c94..6d359757c55 100644 --- a/server/sonar-web/src/main/js/app/types.ts +++ b/server/sonar-web/src/main/js/app/types.ts @@ -337,22 +337,32 @@ export interface Notification { projectName?: string; type: string; } +export interface LightOrganization { + avatar?: string; + description?: string; + guarded?: boolean; + isAdmin?: boolean; + key: string; + name: string; + subscription?: OrganizationSubscription; + url?: string; +} -export interface Organization { +export interface Organization extends LightOrganization { adminPages?: { key: string; name: string }[]; - avatar?: string; canAdmin?: boolean; canDelete?: boolean; canProvisionProjects?: boolean; canUpdateProjectsVisibilityToPrivate?: boolean; - description?: string; - isAdmin?: boolean; isDefault?: boolean; - key: string; - name: string; pages?: { key: string; name: string }[]; - projectVisibility: Visibility; - url?: string; + projectVisibility?: Visibility; +} + +export enum OrganizationSubscription { + Free = 'FREE', + Paid = 'PAID', + SonarQube = 'SONARQUBE' } export interface Paging { diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsMeta.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsMeta.tsx index 110956c04fb..def6cd3f6d5 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsMeta.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsMeta.tsx @@ -39,6 +39,7 @@ import { PopupPlacement } from '../../../components/ui/popups'; interface Props { canWrite: boolean | undefined; + hidePermalink?: boolean; hideSimilarRulesFilter?: boolean; onFilterChange: (changes: Partial<Query>) => void; onTagsChange: (tags: string[]) => void; @@ -230,21 +231,22 @@ export default class RuleDetailsMeta extends React.PureComponent<Props> { }; render() { - const { ruleDetails } = this.props; + const { hidePermalink, ruleDetails } = this.props; const hasTypeData = !ruleDetails.isExternal || ruleDetails.type !== 'UNKNOWN'; return ( <div className="js-rule-meta"> <header className="page-header"> <div className="pull-right"> <span className="note text-middle">{ruleDetails.key}</span> - {!ruleDetails.isExternal && ( - <Link - className="coding-rules-detail-permalink link-no-underline spacer-left text-middle" - title={translate('permalink')} - to={getRuleUrl(ruleDetails.key, this.props.organization)}> - <LinkIcon /> - </Link> - )} + {!ruleDetails.isExternal && + !hidePermalink && ( + <Link + className="coding-rules-detail-permalink link-no-underline spacer-left text-middle" + title={translate('permalink')} + to={getRuleUrl(ruleDetails.key, this.props.organization)}> + <LinkIcon /> + </Link> + )} {!this.props.hideSimilarRulesFilter && ( <SimilarRulesFilter onFilterChange={this.props.onFilterChange} rule={ruleDetails} /> )} diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/RuleDetailsMeta-test.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/RuleDetailsMeta-test.tsx index 1380e6c0bfc..fd496d8f03e 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/RuleDetailsMeta-test.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/RuleDetailsMeta-test.tsx @@ -85,6 +85,14 @@ it('should edit tags', () => { expect(onTagsChange).toBeCalledWith(['foo', 'bar']); }); +it('should not display rule permalink', () => { + expect( + getWrapper({ hidePermalink: true }) + .find('.coding-rules-detail-permalink') + .exists() + ).toBeFalsy(); +}); + function getWrapper(props = {}) { return shallow( <RuleDetailsMeta diff --git a/server/sonar-web/src/main/js/apps/organizations/actions.js b/server/sonar-web/src/main/js/apps/organizations/actions.js index c70eb93dd58..2de045b6e89 100644 --- a/server/sonar-web/src/main/js/apps/organizations/actions.js +++ b/server/sonar-web/src/main/js/apps/organizations/actions.js @@ -27,7 +27,7 @@ import { onFail } from '../../store/rootActions'; import { getOrganizationMembersState } from '../../store/rootReducer'; import { addGlobalSuccessMessage } from '../../store/globalMessages/duck'; import { translate, translateWithParameters } from '../../helpers/l10n'; -/*:: import type { Organization } from '../../store/organizations/duck'; */ +/*:: import type { Organization } from '../../app/types'; */ /*:: import type { Member } from '../../store/organizationsMembers/actions'; */ const PAGE_SIZE = 50; diff --git a/server/sonar-web/src/main/js/apps/organizations/components/MembersList.js b/server/sonar-web/src/main/js/apps/organizations/components/MembersList.js index 5905d330a4c..4ed74e4169e 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/MembersList.js +++ b/server/sonar-web/src/main/js/apps/organizations/components/MembersList.js @@ -21,12 +21,12 @@ import React from 'react'; import MembersListItem from './MembersListItem'; /*:: import type { Member } from '../../../store/organizationsMembers/actions'; */ -/*:: import type { Organization, OrgGroup } from '../../../store/organizations/duck'; */ +/*:: import type { Organization, Group } from '../../../app/types'; */ /*:: type Props = { members: Array<Member>, - organizationGroups: Array<OrgGroup>, + organizationGroups: Array<Group>, organization: Organization, removeMember: Member => void, updateMemberGroups: (member: Member, add: Array<string>, remove: Array<string>) => void diff --git a/server/sonar-web/src/main/js/apps/organizations/components/MembersListItem.js b/server/sonar-web/src/main/js/apps/organizations/components/MembersListItem.js index 792f218ffac..b17f5ee3991 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/MembersListItem.js +++ b/server/sonar-web/src/main/js/apps/organizations/components/MembersListItem.js @@ -29,13 +29,13 @@ import ActionsDropdown, { ActionsDropdownItem } from '../../../components/controls/ActionsDropdown'; /*:: import type { Member } from '../../../store/organizationsMembers/actions'; */ -/*:: import type { Organization, OrgGroup } from '../../../store/organizations/duck'; */ +/*:: import type { Organization, Group } from '../../../app/types'; */ /*:: type Props = { member: Member, organization: Organization, - organizationGroups: Array<OrgGroup>, + organizationGroups: Array<Group>, removeMember: Member => void, updateMemberGroups: (member: Member, add: Array<string>, remove: Array<string>) => void }; diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationAdminContainer.tsx b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationAccessContainer.tsx index 1e1ebd4142b..aab0246bd05 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationAdminContainer.tsx +++ b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationAccessContainer.tsx @@ -20,11 +20,13 @@ import * as React from 'react'; import { connect } from 'react-redux'; import { RouterState } from 'react-router'; -import { getOrganizationByKey } from '../../../store/rootReducer'; +import { getOrganizationByKey, getCurrentUser } from '../../../store/rootReducer'; import handleRequiredAuthorization from '../../../app/utils/handleRequiredAuthorization'; -import { Organization } from '../../../app/types'; +import { Organization, CurrentUser, isLoggedIn } from '../../../app/types'; +import { isCurrentUserMemberOf, hasPrivateAccess } from '../../../helpers/organizations'; interface StateToProps { + currentUser: CurrentUser; organization?: Organization; } @@ -32,9 +34,11 @@ interface OwnProps extends RouterState { children: JSX.Element; } -interface Props extends StateToProps, Pick<OwnProps, 'children' | 'location'> {} +interface Props extends StateToProps, Pick<OwnProps, 'children' | 'location'> { + hasAccess: (props: Props) => boolean; +} -export class OrganizationAdmin extends React.PureComponent<Props> { +export class OrganizationAccess extends React.PureComponent<Props> { componentDidMount() { this.checkPermissions(); } @@ -43,16 +47,14 @@ export class OrganizationAdmin extends React.PureComponent<Props> { this.checkPermissions(); } - isOrganizationAdmin = () => this.props.organization && this.props.organization.canAdmin; - checkPermissions = () => { - if (!this.isOrganizationAdmin()) { + if (!this.props.hasAccess(this.props)) { handleRequiredAuthorization(); } }; render() { - if (!this.isOrganizationAdmin()) { + if (!this.props.hasAccess(this.props)) { return null; } return React.cloneElement(this.props.children, { @@ -63,7 +65,40 @@ export class OrganizationAdmin extends React.PureComponent<Props> { } const mapStateToProps = (state: any, ownProps: OwnProps) => ({ + currentUser: getCurrentUser(state), organization: getOrganizationByKey(state, ownProps.params.organizationKey) }); -export default connect<StateToProps, {}, OwnProps>(mapStateToProps)(OrganizationAdmin); +const OrganizationAccessContainer = connect<StateToProps, {}, OwnProps>(mapStateToProps)( + OrganizationAccess +); + +export function OrganizationPrivateAccess(props: OwnProps) { + return ( + <OrganizationAccessContainer + hasAccess={({ organization }: StateToProps) => hasPrivateAccess(organization)} + {...props} + /> + ); +} + +export function OrganizationMembersAccess(props: OwnProps) { + return ( + <OrganizationAccessContainer + hasAccess={({ organization }: StateToProps) => isCurrentUserMemberOf(organization)} + {...props} + /> + ); +} + +export function hasAdminAccess({ + currentUser, + organization +}: Pick<StateToProps, 'currentUser' | 'organization'>) { + const isAdmin = isLoggedIn(currentUser) && organization && organization.canAdmin; + return Boolean(isAdmin); +} + +export function OrganizationAdminAccess(props: OwnProps) { + return <OrganizationAccessContainer hasAccess={hasAdminAccess} {...props} />; +} diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationEdit.js b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationEdit.js index 770c9587bac..b78290609b8 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationEdit.js +++ b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationEdit.js @@ -23,10 +23,9 @@ import Helmet from 'react-helmet'; import { connect } from 'react-redux'; import { debounce } from 'lodash'; import { translate } from '../../../helpers/l10n'; -/*:: import type { Organization } from '../../../store/organizations/duck'; */ -import { getOrganizationByKey } from '../../../store/rootReducer'; import { updateOrganization } from '../actions'; import { SubmitButton } from '../../../components/ui/buttons'; +/*:: import type { Organization } from '../../../app/types'; */ /*:: type Props = { diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationGroupCheckbox.js b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationGroupCheckbox.js index f62310aa435..95fd1e085e9 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationGroupCheckbox.js +++ b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationGroupCheckbox.js @@ -20,11 +20,11 @@ //@flow import React from 'react'; import Checkbox from '../../../components/controls/Checkbox'; -/*:: import type { OrgGroup } from '../../../store/organizations/duck'; */ +/*:: import type { Group } from '../../../app/types'; */ /*:: type Props = { - group: OrgGroup, + group: Group, checked: boolean, onCheck: (string, boolean) => void }; diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembers.js b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembers.js index b56d8b58188..fdafac5ca40 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembers.js +++ b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembers.js @@ -28,14 +28,14 @@ import Suggestions from '../../../app/components/embed-docs-modal/Suggestions'; import ListFooter from '../../../components/controls/ListFooter'; import DocTooltip from '../../../components/docs/DocTooltip'; import { translate } from '../../../helpers/l10n'; -/*:: import type { Organization, OrgGroup } from '../../../store/organizations/duck'; */ +/*:: import type { Organization, Group } from '../../../app/types'; */ /*:: import type { Member } from '../../../store/organizationsMembers/actions'; */ /*:: type Props = { members: Array<Member>, memberLogins: Array<string>, - organizationGroups: Array<OrgGroup>, + organizationGroups: Array<Group>, status: { loading?: boolean, total?: number, pageIndex?: number, query?: string }, organization: Organization, fetchOrganizationMembers: (organizationKey: string, query?: string) => void, diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationPage.js b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationPage.tsx index 67077233600..4cc5dd277f2 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationPage.js +++ b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationPage.tsx @@ -17,8 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -// @flow -import React from 'react'; +import * as React from 'react'; import Helmet from 'react-helmet'; import { connect } from 'react-redux'; import OrganizationNavigation from '../navigation/OrganizationNavigation'; @@ -26,41 +25,38 @@ import NotFound from '../../../app/components/NotFound'; import Suggestions from '../../../app/components/embed-docs-modal/Suggestions'; import { fetchOrganization } from '../actions'; import { getOrganizationByKey } from '../../../store/rootReducer'; -/*:: import type { Organization } from '../../../store/organizations/duck'; */ - -/*:: -type OwnProps = { - params: { organizationKey: string } -}; -*/ - -/*:: -type Props = { - children?: React.Element<*>, - location: Object, - organization: null | Organization, - params: { organizationKey: string }, - fetchOrganization: string => Promise<*> -}; -*/ - -/*:: -type State = { - loading: boolean -}; -*/ - -export class OrganizationPage extends React.PureComponent { - /*:: mounted: boolean; */ - /*:: props: Props; */ - state /*: State */ = { loading: true }; +import { Organization } from '../../../app/types'; + +interface OwnProps { + children?: React.ReactNode; + location: { pathname: string }; + params: { organizationKey: string }; +} + +interface StateProps { + organization?: Organization; +} + +interface DispatchToProps { + fetchOrganization: (organizationKey: string) => Promise<void>; +} + +type Props = OwnProps & StateProps & DispatchToProps; + +interface State { + loading: boolean; +} + +export class OrganizationPage extends React.PureComponent<Props, State> { + mounted = false; + state: State = { loading: true }; componentDidMount() { this.mounted = true; this.updateOrganization(this.props.params.organizationKey); } - componentWillReceiveProps(nextProps /*: Props */) { + componentWillReceiveProps(nextProps: Props) { if (nextProps.params.organizationKey !== this.props.params.organizationKey) { this.updateOrganization(nextProps.params.organizationKey); } @@ -76,7 +72,7 @@ export class OrganizationPage extends React.PureComponent { } }; - updateOrganization = (organizationKey /*: string */) => { + updateOrganization = (organizationKey: string) => { this.setState({ loading: true }); this.props.fetchOrganization(organizationKey).then(this.stopLoading, this.stopLoading); }; @@ -103,10 +99,12 @@ export class OrganizationPage extends React.PureComponent { } } -const mapStateToProps = (state, ownProps /*: OwnProps */) => ({ +const mapStateToProps = (state: any, ownProps: OwnProps) => ({ organization: getOrganizationByKey(state, ownProps.params.organizationKey) }); -const mapDispatchToProps = { fetchOrganization }; +const mapDispatchToProps = { fetchOrganization: fetchOrganization as any }; -export default connect(mapStateToProps, mapDispatchToProps)(OrganizationPage); +export default connect<StateProps, DispatchToProps, OwnProps>(mapStateToProps, mapDispatchToProps)( + OrganizationPage +); diff --git a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationAccessContainer-test.tsx b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationAccessContainer-test.tsx new file mode 100644 index 00000000000..17b1fd80bb1 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationAccessContainer-test.tsx @@ -0,0 +1,88 @@ +/* + * 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 { Location } from 'history'; +import { hasAdminAccess, OrganizationAccess } from '../OrganizationAccessContainer'; +import { Visibility } from '../../../../app/types'; + +jest.mock('../../../../app/utils/handleRequiredAuthorization', () => ({ default: jest.fn() })); + +const locationMock = {} as Location; + +const currentUser = { + isLoggedIn: false +}; + +const loggedInUser = { + isLoggedIn: true, + login: 'luke', + name: 'Skywalker', + showOnboardingTutorial: false +}; + +const organization = { + canAdmin: false, + key: 'foo', + name: 'Foo', + projectVisibility: Visibility.Public +}; + +const adminOrganization = { ...organization, canAdmin: true }; + +describe('component', () => { + it('should render children', () => { + expect( + shallow( + <OrganizationAccess + currentUser={loggedInUser} + hasAccess={() => true} + location={locationMock} + organization={adminOrganization}> + <div>hello</div> + </OrganizationAccess> + ) + ).toMatchSnapshot(); + }); + + it('should not render anything', () => { + expect( + shallow( + <OrganizationAccess + currentUser={loggedInUser} + hasAccess={() => false} + location={locationMock} + organization={adminOrganization}> + <div>hello</div> + </OrganizationAccess> + ).type() + ).toBeNull(); + }); +}); + +describe('access functions', () => { + it('should correctly handle access to admin only space', () => { + expect( + hasAdminAccess({ currentUser: loggedInUser, organization: adminOrganization }) + ).toBeTruthy(); + expect(hasAdminAccess({ currentUser, organization: adminOrganization })).toBeFalsy(); + expect(hasAdminAccess({ currentUser: loggedInUser, organization })).toBeFalsy(); + }); +}); diff --git a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationAdminContainer-test.tsx b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationAdminContainer-test.tsx deleted file mode 100644 index 4f7f2669758..00000000000 --- a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationAdminContainer-test.tsx +++ /dev/null @@ -1,60 +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 * as React from 'react'; -import { shallow } from 'enzyme'; -import { Location } from 'history'; -import { OrganizationAdmin } from '../OrganizationAdminContainer'; -import { Visibility } from '../../../../app/types'; - -jest.mock('../../../../app/utils/handleRequiredAuthorization', () => ({ default: jest.fn() })); - -const locationMock = {} as Location; - -it('should render children', () => { - const organization = { - canAdmin: true, - key: 'foo', - name: 'Foo', - projectVisibility: Visibility.Public - }; - expect( - shallow( - <OrganizationAdmin organization={organization} location={locationMock}> - <div>hello</div> - </OrganizationAdmin> - ) - ).toMatchSnapshot(); -}); - -it('should not render anything', () => { - const organization = { - canAdmin: false, - key: 'foo', - name: 'Foo', - projectVisibility: Visibility.Public - }; - expect( - shallow( - <OrganizationAdmin organization={organization} location={locationMock}> - <div>hello</div> - </OrganizationAdmin> - ).type() - ).toBeNull(); -}); diff --git a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationAdminContainer-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationAccessContainer-test.tsx.snap index 349db2dd14c..df4447403e6 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationAdminContainer-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationAccessContainer-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should render children 1`] = ` +exports[`component should render children 1`] = ` <div location={Object {}} organization={ diff --git a/server/sonar-web/src/main/js/apps/organizations/components/forms/AddMemberForm.js b/server/sonar-web/src/main/js/apps/organizations/components/forms/AddMemberForm.js index 90e04927efb..1fe1b0d7d6a 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/forms/AddMemberForm.js +++ b/server/sonar-web/src/main/js/apps/organizations/components/forms/AddMemberForm.js @@ -24,7 +24,7 @@ import { searchMembers } from '../../../../api/organizations'; import Modal from '../../../../components/controls/Modal'; import { translate } from '../../../../helpers/l10n'; import { SubmitButton, ResetButtonLink, Button } from '../../../../components/ui/buttons'; -/*:: import type { Organization } from '../../../../store/organizations/duck'; */ +/*:: import type { Organization } from '../../../../app/types'; */ /*:: import type { Member } from '../../../../store/organizationsMembers/actions'; */ /*:: diff --git a/server/sonar-web/src/main/js/apps/organizations/components/forms/ManageMemberGroupsForm.js b/server/sonar-web/src/main/js/apps/organizations/components/forms/ManageMemberGroupsForm.js index bf15a60e97a..563bf17505c 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/forms/ManageMemberGroupsForm.js +++ b/server/sonar-web/src/main/js/apps/organizations/components/forms/ManageMemberGroupsForm.js @@ -26,14 +26,14 @@ import { translate, translateWithParameters } from '../../../../helpers/l10n'; import OrganizationGroupCheckbox from '../OrganizationGroupCheckbox'; import { SubmitButton, ResetButtonLink } from '../../../../components/ui/buttons'; /*:: import type { Member } from '../../../../store/organizationsMembers/actions'; */ -/*:: import type { Organization, OrgGroup } from '../../../../store/organizations/duck'; */ +/*:: import type { Organization, Group } from '../../../../app/types'; */ /*:: type Props = { onClose: () => void; member: Member, organization: Organization, - organizationGroups: Array<OrgGroup>, + organizationGroups: Array<Group>, updateMemberGroups: (member: Member, add: Array<string>, remove: Array<string>) => void }; */ diff --git a/server/sonar-web/src/main/js/apps/organizations/components/forms/RemoveMemberForm.js b/server/sonar-web/src/main/js/apps/organizations/components/forms/RemoveMemberForm.js index e1b48aa57c8..b33c53f0907 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/forms/RemoveMemberForm.js +++ b/server/sonar-web/src/main/js/apps/organizations/components/forms/RemoveMemberForm.js @@ -23,7 +23,7 @@ import Modal from '../../../../components/controls/Modal'; import { translate, translateWithParameters } from '../../../../helpers/l10n'; import { SubmitButton, ResetButtonLink } from '../../../../components/ui/buttons'; /*:: import type { Member } from '../../../../store/organizationsMembers/actions'; */ -/*:: import type { Organization } from '../../../../store/organizations/duck'; */ +/*:: import type { Organization } from '../../../../app/types'; */ /*:: type Props = { diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigation.tsx b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigation.tsx index f9b1e0ddf81..3ee87fc0f36 100644 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigation.tsx +++ b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigation.tsx @@ -30,7 +30,7 @@ interface Props { organization: Organization; } -export default function OrganizationNavigation({ organization, location }: Props) { +export default function OrganizationNavigation({ location, organization }: Props) { return ( <ContextNavBar height={theme.contextNavHeightRaw} id="context-navigation"> <div className="navbar-context-justified"> diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationMenu.tsx b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationMenu.tsx index bbce6164647..e63d8b4b0b3 100644 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationMenu.tsx +++ b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationMenu.tsx @@ -25,6 +25,7 @@ import { Organization } from '../../../app/types'; import NavBarTabs from '../../../components/nav/NavBarTabs'; import { translate } from '../../../helpers/l10n'; import { getQualityGatesUrl } from '../../../helpers/urls'; +import { hasPrivateAccess, isCurrentUserMemberOf } from '../../../helpers/organizations'; interface Props { location: { pathname: string }; @@ -49,26 +50,36 @@ export default function OrganizationNavigationMenu({ location, organization }: P {translate('issues.page')} </Link> </li> - <li> - <Link activeClassName="active" to={`/organizations/${organization.key}/quality_profiles`}> - {translate('quality_profiles.page')} - </Link> - </li> - <li> - <Link activeClassName="active" to={`/organizations/${organization.key}/rules`}> - {translate('coding_rules.page')} - </Link> - </li> - <li> - <Link activeClassName="active" to={getQualityGatesUrl(organization.key)}> - {translate('quality_gates.page')} - </Link> - </li> - <li> - <Link activeClassName="active" to={`/organizations/${organization.key}/members`}> - {translate('organization.members.page')} - </Link> - </li> + {hasPrivateAccess(organization) && ( + <> + <li> + <Link + activeClassName="active" + to={`/organizations/${organization.key}/quality_profiles`}> + {translate('quality_profiles.page')} + </Link> + </li> + <li> + <Link activeClassName="active" to={`/organizations/${organization.key}/rules`}> + {translate('coding_rules.page')} + </Link> + </li> + <li> + <Link activeClassName="active" to={getQualityGatesUrl(organization.key)}> + {translate('quality_gates.page')} + </Link> + </li> + </> + )} + + {isCurrentUserMemberOf(organization) && ( + <li> + <Link activeClassName="active" to={`/organizations/${organization.key}/members`}> + {translate('organization.members.page')} + </Link> + </li> + )} + <OrganizationNavigationExtensions location={location} organization={organization} /> {organization.canAdmin && ( <OrganizationNavigationAdministration location={location} organization={organization} /> diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/OrganizationNavigationMenu-test.tsx b/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/OrganizationNavigationMenu-test.tsx index 55e8fc5e802..251e41573de 100644 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/OrganizationNavigationMenu-test.tsx +++ b/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/OrganizationNavigationMenu-test.tsx @@ -21,19 +21,27 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import OrganizationNavigationMenu from '../OrganizationNavigationMenu'; import { Visibility } from '../../../../app/types'; +import { isCurrentUserMemberOf, hasPrivateAccess } from '../../../../helpers/organizations'; + +jest.mock('../../../../helpers/organizations', () => ({ + isCurrentUserMemberOf: jest.fn().mockReturnValue(true), + hasPrivateAccess: jest.fn().mockReturnValue(true) +})); + +const organization = { + key: 'foo', + name: 'Foo', + projectVisibility: Visibility.Public +}; + +beforeEach(() => { + (isCurrentUserMemberOf as jest.Mock<any>).mockClear(); + (hasPrivateAccess as jest.Mock<any>).mockClear(); +}); it('renders', () => { expect( - shallow( - <OrganizationNavigationMenu - location={{ pathname: '' }} - organization={{ - key: 'foo', - name: 'Foo', - projectVisibility: Visibility.Public - }} - /> - ) + shallow(<OrganizationNavigationMenu location={{ pathname: '' }} organization={organization} />) ).toMatchSnapshot(); }); @@ -42,12 +50,7 @@ it('renders for admin', () => { shallow( <OrganizationNavigationMenu location={{ pathname: '' }} - organization={{ - canAdmin: true, - key: 'foo', - name: 'Foo', - projectVisibility: Visibility.Public - }} + organization={{ ...organization, canAdmin: true }} /> ) ).toMatchSnapshot(); diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationMenu-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationMenu-test.tsx.snap index 1177b7604b7..a51bf8cc513 100644 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationMenu-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationMenu-test.tsx.snap @@ -31,40 +31,42 @@ exports[`renders 1`] = ` issues.page </Link> </li> - <li> - <Link - activeClassName="active" - onlyActiveOnIndex={false} - style={Object {}} - to="/organizations/foo/quality_profiles" - > - quality_profiles.page - </Link> - </li> - <li> - <Link - activeClassName="active" - onlyActiveOnIndex={false} - style={Object {}} - to="/organizations/foo/rules" - > - coding_rules.page - </Link> - </li> - <li> - <Link - activeClassName="active" - onlyActiveOnIndex={false} - style={Object {}} - to={ - Object { - "pathname": "/organizations/foo/quality_gates", + <React.Fragment> + <li> + <Link + activeClassName="active" + onlyActiveOnIndex={false} + style={Object {}} + to="/organizations/foo/quality_profiles" + > + quality_profiles.page + </Link> + </li> + <li> + <Link + activeClassName="active" + onlyActiveOnIndex={false} + style={Object {}} + to="/organizations/foo/rules" + > + coding_rules.page + </Link> + </li> + <li> + <Link + activeClassName="active" + onlyActiveOnIndex={false} + style={Object {}} + to={ + Object { + "pathname": "/organizations/foo/quality_gates", + } } - } - > - quality_gates.page - </Link> - </li> + > + quality_gates.page + </Link> + </li> + </React.Fragment> <li> <Link activeClassName="active" @@ -123,40 +125,42 @@ exports[`renders for admin 1`] = ` issues.page </Link> </li> - <li> - <Link - activeClassName="active" - onlyActiveOnIndex={false} - style={Object {}} - to="/organizations/foo/quality_profiles" - > - quality_profiles.page - </Link> - </li> - <li> - <Link - activeClassName="active" - onlyActiveOnIndex={false} - style={Object {}} - to="/organizations/foo/rules" - > - coding_rules.page - </Link> - </li> - <li> - <Link - activeClassName="active" - onlyActiveOnIndex={false} - style={Object {}} - to={ - Object { - "pathname": "/organizations/foo/quality_gates", + <React.Fragment> + <li> + <Link + activeClassName="active" + onlyActiveOnIndex={false} + style={Object {}} + to="/organizations/foo/quality_profiles" + > + quality_profiles.page + </Link> + </li> + <li> + <Link + activeClassName="active" + onlyActiveOnIndex={false} + style={Object {}} + to="/organizations/foo/rules" + > + coding_rules.page + </Link> + </li> + <li> + <Link + activeClassName="active" + onlyActiveOnIndex={false} + style={Object {}} + to={ + Object { + "pathname": "/organizations/foo/quality_gates", + } } - } - > - quality_gates.page - </Link> - </li> + > + quality_gates.page + </Link> + </li> + </React.Fragment> <li> <Link activeClassName="active" diff --git a/server/sonar-web/src/main/js/apps/organizations/routes.ts b/server/sonar-web/src/main/js/apps/organizations/routes.ts index 02c2bdaec00..18aa7c3f4ba 100644 --- a/server/sonar-web/src/main/js/apps/organizations/routes.ts +++ b/server/sonar-web/src/main/js/apps/organizations/routes.ts @@ -54,25 +54,47 @@ const routes = [ ] }, { - path: 'members', - component: lazyLoad(() => import('./components/OrganizationMembersContainer')) - }, - { - path: 'rules', - component: OrganizationContainer, - childRoutes: codingRulesRoutes - }, - { - path: 'quality_profiles', - childRoutes: qualityProfilesRoutes + component: lazyLoad(() => + import('./components/OrganizationAccessContainer').then(lib => ({ + default: lib.OrganizationMembersAccess + })) + ), + childRoutes: [ + { + path: 'members', + component: lazyLoad(() => import('./components/OrganizationMembersContainer')) + } + ] }, { - path: 'quality_gates', - component: OrganizationContainer, - childRoutes: qualityGatesRoutes + component: lazyLoad(() => + import('./components/OrganizationAccessContainer').then(lib => ({ + default: lib.OrganizationPrivateAccess + })) + ), + childRoutes: [ + { + path: 'rules', + component: OrganizationContainer, + childRoutes: codingRulesRoutes + }, + { + path: 'quality_profiles', + childRoutes: qualityProfilesRoutes + }, + { + path: 'quality_gates', + component: OrganizationContainer, + childRoutes: qualityGatesRoutes + } + ] }, { - component: lazyLoad(() => import('./components/OrganizationAdminContainer')), + component: lazyLoad(() => + import('./components/OrganizationAccessContainer').then(lib => ({ + default: lib.OrganizationAdminAccess + })) + ), childRoutes: [ { path: 'delete', component: lazyLoad(() => import('./components/OrganizationDelete')) }, { path: 'edit', component: lazyLoad(() => import('./components/OrganizationEdit')) }, diff --git a/server/sonar-web/src/main/js/apps/overview/meta/Meta.tsx b/server/sonar-web/src/main/js/apps/overview/meta/Meta.tsx index c2145d14c3b..5eb22f4735d 100644 --- a/server/sonar-web/src/main/js/apps/overview/meta/Meta.tsx +++ b/server/sonar-web/src/main/js/apps/overview/meta/Meta.tsx @@ -32,6 +32,7 @@ import { Visibility, Component, Metric, BranchLike } from '../../../app/types'; import { History } from '../../../api/time-machine'; import { translate } from '../../../helpers/l10n'; import { MeasureEnhanced } from '../../../helpers/measures'; +import { hasPrivateAccess } from '../../../helpers/organizations'; interface Props { branchLike?: BranchLike; @@ -47,10 +48,40 @@ export default class Meta extends React.PureComponent<Props> { organizationsEnabled: PropTypes.bool }; + renderQualityInfos() { + const { organizationsEnabled } = this.context; + const { organization, qualifier, qualityProfiles, qualityGate } = this.props.component; + const isProject = qualifier === 'TRK'; + + if (!isProject || (organizationsEnabled && !hasPrivateAccess(organization))) { + return null; + } + + return ( + <div className="overview-meta-card"> + {qualityGate && ( + <MetaQualityGate + organization={organizationsEnabled ? organization : undefined} + qualityGate={qualityGate} + /> + )} + + {qualityProfiles && + qualityProfiles.length > 0 && ( + <MetaQualityProfiles + headerClassName={qualityGate ? 'big-spacer-top' : undefined} + organization={organizationsEnabled ? organization : undefined} + profiles={qualityProfiles} + /> + )} + </div> + ); + } + render() { const { organizationsEnabled } = this.context; const { branchLike, component, metrics } = this.props; - const { qualifier, description, qualityProfiles, qualityGate, visibility } = component; + const { qualifier, description, visibility } = component; const isProject = qualifier === 'TRK'; const isApp = qualifier === 'APP'; @@ -77,25 +108,7 @@ export default class Meta extends React.PureComponent<Props> { qualifier={component.qualifier} /> - {isProject && ( - <div className="overview-meta-card"> - {qualityGate && ( - <MetaQualityGate - organization={organizationsEnabled ? component.organization : undefined} - qualityGate={qualityGate} - /> - )} - - {qualityProfiles && - qualityProfiles.length > 0 && ( - <MetaQualityProfiles - headerClassName={qualityGate ? 'big-spacer-top' : undefined} - organization={organizationsEnabled ? component.organization : undefined} - profiles={qualityProfiles} - /> - )} - </div> - )} + {this.renderQualityInfos()} {isProject && <MetaLinks component={component} />} diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/AppContainer.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/AppContainer.tsx index 26fb48fa1c8..d208e4e5b9b 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/AppContainer.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/AppContainer.tsx @@ -21,7 +21,7 @@ import * as React from 'react'; import { connect } from 'react-redux'; import App from './App'; import forSingleOrganization from '../organizations/forSingleOrganization'; -import { Organization, LoggedInUser } from '../../app/types'; +import { Organization, LoggedInUser, Visibility } from '../../app/types'; import { getAppState, getOrganizationByKey, getCurrentUser } from '../../store/rootReducer'; import { receiveOrganizations } from '../../store/organizations/duck'; import { changeProjectDefaultVisibility } from '../../api/permissions'; @@ -85,7 +85,7 @@ const mapStateToProps = (state: any, ownProps: any) => ({ ownProps.organization || getOrganizationByKey(state, getAppState(state).defaultOrganization) }); -const onVisibilityChange = (organization: Organization, visibility: string) => ( +const onVisibilityChange = (organization: Organization, visibility: Visibility) => ( dispatch: Function ) => { const currentVisibility = organization.projectVisibility; @@ -97,7 +97,7 @@ const onVisibilityChange = (organization: Organization, visibility: string) => ( const mapDispatchToProps = (dispatch: Function) => ({ fetchOrganization: (key: string) => dispatch(fetchOrganization(key)), - onVisibilityChange: (organization: Organization, visibility: string) => + onVisibilityChange: (organization: Organization, visibility: Visibility) => dispatch(onVisibilityChange(organization, visibility)) }); diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/CreateProjectForm.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/CreateProjectForm.tsx index b3e90fd06b2..b721a8f7e76 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/CreateProjectForm.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/CreateProjectForm.tsx @@ -21,7 +21,7 @@ import * as React from 'react'; import { Link } from 'react-router'; import { FormattedMessage } from 'react-intl'; import { createProject } from '../../api/components'; -import { Organization } from '../../app/types'; +import { Organization, Visibility } from '../../app/types'; import UpgradeOrganizationBox from '../../components/common/UpgradeOrganizationBox'; import VisibilitySelector from '../../components/common/VisibilitySelector'; import Modal from '../../components/controls/Modal'; @@ -40,7 +40,7 @@ interface State { key: string; loading: boolean; name: string; - visibility: string; + visibility?: Visibility; // add index declaration to be able to do `this.setState({ [name]: value });` [x: string]: any; } @@ -81,7 +81,7 @@ export default class CreateProjectForm extends React.PureComponent<Props, State> this.setState({ [name]: value }); }; - handleVisibilityChange = (visibility: string) => { + handleVisibilityChange = (visibility: Visibility) => { this.setState({ visibility }); }; diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx index 2e5411384af..4d75c04cc18 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx @@ -54,18 +54,19 @@ export default class Header extends React.PureComponent<Props, State> { <h1 className="page-title">{translate('projects_management')}</h1> <div className="page-actions"> - {!isSonarCloud() && ( - <span className="big-spacer-right"> - <span className="text-middle"> - {translate('organization.default_visibility_of_new_projects')}{' '} - <strong>{translate('visibility', organization.projectVisibility)}</strong> + {!isSonarCloud() && + organization.projectVisibility && ( + <span className="big-spacer-right"> + <span className="text-middle"> + {translate('organization.default_visibility_of_new_projects')}{' '} + <strong>{translate('visibility', organization.projectVisibility)}</strong> + </span> + <EditButton + className="js-change-visibility spacer-left button-small" + onClick={this.handleChangeVisibilityClick} + /> </span> - <EditButton - className="js-change-visibility spacer-left button-small" - onClick={this.handleChangeVisibilityClick} - /> - </span> - )} + )} {this.props.hasProvisionPermission && ( <Button id="create-project" onClick={this.props.onProjectCreate}> {translate('qualifiers.create.TRK')} diff --git a/server/sonar-web/src/main/js/components/common/VisibilitySelector.tsx b/server/sonar-web/src/main/js/components/common/VisibilitySelector.tsx index 10e25bce6ee..7d6633d1189 100644 --- a/server/sonar-web/src/main/js/components/common/VisibilitySelector.tsx +++ b/server/sonar-web/src/main/js/components/common/VisibilitySelector.tsx @@ -20,25 +20,26 @@ import * as React from 'react'; import * as classNames from 'classnames'; import { translate } from '../../helpers/l10n'; +import { Visibility } from '../../app/types'; interface Props { canTurnToPrivate?: boolean; className?: string; - onChange: (x: string) => void; - visibility: string; + onChange: (visibility: Visibility) => void; + visibility?: Visibility; } export default class VisibilitySelector extends React.PureComponent<Props> { handlePublicClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => { event.preventDefault(); event.currentTarget.blur(); - this.props.onChange('public'); + this.props.onChange(Visibility.Public); }; handlePrivateClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => { event.preventDefault(); event.currentTarget.blur(); - this.props.onChange('private'); + this.props.onChange(Visibility.Private); }; render() { @@ -46,12 +47,12 @@ export default class VisibilitySelector extends React.PureComponent<Props> { <div className={this.props.className}> <a className="link-base-color link-no-underline" - id="visibility-public" href="#" + id="visibility-public" onClick={this.handlePublicClick}> <i className={classNames('icon-radio', { - 'is-checked': this.props.visibility === 'public' + 'is-checked': this.props.visibility === Visibility.Public })} /> <span className="spacer-left">{translate('visibility.public')}</span> @@ -60,12 +61,12 @@ export default class VisibilitySelector extends React.PureComponent<Props> { {this.props.canTurnToPrivate ? ( <a className="link-base-color link-no-underline huge-spacer-left" - id="visibility-private" href="#" + id="visibility-private" onClick={this.handlePrivateClick}> <i className={classNames('icon-radio', { - 'is-checked': this.props.visibility === 'private' + 'is-checked': this.props.visibility === Visibility.Private })} /> <span className="spacer-left">{translate('visibility.private')}</span> @@ -74,7 +75,7 @@ export default class VisibilitySelector extends React.PureComponent<Props> { <span className="huge-spacer-left text-muted cursor-not-allowed" id="visibility-private"> <i className={classNames('icon-radio', { - 'is-checked': this.props.visibility === 'private' + 'is-checked': this.props.visibility === Visibility.Private })} /> <span className="spacer-left">{translate('visibility.private')}</span> diff --git a/server/sonar-web/src/main/js/components/common/__tests__/VisibilitySelector-test.tsx b/server/sonar-web/src/main/js/components/common/__tests__/VisibilitySelector-test.tsx index 655563b8932..73c2b7771e6 100644 --- a/server/sonar-web/src/main/js/components/common/__tests__/VisibilitySelector-test.tsx +++ b/server/sonar-web/src/main/js/components/common/__tests__/VisibilitySelector-test.tsx @@ -21,28 +21,37 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import VisibilitySelector from '../VisibilitySelector'; import { click } from '../../../helpers/testUtils'; +import { Visibility } from '../../../app/types'; it('changes visibility', () => { const onChange = jest.fn(); const wrapper = shallow( - <VisibilitySelector canTurnToPrivate={true} onChange={onChange} visibility="public" /> + <VisibilitySelector + canTurnToPrivate={true} + onChange={onChange} + visibility={Visibility.Public} + /> ); expect(wrapper).toMatchSnapshot(); click(wrapper.find('#visibility-private')); - expect(onChange).toBeCalledWith('private'); + expect(onChange).toBeCalledWith(Visibility.Private); - wrapper.setProps({ visibility: 'private' }); + wrapper.setProps({ visibility: Visibility.Private }); expect(wrapper).toMatchSnapshot(); click(wrapper.find('#visibility-public')); - expect(onChange).toBeCalledWith('public'); + expect(onChange).toBeCalledWith(Visibility.Public); }); it('renders disabled', () => { expect( shallow( - <VisibilitySelector canTurnToPrivate={false} onChange={jest.fn()} visibility="public" /> + <VisibilitySelector + canTurnToPrivate={false} + onChange={jest.fn()} + visibility={Visibility.Public} + /> ) ).toMatchSnapshot(); }); diff --git a/server/sonar-web/src/main/js/components/workspace/WorkspaceRuleDetails.tsx b/server/sonar-web/src/main/js/components/workspace/WorkspaceRuleDetails.tsx index e154a145773..647f4a4fade 100644 --- a/server/sonar-web/src/main/js/components/workspace/WorkspaceRuleDetails.tsx +++ b/server/sonar-web/src/main/js/components/workspace/WorkspaceRuleDetails.tsx @@ -25,10 +25,11 @@ import DeferredSpinner from '../common/DeferredSpinner'; import RuleDetailsMeta from '../../apps/coding-rules/components/RuleDetailsMeta'; import RuleDetailsDescription from '../../apps/coding-rules/components/RuleDetailsDescription'; import '../../apps/coding-rules/styles.css'; +import { hasPrivateAccess } from '../../helpers/organizations'; interface Props { onLoad: (details: { name: string }) => void; - organization: string | undefined; + organizationKey: string | undefined; ruleKey: string; } @@ -50,7 +51,7 @@ export default class WorkspaceRuleDetails extends React.PureComponent<Props, Sta componentDidUpdate(prevProps: Props) { if ( prevProps.ruleKey !== this.props.ruleKey || - prevProps.organization !== this.props.organization + prevProps.organizationKey !== this.props.organizationKey ) { this.fetchRuleDetails(); } @@ -63,8 +64,8 @@ export default class WorkspaceRuleDetails extends React.PureComponent<Props, Sta fetchRuleDetails = () => { this.setState({ loading: true }); Promise.all([ - getRulesApp({ organization: this.props.organization }), - getRuleDetails({ key: this.props.ruleKey, organization: this.props.organization }) + getRulesApp({ organization: this.props.organizationKey }), + getRuleDetails({ key: this.props.ruleKey, organization: this.props.organizationKey }) ]).then( ([{ repositories }, { rule }]) => { if (this.mounted) { @@ -87,23 +88,26 @@ export default class WorkspaceRuleDetails extends React.PureComponent<Props, Sta noOp = () => {}; render() { + const { organizationKey } = this.props; + return ( <DeferredSpinner loading={this.state.loading}> {this.state.ruleDetails && ( <> <RuleDetailsMeta canWrite={false} + hidePermalink={!hasPrivateAccess(organizationKey)} hideSimilarRulesFilter={true} onFilterChange={this.noOp} onTagsChange={this.noOp} - organization={this.props.organization} + organization={organizationKey} referencedRepositories={this.state.referencedRepositories} ruleDetails={this.state.ruleDetails} /> <RuleDetailsDescription canWrite={false} onChange={this.noOp} - organization={this.props.organization} + organization={organizationKey} ruleDetails={this.state.ruleDetails} /> </> diff --git a/server/sonar-web/src/main/js/components/workspace/WorkspaceRuleViewer.tsx b/server/sonar-web/src/main/js/components/workspace/WorkspaceRuleViewer.tsx index 513f2ca8311..bb37eab9a0b 100644 --- a/server/sonar-web/src/main/js/components/workspace/WorkspaceRuleViewer.tsx +++ b/server/sonar-web/src/main/js/components/workspace/WorkspaceRuleViewer.tsx @@ -58,7 +58,7 @@ export default class WorkspaceRuleViewer extends React.PureComponent<Props> { <div className="workspace-viewer-container" style={{ height: this.props.height }}> <WorkspaceRuleDetails onLoad={this.handleLoaded} - organization={rule.organization} + organizationKey={rule.organization} ruleKey={rule.key} /> </div> diff --git a/server/sonar-web/src/main/js/components/workspace/__tests__/WorkspaceRuleDetails-test.tsx b/server/sonar-web/src/main/js/components/workspace/__tests__/WorkspaceRuleDetails-test.tsx index e1b5f68840f..e755c5686d5 100644 --- a/server/sonar-web/src/main/js/components/workspace/__tests__/WorkspaceRuleDetails-test.tsx +++ b/server/sonar-web/src/main/js/components/workspace/__tests__/WorkspaceRuleDetails-test.tsx @@ -21,6 +21,12 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import WorkspaceRuleDetails from '../WorkspaceRuleDetails'; import { waitAndUpdate } from '../../../helpers/testUtils'; +import { OrganizationSubscription, Visibility } from '../../../app/types'; +import { hasPrivateAccess } from '../../../helpers/organizations'; + +jest.mock('../../../helpers/organizations', () => ({ + hasPrivateAccess: jest.fn().mockReturnValue(true) +})); jest.mock('../../../api/rules', () => ({ getRulesApp: jest.fn(() => @@ -29,9 +35,20 @@ jest.mock('../../../api/rules', () => ({ getRuleDetails: jest.fn(() => Promise.resolve({ rule: { key: 'foo', name: 'Foo' } })) })); +const organization = { + key: 'foo', + name: 'Foo', + projectVisibility: Visibility.Public, + subscription: OrganizationSubscription.Paid +}; + +beforeEach(() => { + (hasPrivateAccess as jest.Mock<any>).mockClear(); +}); + it('should render', async () => { const wrapper = shallow( - <WorkspaceRuleDetails onLoad={jest.fn()} organization="org" ruleKey="foo" /> + <WorkspaceRuleDetails onLoad={jest.fn()} organizationKey={undefined} ruleKey="foo" /> ); expect(wrapper).toMatchSnapshot(); @@ -42,8 +59,18 @@ it('should render', async () => { it('should call back on load', async () => { const onLoad = jest.fn(); const wrapper = shallow( - <WorkspaceRuleDetails onLoad={onLoad} organization="org" ruleKey="foo" /> + <WorkspaceRuleDetails onLoad={onLoad} organizationKey={undefined} ruleKey="foo" /> ); await waitAndUpdate(wrapper); expect(onLoad).toBeCalledWith({ name: 'Foo' }); }); + +it('should render without permalink', async () => { + (hasPrivateAccess as jest.Mock<any>).mockReturnValueOnce(false); + const wrapper = shallow( + <WorkspaceRuleDetails onLoad={jest.fn()} organizationKey={organization.key} ruleKey="foo" /> + ); + + await waitAndUpdate(wrapper); + expect(wrapper.find('RuleDetailsMeta').prop('hidePermalink')).toBeTruthy(); +}); diff --git a/server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/WorkspaceRuleDetails-test.tsx.snap b/server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/WorkspaceRuleDetails-test.tsx.snap index 682c1d5ac77..566b4a80db2 100644 --- a/server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/WorkspaceRuleDetails-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/WorkspaceRuleDetails-test.tsx.snap @@ -15,10 +15,10 @@ exports[`should render 2`] = ` <React.Fragment> <RuleDetailsMeta canWrite={false} + hidePermalink={false} hideSimilarRulesFilter={true} onFilterChange={[Function]} onTagsChange={[Function]} - organization="org" referencedRepositories={ Object { "repo": Object { @@ -38,7 +38,6 @@ exports[`should render 2`] = ` <RuleDetailsDescription canWrite={false} onChange={[Function]} - organization="org" ruleDetails={ Object { "key": "foo", diff --git a/server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/WorkspaceRuleViewer-test.tsx.snap b/server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/WorkspaceRuleViewer-test.tsx.snap index f8bc63a9a99..00b9e074c45 100644 --- a/server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/WorkspaceRuleViewer-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/WorkspaceRuleViewer-test.tsx.snap @@ -30,7 +30,7 @@ exports[`should render 1`] = ` > <WorkspaceRuleDetails onLoad={[Function]} - organization="org" + organizationKey="org" ruleKey="foo" /> </div> diff --git a/server/sonar-web/src/main/js/helpers/__tests__/organizations-test.ts b/server/sonar-web/src/main/js/helpers/__tests__/organizations-test.ts new file mode 100644 index 00000000000..17429129955 --- /dev/null +++ b/server/sonar-web/src/main/js/helpers/__tests__/organizations-test.ts @@ -0,0 +1,84 @@ +/* + * 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 { hasPrivateAccess, isCurrentUserMemberOf } from '../organizations'; +import { getCurrentUser, getMyOrganizations } from '../../store/rootReducer'; +import { OrganizationSubscription } from '../../app/types'; + +jest.mock('../../app/utils/getStore', () => ({ + default: () => ({ + getState: jest.fn() + }) +})); + +jest.mock('../../store/rootReducer', () => ({ + getCurrentUser: jest.fn().mockReturnValue({ + isLoggedIn: true, + login: 'luke', + name: 'Skywalker', + showOnboardingTutorial: false + }), + getMyOrganizations: jest.fn().mockReturnValue([]) +})); + +const organization = { + key: 'foo', + name: 'Foo', + subscription: OrganizationSubscription.Paid +}; + +const loggedOut = { isLoggedIn: false }; + +beforeEach(() => { + (getCurrentUser as jest.Mock<any>).mockClear(); + (getMyOrganizations as jest.Mock<any>).mockClear(); +}); + +describe('isCurrentUserMemberOf', () => { + it('should be a member', () => { + expect(isCurrentUserMemberOf({ key: 'bar', name: 'Bar', canAdmin: true })).toBeTruthy(); + + (getMyOrganizations as jest.Mock<any>).mockReturnValueOnce([organization]); + expect(isCurrentUserMemberOf(organization)).toBeTruthy(); + }); + + it('should not be a member', () => { + expect(isCurrentUserMemberOf(undefined)).toBeFalsy(); + expect(isCurrentUserMemberOf(organization)).toBeFalsy(); + + (getMyOrganizations as jest.Mock<any>).mockReturnValueOnce([{ key: 'bar', name: 'Bar' }]); + expect(isCurrentUserMemberOf(organization)).toBeFalsy(); + + (getCurrentUser as jest.Mock<any>).mockReturnValueOnce(loggedOut); + expect(isCurrentUserMemberOf(organization)).toBeFalsy(); + }); +}); + +describe('hasPrivateAccess', () => { + it('should have access', () => { + expect(hasPrivateAccess({ key: 'bar', name: 'Bar' })).toBeTruthy(); + + (getMyOrganizations as jest.Mock<any>).mockReturnValueOnce([organization]); + expect(hasPrivateAccess(organization)).toBeTruthy(); + }); + + it('should not have access', () => { + expect(hasPrivateAccess(organization)).toBeFalsy(); + }); +}); diff --git a/server/sonar-web/src/main/js/helpers/organizations.ts b/server/sonar-web/src/main/js/helpers/organizations.ts new file mode 100644 index 00000000000..8489cb9ed12 --- /dev/null +++ b/server/sonar-web/src/main/js/helpers/organizations.ts @@ -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 getStore from '../app/utils/getStore'; +import { Organization, isLoggedIn, OrganizationSubscription } from '../app/types'; +import { getCurrentUser, getMyOrganizations, getOrganizationByKey } from '../store/rootReducer'; + +function getRealOrganization( + organization?: Organization | string, + state?: any +): Organization | undefined { + if (typeof organization === 'string') { + state = state || getStore().getState(); + return getOrganizationByKey(state, organization); + } + + return organization; +} + +function isPaidOrganization(organization: Organization | undefined): boolean { + return Boolean(organization && organization.subscription === OrganizationSubscription.Paid); +} + +export function hasPrivateAccess(organization: Organization | string | undefined): boolean { + const realOrg = getRealOrganization(organization); + return !isPaidOrganization(realOrg) || isCurrentUserMemberOf(realOrg); +} + +export function isCurrentUserMemberOf(organization: Organization | string | undefined): boolean { + const state = getStore().getState(); + const currentUser = getCurrentUser(state); + const userOrganizations = getMyOrganizations(state); + const realOrg = getRealOrganization(organization, state); + return Boolean( + realOrg && + isLoggedIn(currentUser) && + (realOrg.canAdmin || userOrganizations.some(org => org.key === realOrg.key)) + ); +} diff --git a/server/sonar-web/src/main/js/store/organizations/duck.js b/server/sonar-web/src/main/js/store/organizations/duck.ts index 1571e3a40f9..8b6c76e3993 100644 --- a/server/sonar-web/src/main/js/store/organizations/duck.js +++ b/server/sonar-web/src/main/js/store/organizations/duck.ts @@ -17,118 +17,67 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -// @flow import { combineReducers } from 'redux'; import { omit, uniq, without } from 'lodash'; +import { Group, Organization } from '../../app/types'; -/*:: -export type Organization = { - adminPages?: Array<{ key: string, name: string }>, - avatar?: string, - canAdmin?: boolean, - canDelete?: boolean, - canProvisionProjects?: boolean, - canUpdateProjectsVisibilityToPrivate?: boolean, - description?: string, - isAdmin: bool, - key: string, - name: string, - pages?: Array<{ key: string, name: string }>, - projectVisibility: string, - url?: string -}; -*/ - -/*:: -export type OrgGroup = { - id: string, - default: boolean, - description: string, - membersCount: number, - name: string -}; -*/ - -/*:: -type ReceiveOrganizationsAction = { - type: 'RECEIVE_ORGANIZATIONS', - organizations: Array<Organization> -}; -*/ +interface ReceiveOrganizationsAction { + type: 'RECEIVE_ORGANIZATIONS'; + organizations: Organization[]; +} -/*:: -type ReceiveMyOrganizationsAction = { - type: 'RECEIVE_MY_ORGANIZATIONS', - organizations: Array<Organization> -}; -*/ +interface ReceiveMyOrganizationsAction { + type: 'RECEIVE_MY_ORGANIZATIONS'; + organizations: Organization[]; +} -/*:: -type ReceiveOrganizationGroups = { - type: 'RECEIVE_ORGANIZATION_GROUPS', - key: string, - groups: Array<OrgGroup> -}; -*/ +interface ReceiveOrganizationGroups { + type: 'RECEIVE_ORGANIZATION_GROUPS'; + key: string; + groups: Group[]; +} -/*:: -type CreateOrganizationAction = { - type: 'CREATE_ORGANIZATION', - organization: Organization -}; -*/ +interface CreateOrganizationAction { + type: 'CREATE_ORGANIZATION'; + organization: Organization; +} -/*:: -type UpdateOrganizationAction = { - type: 'UPDATE_ORGANIZATION', - key: string, - changes: {} -}; -*/ +interface UpdateOrganizationAction { + type: 'UPDATE_ORGANIZATION'; + key: string; + changes: {}; +} -/*:: -type DeleteOrganizationAction = { - type: 'DELETE_ORGANIZATION', - key: string -}; -*/ +interface DeleteOrganizationAction { + type: 'DELETE_ORGANIZATION'; + key: string; +} -/*:: type Action = | ReceiveOrganizationsAction | ReceiveMyOrganizationsAction | ReceiveOrganizationGroups | CreateOrganizationAction | UpdateOrganizationAction - | DeleteOrganizationAction; */ + | DeleteOrganizationAction; -/*:: -type ByKeyState = { - [key: string]: Organization -}; -*/ +interface ByKeyState { + [key: string]: Organization; +} -/*:: -type GroupsState = { - [key: string]: Array<OrgGroup> -}; -*/ +interface GroupsState { + [key: string]: Group[]; +} -/*:: -type MyState = Array<string>; -*/ +type MyState = string[]; -/*:: -type State = { - byKey: ByKeyState, - my: MyState, - groups: GroupsState -}; -*/ +interface State { + byKey: ByKeyState; + my: MyState; + groups: GroupsState; +} -export function receiveOrganizations( - organizations /*: Array<Organization> */ -) /*: ReceiveOrganizationsAction */ { +export function receiveOrganizations(organizations: Organization[]): ReceiveOrganizationsAction { return { type: 'RECEIVE_ORGANIZATIONS', organizations @@ -136,18 +85,15 @@ export function receiveOrganizations( } export function receiveMyOrganizations( - organizations /*: Array<Organization> */ -) /*: ReceiveMyOrganizationsAction */ { + organizations: Organization[] +): ReceiveMyOrganizationsAction { return { type: 'RECEIVE_MY_ORGANIZATIONS', organizations }; } -export function receiveOrganizationGroups( - key /*: string */, - groups /*: Array<OrgGroup> */ -) /*: receiveOrganizationGroups */ { +export function receiveOrganizationGroups(key: string, groups: Group[]): ReceiveOrganizationGroups { return { type: 'RECEIVE_ORGANIZATION_GROUPS', key, @@ -155,19 +101,14 @@ export function receiveOrganizationGroups( }; } -export function createOrganization( - organization /*: Organization */ -) /*: CreateOrganizationAction */ { +export function createOrganization(organization: Organization): CreateOrganizationAction { return { type: 'CREATE_ORGANIZATION', organization }; } -export function updateOrganization( - key /*: string */, - changes /*: {} */ -) /*: UpdateOrganizationAction */ { +export function updateOrganization(key: string, changes: {}): UpdateOrganizationAction { return { type: 'UPDATE_ORGANIZATION', key, @@ -175,7 +116,7 @@ export function updateOrganization( }; } -export function deleteOrganization(key /*: string */) /*: DeleteOrganizationAction */ { +export function deleteOrganization(key: string): DeleteOrganizationAction { return { type: 'DELETE_ORGANIZATION', key @@ -183,9 +124,9 @@ export function deleteOrganization(key /*: string */) /*: DeleteOrganizationActi } function onReceiveOrganizations( - state /*: ByKeyState */, - action /*: ReceiveOrganizationsAction | ReceiveMyOrganizationsAction */ -) /*: ByKeyState */ { + state: ByKeyState, + action: ReceiveOrganizationsAction | ReceiveMyOrganizationsAction +): ByKeyState { const nextState = { ...state }; action.organizations.forEach(organization => { nextState[organization.key] = { ...state[organization.key], ...organization }; @@ -193,7 +134,7 @@ function onReceiveOrganizations( return nextState; } -function byKey(state /*: ByKeyState */ = {}, action /*: Action */) { +function byKey(state: ByKeyState = {}, action: Action) { switch (action.type) { case 'RECEIVE_ORGANIZATIONS': case 'RECEIVE_MY_ORGANIZATIONS': @@ -216,7 +157,7 @@ function byKey(state /*: ByKeyState */ = {}, action /*: Action */) { } } -function my(state /*: MyState */ = [], action /*: Action */) { +function my(state: MyState = [], action: Action) { switch (action.type) { case 'RECEIVE_MY_ORGANIZATIONS': return uniq([...state, ...action.organizations.map(o => o.key)]); @@ -229,7 +170,7 @@ function my(state /*: MyState */ = [], action /*: Action */) { } } -function groups(state /*: GroupsState */ = {}, action /*: Action */) { +function groups(state: GroupsState = {}, action: Action) { if (action.type === 'RECEIVE_ORGANIZATION_GROUPS') { return { ...state, [action.key]: action.groups }; } @@ -238,21 +179,18 @@ function groups(state /*: GroupsState */ = {}, action /*: Action */) { export default combineReducers({ byKey, my, groups }); -export function getOrganizationByKey(state /*: State */, key /*: string */) /*: Organization */ { +export function getOrganizationByKey(state: State, key: string): Organization | undefined { return state.byKey[key]; } -export function getOrganizationGroupsByKey( - state /*: State */, - key /*: string */ -) /*: Array<OrgGroup> */ { +export function getOrganizationGroupsByKey(state: State, key: string): Group[] { return state.groups[key] || []; } -export function getMyOrganizations(state /*: State */) /*: Array<Organization> */ { - return state.my.map(key => getOrganizationByKey(state, key)); +export function getMyOrganizations(state: State): Organization[] { + return state.my.map(key => getOrganizationByKey(state, key) as Organization); } -export function areThereCustomOrganizations(state /*: State */) /*: boolean */ { +export function areThereCustomOrganizations(state: State): boolean { return Object.keys(state.byKey).length > 1; } |