From 477cfbd296c49853604563e24fc2c9987b4f751e Mon Sep 17 00:00:00 2001 From: Grégoire Aubert Date: Fri, 29 Jun 2018 16:33:30 +0200 Subject: 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 --- .../organization-and-project-privacy.md | 6 + server/sonar-web/src/main/js/api/organizations.ts | 30 +-- .../extensions/OrganizationPageExtension.js | 2 +- .../nav/component/ComponentNavHeader.tsx | 3 +- server/sonar-web/src/main/js/app/types.ts | 26 ++- .../coding-rules/components/RuleDetailsMeta.tsx | 20 +- .../components/__tests__/RuleDetailsMeta-test.tsx | 8 + .../src/main/js/apps/organizations/actions.js | 2 +- .../apps/organizations/components/MembersList.js | 4 +- .../organizations/components/MembersListItem.js | 4 +- .../components/OrganizationAccessContainer.tsx | 104 +++++++++ .../components/OrganizationAdminContainer.tsx | 69 ------ .../organizations/components/OrganizationEdit.js | 3 +- .../components/OrganizationGroupCheckbox.js | 4 +- .../components/OrganizationMembers.js | 4 +- .../organizations/components/OrganizationPage.js | 112 --------- .../organizations/components/OrganizationPage.tsx | 110 +++++++++ .../__tests__/OrganizationAccessContainer-test.tsx | 88 +++++++ .../__tests__/OrganizationAdminContainer-test.tsx | 60 ----- .../OrganizationAccessContainer-test.tsx.snap | 17 ++ .../OrganizationAdminContainer-test.tsx.snap | 17 -- .../components/forms/AddMemberForm.js | 2 +- .../components/forms/ManageMemberGroupsForm.js | 4 +- .../components/forms/RemoveMemberForm.js | 2 +- .../navigation/OrganizationNavigation.tsx | 2 +- .../navigation/OrganizationNavigationMenu.tsx | 51 ++-- .../__tests__/OrganizationNavigationMenu-test.tsx | 35 +-- .../OrganizationNavigationMenu-test.tsx.snap | 136 +++++------ .../src/main/js/apps/organizations/routes.ts | 52 +++-- .../src/main/js/apps/overview/meta/Meta.tsx | 53 +++-- .../js/apps/projectsManagement/AppContainer.tsx | 6 +- .../apps/projectsManagement/CreateProjectForm.tsx | 6 +- .../src/main/js/apps/projectsManagement/Header.tsx | 23 +- .../js/components/common/VisibilitySelector.tsx | 19 +- .../common/__tests__/VisibilitySelector-test.tsx | 19 +- .../components/workspace/WorkspaceRuleDetails.tsx | 16 +- .../components/workspace/WorkspaceRuleViewer.tsx | 2 +- .../__tests__/WorkspaceRuleDetails-test.tsx | 31 ++- .../WorkspaceRuleDetails-test.tsx.snap | 3 +- .../WorkspaceRuleViewer-test.tsx.snap | 2 +- .../js/helpers/__tests__/organizations-test.ts | 84 +++++++ .../sonar-web/src/main/js/helpers/organizations.ts | 56 +++++ .../src/main/js/store/organizations/duck.js | 258 --------------------- .../src/main/js/store/organizations/duck.ts | 196 ++++++++++++++++ 44 files changed, 995 insertions(+), 756 deletions(-) create mode 100644 server/sonar-docs/src/pages/organizations/organization-and-project-privacy.md create mode 100644 server/sonar-web/src/main/js/apps/organizations/components/OrganizationAccessContainer.tsx delete mode 100644 server/sonar-web/src/main/js/apps/organizations/components/OrganizationAdminContainer.tsx delete mode 100644 server/sonar-web/src/main/js/apps/organizations/components/OrganizationPage.js create mode 100644 server/sonar-web/src/main/js/apps/organizations/components/OrganizationPage.tsx create mode 100644 server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationAccessContainer-test.tsx delete mode 100644 server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationAdminContainer-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationAccessContainer-test.tsx.snap delete mode 100644 server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationAdminContainer-test.tsx.snap create mode 100644 server/sonar-web/src/main/js/helpers/__tests__/organizations-test.ts create mode 100644 server/sonar-web/src/main/js/helpers/organizations.ts delete mode 100644 server/sonar-web/src/main/js/store/organizations/duck.js create mode 100644 server/sonar-web/src/main/js/store/organizations/duck.ts 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 { +}): 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) => void; onTagsChange: (tags: string[]) => void; @@ -230,21 +231,22 @@ export default class RuleDetailsMeta extends React.PureComponent { }; render() { - const { ruleDetails } = this.props; + const { hidePermalink, ruleDetails } = this.props; const hasTypeData = !ruleDetails.isExternal || ruleDetails.type !== 'UNKNOWN'; return (
{ruleDetails.key} - {!ruleDetails.isExternal && ( - - - - )} + {!ruleDetails.isExternal && + !hidePermalink && ( + + + + )} {!this.props.hideSimilarRulesFilter && ( )} 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( , - organizationGroups: Array, + organizationGroups: Array, organization: Organization, removeMember: Member => void, updateMemberGroups: (member: Member, add: Array, remove: Array) => 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, + organizationGroups: Array, removeMember: Member => void, updateMemberGroups: (member: Member, add: Array, remove: Array) => void }; diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationAccessContainer.tsx b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationAccessContainer.tsx new file mode 100644 index 00000000000..aab0246bd05 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationAccessContainer.tsx @@ -0,0 +1,104 @@ +/* + * 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 { connect } from 'react-redux'; +import { RouterState } from 'react-router'; +import { getOrganizationByKey, getCurrentUser } from '../../../store/rootReducer'; +import handleRequiredAuthorization from '../../../app/utils/handleRequiredAuthorization'; +import { Organization, CurrentUser, isLoggedIn } from '../../../app/types'; +import { isCurrentUserMemberOf, hasPrivateAccess } from '../../../helpers/organizations'; + +interface StateToProps { + currentUser: CurrentUser; + organization?: Organization; +} + +interface OwnProps extends RouterState { + children: JSX.Element; +} + +interface Props extends StateToProps, Pick { + hasAccess: (props: Props) => boolean; +} + +export class OrganizationAccess extends React.PureComponent { + componentDidMount() { + this.checkPermissions(); + } + + componentDidUpdate() { + this.checkPermissions(); + } + + checkPermissions = () => { + if (!this.props.hasAccess(this.props)) { + handleRequiredAuthorization(); + } + }; + + render() { + if (!this.props.hasAccess(this.props)) { + return null; + } + return React.cloneElement(this.props.children, { + location: this.props.location, + organization: this.props.organization + }); + } +} + +const mapStateToProps = (state: any, ownProps: OwnProps) => ({ + currentUser: getCurrentUser(state), + organization: getOrganizationByKey(state, ownProps.params.organizationKey) +}); + +const OrganizationAccessContainer = connect(mapStateToProps)( + OrganizationAccess +); + +export function OrganizationPrivateAccess(props: OwnProps) { + return ( + hasPrivateAccess(organization)} + {...props} + /> + ); +} + +export function OrganizationMembersAccess(props: OwnProps) { + return ( + isCurrentUserMemberOf(organization)} + {...props} + /> + ); +} + +export function hasAdminAccess({ + currentUser, + organization +}: Pick) { + const isAdmin = isLoggedIn(currentUser) && organization && organization.canAdmin; + return Boolean(isAdmin); +} + +export function OrganizationAdminAccess(props: OwnProps) { + return ; +} diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationAdminContainer.tsx b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationAdminContainer.tsx deleted file mode 100644 index 1e1ebd4142b..00000000000 --- a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationAdminContainer.tsx +++ /dev/null @@ -1,69 +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 { connect } from 'react-redux'; -import { RouterState } from 'react-router'; -import { getOrganizationByKey } from '../../../store/rootReducer'; -import handleRequiredAuthorization from '../../../app/utils/handleRequiredAuthorization'; -import { Organization } from '../../../app/types'; - -interface StateToProps { - organization?: Organization; -} - -interface OwnProps extends RouterState { - children: JSX.Element; -} - -interface Props extends StateToProps, Pick {} - -export class OrganizationAdmin extends React.PureComponent { - componentDidMount() { - this.checkPermissions(); - } - - componentDidUpdate() { - this.checkPermissions(); - } - - isOrganizationAdmin = () => this.props.organization && this.props.organization.canAdmin; - - checkPermissions = () => { - if (!this.isOrganizationAdmin()) { - handleRequiredAuthorization(); - } - }; - - render() { - if (!this.isOrganizationAdmin()) { - return null; - } - return React.cloneElement(this.props.children, { - location: this.props.location, - organization: this.props.organization - }); - } -} - -const mapStateToProps = (state: any, ownProps: OwnProps) => ({ - organization: getOrganizationByKey(state, ownProps.params.organizationKey) -}); - -export default connect(mapStateToProps)(OrganizationAdmin); 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, memberLogins: Array, - organizationGroups: Array, + organizationGroups: Array, 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.js deleted file mode 100644 index 67077233600..00000000000 --- a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationPage.js +++ /dev/null @@ -1,112 +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. - */ -// @flow -import React from 'react'; -import Helmet from 'react-helmet'; -import { connect } from 'react-redux'; -import OrganizationNavigation from '../navigation/OrganizationNavigation'; -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 }; - - componentDidMount() { - this.mounted = true; - this.updateOrganization(this.props.params.organizationKey); - } - - componentWillReceiveProps(nextProps /*: Props */) { - if (nextProps.params.organizationKey !== this.props.params.organizationKey) { - this.updateOrganization(nextProps.params.organizationKey); - } - } - - componentWillUnmount() { - this.mounted = false; - } - - stopLoading = () => { - if (this.mounted) { - this.setState({ loading: false }); - } - }; - - updateOrganization = (organizationKey /*: string */) => { - this.setState({ loading: true }); - this.props.fetchOrganization(organizationKey).then(this.stopLoading, this.stopLoading); - }; - - render() { - const { organization } = this.props; - - if (!organization || organization.canAdmin == null) { - if (this.state.loading) { - return null; - } else { - return ; - } - } - - return ( -
- - - - {this.props.children} -
- ); - } -} - -const mapStateToProps = (state, ownProps /*: OwnProps */) => ({ - organization: getOrganizationByKey(state, ownProps.params.organizationKey) -}); - -const mapDispatchToProps = { fetchOrganization }; - -export default connect(mapStateToProps, mapDispatchToProps)(OrganizationPage); diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationPage.tsx b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationPage.tsx new file mode 100644 index 00000000000..4cc5dd277f2 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationPage.tsx @@ -0,0 +1,110 @@ +/* + * 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 Helmet from 'react-helmet'; +import { connect } from 'react-redux'; +import OrganizationNavigation from '../navigation/OrganizationNavigation'; +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 { 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; +} + +type Props = OwnProps & StateProps & DispatchToProps; + +interface State { + loading: boolean; +} + +export class OrganizationPage extends React.PureComponent { + mounted = false; + state: State = { loading: true }; + + componentDidMount() { + this.mounted = true; + this.updateOrganization(this.props.params.organizationKey); + } + + componentWillReceiveProps(nextProps: Props) { + if (nextProps.params.organizationKey !== this.props.params.organizationKey) { + this.updateOrganization(nextProps.params.organizationKey); + } + } + + componentWillUnmount() { + this.mounted = false; + } + + stopLoading = () => { + if (this.mounted) { + this.setState({ loading: false }); + } + }; + + updateOrganization = (organizationKey: string) => { + this.setState({ loading: true }); + this.props.fetchOrganization(organizationKey).then(this.stopLoading, this.stopLoading); + }; + + render() { + const { organization } = this.props; + + if (!organization || organization.canAdmin == null) { + if (this.state.loading) { + return null; + } else { + return ; + } + } + + return ( +
+ + + + {this.props.children} +
+ ); + } +} + +const mapStateToProps = (state: any, ownProps: OwnProps) => ({ + organization: getOrganizationByKey(state, ownProps.params.organizationKey) +}); + +const mapDispatchToProps = { fetchOrganization: fetchOrganization as any }; + +export default connect(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( + true} + location={locationMock} + organization={adminOrganization}> +
hello
+
+ ) + ).toMatchSnapshot(); + }); + + it('should not render anything', () => { + expect( + shallow( + false} + location={locationMock} + organization={adminOrganization}> +
hello
+
+ ).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( - -
hello
-
- ) - ).toMatchSnapshot(); -}); - -it('should not render anything', () => { - const organization = { - canAdmin: false, - key: 'foo', - name: 'Foo', - projectVisibility: Visibility.Public - }; - expect( - shallow( - -
hello
-
- ).type() - ).toBeNull(); -}); diff --git a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationAccessContainer-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationAccessContainer-test.tsx.snap new file mode 100644 index 00000000000..df4447403e6 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationAccessContainer-test.tsx.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`component should render children 1`] = ` +
+ hello +
+`; 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__/OrganizationAdminContainer-test.tsx.snap deleted file mode 100644 index 349db2dd14c..00000000000 --- a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationAdminContainer-test.tsx.snap +++ /dev/null @@ -1,17 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render children 1`] = ` -
- hello -
-`; 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, + organizationGroups: Array, updateMemberGroups: (member: Member, add: Array, remove: Array) => 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 (
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')} -
  • - - {translate('quality_profiles.page')} - -
  • -
  • - - {translate('coding_rules.page')} - -
  • -
  • - - {translate('quality_gates.page')} - -
  • -
  • - - {translate('organization.members.page')} - -
  • + {hasPrivateAccess(organization) && ( + <> +
  • + + {translate('quality_profiles.page')} + +
  • +
  • + + {translate('coding_rules.page')} + +
  • +
  • + + {translate('quality_gates.page')} + +
  • + + )} + + {isCurrentUserMemberOf(organization) && ( +
  • + + {translate('organization.members.page')} + +
  • + )} + {organization.canAdmin && ( 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).mockClear(); + (hasPrivateAccess as jest.Mock).mockClear(); +}); it('renders', () => { expect( - shallow( - - ) + shallow() ).toMatchSnapshot(); }); @@ -42,12 +50,7 @@ it('renders for admin', () => { shallow( ) ).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 -
  • - - quality_profiles.page - -
  • -
  • - - coding_rules.page - -
  • -
  • - +
  • + + quality_profiles.page + +
  • +
  • + + coding_rules.page + +
  • +
  • + - quality_gates.page - -
  • + > + quality_gates.page + + +
  • -
  • - - quality_profiles.page - -
  • -
  • - - coding_rules.page - -
  • -
  • - +
  • + + quality_profiles.page + +
  • +
  • + + coding_rules.page + +
  • +
  • + - quality_gates.page - -
  • + > + quality_gates.page + + +
  • 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 { 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 ( +
    + {qualityGate && ( + + )} + + {qualityProfiles && + qualityProfiles.length > 0 && ( + + )} +
    + ); + } + 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 { qualifier={component.qualifier} /> - {isProject && ( -
    - {qualityGate && ( - - )} - - {qualityProfiles && - qualityProfiles.length > 0 && ( - - )} -
    - )} + {this.renderQualityInfos()} {isProject && } 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 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 {

    {translate('projects_management')}

    - {!isSonarCloud() && ( - - - {translate('organization.default_visibility_of_new_projects')}{' '} - {translate('visibility', organization.projectVisibility)} + {!isSonarCloud() && + organization.projectVisibility && ( + + + {translate('organization.default_visibility_of_new_projects')}{' '} + {translate('visibility', organization.projectVisibility)} + + - - - )} + )} {this.props.hasProvisionPermission && (