]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-10945 QP and QG pages should only be visible only to members of paid organizations
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>
Fri, 29 Jun 2018 14:33:30 +0000 (16:33 +0200)
committerSonarTech <sonartech@sonarsource.com>
Wed, 11 Jul 2018 18:21:21 +0000 (20:21 +0200)
* 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

44 files changed:
server/sonar-docs/src/pages/organizations/organization-and-project-privacy.md [new file with mode: 0644]
server/sonar-web/src/main/js/api/organizations.ts
server/sonar-web/src/main/js/app/components/extensions/OrganizationPageExtension.js
server/sonar-web/src/main/js/app/components/nav/component/ComponentNavHeader.tsx
server/sonar-web/src/main/js/app/types.ts
server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsMeta.tsx
server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/RuleDetailsMeta-test.tsx
server/sonar-web/src/main/js/apps/organizations/actions.js
server/sonar-web/src/main/js/apps/organizations/components/MembersList.js
server/sonar-web/src/main/js/apps/organizations/components/MembersListItem.js
server/sonar-web/src/main/js/apps/organizations/components/OrganizationAccessContainer.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/organizations/components/OrganizationAdminContainer.tsx [deleted file]
server/sonar-web/src/main/js/apps/organizations/components/OrganizationEdit.js
server/sonar-web/src/main/js/apps/organizations/components/OrganizationGroupCheckbox.js
server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembers.js
server/sonar-web/src/main/js/apps/organizations/components/OrganizationPage.js [deleted file]
server/sonar-web/src/main/js/apps/organizations/components/OrganizationPage.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationAccessContainer-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationAdminContainer-test.tsx [deleted file]
server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationAccessContainer-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationAdminContainer-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/apps/organizations/components/forms/AddMemberForm.js
server/sonar-web/src/main/js/apps/organizations/components/forms/ManageMemberGroupsForm.js
server/sonar-web/src/main/js/apps/organizations/components/forms/RemoveMemberForm.js
server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigation.tsx
server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationMenu.tsx
server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/OrganizationNavigationMenu-test.tsx
server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationMenu-test.tsx.snap
server/sonar-web/src/main/js/apps/organizations/routes.ts
server/sonar-web/src/main/js/apps/overview/meta/Meta.tsx
server/sonar-web/src/main/js/apps/projectsManagement/AppContainer.tsx
server/sonar-web/src/main/js/apps/projectsManagement/CreateProjectForm.tsx
server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx
server/sonar-web/src/main/js/components/common/VisibilitySelector.tsx
server/sonar-web/src/main/js/components/common/__tests__/VisibilitySelector-test.tsx
server/sonar-web/src/main/js/components/workspace/WorkspaceRuleDetails.tsx
server/sonar-web/src/main/js/components/workspace/WorkspaceRuleViewer.tsx
server/sonar-web/src/main/js/components/workspace/__tests__/WorkspaceRuleDetails-test.tsx
server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/WorkspaceRuleDetails-test.tsx.snap
server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/WorkspaceRuleViewer-test.tsx.snap
server/sonar-web/src/main/js/helpers/__tests__/organizations-test.ts [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/organizations.ts [new file with mode: 0644]
server/sonar-web/src/main/js/store/organizations/duck.js [deleted file]
server/sonar-web/src/main/js/store/organizations/duck.ts [new file with mode: 0644]

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 (file)
index 0000000..639f9ce
--- /dev/null
@@ -0,0 +1,6 @@
+---
+title: Organization and Project Privacy
+scope: sonarcloud
+---
+
+## TODO
index b793d7412c361583b07880876765a3392fc68183..95918a609b341fb8b2181408019b32e745d6deda 100644 (file)
  */
 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);
 }
 
index dc9980c1c1f864d076e85f3f7aa2a2852054cb73..f93fc31fba3ebb6795037ab78f70fafe97df500d 100644 (file)
@@ -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 = {
index 325d8e0b61380e97a13726fab76fdcf12c410462..150c9c5092338383d263ddf8dc649c928825505f 100644 (file)
@@ -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)
 });
 
index 97168176c9402d8ad7a57d26be0dbbbe23437314..6d359757c55d789605ee64f4e8717612449c8979 100644 (file)
@@ -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 {
index 110956c04fb00b4108da17cb39177f852cb4304c..def6cd3f6d5ffeb22154b3a2433e127accbb4eba 100644 (file)
@@ -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} />
             )}
index 1380e6c0bfc060ff1735f6a4f95b58e8f09a908e..fd496d8f03ea8d32319ee954359afc5f9e415f54 100644 (file)
@@ -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
index c70eb93dd5898477302b2e6817e8f675377c8caa..2de045b6e89b17a4a0e9f89902f95a039cbf3c99 100644 (file)
@@ -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;
index 5905d330a4c335744e128d307b95c15978ce6bf7..4ed74e4169e3b217631b927e8fb18bc3df6be8f4 100644 (file)
 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
index 792f218ffacfbdced408d77e1de3ae4104d079fe..b17f5ee3991352f434049b10865ab5b1c6bd63cd 100644 (file)
@@ -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/OrganizationAccessContainer.tsx b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationAccessContainer.tsx
new file mode 100644 (file)
index 0000000..aab0246
--- /dev/null
@@ -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<OwnProps, 'children' | 'location'> {
+  hasAccess: (props: Props) => boolean;
+}
+
+export class OrganizationAccess extends React.PureComponent<Props> {
+  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<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/OrganizationAdminContainer.tsx b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationAdminContainer.tsx
deleted file mode 100644 (file)
index 1e1ebd4..0000000
+++ /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<OwnProps, 'children' | 'location'> {}
-
-export class OrganizationAdmin extends React.PureComponent<Props> {
-  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<StateToProps, {}, OwnProps>(mapStateToProps)(OrganizationAdmin);
index 770c9587bac1cacb639326c0ed844b9769d8bfd1..b78290609b87dc1aa49eef984443225b505f3c38 100644 (file)
@@ -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 = {
index f62310aa4358acf63538b9e4a1f595f61e9f5134..95fd1e085e9bc1cf335605bd4c49a2d3853a87f5 100644 (file)
 //@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
 };
index b56d8b58188c195c562cc0fd82b4aa1a30b75cd5..fdafac5ca402cf31215fce8adb9d38040eb9c963 100644 (file)
@@ -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.js
deleted file mode 100644 (file)
index 6707723..0000000
+++ /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 <NotFound />;
-      }
-    }
-
-    return (
-      <div>
-        <Helmet defaultTitle={organization.name} titleTemplate={'%s - ' + organization.name} />
-        <Suggestions suggestions="organization_space" />
-        <OrganizationNavigation location={this.props.location} organization={organization} />
-        {this.props.children}
-      </div>
-    );
-  }
-}
-
-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 (file)
index 0000000..4cc5dd2
--- /dev/null
@@ -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<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) {
+    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 <NotFound />;
+      }
+    }
+
+    return (
+      <div>
+        <Helmet defaultTitle={organization.name} titleTemplate={'%s - ' + organization.name} />
+        <Suggestions suggestions="organization_space" />
+        <OrganizationNavigation location={this.props.location} organization={organization} />
+        {this.props.children}
+      </div>
+    );
+  }
+}
+
+const mapStateToProps = (state: any, ownProps: OwnProps) => ({
+  organization: getOrganizationByKey(state, ownProps.params.organizationKey)
+});
+
+const mapDispatchToProps = { fetchOrganization: fetchOrganization as any };
+
+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 (file)
index 0000000..17b1fd8
--- /dev/null
@@ -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 (file)
index 4f7f266..0000000
+++ /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__/OrganizationAccessContainer-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationAccessContainer-test.tsx.snap
new file mode 100644 (file)
index 0000000..df44474
--- /dev/null
@@ -0,0 +1,17 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`component should render children 1`] = `
+<div
+  location={Object {}}
+  organization={
+    Object {
+      "canAdmin": true,
+      "key": "foo",
+      "name": "Foo",
+      "projectVisibility": "public",
+    }
+  }
+>
+  hello
+</div>
+`;
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 (file)
index 349db2d..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render children 1`] = `
-<div
-  location={Object {}}
-  organization={
-    Object {
-      "canAdmin": true,
-      "key": "foo",
-      "name": "Foo",
-      "projectVisibility": "public",
-    }
-  }
->
-  hello
-</div>
-`;
index 90e04927efbb3e2054e439d87f60ff6ec2d34e7d..1fe1b0d7d6ac50dad52a53139bb32edadc06f88e 100644 (file)
@@ -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'; */
 
 /*::
index bf15a60e97ab00ab7ec44521964c2c184a4f0dcc..563bf17505c875a612a1f98487fcd5e636db83b8 100644 (file)
@@ -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
 };
 */
index e1b48aa57c8092033effeeda6cc7af9c11bfd99e..b33c53f090721433bc67084a60166ec803c7e9fe 100644 (file)
@@ -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 = {
index f9b1e0ddf81af13939ff938de86f7a809fa592ec..3ee87fc0f36ae894361dc0e158690029e6ad683f 100644 (file)
@@ -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">
index bbce61646477bf8cb10499b8d641c910d78928f5..e63d8b4b0b3657de5aa62d1e4562cca6202e0865 100644 (file)
@@ -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} />
index 55e8fc5e802ab1feeb64bd8562a270318e175ccb..251e41573de07c097995266ca7023b252bfee938 100644 (file)
@@ -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();
index 1177b7604b75cbfa75bba0549603d46061a38d9e..a51bf8cc5131d3ba64631ee6b137263caebd33ef 100644 (file)
@@ -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"
index 02c2bdaec00022f2f2fa66b5ececa4156f1e8c40..18aa7c3f4ba0133cb1f5953ee9d68287b0108b36 100644 (file)
@@ -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')) },
index c2145d14c3b0254e4168652bb1c282be720a7d50..5eb22f4735d6340a7945d3eade9045aa28d9934f 100644 (file)
@@ -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} />}
 
index 26fb48fa1c80633877c6d91cb61e24884ea8c6bb..d208e4e5b9b8535e13e1b33a26c89b585a9ad592 100644 (file)
@@ -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))
 });
 
index b3e90fd06b2dbb363bfc3f65b31929d832f48320..b721a8f7e76ffe2a6b635321d9b9a833078e4d59 100644 (file)
@@ -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 });
   };
 
index 2e5411384af260ff98fbcc67c2ee4c3a5ebf0633..4d75c04cc18cfd07efae964b696684297abfe466 100644 (file)
@@ -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')}
index 10e25bce6ee8acba3ce565e1f7200be0ba443aba..7d6633d11891c704c847051c6782c65b62f5ad36 100644 (file)
 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>
index 655563b8932ba3fdc915e30c11a6aa156c718416..73c2b7771e6e2d04666ce9cad1df0b9e688fee8e 100644 (file)
@@ -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();
 });
index e154a145773ea88714fb2233700905d882491952..647f4a4fade250aeba0cdb67a2b778ba5dd00790 100644 (file)
@@ -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}
             />
           </>
index 513f2ca83113bc276916580e95b274a927d50f4f..bb37eab9a0be536e20a5711e9f0cd2c1e2a67308 100644 (file)
@@ -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>
index e1b5f68840f5f5a866c48f54a6f3f16221cbc895..e755c5686d5fbf0bf7a890ae796ca118d66d3401 100644 (file)
@@ -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();
+});
index 682c1d5ac77efd0ceade03ec015ca6ef5f308252..566b4a80db2a73b99dcb67dfd253a081c5848ad8 100644 (file)
@@ -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",
index f8bc63a9a993f842cdcbde957b25099e8a633fc2..00b9e074c455718bc89036619dc41872379dd53c 100644 (file)
@@ -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 (file)
index 0000000..1742912
--- /dev/null
@@ -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 (file)
index 0000000..8489cb9
--- /dev/null
@@ -0,0 +1,56 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import 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.js
deleted file mode 100644 (file)
index 1571e3a..0000000
+++ /dev/null
@@ -1,258 +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 { combineReducers } from 'redux';
-import { omit, uniq, without } from 'lodash';
-
-/*::
-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>
-};
-*/
-
-/*::
-type ReceiveMyOrganizationsAction = {
-  type: 'RECEIVE_MY_ORGANIZATIONS',
-  organizations: Array<Organization>
-};
-*/
-
-/*::
-type ReceiveOrganizationGroups = {
-  type: 'RECEIVE_ORGANIZATION_GROUPS',
-  key: string,
-  groups: Array<OrgGroup>
-};
-*/
-
-/*::
-type CreateOrganizationAction = {
-  type: 'CREATE_ORGANIZATION',
-  organization: Organization
-};
-*/
-
-/*::
-type UpdateOrganizationAction = {
-  type: 'UPDATE_ORGANIZATION',
-  key: string,
-  changes: {}
-};
-*/
-
-/*::
-type DeleteOrganizationAction = {
-  type: 'DELETE_ORGANIZATION',
-  key: string
-};
-*/
-
-/*::
-type Action =
-  | ReceiveOrganizationsAction
-  | ReceiveMyOrganizationsAction
-  | ReceiveOrganizationGroups
-  | CreateOrganizationAction
-  | UpdateOrganizationAction
-  | DeleteOrganizationAction; */
-
-/*::
-type ByKeyState = {
-  [key: string]: Organization
-};
-*/
-
-/*::
-type GroupsState = {
-  [key: string]: Array<OrgGroup>
-};
-*/
-
-/*::
-type MyState = Array<string>;
-*/
-
-/*::
-type State = {
-  byKey: ByKeyState,
-  my: MyState,
-  groups: GroupsState
-};
-*/
-
-export function receiveOrganizations(
-  organizations /*: Array<Organization> */
-) /*: ReceiveOrganizationsAction */ {
-  return {
-    type: 'RECEIVE_ORGANIZATIONS',
-    organizations
-  };
-}
-
-export function receiveMyOrganizations(
-  organizations /*: Array<Organization> */
-) /*: ReceiveMyOrganizationsAction */ {
-  return {
-    type: 'RECEIVE_MY_ORGANIZATIONS',
-    organizations
-  };
-}
-
-export function receiveOrganizationGroups(
-  key /*: string */,
-  groups /*: Array<OrgGroup> */
-) /*: receiveOrganizationGroups */ {
-  return {
-    type: 'RECEIVE_ORGANIZATION_GROUPS',
-    key,
-    groups
-  };
-}
-
-export function createOrganization(
-  organization /*: Organization */
-) /*: CreateOrganizationAction */ {
-  return {
-    type: 'CREATE_ORGANIZATION',
-    organization
-  };
-}
-
-export function updateOrganization(
-  key /*: string */,
-  changes /*: {} */
-) /*: UpdateOrganizationAction */ {
-  return {
-    type: 'UPDATE_ORGANIZATION',
-    key,
-    changes
-  };
-}
-
-export function deleteOrganization(key /*: string */) /*: DeleteOrganizationAction */ {
-  return {
-    type: 'DELETE_ORGANIZATION',
-    key
-  };
-}
-
-function onReceiveOrganizations(
-  state /*: ByKeyState */,
-  action /*: ReceiveOrganizationsAction | ReceiveMyOrganizationsAction */
-) /*: ByKeyState */ {
-  const nextState = { ...state };
-  action.organizations.forEach(organization => {
-    nextState[organization.key] = { ...state[organization.key], ...organization };
-  });
-  return nextState;
-}
-
-function byKey(state /*: ByKeyState */ = {}, action /*: Action */) {
-  switch (action.type) {
-    case 'RECEIVE_ORGANIZATIONS':
-    case 'RECEIVE_MY_ORGANIZATIONS':
-      return onReceiveOrganizations(state, action);
-    case 'CREATE_ORGANIZATION':
-      return { ...state, [action.organization.key]: { ...action.organization, isAdmin: true } };
-    case 'UPDATE_ORGANIZATION':
-      return {
-        ...state,
-        [action.key]: {
-          ...state[action.key],
-          key: action.key,
-          ...action.changes
-        }
-      };
-    case 'DELETE_ORGANIZATION':
-      return omit(state, action.key);
-    default:
-      return state;
-  }
-}
-
-function my(state /*: MyState */ = [], action /*: Action */) {
-  switch (action.type) {
-    case 'RECEIVE_MY_ORGANIZATIONS':
-      return uniq([...state, ...action.organizations.map(o => o.key)]);
-    case 'CREATE_ORGANIZATION':
-      return uniq([...state, action.organization.key]);
-    case 'DELETE_ORGANIZATION':
-      return without(state, action.key);
-    default:
-      return state;
-  }
-}
-
-function groups(state /*: GroupsState */ = {}, action /*: Action */) {
-  if (action.type === 'RECEIVE_ORGANIZATION_GROUPS') {
-    return { ...state, [action.key]: action.groups };
-  }
-  return state;
-}
-
-export default combineReducers({ byKey, my, groups });
-
-export function getOrganizationByKey(state /*: State */, key /*: string */) /*: Organization */ {
-  return state.byKey[key];
-}
-
-export function getOrganizationGroupsByKey(
-  state /*: State */,
-  key /*: string */
-) /*: Array<OrgGroup> */ {
-  return state.groups[key] || [];
-}
-
-export function getMyOrganizations(state /*: State */) /*: Array<Organization> */ {
-  return state.my.map(key => getOrganizationByKey(state, key));
-}
-
-export function areThereCustomOrganizations(state /*: State */) /*: boolean */ {
-  return Object.keys(state.byKey).length > 1;
-}
diff --git a/server/sonar-web/src/main/js/store/organizations/duck.ts b/server/sonar-web/src/main/js/store/organizations/duck.ts
new file mode 100644 (file)
index 0000000..8b6c76e
--- /dev/null
@@ -0,0 +1,196 @@
+/*
+ * 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 { combineReducers } from 'redux';
+import { omit, uniq, without } from 'lodash';
+import { Group, Organization } from '../../app/types';
+
+interface ReceiveOrganizationsAction {
+  type: 'RECEIVE_ORGANIZATIONS';
+  organizations: Organization[];
+}
+
+interface ReceiveMyOrganizationsAction {
+  type: 'RECEIVE_MY_ORGANIZATIONS';
+  organizations: Organization[];
+}
+
+interface ReceiveOrganizationGroups {
+  type: 'RECEIVE_ORGANIZATION_GROUPS';
+  key: string;
+  groups: Group[];
+}
+
+interface CreateOrganizationAction {
+  type: 'CREATE_ORGANIZATION';
+  organization: Organization;
+}
+
+interface UpdateOrganizationAction {
+  type: 'UPDATE_ORGANIZATION';
+  key: string;
+  changes: {};
+}
+
+interface DeleteOrganizationAction {
+  type: 'DELETE_ORGANIZATION';
+  key: string;
+}
+
+type Action =
+  | ReceiveOrganizationsAction
+  | ReceiveMyOrganizationsAction
+  | ReceiveOrganizationGroups
+  | CreateOrganizationAction
+  | UpdateOrganizationAction
+  | DeleteOrganizationAction;
+
+interface ByKeyState {
+  [key: string]: Organization;
+}
+
+interface GroupsState {
+  [key: string]: Group[];
+}
+
+type MyState = string[];
+
+interface State {
+  byKey: ByKeyState;
+  my: MyState;
+  groups: GroupsState;
+}
+
+export function receiveOrganizations(organizations: Organization[]): ReceiveOrganizationsAction {
+  return {
+    type: 'RECEIVE_ORGANIZATIONS',
+    organizations
+  };
+}
+
+export function receiveMyOrganizations(
+  organizations: Organization[]
+): ReceiveMyOrganizationsAction {
+  return {
+    type: 'RECEIVE_MY_ORGANIZATIONS',
+    organizations
+  };
+}
+
+export function receiveOrganizationGroups(key: string, groups: Group[]): ReceiveOrganizationGroups {
+  return {
+    type: 'RECEIVE_ORGANIZATION_GROUPS',
+    key,
+    groups
+  };
+}
+
+export function createOrganization(organization: Organization): CreateOrganizationAction {
+  return {
+    type: 'CREATE_ORGANIZATION',
+    organization
+  };
+}
+
+export function updateOrganization(key: string, changes: {}): UpdateOrganizationAction {
+  return {
+    type: 'UPDATE_ORGANIZATION',
+    key,
+    changes
+  };
+}
+
+export function deleteOrganization(key: string): DeleteOrganizationAction {
+  return {
+    type: 'DELETE_ORGANIZATION',
+    key
+  };
+}
+
+function onReceiveOrganizations(
+  state: ByKeyState,
+  action: ReceiveOrganizationsAction | ReceiveMyOrganizationsAction
+): ByKeyState {
+  const nextState = { ...state };
+  action.organizations.forEach(organization => {
+    nextState[organization.key] = { ...state[organization.key], ...organization };
+  });
+  return nextState;
+}
+
+function byKey(state: ByKeyState = {}, action: Action) {
+  switch (action.type) {
+    case 'RECEIVE_ORGANIZATIONS':
+    case 'RECEIVE_MY_ORGANIZATIONS':
+      return onReceiveOrganizations(state, action);
+    case 'CREATE_ORGANIZATION':
+      return { ...state, [action.organization.key]: { ...action.organization, isAdmin: true } };
+    case 'UPDATE_ORGANIZATION':
+      return {
+        ...state,
+        [action.key]: {
+          ...state[action.key],
+          key: action.key,
+          ...action.changes
+        }
+      };
+    case 'DELETE_ORGANIZATION':
+      return omit(state, action.key);
+    default:
+      return state;
+  }
+}
+
+function my(state: MyState = [], action: Action) {
+  switch (action.type) {
+    case 'RECEIVE_MY_ORGANIZATIONS':
+      return uniq([...state, ...action.organizations.map(o => o.key)]);
+    case 'CREATE_ORGANIZATION':
+      return uniq([...state, action.organization.key]);
+    case 'DELETE_ORGANIZATION':
+      return without(state, action.key);
+    default:
+      return state;
+  }
+}
+
+function groups(state: GroupsState = {}, action: Action) {
+  if (action.type === 'RECEIVE_ORGANIZATION_GROUPS') {
+    return { ...state, [action.key]: action.groups };
+  }
+  return state;
+}
+
+export default combineReducers({ byKey, my, groups });
+
+export function getOrganizationByKey(state: State, key: string): Organization | undefined {
+  return state.byKey[key];
+}
+
+export function getOrganizationGroupsByKey(state: State, key: string): Group[] {
+  return state.groups[key] || [];
+}
+
+export function getMyOrganizations(state: State): Organization[] {
+  return state.my.map(key => getOrganizationByKey(state, key) as Organization);
+}
+
+export function areThereCustomOrganizations(state: State): boolean {
+  return Object.keys(state.byKey).length > 1;
+}