]> source.dussan.org Git - sonarqube.git/commitdiff
MMF-769 User can close their account (#1861)
authorBenoit <benoit.gianinetti@sonarsource.com>
Fri, 12 Jul 2019 12:06:47 +0000 (14:06 +0200)
committerSonarTech <sonartech@sonarsource.com>
Fri, 12 Jul 2019 18:21:16 +0000 (20:21 +0200)
125 files changed:
server/sonar-db-core/src/test/java/org/sonar/db/DefaultOrganizationTesting.java
server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationDto.java
server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationHelper.java [new file with mode: 0644]
server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationQuery.java
server/sonar-db-dao/src/main/java/org/sonar/db/user/UserDao.java
server/sonar-db-dao/src/main/java/org/sonar/db/user/UserDto.java
server/sonar-db-dao/src/main/java/org/sonar/db/user/UserMapper.java
server/sonar-db-dao/src/main/resources/org/sonar/db/organization/OrganizationMapper.xml
server/sonar-db-dao/src/main/resources/org/sonar/db/user/UserMapper.xml
server/sonar-db-dao/src/test/java/org/sonar/db/organization/OrganizationDaoTest.java
server/sonar-db-dao/src/test/java/org/sonar/db/organization/OrganizationHelperTest.java [new file with mode: 0644]
server/sonar-db-dao/src/test/java/org/sonar/db/organization/OrganizationQueryTest.java [deleted file]
server/sonar-db-dao/src/test/java/org/sonar/db/user/UserDaoTest.java
server/sonar-db-migration/src/test/resources/org/sonar/server/platform/db/migration/version/v64/PopulateRulesMetadataTest/rules_and_rules_metadata_and_organization_and_internal_properties.sql
server/sonar-docs/src/pages/sonarcloud/organizations/index.md
server/sonar-docs/src/pages/user-guide/user-account.md
server/sonar-docs/static/SonarCloudNavigationTree.json
server/sonar-server/src/main/java/org/sonar/server/authentication/SafeModeUserSession.java
server/sonar-server/src/main/java/org/sonar/server/issue/ws/ChangelogAction.java
server/sonar-server/src/main/java/org/sonar/server/organization/OrganizationUpdater.java
server/sonar-server/src/main/java/org/sonar/server/organization/ws/AddMemberAction.java
server/sonar-server/src/main/java/org/sonar/server/organization/ws/DeleteEmptyPersonalOrgsAction.java [deleted file]
server/sonar-server/src/main/java/org/sonar/server/organization/ws/OrganizationsWsModule.java
server/sonar-server/src/main/java/org/sonar/server/organization/ws/PreventUserDeletionAction.java [new file with mode: 0644]
server/sonar-server/src/main/java/org/sonar/server/organization/ws/SearchMembersAction.java
server/sonar-server/src/main/java/org/sonar/server/qualityprofile/ws/SearchUsersAction.java
server/sonar-server/src/main/java/org/sonar/server/user/DoPrivileged.java
server/sonar-server/src/main/java/org/sonar/server/user/ServerUserSession.java
server/sonar-server/src/main/java/org/sonar/server/user/ThreadLocalUserSession.java
server/sonar-server/src/main/java/org/sonar/server/user/UserSession.java
server/sonar-server/src/main/java/org/sonar/server/user/UserUpdater.java
server/sonar-server/src/main/java/org/sonar/server/user/ws/CreateAction.java
server/sonar-server/src/main/java/org/sonar/server/user/ws/DeactivateAction.java
server/sonar-server/src/main/java/org/sonar/server/user/ws/UpdateLoginAction.java
server/sonar-server/src/main/java/org/sonar/server/user/ws/UserJsonWriter.java
server/sonar-server/src/main/resources/org/sonar/server/issue/ws/changelog-example.json
server/sonar-server/src/main/resources/org/sonar/server/organization/ws/prevent_user_deletion-example.json [new file with mode: 0644]
server/sonar-server/src/test/java/org/sonar/server/issue/ws/ChangelogActionTest.java
server/sonar-server/src/test/java/org/sonar/server/organization/OrganizationUpdaterImplTest.java
server/sonar-server/src/test/java/org/sonar/server/organization/ws/DeleteEmptyPersonalOrgsActionTest.java [deleted file]
server/sonar-server/src/test/java/org/sonar/server/organization/ws/PreventUserDeletionActionTest.java [new file with mode: 0644]
server/sonar-server/src/test/java/org/sonar/server/organization/ws/SearchActionTest.java
server/sonar-server/src/test/java/org/sonar/server/tester/AnonymousMockUserSession.java
server/sonar-server/src/test/java/org/sonar/server/tester/MockUserSession.java
server/sonar-server/src/test/java/org/sonar/server/tester/UserSessionRule.java
server/sonar-server/src/test/java/org/sonar/server/ui/ws/OrganizationActionTest.java
server/sonar-server/src/test/java/org/sonar/server/user/ServerUserSessionTest.java
server/sonar-server/src/test/java/org/sonar/server/user/TestUserSessionFactory.java
server/sonar-server/src/test/java/org/sonar/server/user/ws/DeactivateActionTest.java
server/sonar-server/src/test/java/org/sonar/server/user/ws/UpdateLoginActionTest.java
server/sonar-web/package.json
server/sonar-web/src/main/js/api/issues.ts
server/sonar-web/src/main/js/api/organizations.ts
server/sonar-web/src/main/js/api/quality-profiles.ts
server/sonar-web/src/main/js/api/user_groups.ts
server/sonar-web/src/main/js/app/components/AccountDeleted.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/__tests__/AccountDeleted-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/AccountDeleted-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/app/styles/init/lists.css
server/sonar-web/src/main/js/app/types.d.ts
server/sonar-web/src/main/js/app/utils/startReactApp.tsx
server/sonar-web/src/main/js/apps/account/profile/Profile.tsx
server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccount.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccountContent.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccountModal.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccount-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccountContent-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccountModal-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccount-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccountContent-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccountModal-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/custom-measures/components/Item.tsx
server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/Item-test.tsx
server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/__snapshots__/Item-test.tsx.snap
server/sonar-web/src/main/js/apps/groups/components/EditMembersModal.tsx
server/sonar-web/src/main/js/apps/issues/components/App.tsx
server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.tsx.snap
server/sonar-web/src/main/js/apps/issues/utils.ts
server/sonar-web/src/main/js/apps/projects/components/NoFavoriteProjects.tsx
server/sonar-web/src/main/js/apps/projects/components/__tests__/NoFavoriteProjects-test.tsx
server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/NoFavoriteProjects-test.tsx.snap
server/sonar-web/src/main/js/apps/projectsManagement/App.tsx
server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.tsx
server/sonar-web/src/main/js/apps/projectsManagement/ProjectRowActions.tsx
server/sonar-web/src/main/js/apps/projectsManagement/Projects.tsx
server/sonar-web/src/main/js/apps/projectsManagement/RestoreAccessModal.tsx
server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissions.tsx
server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsForm.tsx
server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsFormSelect.tsx
server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsUser.tsx
server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsUser-test.tsx
server/sonar-web/src/main/js/apps/sessions/components/Logout.tsx
server/sonar-web/src/main/js/apps/sessions/components/__tests__/Logout-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/sessions/components/__tests__/__snapshots__/Logout-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx
server/sonar-web/src/main/js/apps/users/components/UserActions.tsx
server/sonar-web/src/main/js/apps/users/components/UserForm.tsx
server/sonar-web/src/main/js/apps/users/components/__tests__/UserActions-test.tsx
server/sonar-web/src/main/js/components/controls/ValidationModal.tsx
server/sonar-web/src/main/js/components/controls/__tests__/ValidationModal-test.tsx
server/sonar-web/src/main/js/components/issue/components/IssueAssign.tsx
server/sonar-web/src/main/js/components/issue/components/IssueChangelogDiff.tsx
server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.tsx
server/sonar-web/src/main/js/components/issue/components/__tests__/IssueCommentLine-test.tsx
server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueAssign-test.tsx.snap
server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueCommentLine-test.tsx.snap
server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.tsx
server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.tsx
server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.tsx
server/sonar-web/src/main/js/components/issue/popups/__tests__/ChangelogPopup-test.tsx
server/sonar-web/src/main/js/components/issue/popups/__tests__/SetAssigneePopup-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/issue/popups/__tests__/SimilarIssuesPopup-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/ChangelogPopup-test.tsx.snap
server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetAssigneePopup-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SimilarIssuesPopup-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/issues.ts
server/sonar-web/src/main/js/helpers/users.ts
server/sonar-web/yarn.lock
sonar-core/src/main/resources/org/sonar/l10n/core.properties
sonar-ws/src/main/protobuf/ws-issues.proto
sonar-ws/src/main/protobuf/ws-organizations.proto

index 1931be9e68f1b365b26d66a464fc05c4c3958876..f2bb100e27cec16f46d7dd8dd4608101b717f340 100644 (file)
@@ -46,7 +46,6 @@ public class DefaultOrganizationTesting {
       "UUID", uuid,
       "KEE", uuid,
       "NAME", uuid,
-      "GUARDED", String.valueOf(false),
       "CREATED_AT", "1000",
       "UPDATED_AT", "1000");
   }
index f82bd6588a8c59dbb562d4b7e8b1561708b464d6..33a703a0be9381d54b0890fd3d77ff9137a7a4fe 100644 (file)
@@ -73,10 +73,6 @@ public class OrganizationDto {
 
   private Subscription subscription;
 
-  /**
-   * Flag indicated whether being root is required to be able to delete this organization.
-   */
-  private boolean guarded = false;
   private Integer defaultGroupId;
   private String defaultQualityGateUuid;
   private long createdAt;
@@ -139,15 +135,6 @@ public class OrganizationDto {
     return this;
   }
 
-  public boolean isGuarded() {
-    return guarded;
-  }
-
-  public OrganizationDto setGuarded(boolean guarded) {
-    this.guarded = guarded;
-    return this;
-  }
-
   @CheckForNull
   public Integer getDefaultGroupId() {
     return defaultGroupId;
@@ -220,7 +207,6 @@ public class OrganizationDto {
       ", description='" + description + '\'' +
       ", url='" + url + '\'' +
       ", avatarUrl='" + avatarUrl + '\'' +
-      ", guarded=" + guarded +
       ", defaultQualityGateUuid=" + defaultQualityGateUuid +
       ", subscription=" + subscription +
       ", createdAt=" + createdAt +
diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationHelper.java b/server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationHelper.java
new file mode 100644 (file)
index 0000000..ef30488
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.
+ */
+package org.sonar.db.organization;
+
+import java.util.List;
+import java.util.stream.Collectors;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.permission.OrganizationPermission;
+
+public class OrganizationHelper {
+
+  private static final String ADMIN_PERMISSION = OrganizationPermission.ADMINISTER.getKey();
+
+  private final DbClient dbClient;
+
+  public OrganizationHelper(DbClient dbClient) {
+    this.dbClient = dbClient;
+  }
+
+  public List<OrganizationDto> selectOrganizationsWithLastAdmin(DbSession dbSession, int userId) {
+    return dbClient.organizationDao().selectByPermission(dbSession, userId, ADMIN_PERMISSION).stream()
+      .filter(org -> isLastAdmin(dbSession, org, userId))
+      .collect(Collectors.toList());
+  }
+
+  private boolean isLastAdmin(DbSession dbSession, OrganizationDto org, int userId) {
+    return dbClient.authorizationDao().countUsersWithGlobalPermissionExcludingUser(dbSession, org.getUuid(), ADMIN_PERMISSION, userId) == 0;
+  }
+}
index da63b59d5127e3326af80be24cda5bb1c304d4c4..899bcba33cbdd0fff379b5bcb8a8b5fde79a3369 100644 (file)
@@ -32,27 +32,15 @@ public class OrganizationQuery {
   private final Set<String> keys;
   @Nullable
   private final Integer userId;
-  private final boolean onlyTeam;
-  private final boolean onlyPersonal;
   private final boolean withAnalyses;
-  private final boolean withoutProjects;
   @Nullable
   private final Long analyzedAfter;
 
   private OrganizationQuery(Builder builder) {
     this.keys = builder.keys;
     this.userId = builder.member;
-    this.onlyPersonal = builder.onlyPersonal;
-    this.onlyTeam = builder.onlyTeam;
-    if (this.onlyPersonal && this.onlyTeam) {
-      throw new IllegalArgumentException("Only one of onlyPersonal and onlyTeam can be true");
-    }
     this.withAnalyses = builder.withAnalyses;
     this.analyzedAfter = builder.analyzedAfter;
-    this.withoutProjects = builder.withoutProjects;
-    if ((this.withAnalyses || this.analyzedAfter != null) && this.withoutProjects) {
-      throw new IllegalArgumentException("withoutProjects cannot be used together with withAnalyses or analyzedAfter");
-    }
   }
 
   @CheckForNull
@@ -65,14 +53,6 @@ public class OrganizationQuery {
     return userId;
   }
 
-  public boolean isOnlyTeam() {
-    return onlyTeam;
-  }
-
-  public boolean isOnlyPersonal() {
-    return onlyPersonal;
-  }
-
   public boolean isWithAnalyses() {
     return withAnalyses;
   }
@@ -82,10 +62,6 @@ public class OrganizationQuery {
     return analyzedAfter;
   }
 
-  public boolean isWithoutProjects() {
-    return withoutProjects;
-  }
-
   public static OrganizationQuery returnAll() {
     return NO_FILTER;
   }
@@ -98,10 +74,7 @@ public class OrganizationQuery {
     private Set<String> keys;
     @Nullable
     private Integer member;
-    private boolean onlyTeam = false;
-    private boolean onlyPersonal = false;
     private boolean withAnalyses = false;
-    private boolean withoutProjects = false;
     @Nullable
     private Long analyzedAfter;
 
@@ -123,16 +96,6 @@ public class OrganizationQuery {
       return this;
     }
 
-    public Builder setOnlyTeam() {
-      this.onlyTeam = true;
-      return this;
-    }
-
-    public Builder setOnlyPersonal() {
-      this.onlyPersonal = true;
-      return this;
-    }
-
     public Builder setWithAnalyses() {
       this.withAnalyses = true;
       return this;
@@ -143,11 +106,6 @@ public class OrganizationQuery {
       return this;
     }
 
-    public Builder setWithoutProjects() {
-      this.withoutProjects = true;
-      return this;
-    }
-
     public OrganizationQuery build() {
       return new OrganizationQuery(this);
     }
index 7fe0e24e73df7fb97c33b5d50b9d0d2ab802d703..744249a2ce60f4e8080faa64fed91b183f040ff5 100644 (file)
@@ -135,6 +135,10 @@ public class UserDao implements Dao {
     mapper(dbSession).deactivateUser(user.getLogin(), system2.now());
   }
 
+  public void deactivateSonarCloudUser(DbSession dbSession, UserDto user) {
+    mapper(dbSession).deactivateSonarCloudUser(user.getLogin(), system2.now());
+  }
+
   public void cleanHomepage(DbSession dbSession, OrganizationDto organization) {
     mapper(dbSession).clearHomepages("ORGANIZATION", organization.getUuid(), system2.now());
   }
index 2110735f6604ee6b8d659048746dd17cac3a6105..c8a414f934a9b6640b570e48d27acb0d70c57703 100644 (file)
@@ -55,7 +55,6 @@ public class UserDto {
   private boolean local = true;
   private boolean root = false;
   private boolean onboarded = false;
-  private String organizationUuid;
 
   /**
    * Date of the last time the user has accessed to the server.
@@ -272,16 +271,6 @@ public class UserDto {
     return this;
   }
 
-  @CheckForNull
-  public String getOrganizationUuid() {
-    return organizationUuid;
-  }
-
-  public UserDto setOrganizationUuid(@Nullable String organizationUuid) {
-    this.organizationUuid = organizationUuid;
-    return this;
-  }
-
   @CheckForNull
   public Long getLastConnectionDate() {
     return lastConnectionDate;
index 8f1a77c7b86ee418ac569c80d4c97460026b3121..8c83ba012c3f4c69374c5ae8a6ba99da42ca4d5a 100644 (file)
@@ -82,6 +82,8 @@ public interface UserMapper {
 
   void deactivateUser(@Param("login") String login, @Param("now") long now);
 
+  void deactivateSonarCloudUser(@Param("login") String login, @Param("now") long now);
+
   void clearHomepages(@Param("homepageType") String type, @Param("homepageParameter") String value, @Param("now") long now);
 
   void clearHomepage(@Param("login") String login, @Param("now") long now);
index a806a01d78ad8e6f9760893dd699de3d9b8eb6e8..b23b1b46960d39412c9091985825c5b56522d053 100644 (file)
@@ -10,7 +10,6 @@
     org.default_quality_gate_uuid as "defaultQualityGateUuid",
     org.url as "url",
     org.avatar_url as "avatarUrl",
-    org.guarded as "guarded",
     org.subscription as "subscription",
     org.created_at as "createdAt",
     org.updated_at as "updatedAt"
             #{key, jdbcType=VARCHAR}
           </foreach>
       </if>
-      <if test="query.onlyTeam">
-        and not exists(
-          select 1
-          from users u
-          where u.organization_uuid = org.uuid
-          and u.active = ${_true}
-        )
-      </if>
-      <if test="query.onlyPersonal">
-        and exists(
-          select 1
-          from users u
-          where u.organization_uuid = org.uuid
-          and u.active = ${_true}
-        )
-      </if>
       <if test="query.withAnalyses">
         and exists(
           select 1
           and s.islast = ${_true}
         )
       </if>
-      <if test="query.withoutProjects">
-        and not exists(
-          select 1
-          from projects p
-          where p.organization_uuid = org.uuid
-          and p.enabled = ${_true}
-        )
-      </if>
       <if test="query.analyzedAfter != null">
         and exists(
         select 1
       description,
       url,
       avatar_url,
-      guarded,
       new_project_private,
       default_quality_gate_uuid,
       subscription,
       #{organization.description, jdbcType=VARCHAR},
       #{organization.url, jdbcType=VARCHAR},
       #{organization.avatarUrl, jdbcType=VARCHAR},
-      #{organization.guarded, jdbcType=BOOLEAN},
       #{newProjectPrivate, jdbcType=BOOLEAN},
       #{organization.defaultQualityGateUuid, jdbcType=VARCHAR},
       #{organization.subscription, jdbcType=VARCHAR},
index bf0ee3a5d812a1bb00d1d145d145f07e34c12210..d6c6c9b059f09c0a22c67ff95be50f9f7b21d70b 100644 (file)
@@ -22,7 +22,6 @@
         u.onboarded as "onboarded",
         u.homepage_type as "homepageType",
         u.homepage_parameter as "homepageParameter",
-        u.organization_uuid as organizationUuid,
         u.last_connection_date as "lastConnectionDate",
         u.created_at as "createdAt",
         u.updated_at as "updatedAt"
         and u.login &lt;&gt; #{login}
     </select>
 
-    <update id="deactivateUser" parameterType="map">
-        update users set
+    <sql id="deactivateUserUpdatedFields">
         active = ${_false},
         email = null,
         scm_accounts = null,
         crypted_password = null,
         last_connection_date = null,
         updated_at = #{now, jdbcType=BIGINT}
+    </sql>
+
+    <update id="deactivateUser" parameterType="map">
+        update users set
+        <include refid="deactivateUserUpdatedFields"/>
+        where
+        login = #{login, jdbcType=VARCHAR}
+    </update>
+
+    <update id="deactivateSonarCloudUser" parameterType="map">
+        update users set
+        name = null,
+        <include refid="deactivateUserUpdatedFields"/>
         where
         login = #{login, jdbcType=VARCHAR}
     </update>
         onboarded,
         homepage_type,
         homepage_parameter,
-        organization_uuid,
         created_at,
         updated_at
         ) values (
         #{user.onboarded,jdbcType=BOOLEAN},
         #{user.homepageType,jdbcType=VARCHAR},
         #{user.homepageParameter,jdbcType=VARCHAR},
-        #{user.organizationUuid,jdbcType=VARCHAR},
         #{user.createdAt,jdbcType=BIGINT},
         #{user.updatedAt,jdbcType=BIGINT}
         )
         hash_method = #{user.hashMethod, jdbcType=VARCHAR},
         homepage_type = #{user.homepageType, jdbcType=VARCHAR},
         homepage_parameter = #{user.homepageParameter, jdbcType=VARCHAR},
-        organization_uuid = #{user.organizationUuid, jdbcType=VARCHAR},
         last_connection_date = #{user.lastConnectionDate,jdbcType=BIGINT},
         updated_at = #{user.updatedAt,jdbcType=BIGINT}
         where
index ef389bc5223a1881b55fa3ef4da4b8f30cf8db70..debde182edec72675f992b210dfb00d4f92f9172 100644 (file)
@@ -50,8 +50,6 @@ import org.sonar.db.Pagination;
 import org.sonar.db.alm.ALM;
 import org.sonar.db.alm.AlmAppInstallDto;
 import org.sonar.db.component.ComponentDto;
-import org.sonar.db.dialect.Dialect;
-import org.sonar.db.dialect.Oracle;
 import org.sonar.db.metric.MetricDto;
 import org.sonar.db.qualitygate.QGateWithOrgDto;
 import org.sonar.db.user.GroupDto;
@@ -86,7 +84,6 @@ public class OrganizationDaoTest {
     .setDescription("the description 1")
     .setUrl("the url 1")
     .setAvatarUrl("the avatar url 1")
-    .setGuarded(false)
     .setSubscription(FREE)
     .setDefaultQualityGateUuid("1");
   private static final OrganizationDto ORGANIZATION_DTO_2 = new OrganizationDto()
@@ -96,7 +93,6 @@ public class OrganizationDaoTest {
     .setDescription("the description 2")
     .setUrl("the url 2")
     .setAvatarUrl("the avatar url 2")
-    .setGuarded(true)
     .setSubscription(FREE)
     .setDefaultQualityGateUuid("1");
   private static final String PERMISSION_1 = "foo";
@@ -148,21 +144,12 @@ public class OrganizationDaoTest {
     assertThat(row.get("avatarUrl")).isEqualTo(organization.getAvatarUrl());
     assertThat(row.get("createdAt")).isEqualTo(organization.getCreatedAt());
     assertThat(row.get("updatedAt")).isEqualTo(organization.getUpdatedAt());
-    assertThat(row.get("guarded")).isEqualTo(toBool(organization.isGuarded()));
     assertThat(row.get("subscription")).isEqualTo(organization.getSubscription().name());
     assertThat(row.get("defaultTemplate")).isNull();
     assertThat(row.get("projectDefaultTemplate")).isNull();
     assertThat(row.get("viewDefaultTemplate")).isNull();
   }
 
-  @Test
-  public void insert_persists_boolean_property_guarded_of_OrganizationDto() {
-    insertOrganization(ORGANIZATION_DTO_2);
-
-    Map<String, Object> row = selectSingleRow();
-    assertThat(row.get("guarded")).isEqualTo(toBool(ORGANIZATION_DTO_2.isGuarded()));
-  }
-
   @Test
   public void description_url_avatarUrl_and_userId_are_optional() {
     when(system2.now()).thenReturn(SOME_DATE);
@@ -175,7 +162,6 @@ public class OrganizationDaoTest {
     assertThat(row.get("description")).isNull();
     assertThat(row.get("url")).isNull();
     assertThat(row.get("avatarUrl")).isNull();
-    assertThat(row.get("guarded")).isEqualTo(toBool(ORGANIZATION_DTO_1.isGuarded()));
     assertThat(row.get("userId")).isNull();
     assertThat(row.get("createdAt")).isEqualTo(SOME_DATE);
     assertThat(row.get("updatedAt")).isEqualTo(SOME_DATE);
@@ -184,14 +170,6 @@ public class OrganizationDaoTest {
     assertThat(row.get("viewDefaultTemplate")).isNull();
   }
 
-  private Object toBool(boolean guarded) {
-    Dialect dialect = db.database().getDialect();
-    if (dialect.getId().equals(Oracle.ID)) {
-      return guarded ? 1L : 0L;
-    }
-    return guarded;
-  }
-
   @Test
   public void insert_fails_if_row_with_uuid_already_exists() {
     insertOrganization(ORGANIZATION_DTO_1);
@@ -584,20 +562,6 @@ public class OrganizationDaoTest {
       .doesNotContain(organizationWithoutKeyProvided.getUuid(), organizationWithoutMember.getUuid());
   }
 
-  @Test
-  public void selectByQuery_filter_on_type() {
-    OrganizationDto personalOrg1 = db.organizations().insert();
-    db.users().insertUser(u -> u.setOrganizationUuid(personalOrg1.getUuid()));
-    OrganizationDto personalOrg2 = db.organizations().insert();
-    db.users().insertUser(u -> u.setOrganizationUuid(personalOrg2.getUuid()));
-    OrganizationDto teamOrg1 = db.organizations().insert();
-
-    assertThat(selectUuidsByQuery(q -> q.setOnlyPersonal(), forPage(1).andSize(100)))
-      .containsExactlyInAnyOrder(personalOrg1.getUuid(), personalOrg2.getUuid());
-    assertThat(selectUuidsByQuery(q -> q.setOnlyTeam(), forPage(1).andSize(100)))
-      .containsExactlyInAnyOrder(teamOrg1.getUuid());
-  }
-
   @Test
   public void selectByQuery_filter_on_withAnalyses() {
     assertThat(selectUuidsByQuery(q -> q.setWithAnalyses(), forPage(1).andSize(100)))
@@ -629,25 +593,6 @@ public class OrganizationDaoTest {
       .collect(Collectors.toList());
   }
 
-  @Test
-  public void selectByQuery_filter_on_withoutProjects() {
-    assertThat(selectUuidsByQuery(q -> q.setWithoutProjects(), forPage(1).andSize(100)))
-      .isEmpty();
-
-    // has projects
-    OrganizationDto orgWithProjects = db.organizations().insert();
-    db.components().insertPrivateProject(orgWithProjects);
-    db.components().insertPrivateProject(orgWithProjects, p -> p.setEnabled(false));
-    // has no projects
-    OrganizationDto orgWithoutProjects = db.organizations().insert();
-    // has only disabled projects
-    OrganizationDto orgWithOnlyDisabledProjects = db.organizations().insert();
-    db.components().insertPrivateProject(orgWithOnlyDisabledProjects, p -> p.setEnabled(false));
-
-    assertThat(selectUuidsByQuery(q -> q.setWithoutProjects(), forPage(1).andSize(100)))
-      .containsExactlyInAnyOrder(orgWithoutProjects.getUuid(), orgWithOnlyDisabledProjects.getUuid());
-  }
-
   @Test
   public void getDefaultTemplates_returns_empty_when_table_is_empty() {
     assertThat(underTest.getDefaultTemplates(dbSession, ORGANIZATION_DTO_1.getUuid())).isEmpty();
@@ -1155,7 +1100,6 @@ public class OrganizationDaoTest {
           "      default_perm_template_app," +
           "      default_perm_template_port," +
           "      new_project_private," +
-          "      guarded," +
           "      default_quality_gate_uuid," +
           "      subscription," +
           "      created_at," +
@@ -1173,7 +1117,6 @@ public class OrganizationDaoTest {
           "      ?," +
           "      ?," +
           "      ?," +
-          "      ?," +
           "      ?" +
           "    )")) {
       preparedStatement.setString(1, organizationUuid);
@@ -1183,11 +1126,10 @@ public class OrganizationDaoTest {
       preparedStatement.setString(5, view);
       preparedStatement.setString(6, view);
       preparedStatement.setBoolean(7, false);
-      preparedStatement.setBoolean(8, false);
-      preparedStatement.setString(9, "1");
-      preparedStatement.setString(10, FREE.name());
-      preparedStatement.setLong(11, 1000L);
-      preparedStatement.setLong(12, 2000L);
+      preparedStatement.setString(8, "1");
+      preparedStatement.setString(9, FREE.name());
+      preparedStatement.setLong(10, 1000L);
+      preparedStatement.setLong(11, 2000L);
       preparedStatement.execute();
     } catch (SQLException e) {
       throw new RuntimeException("dirty insert failed", e);
@@ -1210,7 +1152,6 @@ public class OrganizationDaoTest {
     assertThat(dto.getName()).isEqualTo(ORGANIZATION_DTO_1.getName());
     assertThat(dto.getDescription()).isEqualTo(ORGANIZATION_DTO_1.getDescription());
     assertThat(dto.getUrl()).isEqualTo(ORGANIZATION_DTO_1.getUrl());
-    assertThat(dto.isGuarded()).isEqualTo(ORGANIZATION_DTO_1.isGuarded());
     assertThat(dto.getAvatarUrl()).isEqualTo(ORGANIZATION_DTO_1.getAvatarUrl());
     assertThat(dto.getCreatedAt()).isEqualTo(ORGANIZATION_DTO_1.getCreatedAt());
     assertThat(dto.getUpdatedAt()).isEqualTo(ORGANIZATION_DTO_1.getUpdatedAt());
@@ -1222,7 +1163,6 @@ public class OrganizationDaoTest {
     assertThat(dto.getName()).isEqualTo(expected.getName());
     assertThat(dto.getDescription()).isEqualTo(expected.getDescription());
     assertThat(dto.getUrl()).isEqualTo(expected.getUrl());
-    assertThat(dto.isGuarded()).isEqualTo(expected.isGuarded());
     assertThat(dto.getAvatarUrl()).isEqualTo(expected.getAvatarUrl());
     assertThat(dto.getSubscription()).isEqualTo(expected.getSubscription());
     assertThat(dto.getCreatedAt()).isEqualTo(expected.getCreatedAt());
@@ -1232,7 +1172,6 @@ public class OrganizationDaoTest {
   private Map<String, Object> selectSingleRow() {
     List<Map<String, Object>> rows = db.select("select" +
       " uuid as \"uuid\", kee as \"key\", name as \"name\",  description as \"description\", url as \"url\", avatar_url as \"avatarUrl\"," +
-      " guarded as \"guarded\"," +
       " subscription as \"subscription\"," +
       " created_at as \"createdAt\", updated_at as \"updatedAt\"," +
       " default_perm_template_project as \"projectDefaultPermTemplate\"," +
diff --git a/server/sonar-db-dao/src/test/java/org/sonar/db/organization/OrganizationHelperTest.java b/server/sonar-db-dao/src/test/java/org/sonar/db/organization/OrganizationHelperTest.java
new file mode 100644 (file)
index 0000000..bc5d738
--- /dev/null
@@ -0,0 +1,141 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.
+ */
+package org.sonar.db.organization;
+
+import com.tngtech.java.junit.dataprovider.DataProvider;
+import com.tngtech.java.junit.dataprovider.DataProviderRunner;
+import com.tngtech.java.junit.dataprovider.UseDataProvider;
+import java.util.List;
+import java.util.Random;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+import org.sonar.api.utils.System2;
+import org.sonar.db.DbSession;
+import org.sonar.db.DbTester;
+import org.sonar.db.user.GroupDto;
+import org.sonar.db.user.UserDto;
+
+import static java.util.Arrays.asList;
+import static java.util.Collections.emptyList;
+import static java.util.Collections.singletonList;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.sonar.db.permission.OrganizationPermission.ADMINISTER;
+
+@RunWith(DataProviderRunner.class)
+public class OrganizationHelperTest {
+
+  private static final Random RANDOM = new Random();
+
+  @Rule
+  public DbTester db = DbTester.create(mock(System2.class)).setDisableDefaultOrganization(true);
+  public DbSession dbSession = db.getSession();
+
+  @Rule
+  public ExpectedException expectedException = ExpectedException.none();
+
+  private final OrganizationHelper underTest = new OrganizationHelper(db.getDbClient());
+
+  @Test
+  public void returns_empty_list_when_user_is_not_admin_of_any_orgs() {
+    UserDto user1 = db.users().insertUser();
+
+    OrganizationDto org1 = db.organizations().insert();
+    GroupDto group1 = db.users().insertGroup(org1);
+    db.users().insertMember(group1, user1);
+
+    assertThat(underTest.selectOrganizationsWithLastAdmin(dbSession, user1.getId())).isEmpty();
+  }
+
+  @Test
+  public void returns_orgs_where_user_is_last_admin() {
+    UserDto user1 = db.users().insertUser();
+    UserDto user2 = db.users().insertUser();
+
+    OrganizationDto org1 = db.organizations().insert();
+    OrganizationDto org2 = db.organizations().insert();
+
+    setAsDirectOrIndirectAdmin(user1, org1);
+    setAsDirectOrIndirectAdmin(user2, org1);
+    setAsDirectOrIndirectAdmin(user1, org2);
+
+    assertThat(underTest.selectOrganizationsWithLastAdmin(dbSession, user1.getId()))
+      .extracting(OrganizationDto::getKey)
+      .containsExactly(org2.getKey());
+  }
+
+  @Test
+  @UseDataProvider("adminUserCombinationsAndExpectedOrgKeys")
+  public void returns_correct_orgs_for_interesting_combinations_of_last_admin_or_not(
+    boolean user2IsAdminOfOrg1, boolean user1IsAdminOfOrg2, boolean user2IsAdminOfOrg2, List<String> expectedOrgKeys) {
+    UserDto user1 = db.users().insertUser();
+    UserDto user2 = db.users().insertUser();
+
+    OrganizationDto org1 = db.organizations().insert(o -> o.setKey("org1"));
+    OrganizationDto org2 = db.organizations().insert(o -> o.setKey("org2"));
+
+    setAsDirectOrIndirectAdmin(user1, org1);
+    if (user2IsAdminOfOrg1) {
+      setAsDirectOrIndirectAdmin(user2, org1);
+    }
+    if (user1IsAdminOfOrg2) {
+      setAsDirectOrIndirectAdmin(user1, org2);
+    }
+    if (user2IsAdminOfOrg2) {
+      setAsDirectOrIndirectAdmin(user2, org2);
+    }
+
+    assertThat(underTest.selectOrganizationsWithLastAdmin(dbSession, user1.getId()))
+      .extracting(OrganizationDto::getKey)
+      .containsExactlyInAnyOrderElementsOf(expectedOrgKeys);
+  }
+
+  @DataProvider
+  public static Object[][] adminUserCombinationsAndExpectedOrgKeys() {
+    return new Object[][] {
+      // note: user1 is always admin of org1
+      // param 1: user2 is admin of org1
+      // param 2: user1 is admin of org2
+      // param 3: user2 is admin of org2
+      // param 4: list of orgs preventing user1 to delete
+      {true, true, true, emptyList()},
+      {true, true, false, singletonList("org2")},
+      {true, false, true, emptyList()},
+      {true, false, false, emptyList()},
+      {false, true, true, singletonList("org1")},
+      {false, true, false, asList("org1", "org2")},
+      {false, false, true, singletonList("org1")},
+      {false, false, false, singletonList("org1")},
+    };
+  }
+
+  private void setAsDirectOrIndirectAdmin(UserDto user, OrganizationDto organization) {
+    boolean useDirectAdmin = RANDOM.nextBoolean();
+    if (useDirectAdmin) {
+      db.users().insertPermissionOnUser(organization, user, ADMINISTER);
+    } else {
+      GroupDto group = db.users().insertGroup(organization);
+      db.users().insertPermissionOnGroup(group, ADMINISTER);
+      db.users().insertMember(group, user);
+    }
+  }
+}
diff --git a/server/sonar-db-dao/src/test/java/org/sonar/db/organization/OrganizationQueryTest.java b/server/sonar-db-dao/src/test/java/org/sonar/db/organization/OrganizationQueryTest.java
deleted file mode 100644 (file)
index 74f6014..0000000
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2019 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.
- */
-package org.sonar.db.organization;
-
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.rules.ExpectedException;
-
-public class OrganizationQueryTest {
-
-  @Rule
-  public ExpectedException expectedException = ExpectedException.none();
-
-  @Test
-  public void throws_IAE_when_both_onlyPersonal_and_onlyTeam_are_set() {
-    expectedException.expect(IllegalArgumentException.class);
-    OrganizationQuery.newOrganizationQueryBuilder()
-      .setOnlyPersonal()
-      .setOnlyTeam()
-      .build();
-  }
-
-  @Test
-  public void throws_IAE_when_withoutProjects_is_used_together_with_withAnalyses() {
-    expectedException.expect(IllegalArgumentException.class);
-    OrganizationQuery.newOrganizationQueryBuilder()
-      .setWithoutProjects()
-      .setWithAnalyses()
-      .build();
-  }
-
-  @Test
-  public void throws_IAE_when_withoutProjects_is_used_together_with_analyzedAfter() {
-    expectedException.expect(IllegalArgumentException.class);
-    OrganizationQuery.newOrganizationQueryBuilder()
-      .setWithoutProjects()
-      .setAnalyzedAfter(1)
-      .build();
-  }
-
-  @Test
-  public void throws_IAE_when_withoutProjects_is_used_together_with_both_withAnalyses_and_analyzedAfter() {
-    expectedException.expect(IllegalArgumentException.class);
-    OrganizationQuery.newOrganizationQueryBuilder()
-      .setWithoutProjects()
-      .setWithAnalyses()
-      .setAnalyzedAfter(1)
-      .build();
-  }
-}
index e739b57e69a2ea6dbbf577c3bb229259591a2e36..1f02bd04a3467c92ae5f1942d20002c8a28317b4 100644 (file)
  */
 package org.sonar.db.user;
 
+import com.tngtech.java.junit.dataprovider.DataProvider;
+import com.tngtech.java.junit.dataprovider.DataProviderRunner;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import org.junit.Rule;
 import org.junit.Test;
+import org.junit.runner.RunWith;
 import org.sonar.api.user.UserQuery;
 import org.sonar.api.utils.DateUtils;
 import org.sonar.api.impl.utils.TestSystem2;
@@ -44,6 +47,7 @@ import static org.assertj.core.groups.Tuple.tuple;
 import static org.sonar.db.user.GroupTesting.newGroupDto;
 import static org.sonar.db.user.UserTesting.newUserDto;
 
+@RunWith(DataProviderRunner.class)
 public class UserDaoTest {
   private static final long NOW = 1_500_000_000_000L;
 
@@ -336,7 +340,6 @@ public class UserDaoTest {
       .setLocal(true)
       .setHomepageType("project")
       .setHomepageParameter("OB1")
-      .setOrganizationUuid("ORG_UUID")
       .setCreatedAt(date)
       .setUpdatedAt(date);
     underTest.insert(db.getSession(), userDto);
@@ -361,7 +364,6 @@ public class UserDaoTest {
     assertThat(user.isRoot()).isFalse();
     assertThat(user.getHomepageType()).isEqualTo("project");
     assertThat(user.getHomepageParameter()).isEqualTo("OB1");
-    assertThat(user.getOrganizationUuid()).isEqualTo("ORG_UUID");
   }
 
   @Test
@@ -383,8 +385,7 @@ public class UserDaoTest {
       .setEmail("jo@hn.com")
       .setActive(true)
       .setLocal(true)
-      .setOnboarded(false)
-      .setOrganizationUuid("OLD_ORG_UUID"));
+      .setOnboarded(false));
 
     underTest.update(db.getSession(), newUserDto()
       .setUuid(user.getUuid())
@@ -403,7 +404,6 @@ public class UserDaoTest {
       .setLocal(false)
       .setHomepageType("project")
       .setHomepageParameter("OB1")
-      .setOrganizationUuid("ORG_UUID")
       .setLastConnectionDate(10_000_000_000L));
 
     UserDto reloaded = underTest.selectByUuid(db.getSession(), user.getUuid());
@@ -425,7 +425,6 @@ public class UserDaoTest {
     assertThat(reloaded.isRoot()).isFalse();
     assertThat(reloaded.getHomepageType()).isEqualTo("project");
     assertThat(reloaded.getHomepageParameter()).isEqualTo("OB1");
-    assertThat(reloaded.getOrganizationUuid()).isEqualTo("ORG_UUID");
     assertThat(reloaded.getLastConnectionDate()).isEqualTo(10_000_000_000L);
   }
 
@@ -441,10 +440,40 @@ public class UserDaoTest {
 
     UserDto userReloaded = underTest.selectUserById(session, user.getId());
     assertThat(userReloaded.isActive()).isFalse();
-    assertThat(userReloaded.getLogin()).isNotNull();
-    assertThat(userReloaded.getExternalId()).isNotNull();
-    assertThat(userReloaded.getExternalLogin()).isNotNull();
-    assertThat(userReloaded.getExternalIdentityProvider()).isNotNull();
+    assertThat(userReloaded.getName()).isEqualTo(user.getName());
+    assertThat(userReloaded.getLogin()).isEqualTo(user.getLogin());
+    assertThat(userReloaded.getExternalId()).isEqualTo(user.getExternalId());
+    assertThat(userReloaded.getExternalLogin()).isEqualTo(user.getExternalLogin());
+    assertThat(userReloaded.getExternalIdentityProvider()).isEqualTo(user.getExternalIdentityProvider());
+    assertThat(userReloaded.getEmail()).isNull();
+    assertThat(userReloaded.getScmAccounts()).isNull();
+    assertThat(userReloaded.getSalt()).isNull();
+    assertThat(userReloaded.getCryptedPassword()).isNull();
+    assertThat(userReloaded.isRoot()).isFalse();
+    assertThat(userReloaded.getUpdatedAt()).isEqualTo(NOW);
+    assertThat(userReloaded.getHomepageType()).isNull();
+    assertThat(userReloaded.getHomepageParameter()).isNull();
+    assertThat(userReloaded.getLastConnectionDate()).isNull();
+    assertThat(underTest.selectUserById(session, otherUser.getId())).isNotNull();
+  }
+
+  @Test
+  public void deactivate_sonarcloud_user() {
+    UserDto user = insertActiveUser();
+    insertUserGroup(user);
+    UserDto otherUser = insertActiveUser();
+    underTest.update(db.getSession(), user.setLastConnectionDate(10_000_000_000L));
+    session.commit();
+
+    underTest.deactivateSonarCloudUser(session, user);
+
+    UserDto userReloaded = underTest.selectUserById(session, user.getId());
+    assertThat(userReloaded.isActive()).isFalse();
+    assertThat(userReloaded.getName()).isNull();
+    assertThat(userReloaded.getLogin()).isEqualTo(user.getLogin());
+    assertThat(userReloaded.getExternalId()).isEqualTo(user.getExternalId());
+    assertThat(userReloaded.getExternalLogin()).isEqualTo(user.getExternalLogin());
+    assertThat(userReloaded.getExternalIdentityProvider()).isEqualTo(user.getExternalIdentityProvider());
     assertThat(userReloaded.getEmail()).isNull();
     assertThat(userReloaded.getScmAccounts()).isNull();
     assertThat(userReloaded.getSalt()).isNull();
index 22d57f1dee47df58d013c9938a6a839b5e3bd390..bb16634f7b04bb31a0c2d566c281197619316baa 100644 (file)
@@ -8,24 +8,28 @@ url: /organizations/overview/
 An organization is a space where a team or a whole company can collaborate across many projects.
 
 An organization consists of:
-* Projects, on which users collaborate
-* [Members](/organizations/manage-team/), who can have different permissions on the projects
-* [Quality Profiles](/instance-administration/quality-profiles/) and [Quality Gates](/user-guide/quality-gates/), which can be customized and shared accross projects
+
+- Projects, on which users collaborate
+- [Members](/organizations/manage-team/), who can have different permissions on the projects
+- [Quality Profiles](/instance-administration/quality-profiles/) and [Quality Gates](/user-guide/quality-gates/), which can be customized and shared accross projects
 
 Organizations can be on:
-* **Free plan**. This is the default plan. Every project in an organization on the free plan is public.
-* **Paid plan**. This plan unlocks the ability to have private projects. Go to the "Billing" page of your organization to upgrade it to the paid plan.
+
+- **Free plan**. This is the default plan. Every project in an organization on the free plan is public.
+- **Paid plan**. This plan unlocks the ability to have private projects. Go to the "Billing" page of your organization to upgrade it to the paid plan.
 
 Depending on which plan the organization is in, its [visibility](/organizations/organization-visibility/) will change.
 
 You can create organizations from the top right menu "+ > Create new organization"
 
-## How to bind an existing organization to GitHub or Bitbucket Cloud?
+## FAQ
+
+### How to bind an existing organization to GitHub or Bitbucket Cloud?
 
 You might notice the following warning message on your pull requests inside SonarCloud:
 
-    The SonarCloud GitHub application is installed on your GitHub organization, but the 
-    SonarCloud organization is not bound to it. Please read "How to bind an existing 
+    The SonarCloud GitHub application is installed on your GitHub organization, but the
+    SonarCloud organization is not bound to it. Please read "How to bind an existing
     organization?" section in the "Organizations" documentation page to fix your setup.
 
 This means that your SonarCloud organization is not bound to GitHub or Bitbucket Cloud whereas you had already installed the SonarCloud application (probably to annotate pull requests). To fix your setup, here are the steps to follow.
@@ -49,3 +53,12 @@ This means that your SonarCloud organization is not bound to GitHub or Bitbucket
    [[warning]]
    | If you get a 405 error page from Bitbucket Cloud at this stage, this means that you did not approve a recent scope change - for which you should have received an email from Bitbucket Cloud. The easiest way to get around this is to uninstall the SonarCloud application in your Bitbucket Cloud "Install apps" settings, and reinstall it.
 5. You are all set! You should see a Bitbucket Cloud icon close to the name of your organization at the top of the page.
+
+### How to transfer ownership of an organization?
+
+You may want to transfer ownership of you organization when you want to delete your account, or when you are leaving a team or company.  
+You can manage your organization members permissions in: "Administration > Permissions" and [grant "Administer Organization" permission](/organizations/manage-team/#granting-permissions) to another member.
+
+### How to delete an organization?
+
+You can delete your organization in: "Administration > Organization Settings > Delete Organization".
index 636279f7e603dc873af9f1735fd6eadf2643963c..bdeef3cf68d0f2abe26916179a88115a5b5c6853 100644 (file)
@@ -5,16 +5,45 @@ url: /user-guide/user-account/
 
 As a {instance} user you have your own space where you can see the things that are relevant to you:
 
-## Home Page
+## Profile
+
+<!-- sonarqube -->
 
 It gives you a summary of:
 
-* your Groups
-* your SCM accounts
+- your Groups
+- your SCM accounts
 
 ## Security
 
-If <!-- sonarqube -->your instance is<!-- /sonarqube --> <!-- sonarcloud -->you are<!-- /sonarcloud --> not using a 3rd party authentication mechanism such as <!-- sonarqube -->LDAP or<!-- /sonarqube --> an OAuth provider (GitHub, Google Account, ...), you can change your password from here. Additionally, you can also manage your own authentication tokens.
+If your instance is not using a 3rd party authentication mechanism such as LDAP or an OAuth provider (GitHub, Google Account, ...), you can change your password from here. Additionally, you can also manage your own authentication tokens.
+
+You can create as many Tokens as you want. Once a Token is created, you can use it to perform analysis on a project where you have the [Execute Analysis](/instance-administration/security/) permission.
+
+<!-- /sonarqube -->
+
+<!-- sonarcloud -->
+
+It gives you a summary of your SCM accounts and allows you to delete your account.
+
+## Security
 
 You can create as many Tokens as you want. Once a Token is created, you can use it to perform analysis on a project where you have the [Execute Analysis](/instance-administration/security/) permission.
 
+## Organizations
+
+This is an overview of all the organizations you are member of.
+
+## Delete your user account
+
+Go to [User > My Account > Profile](/#sonarcloud#/account) and click on **Delete**. Once your account is deleted, all you data will be removed except your login that will still be displayed in different places:
+
+- issues assignee
+- issues comments
+- issues changelog
+
+Note that you can manually unassign yourself from all your issues and/or remove your comments before deleting your account.
+
+The information used to identify yourself in SCM (name, email) are part of the SCM data and can not be removed.
+
+<!-- /sonarcloud -->
index 6e6f8d79ca420ad9ee8c70b05e0717a8d3d3a784..1f50dc695b0402778a435f7f2fa55fefee7ace82 100644 (file)
@@ -71,6 +71,7 @@
       "/user-guide/visualizations/",
       "/user-guide/sonarlint-notifications/",
       "/user-guide/security-reports/",
+      "/user-guide/user-account/",
       "/user-guide/user-token/",
       "/user-guide/keyboard-shortcuts/"
     ]
index d1e7a9d7fa01664398f8a17572e34c0acd1ae99c..85828432ad18e03236cdb294feb138ce49815180 100644 (file)
@@ -91,11 +91,6 @@ public class SafeModeUserSession extends AbstractUserSession {
     return Optional.empty();
   }
 
-  @Override
-  public Optional<String> getPersonalOrganizationUuid() {
-    return Optional.empty();
-  }
-
   @Override
   public boolean isLoggedIn() {
     return false;
index 11c91719c79e5855408dd883894d86b44554b92e..bbd294d475ec1ae530f2acc82a6c1b07374651fc 100644 (file)
@@ -127,7 +127,8 @@ public class ChangelogAction implements IssuesWsAction {
       UserDto user = userUUuid == null ? null : results.users.get(userUUuid);
       if (user != null) {
         changelogBuilder.setUser(user.getLogin());
-        changelogBuilder.setUserName(user.getName());
+        changelogBuilder.setIsUserActive(user.isActive());
+        ofNullable(user.getName()).ifPresent(changelogBuilder::setUserName);
         ofNullable(emptyToNull(user.getEmail())).ifPresent(email -> changelogBuilder.setAvatar(avatarFactory.create(user)));
       }
       change.diffs().entrySet().stream()
index 5bc4e3b66750268e11909b73f0349ff3dbe06831..de4f15b2ee7c2fe7d964a4c9f2a7d905a60347f2 100644 (file)
@@ -37,7 +37,7 @@ public interface OrganizationUpdater {
   String PERM_TEMPLATE_DESCRIPTION_PATTERN = "Default permission template of organization %s";
 
   /**
-   * Create a new non guarded organization with the specified properties and of which the specified user will assign
+   * Create a new organization with the specified properties and of which the specified user will assign
    * Administer Organization permission.
    * <p>
    * This method does several operations at once:
index 6e3dfb7d8dd90c6b587f5c144ac8f03d003cbec4..54a1c2e60d74479452a6659bfaa77a5857d07975 100644 (file)
@@ -111,9 +111,9 @@ public class AddMemberAction implements OrganizationsWsAction {
     AddMemberWsResponse.Builder response = AddMemberWsResponse.newBuilder();
     User.Builder wsUser = User.newBuilder()
       .setLogin(user.getLogin())
-      .setName(user.getName())
       .setGroupCount(groups);
     ofNullable(emptyToNull(user.getEmail())).ifPresent(text -> wsUser.setAvatar(avatarResolver.create(user)));
+    ofNullable(user.getName()).ifPresent(wsUser::setName);
     response.setUser(wsUser);
     return response.build();
   }
diff --git a/server/sonar-server/src/main/java/org/sonar/server/organization/ws/DeleteEmptyPersonalOrgsAction.java b/server/sonar-server/src/main/java/org/sonar/server/organization/ws/DeleteEmptyPersonalOrgsAction.java
deleted file mode 100644 (file)
index d0fb968..0000000
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2019 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.
- */
-package org.sonar.server.organization.ws;
-
-import org.sonar.api.server.ws.Request;
-import org.sonar.api.server.ws.Response;
-import org.sonar.api.server.ws.WebService;
-import org.sonar.api.utils.log.Logger;
-import org.sonar.api.utils.log.Loggers;
-import org.sonar.db.organization.OrganizationQuery;
-import org.sonar.server.user.AbstractUserSession;
-import org.sonar.server.user.SystemPasscode;
-import org.sonar.server.user.UserSession;
-
-public class DeleteEmptyPersonalOrgsAction implements OrganizationsWsAction {
-
-  private static final Logger LOGGER = Loggers.get(DeleteEmptyPersonalOrgsAction.class);
-
-  private static final String ACTION = "delete_empty_personal_orgs";
-
-  private final SystemPasscode passcode;
-  private final UserSession userSession;
-  private final OrganizationDeleter organizationDeleter;
-
-  public DeleteEmptyPersonalOrgsAction(SystemPasscode passcode, UserSession userSession, OrganizationDeleter organizationDeleter) {
-    this.passcode = passcode;
-    this.userSession = userSession;
-    this.organizationDeleter = organizationDeleter;
-  }
-
-  @Override
-  public void define(WebService.NewController context) {
-    context.createAction(ACTION)
-      .setDescription("Internal use. Requires system administration permission. Delete empty personal organizations.")
-      .setInternal(true)
-      .setPost(true)
-      .setHandler(this);
-  }
-
-  @Override
-  public void handle(Request request, Response response) throws Exception {
-    if (!passcode.isValid(request) && !userSession.isSystemAdministrator()) {
-      throw AbstractUserSession.insufficientPrivilegesException();
-    }
-
-    LOGGER.info("deleting empty personal organizations");
-
-    OrganizationQuery query = OrganizationQuery.newOrganizationQueryBuilder()
-      .setOnlyPersonal()
-      .setWithoutProjects()
-      .build();
-
-    organizationDeleter.deleteByQuery(query);
-
-    LOGGER.info("Deleted empty personal organizations");
-
-    response.noContent();
-  }
-
-}
index a19b95ebff3adece290f9e4b0a56b371f732ab61..a3df5bf41d68be95bb3f0859c4c01d0392374a48 100644 (file)
@@ -52,9 +52,9 @@ public class OrganizationsWsModule extends Module {
         CreateAction.class,
         OrganizationDeleter.class,
         DeleteAction.class,
-        DeleteEmptyPersonalOrgsAction.class,
         RemoveMemberAction.class,
-        UpdateAction.class);
+        UpdateAction.class,
+        PreventUserDeletionAction.class);
     }
   }
 
diff --git a/server/sonar-server/src/main/java/org/sonar/server/organization/ws/PreventUserDeletionAction.java b/server/sonar-server/src/main/java/org/sonar/server/organization/ws/PreventUserDeletionAction.java
new file mode 100644 (file)
index 0000000..7ff1feb
--- /dev/null
@@ -0,0 +1,87 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.
+ */
+package org.sonar.server.organization.ws;
+
+import java.util.List;
+import org.sonar.api.server.ws.Request;
+import org.sonar.api.server.ws.Response;
+import org.sonar.api.server.ws.WebService;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.organization.OrganizationDto;
+import org.sonar.db.organization.OrganizationHelper;
+import org.sonar.server.user.UserSession;
+import org.sonarqube.ws.Organizations;
+
+import static org.sonar.server.ws.WsUtils.writeProtobuf;
+
+public class PreventUserDeletionAction implements OrganizationsWsAction {
+
+  private static final String ACTION = "prevent_user_deletion";
+
+  private final DbClient dbClient;
+  private final UserSession userSession;
+
+  public PreventUserDeletionAction(DbClient dbClient, UserSession userSession) {
+    this.dbClient = dbClient;
+    this.userSession = userSession;
+  }
+
+  @Override
+  public void define(WebService.NewController context) {
+    context.createAction(ACTION)
+      .setPost(false)
+      .setDescription("List organizations that prevent the deletion of the authenticated user.")
+      .setResponseExample(getClass().getResource("prevent_user_deletion-example.json"))
+      .setInternal(true)
+      .setSince("7.9")
+      .setHandler(this);
+  }
+
+  @Override
+  public void handle(Request request, Response response) throws Exception {
+    userSession.checkLoggedIn();
+
+    try (DbSession dbSession = dbClient.openSession(false)) {
+      int userId = userSession.getUserId();
+      List<OrganizationDto> organizationsThatPreventUserDeletion = new OrganizationHelper(dbClient).selectOrganizationsWithLastAdmin(dbSession, userId);
+
+      Organizations.PreventUserDeletionWsResponse wsResponse = buildResponse(organizationsThatPreventUserDeletion);
+      writeProtobuf(wsResponse, request, response);
+    }
+  }
+
+  private Organizations.PreventUserDeletionWsResponse buildResponse(List<OrganizationDto> organizations) {
+    Organizations.PreventUserDeletionWsResponse.Builder response = Organizations.PreventUserDeletionWsResponse.newBuilder();
+    Organizations.PreventUserDeletionWsResponse.Organization.Builder wsOrganization = Organizations.PreventUserDeletionWsResponse.Organization.newBuilder();
+    organizations.forEach(o -> {
+        wsOrganization.clear();
+        response.addOrganizations(toOrganization(wsOrganization, o));
+      });
+    return response.build();
+  }
+
+  private static Organizations.PreventUserDeletionWsResponse.Organization.Builder toOrganization(
+    Organizations.PreventUserDeletionWsResponse.Organization.Builder builder, OrganizationDto organization) {
+    return builder
+      .setName(organization.getName())
+      .setKey(organization.getKey());
+  }
+}
index eee4c7a34eaec09c8496b429d38a51e9be003057..cc58894955598eac4b220142bfa4ae9adbc0e2f9 100644 (file)
@@ -137,9 +137,9 @@ public class SearchMembersAction implements OrganizationsWsAction {
         String login = userDto.getLogin();
         wsUser
           .clear()
-          .setLogin(login)
-          .setName(userDto.getName());
+          .setLogin(login);
         ofNullable(emptyToNull(userDto.getEmail())).ifPresent(text -> wsUser.setAvatar(avatarResolver.create(userDto)));
+        ofNullable(userDto.getName()).ifPresent(wsUser::setName);
         ofNullable(groupCountByLogin).ifPresent(count -> wsUser.setGroupCount(groupCountByLogin.count(login)));
         return wsUser;
       })
index 355a41caa7e0b3776a2582fddf43013afa567542..a60f5a400587c66f78ccd7cbf9291545e9f3b3ef 100644 (file)
@@ -156,8 +156,8 @@ public class SearchUsersAction implements QProfileWsAction {
   private SearchUsersResponse.User toUser(UserDto user, boolean isSelected) {
     SearchUsersResponse.User.Builder builder = SearchUsersResponse.User.newBuilder()
       .setLogin(user.getLogin())
-      .setName(user.getName())
       .setSelected(isSelected);
+    ofNullable(user.getName()).ifPresent(builder::setName);
     ofNullable(emptyToNull(user.getEmail())).ifPresent(e -> builder.setAvatar(avatarResolver.create(user)));
     return builder
       .build();
index d9bc5ee1a26f80aa8a251a6a9cb875bb95338a15..4f695b25f8d7ca19e3226c4bd5a3c4ed3eab71f6 100644 (file)
@@ -112,11 +112,6 @@ public final class DoPrivileged {
         return Optional.empty();
       }
 
-      @Override
-      public Optional<String> getPersonalOrganizationUuid() {
-        return Optional.empty();
-      }
-
       @Override
       protected boolean hasPermissionImpl(OrganizationPermission permission, String organizationUuid) {
         return true;
index 2564c4c8e25b4c3ef3088bdab07d7df1fa490461..1485eca1891c2f8f94231dfbc897ed8696c1319d 100644 (file)
@@ -132,11 +132,6 @@ public class ServerUserSession extends AbstractUserSession {
     return ofNullable(userDto).map(d -> computeIdentity(d).getExternalIdentity());
   }
 
-  @Override
-  public Optional<String> getPersonalOrganizationUuid() {
-    return ofNullable(userDto).map(UserDto::getOrganizationUuid);
-  }
-
   @Override
   protected boolean hasPermissionImpl(OrganizationPermission permission, String organizationUuid) {
     if (permissionsByOrganizationUuid == null) {
index 6a04149538b3198d50233a6468a43de90aae9a22..3e5712bc5fa99ea2e5b20c070e58c0cbd095b607 100644 (file)
@@ -95,11 +95,6 @@ public class ThreadLocalUserSession implements UserSession {
     return get().getExternalIdentity();
   }
 
-  @Override
-  public Optional<String> getPersonalOrganizationUuid() {
-    return get().getPersonalOrganizationUuid();
-  }
-
   @Override
   public boolean isLoggedIn() {
     return get().isLoggedIn();
index 85a097600528a2422a27ecb4c8ffed4f20bcee5e..3399e36cdccc57eca1b796109cfbfa0786e16ac7 100644 (file)
@@ -148,11 +148,6 @@ public interface UserSession {
    */
   Optional<ExternalIdentity> getExternalIdentity();
 
-  /**
-   * The UUID of the personal organization of the authenticated user.
-   */
-  Optional<String> getPersonalOrganizationUuid();
-
   /**
    * Whether the user is logged-in or anonymous.
    */
index bf54d87f49ee5ca7c556d72c526000bd21e19bf1..8a6da917e1cffed5caa4193a2ae8b53eab3fb7f7 100644 (file)
@@ -397,7 +397,7 @@ public class UserUpdater {
           if (existingUser != null && matchingUser.getId().equals(existingUser.getId())) {
             continue;
           }
-          matchingUsersWithoutExistingUser.add(matchingUser.getName() + " (" + matchingUser.getLogin() + ")");
+          matchingUsersWithoutExistingUser.add(getNameOrLogin(matchingUser) + " (" + matchingUser.getLogin() + ")");
         }
         if (!matchingUsersWithoutExistingUser.isEmpty()) {
           messages.add(format("The scm account '%s' is already used by user(s) : '%s'", scmAccount, Joiner.on(", ").join(matchingUsersWithoutExistingUser)));
@@ -408,6 +408,11 @@ public class UserUpdater {
     return isValid;
   }
 
+  private static String getNameOrLogin(UserDto user) {
+    String name = user.getName();
+    return name != null ? name : user.getLogin();
+  }
+
   private static List<String> sanitizeScmAccounts(@Nullable List<String> scmAccounts) {
     if (scmAccounts != null) {
       return new HashSet<>(scmAccounts).stream()
index d88102d14b4ddb34ec793183b2dd649705978538..dfc25e18ef83eb88838a3d3a1153979cf2ba8299 100644 (file)
@@ -150,8 +150,7 @@ public class CreateAction implements UsersWsAction {
         }));
       }
       checkArgument(!existingUser.isActive(), "An active user with login '%s' already exists", login);
-      return buildResponse(userUpdater.reactivateAndCommit(dbSession, existingUser, newUser.build(), u -> {
-      }));
+      return buildResponse(userUpdater.reactivateAndCommit(dbSession, existingUser, newUser.build(), u -> {}));
     }
   }
 
index c53c29885bddb651a4f30a2957ca0aeaa4fd1e53..b14463ecd7cfd838c5ab503d5c3753d72e9be9da 100644 (file)
  */
 package org.sonar.server.user.ws;
 
-import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 import java.util.stream.Collectors;
+import org.sonar.api.config.Configuration;
 import org.sonar.api.server.ws.Request;
 import org.sonar.api.server.ws.Response;
 import org.sonar.api.server.ws.WebService;
 import org.sonar.api.server.ws.WebService.NewAction;
+import org.sonar.api.utils.log.Logger;
+import org.sonar.api.utils.log.Loggers;
 import org.sonar.api.utils.text.JsonWriter;
 import org.sonar.db.DbClient;
 import org.sonar.db.DbSession;
 import org.sonar.db.organization.OrganizationDto;
-import org.sonar.db.permission.OrganizationPermission;
+import org.sonar.db.organization.OrganizationHelper;
 import org.sonar.db.property.PropertyQuery;
 import org.sonar.db.user.UserDto;
 import org.sonar.server.exceptions.BadRequestException;
+import org.sonar.server.exceptions.ForbiddenException;
 import org.sonar.server.organization.DefaultOrganizationProvider;
 import org.sonar.server.user.UserSession;
 import org.sonar.server.user.index.UserIndexer;
@@ -43,11 +46,14 @@ import org.sonar.server.user.index.UserIndexer;
 import static java.lang.String.format;
 import static java.util.Collections.singletonList;
 import static org.sonar.api.CoreProperties.DEFAULT_ISSUE_ASSIGNEE;
+import static org.sonar.process.ProcessProperties.Property.SONARCLOUD_ENABLED;
 import static org.sonar.server.ws.WsUtils.checkFound;
 import static org.sonar.server.ws.WsUtils.checkRequest;
 
 public class DeactivateAction implements UsersWsAction {
 
+  private static final Logger LOGGER = Loggers.get(DeactivateAction.class);
+
   private static final String PARAM_LOGIN = "login";
 
   private final DbClient dbClient;
@@ -55,14 +61,16 @@ public class DeactivateAction implements UsersWsAction {
   private final UserSession userSession;
   private final UserJsonWriter userWriter;
   private final DefaultOrganizationProvider defaultOrganizationProvider;
+  private final boolean isSonarCloud;
 
   public DeactivateAction(DbClient dbClient, UserIndexer userIndexer, UserSession userSession, UserJsonWriter userWriter,
-    DefaultOrganizationProvider defaultOrganizationProvider) {
+    DefaultOrganizationProvider defaultOrganizationProvider, Configuration configuration) {
     this.dbClient = dbClient;
     this.userIndexer = userIndexer;
     this.userSession = userSession;
     this.userWriter = userWriter;
     this.defaultOrganizationProvider = defaultOrganizationProvider;
+    this.isSonarCloud = configuration.getBoolean(SONARCLOUD_ENABLED.getKey()).orElse(false);
   }
 
   @Override
@@ -82,10 +90,18 @@ public class DeactivateAction implements UsersWsAction {
 
   @Override
   public void handle(Request request, Response response) throws Exception {
-    userSession.checkLoggedIn().checkIsSystemAdministrator();
+    String login;
 
-    String login = request.mandatoryParam(PARAM_LOGIN);
-    checkRequest(!login.equals(userSession.getLogin()), "Self-deactivation is not possible");
+    if (isSonarCloud) {
+      login = request.mandatoryParam(PARAM_LOGIN);
+      if (!login.equals(userSession.getLogin()) && !userSession.checkLoggedIn().isSystemAdministrator()) {
+        throw new ForbiddenException("Insufficient privileges");
+      }
+    } else {
+      userSession.checkLoggedIn().checkIsSystemAdministrator();
+      login = request.mandatoryParam(PARAM_LOGIN);
+      checkRequest(!login.equals(userSession.getLogin()), "Self-deactivation is not possible");
+    }
 
     try (DbSession dbSession = dbClient.openSession(false)) {
       UserDto user = dbClient.userDao().selectByLogin(dbSession, login);
@@ -103,13 +119,23 @@ public class DeactivateAction implements UsersWsAction {
       dbClient.qProfileEditUsersDao().deleteByUser(dbSession, user);
       dbClient.organizationMemberDao().deleteByUserId(dbSession, userId);
       dbClient.userPropertiesDao().deleteByUser(dbSession, user);
-      dbClient.userDao().deactivateUser(dbSession, user);
+      deactivateUser(dbSession, user);
       userIndexer.commitAndIndex(dbSession, user);
+
+      LOGGER.info("Deactivate user: {}; by admin: {}", login, userSession.isSystemAdministrator());
     }
 
     writeResponse(response, login);
   }
 
+  private void deactivateUser(DbSession dbSession, UserDto user) {
+    if (isSonarCloud) {
+      dbClient.userDao().deactivateSonarCloudUser(dbSession, user);
+    } else {
+      dbClient.userDao().deactivateUser(dbSession, user);
+    }
+  }
+
   private void writeResponse(Response response, String login) {
     try (DbSession dbSession = dbClient.openSession(false)) {
       UserDto user = dbClient.userDao().selectByLogin(dbSession, login);
@@ -129,38 +155,18 @@ public class DeactivateAction implements UsersWsAction {
   }
 
   private void ensureNotLastAdministrator(DbSession dbSession, UserDto user) {
-    List<String> problematicOrgs = selectOrganizationsWithNoMoreAdministrators(dbSession, user);
+    List<OrganizationDto> problematicOrgs = new OrganizationHelper(dbClient).selectOrganizationsWithLastAdmin(dbSession, user.getId());
     if (problematicOrgs.isEmpty()) {
       return;
     }
-    checkRequest(problematicOrgs.size() != 1 || !defaultOrganizationProvider.get().getUuid().equals(problematicOrgs.get(0)),
+    checkRequest(problematicOrgs.size() != 1 || !defaultOrganizationProvider.get().getUuid().equals(problematicOrgs.get(0).getUuid()),
       "User is last administrator, and cannot be deactivated");
     String keys = problematicOrgs
       .stream()
-      .map(orgUuid -> selectOrganizationByUuid(dbSession, orgUuid, user))
       .map(OrganizationDto::getKey)
       .sorted()
       .collect(Collectors.joining(", "));
     throw BadRequestException.create(format("User '%s' is last administrator of organizations [%s], and cannot be deactivated", user.getLogin(), keys));
   }
 
-  private List<String> selectOrganizationsWithNoMoreAdministrators(DbSession dbSession, UserDto user) {
-    Set<String> organizationUuids = dbClient.authorizationDao().selectOrganizationUuidsOfUserWithGlobalPermission(
-      dbSession, user.getId(), OrganizationPermission.ADMINISTER.getKey());
-    List<String> problematicOrganizations = new ArrayList<>();
-    for (String organizationUuid : organizationUuids) {
-      int remaining = dbClient.authorizationDao().countUsersWithGlobalPermissionExcludingUser(dbSession,
-        organizationUuid, OrganizationPermission.ADMINISTER.getKey(), user.getId());
-      if (remaining == 0) {
-        problematicOrganizations.add(organizationUuid);
-      }
-    }
-    return problematicOrganizations;
-  }
-
-  private OrganizationDto selectOrganizationByUuid(DbSession dbSession, String orgUuid, UserDto user) {
-    return dbClient.organizationDao()
-      .selectByUuid(dbSession, orgUuid)
-      .orElseThrow(() -> new IllegalStateException("Organization with UUID " + orgUuid + " does not exist in DB but is referenced in permissions of user " + user.getLogin()));
-  }
 }
index e67093cb407b43470321ed39098cb1c5cfd4c8a5..21410bf8a2f8fc266da609eef2db12c789db4223 100644 (file)
@@ -81,11 +81,7 @@ public class UpdateLoginAction implements UsersWsAction {
     String newLogin = request.mandatoryParam(PARAM_NEW_LOGIN);
     try (DbSession dbSession = dbClient.openSession(false)) {
       UserDto user = getUser(dbSession, login);
-      userUpdater.updateAndCommit(
-        dbSession,
-        user,
-        new UpdateUser().setLogin(newLogin),
-        u -> updatePersonalOrganization(dbSession, u));
+      userUpdater.updateAndCommit(dbSession, user, new UpdateUser().setLogin(newLogin), u -> {});
       response.noContent();
     }
   }
@@ -98,13 +94,4 @@ public class UpdateLoginAction implements UsersWsAction {
     return user;
   }
 
-  private void updatePersonalOrganization(DbSession dbSession, UserDto user) {
-    String personalOrganizationUuid = user.getOrganizationUuid();
-    if (personalOrganizationUuid == null) {
-      return;
-    }
-    dbClient.organizationDao().selectByUuid(dbSession, personalOrganizationUuid)
-      .ifPresent(organization -> organizationUpdater.updateOrganizationKey(dbSession, organization, user.getLogin()));
-  }
-
 }
index 8ac76d0c030014be82c472811708ff3aa785f008..a9925a1bf6325227401a3c35f8b641fe8fac45aa 100644 (file)
@@ -28,6 +28,7 @@ import org.sonar.api.utils.text.JsonWriter;
 import org.sonar.db.user.UserDto;
 import org.sonar.server.user.UserSession;
 
+import static java.util.Optional.ofNullable;
 import static org.sonar.server.ws.JsonWriterUtils.isFieldNeeded;
 import static org.sonar.server.ws.JsonWriterUtils.writeIfNeeded;
 
@@ -61,7 +62,7 @@ public class UserJsonWriter {
   public void write(JsonWriter json, UserDto user, Collection<String> groups, @Nullable Collection<String> fields) {
     json.beginObject();
     json.prop(FIELD_LOGIN, user.getLogin());
-    writeIfNeeded(json, user.getName(), FIELD_NAME, fields);
+    ofNullable(user.getName()).ifPresent(name -> writeIfNeeded(json, name, FIELD_NAME, fields));
     if (userSession.isLoggedIn()) {
       writeIfNeeded(json, user.getEmail(), FIELD_EMAIL, fields);
       writeIfNeeded(json, user.isActive(), FIELD_ACTIVE, fields);
index 3b70838b6709707b0924570c38657a26c1107af6..45a0a287e07931a5332f1946847e518352a9f7f8 100644 (file)
@@ -3,6 +3,7 @@
     {
       "user": "john.smith",
       "userName": "John Smith",
+      "isUserActive": true,
       "avatar": "b0d8c6e5ea589e6fc3d3e08afb1873bb",
       "creationDate": "2014-03-04T23:03:44+0100",
       "diffs": [
diff --git a/server/sonar-server/src/main/resources/org/sonar/server/organization/ws/prevent_user_deletion-example.json b/server/sonar-server/src/main/resources/org/sonar/server/organization/ws/prevent_user_deletion-example.json
new file mode 100644 (file)
index 0000000..149a283
--- /dev/null
@@ -0,0 +1,12 @@
+{
+  "organizations": [
+    {
+      "key": "foo-company",
+      "name": "Foo Company"
+    },
+    {
+      "key": "bar-company",
+      "name": "Bar Company"
+    }
+  ]
+}
index 22ab82cb47a0023ca3b03bd4b6dac5de8992c7f4..918feb3e52fba6d9e553010050f453b997593626 100644 (file)
@@ -90,6 +90,7 @@ public class ChangelogActionTest {
     assertThat(result.getChangelogList()).hasSize(1);
     assertThat(result.getChangelogList().get(0).getUser()).isNotNull().isEqualTo(user.getLogin());
     assertThat(result.getChangelogList().get(0).getUserName()).isNotNull().isEqualTo(user.getName());
+    assertThat(result.getChangelogList().get(0).getIsUserActive()).isTrue();
     assertThat(result.getChangelogList().get(0).getAvatar()).isNotNull().isEqualTo("93942e96f5acd83e2e047ad8fe03114d");
     assertThat(result.getChangelogList().get(0).getCreationDate()).isNotEmpty();
     assertThat(result.getChangelogList().get(0).getDiffsList()).extracting(Diff::getKey, Diff::getOldValue, Diff::getNewValue).containsOnly(tuple("severity", "MAJOR", "BLOCKER"));
@@ -196,6 +197,25 @@ public class ChangelogActionTest {
     assertThat(result.getChangelogList().get(0).getDiffsList()).isNotEmpty();
   }
 
+  @Test
+  public void return_changelog_on_deactivated_user() {
+    UserDto user = db.users().insertDisabledUser();
+    IssueDto issueDto = db.issues().insertIssue(newIssue());
+    userSession.logIn("john")
+      .addMembership(db.getDefaultOrganization())
+      .addProjectPermission(USER, project, file);
+    db.issues().insertFieldDiffs(issueDto, new FieldDiffs().setUserUuid(user.getUuid()).setDiff("severity", "MAJOR", "BLOCKER").setCreationDate(new Date()));
+
+    ChangelogWsResponse result = call(issueDto.getKey());
+
+    assertThat(result.getChangelogList()).hasSize(1);
+    assertThat(result.getChangelogList().get(0).getUser()).isEqualTo(user.getLogin());
+    assertThat(result.getChangelogList().get(0).getIsUserActive()).isFalse();
+    assertThat(result.getChangelogList().get(0).getUserName()).isEqualTo(user.getName());
+    assertThat(result.getChangelogList().get(0).hasAvatar()).isFalse();
+    assertThat(result.getChangelogList().get(0).getDiffsList()).isNotEmpty();
+  }
+
   @Test
   public void return_multiple_diffs() {
     UserDto user = insertUser();
index 2bcfb03b97305ff05ee58d3391dd63419e89dfa3..9eb1ed9f23b458148a9b193fc5c868143f7be0b7 100644 (file)
@@ -73,9 +73,6 @@ import static org.sonar.server.organization.OrganizationUpdater.NewOrganization.
 
 public class OrganizationUpdaterImplTest {
   private static final long A_DATE = 12893434L;
-  private static final String A_LOGIN = "a-login";
-  private static final String SLUG_OF_A_LOGIN = "slug-of-a-login";
-  private static final String A_NAME = "a name";
 
   private OrganizationUpdater.NewOrganization FULL_POPULATED_NEW_ORGANIZATION = newOrganizationBuilder()
     .setName("a-name")
@@ -116,7 +113,7 @@ public class OrganizationUpdaterImplTest {
     builtInQProfileRepositoryRule, defaultGroupCreator, permissionService);
 
   @Test
-  public void create_creates_unguarded_organization_with_properties_from_NewOrganization_arg() throws OrganizationUpdater.KeyConflictException {
+  public void create_creates_organization_with_properties_from_NewOrganization_arg() throws OrganizationUpdater.KeyConflictException {
     builtInQProfileRepositoryRule.initialize();
     UserDto user = db.users().insertUser();
     db.qualityGates().insertBuiltInQualityGate();
@@ -130,7 +127,6 @@ public class OrganizationUpdaterImplTest {
     assertThat(organization.getDescription()).isEqualTo(FULL_POPULATED_NEW_ORGANIZATION.getDescription());
     assertThat(organization.getUrl()).isEqualTo(FULL_POPULATED_NEW_ORGANIZATION.getUrl());
     assertThat(organization.getAvatarUrl()).isEqualTo(FULL_POPULATED_NEW_ORGANIZATION.getAvatar());
-    assertThat(organization.isGuarded()).isFalse();
     assertThat(organization.getSubscription()).isEqualTo(Subscription.FREE);
     assertThat(organization.getCreatedAt()).isEqualTo(A_DATE);
     assertThat(organization.getUpdatedAt()).isEqualTo(A_DATE);
@@ -175,7 +171,6 @@ public class OrganizationUpdaterImplTest {
     assertThat(organization.getDescription()).isNull();
     assertThat(organization.getUrl()).isNull();
     assertThat(organization.getAvatarUrl()).isNull();
-    assertThat(organization.isGuarded()).isFalse();
   }
 
   @Test
diff --git a/server/sonar-server/src/test/java/org/sonar/server/organization/ws/DeleteEmptyPersonalOrgsActionTest.java b/server/sonar-server/src/test/java/org/sonar/server/organization/ws/DeleteEmptyPersonalOrgsActionTest.java
deleted file mode 100644 (file)
index ccf9608..0000000
+++ /dev/null
@@ -1,141 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2019 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.
- */
-package org.sonar.server.organization.ws;
-
-import java.util.Arrays;
-import java.util.List;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.rules.ExpectedException;
-import org.sonar.api.server.ws.WebService;
-import org.sonar.api.utils.System2;
-import org.sonar.core.util.UuidFactoryFast;
-import org.sonar.db.DbClient;
-import org.sonar.db.DbTester;
-import org.sonar.db.component.ResourceTypesRule;
-import org.sonar.db.organization.OrganizationDto;
-import org.sonar.db.user.UserDto;
-import org.sonar.server.component.ComponentCleanerService;
-import org.sonar.server.es.EsClient;
-import org.sonar.server.es.EsTester;
-import org.sonar.server.es.ProjectIndexersImpl;
-import org.sonar.server.exceptions.ForbiddenException;
-import org.sonar.server.organization.BillingValidationsProxyImpl;
-import org.sonar.server.project.ProjectLifeCycleListener;
-import org.sonar.server.project.ProjectLifeCycleListenersImpl;
-import org.sonar.server.qualityprofile.QProfileFactoryImpl;
-import org.sonar.server.qualityprofile.index.ActiveRuleIndexer;
-import org.sonar.server.tester.UserSessionRule;
-import org.sonar.server.user.SystemPasscode;
-import org.sonar.server.user.index.UserIndexer;
-import org.sonar.server.ws.WsActionTester;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
-import static org.sonar.db.permission.OrganizationPermission.ADMINISTER;
-
-public class DeleteEmptyPersonalOrgsActionTest {
-
-  @Rule
-  public final UserSessionRule userSession = UserSessionRule.standalone();
-
-  @Rule
-  public final DbTester db = DbTester.create(new System2());
-  private final DbClient dbClient = db.getDbClient();
-
-  @Rule
-  public final EsTester es = EsTester.create();
-  private final EsClient esClient = es.client();
-
-  @Rule
-  public final ExpectedException expectedException = ExpectedException.none();
-
-  private SystemPasscode passcode = mock(SystemPasscode.class);
-  private final OrganizationDeleter organizationDeleter = new OrganizationDeleter(dbClient,
-    new ComponentCleanerService(dbClient, new ResourceTypesRule(), new ProjectIndexersImpl()),
-    new UserIndexer(dbClient, esClient),
-    new QProfileFactoryImpl(dbClient, UuidFactoryFast.getInstance(), new System2(), new ActiveRuleIndexer(dbClient, esClient)),
-    new ProjectLifeCycleListenersImpl(new ProjectLifeCycleListener[0]),
-    new BillingValidationsProxyImpl());
-
-  private final DeleteEmptyPersonalOrgsAction underTest = new DeleteEmptyPersonalOrgsAction(passcode, userSession, organizationDeleter);
-  private final WsActionTester ws = new WsActionTester(underTest);
-
-  @Test
-  public void request_fails_if_user_is_not_root() {
-    userSession.logIn();
-
-    expectedException.expect(ForbiddenException.class);
-    expectedException.expectMessage("Insufficient privileges");
-
-    ws.newRequest().execute();
-  }
-
-  @Test
-  public void delete_empty_personal_orgs() {
-    UserDto admin = db.users().insertUser();
-    db.users().insertPermissionOnUser(admin, ADMINISTER);
-    userSession.logIn().setSystemAdministrator();
-
-    doRun();
-  }
-
-  @Test
-  public void authenticate_with_system_passcode() {
-    when(passcode.isValid(any())).thenReturn(true);
-
-    doRun();
-  }
-
-  private void doRun() {
-    OrganizationDto emptyPersonal = db.organizations().insert(o -> o.setGuarded(true));
-    db.users().insertUser(u -> u.setOrganizationUuid(emptyPersonal.getUuid()));
-
-    OrganizationDto nonEmptyPersonal = db.organizations().insert(o -> o.setGuarded(true));
-    db.users().insertUser(u -> u.setOrganizationUuid(nonEmptyPersonal.getUuid()));
-    db.components().insertPublicProject(nonEmptyPersonal);
-
-    OrganizationDto emptyRegular = db.organizations().insert();
-
-    OrganizationDto nonEmptyRegular = db.organizations().insert();
-    db.components().insertPublicProject(nonEmptyRegular);
-
-    ws.newRequest().execute();
-
-    List<String> notDeleted = Arrays.asList(
-      db.getDefaultOrganization().getUuid(),
-      nonEmptyPersonal.getUuid(),
-      emptyRegular.getUuid(),
-      nonEmptyRegular.getUuid());
-
-    assertThat(dbClient.organizationDao().selectAllUuids(db.getSession()))
-      .containsExactlyInAnyOrderElementsOf(notDeleted);
-  }
-
-  @Test
-  public void definition() {
-    WebService.Action action = ws.getDef();
-    assertThat(action.isPost()).isTrue();
-    assertThat(action.isInternal()).isTrue();
-    assertThat(action.handler()).isNotNull();
-  }
-}
diff --git a/server/sonar-server/src/test/java/org/sonar/server/organization/ws/PreventUserDeletionActionTest.java b/server/sonar-server/src/test/java/org/sonar/server/organization/ws/PreventUserDeletionActionTest.java
new file mode 100644 (file)
index 0000000..060e845
--- /dev/null
@@ -0,0 +1,208 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.
+ */
+package org.sonar.server.organization.ws;
+
+import com.tngtech.java.junit.dataprovider.DataProvider;
+import com.tngtech.java.junit.dataprovider.DataProviderRunner;
+import com.tngtech.java.junit.dataprovider.UseDataProvider;
+import java.util.List;
+import java.util.Random;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+import org.sonar.api.server.ws.WebService;
+import org.sonar.api.utils.System2;
+import org.sonar.db.DbTester;
+import org.sonar.db.organization.OrganizationDto;
+import org.sonar.db.user.GroupDto;
+import org.sonar.db.user.UserDto;
+import org.sonar.server.exceptions.UnauthorizedException;
+import org.sonar.server.tester.UserSessionRule;
+import org.sonar.server.ws.WsActionTester;
+import org.sonarqube.ws.Organizations;
+
+import static java.util.Arrays.asList;
+import static java.util.Collections.emptyList;
+import static java.util.Collections.singletonList;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.sonar.db.permission.OrganizationPermission.ADMINISTER;
+import static org.sonar.test.JsonAssert.assertJson;
+
+@RunWith(DataProviderRunner.class)
+public class PreventUserDeletionActionTest {
+
+  private static final Random RANDOM = new Random();
+
+  @Rule
+  public UserSessionRule userSession = UserSessionRule.standalone();
+
+  @Rule
+  public DbTester db = DbTester.create(mock(System2.class)).setDisableDefaultOrganization(true);
+
+  @Rule
+  public ExpectedException expectedException = ExpectedException.none();
+
+  private final PreventUserDeletionAction underTest = new PreventUserDeletionAction(db.getDbClient(), userSession);
+  private final WsActionTester ws = new WsActionTester(underTest);
+
+  @Test
+  public void fail_if_user_is_not_logged_in() {
+    UserDto user1 = db.users().insertUser();
+
+    OrganizationDto org1 = db.organizations().insert();
+    GroupDto group1 = db.users().insertGroup(org1);
+    db.users().insertMember(group1, user1);
+
+    expectedException.expect(UnauthorizedException.class);
+
+    call();
+  }
+
+  @Test
+  public void returns_empty_list_when_user_is_not_admin_of_any_orgs() {
+    UserDto user1 = db.users().insertUser();
+
+    OrganizationDto org1 = db.organizations().insert();
+    GroupDto group1 = db.users().insertGroup(org1);
+    db.users().insertMember(group1, user1);
+
+    userSession.logIn(user1);
+    assertThat(call().getOrganizationsList()).isEmpty();
+  }
+
+  @Test
+  public void returns_orgs_where_user_is_last_admin() {
+    UserDto user1 = db.users().insertUser();
+    UserDto user2 = db.users().insertUser();
+
+    OrganizationDto org1 = db.organizations().insert();
+    OrganizationDto org2 = db.organizations().insert();
+
+    setAsDirectOrIndirectAdmin(user1, org1);
+    setAsDirectOrIndirectAdmin(user2, org1);
+    setAsDirectOrIndirectAdmin(user1, org2);
+
+    userSession.logIn(user1);
+    assertThat(call().getOrganizationsList())
+      .extracting(Organizations.Organization::getKey)
+      .containsExactly(org2.getKey());
+  }
+
+  @Test
+  @UseDataProvider("adminUserCombinationsAndExpectedOrgKeys")
+  public void returns_correct_orgs_for_interesting_combinations_of_last_admin_or_not(
+    boolean user2IsAdminOfOrg1, boolean user1IsAdminOfOrg2, boolean user2IsAdminOfOrg2, List<String> expectedOrgKeys) {
+    UserDto user1 = db.users().insertUser();
+    UserDto user2 = db.users().insertUser();
+
+    OrganizationDto org1 = db.organizations().insert(o -> o.setKey("org1"));
+    OrganizationDto org2 = db.organizations().insert(o -> o.setKey("org2"));
+
+    setAsDirectOrIndirectAdmin(user1, org1);
+    if (user2IsAdminOfOrg1) {
+      setAsDirectOrIndirectAdmin(user2, org1);
+    }
+    if (user1IsAdminOfOrg2) {
+      setAsDirectOrIndirectAdmin(user1, org2);
+    }
+    if (user2IsAdminOfOrg2) {
+      setAsDirectOrIndirectAdmin(user2, org2);
+    }
+
+    userSession.logIn(user1);
+    assertThat(call().getOrganizationsList())
+      .extracting(Organizations.Organization::getKey)
+      .containsExactlyInAnyOrderElementsOf(expectedOrgKeys);
+  }
+
+  @DataProvider
+  public static Object[][] adminUserCombinationsAndExpectedOrgKeys() {
+    return new Object[][] {
+      // note: user1 is always admin of org1
+      // param 1: user2 is admin of org1
+      // param 2: user1 is admin of org2
+      // param 3: user2 is admin of org2
+      // param 4: list of orgs preventing user1 to delete
+      {true, true, true, emptyList()},
+      {true, true, false, singletonList("org2")},
+      {true, false, true, emptyList()},
+      {true, false, false, emptyList()},
+      {false, true, true, singletonList("org1")},
+      {false, true, false, asList("org1", "org2")},
+      {false, false, true, singletonList("org1")},
+      {false, false, false, singletonList("org1")},
+    };
+  }
+
+  @Test
+  public void json_example() {
+    UserDto user1 = db.users().insertUser();
+
+    OrganizationDto org1 = db.organizations().insert(o -> {
+      o.setKey("foo-company");
+      o.setName("Foo Company");
+    });
+    OrganizationDto org2 = db.organizations().insert(o -> {
+      o.setKey("bar-company");
+      o.setName("Bar Company");
+    });
+
+    setAsDirectOrIndirectAdmin(user1, org1);
+    setAsDirectOrIndirectAdmin(user1, org2);
+
+    userSession.logIn(user1);
+
+    String result = ws.newRequest()
+      .execute()
+      .getInput();
+
+    assertJson(result).isSimilarTo(ws.getDef().responseExampleAsString());
+  }
+
+  @Test
+  public void definition() {
+    WebService.Action action = ws.getDef();
+
+    assertThat(action.key()).isEqualTo("prevent_user_deletion");
+    assertThat(action.params()).isEmpty();
+    assertThat(action.description()).isNotEmpty();
+    assertThat(action.responseExampleAsString()).isNotEmpty();
+    assertThat(action.since()).isEqualTo("7.9");
+    assertThat(action.isInternal()).isTrue();
+    assertThat(action.isPost()).isFalse();
+  }
+
+  private void setAsDirectOrIndirectAdmin(UserDto user, OrganizationDto organization) {
+    boolean useDirectAdmin = RANDOM.nextBoolean();
+    if (useDirectAdmin) {
+      db.users().insertPermissionOnUser(organization, user, ADMINISTER);
+    } else {
+      GroupDto group = db.users().insertGroup(organization);
+      db.users().insertPermissionOnGroup(group, ADMINISTER);
+      db.users().insertMember(group, user);
+    }
+  }
+
+  private Organizations.SearchWsResponse call() {
+    return ws.newRequest().executeProtobuf(Organizations.SearchWsResponse.class);
+  }
+}
index 7f141946b7adfd3b05deb52ff75e87b558bc734b..9c229d7176a86ccb713339f93a711bba65c1748d 100644 (file)
@@ -99,22 +99,6 @@ public class SearchActionTest {
         tuple(groupAdminOrganization.getKey(), true, true));
   }
 
-  @Test
-  public void root_can_do_everything() {
-    OrganizationDto organization = db.organizations().insert();
-    OrganizationDto guardedOrganization = db.organizations().insert(dto -> dto.setGuarded(true));
-    UserDto user = db.users().insertUser();
-    userSession.logIn(user).setRoot();
-
-    SearchWsResponse result = call(ws.newRequest());
-
-    assertThat(result.getOrganizationsList())
-      .extracting(Organization::getKey, o -> o.getActions().getAdmin(), o -> o.getActions().getDelete(), o -> o.getActions().getProvision())
-      .containsExactlyInAnyOrder(
-        tuple(organization.getKey(), true, true, true),
-        tuple(guardedOrganization.getKey(), true, true, true));
-  }
-
   @Test
   public void provision_action_available_for_each_organization() {
     OrganizationDto userProvisionOrganization = db.organizations().insert();
@@ -402,8 +386,7 @@ public class SearchActionTest {
       .setDescription("The Bar company produces quality software too.")
       .setUrl("https://www.bar.com")
       .setAvatarUrl("https://www.bar.com/logo.png")
-      .setSubscription(PAID)
-      .setGuarded(false));
+      .setSubscription(PAID));
     OrganizationDto fooOrganization = db.organizations().insert(organization -> organization
       .setUuid(Uuids.UUID_EXAMPLE_01)
       .setKey("foo-company")
@@ -411,8 +394,7 @@ public class SearchActionTest {
       .setSubscription(FREE)
       .setDescription(null)
       .setUrl(null)
-      .setAvatarUrl(null)
-      .setGuarded(true));
+      .setAvatarUrl(null));
     UserDto user = db.users().insertUser();
     db.organizations().addMember(barOrganization, user);
     db.organizations().addMember(fooOrganization, user);
index 0e4b7bbf1b42f64dbad22d0ea45a0de026a40aaa..3c5d4068c61399b9c6a1164e4e461d31465f3182 100644 (file)
@@ -75,11 +75,6 @@ public class AnonymousMockUserSession extends AbstractMockUserSession<AnonymousM
     return Optional.empty();
   }
 
-  @Override
-  public Optional<String> getPersonalOrganizationUuid() {
-    return Optional.empty();
-  }
-
   @Override
   public boolean hasMembershipImpl(OrganizationDto organizationDto) {
     return false;
index daff1a67c783c42758623229726e847ef71131f5..e59b0eda6f5ac0277e7845f9a7da1206bbd950d2 100644 (file)
@@ -144,8 +144,4 @@ public class MockUserSession extends AbstractMockUserSession<MockUserSession> {
     return Optional.ofNullable(externalIdentity);
   }
 
-  @Override
-  public Optional<String> getPersonalOrganizationUuid() {
-    return Optional.empty();
-  }
 }
index 13f053712715f081d943c6c1fcb06756171dfd70..5e7cc4147d5fc62cc140a20247d566bdbc6909ba 100644 (file)
@@ -289,11 +289,6 @@ public class UserSessionRule implements TestRule, UserSession {
     return currentUserSession.getExternalIdentity();
   }
 
-  @Override
-  public Optional<String> getPersonalOrganizationUuid() {
-    return currentUserSession.getPersonalOrganizationUuid();
-  }
-
   @Override
   public boolean isLoggedIn() {
     return currentUserSession.isLoggedIn();
index 7d2e3f9905a042d8dd91a6ed98df8cefeaacc7e9..605560481b6cc1ddf0bd39daf33f983abf9e984a 100644 (file)
@@ -81,7 +81,7 @@ public class OrganizationActionTest {
     initWithPages(
       Page.builder("my-plugin/org-page").setName("Organization page").setScope(ORGANIZATION).build(),
       Page.builder("my-plugin/org-admin-page").setName("Organization admin page").setScope(ORGANIZATION).setAdmin(true).build());
-    OrganizationDto organization = db.organizations().insert(dto -> dto.setGuarded(true));
+    OrganizationDto organization = db.organizations().insert();
     userSession.logIn()
       .addPermission(PROVISION_PROJECTS, organization);
 
@@ -214,7 +214,7 @@ public class OrganizationActionTest {
     initWithPages(
       Page.builder("my-plugin/org-page").setName("Organization page").setScope(ORGANIZATION).build(),
       Page.builder("my-plugin/org-admin-page").setName("Organization admin page").setScope(ORGANIZATION).setAdmin(true).build());
-    OrganizationDto organization = db.organizations().insert(dto -> dto.setGuarded(true));
+    OrganizationDto organization = db.organizations().insert();
     userSession.logIn()
       .addPermission(ADMINISTER, organization)
       .addPermission(PROVISION_PROJECTS, organization);
index 4af4d3c807bf899153db2f45aac9df77f0b837a8..af654c54493b2969e6d7cc978cb0c5466bb4e72c 100644 (file)
@@ -24,14 +24,11 @@ import javax.annotation.Nullable;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.ExpectedException;
-import org.sonar.api.resources.Qualifiers;
-import org.sonar.api.resources.ResourceTypes;
 import org.sonar.api.utils.System2;
 import org.sonar.api.web.UserRole;
 import org.sonar.db.DbClient;
 import org.sonar.db.DbTester;
 import org.sonar.db.component.ComponentDto;
-import org.sonar.db.component.ResourceTypesRule;
 import org.sonar.db.organization.OrganizationDto;
 import org.sonar.db.user.GroupDto;
 import org.sonar.db.user.UserDto;
index 3957e22959f39b5429075bf7e1d3f80e070cf664..2dfa06ab5699322f6ef95b6e85c081c62a7101f6 100644 (file)
@@ -97,11 +97,6 @@ public class TestUserSessionFactory implements UserSessionFactory {
       throw notImplemented();
     }
 
-    @Override
-    public Optional<String> getPersonalOrganizationUuid() {
-      throw notImplemented();
-    }
-
     @Override
     public boolean isLoggedIn() {
       return user != null;
index 2f7bebc48fb9f7ed5cbf4e6484161eb55b4d73e0..afb7691885bca3401a6bee7bc1367f03aa4a3c08 100644 (file)
@@ -24,6 +24,7 @@ import javax.annotation.Nullable;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.ExpectedException;
+import org.sonar.api.config.internal.MapSettings;
 import org.sonar.api.impl.utils.AlwaysIncreasingSystem2;
 import org.sonar.api.utils.System2;
 import org.sonar.db.DbClient;
@@ -63,6 +64,7 @@ import static org.sonar.db.permission.OrganizationPermission.ADMINISTER;
 import static org.sonar.db.permission.OrganizationPermission.ADMINISTER_QUALITY_PROFILES;
 import static org.sonar.db.permission.OrganizationPermission.SCAN;
 import static org.sonar.db.property.PropertyTesting.newUserPropertyDto;
+import static org.sonar.process.ProcessProperties.Property.SONARCLOUD_ENABLED;
 import static org.sonar.server.user.index.UserIndexDefinition.FIELD_ACTIVE;
 import static org.sonar.server.user.index.UserIndexDefinition.FIELD_UUID;
 import static org.sonar.test.JsonAssert.assertJson;
@@ -87,9 +89,9 @@ public class DeactivateActionTest {
   private DbClient dbClient = db.getDbClient();
   private UserIndexer userIndexer = new UserIndexer(dbClient, es.client());
   private DbSession dbSession = db.getSession();
-
-  private WsActionTester ws = new WsActionTester(new DeactivateAction(
-    dbClient, userIndexer, userSession, new UserJsonWriter(userSession), defaultOrganizationProvider));
+  private MapSettings settings = new MapSettings();
+  private WsActionTester ws = new WsActionTester(new DeactivateAction(dbClient, userIndexer, userSession,
+    new UserJsonWriter(userSession), defaultOrganizationProvider, settings.asConfig()));
 
   @Test
   public void deactivate_user_and_delete_his_related_data() {
@@ -102,7 +104,7 @@ public class DeactivateActionTest {
 
     deactivate(user.getLogin());
 
-    verifyThatUserIsDeactivated(user.getLogin());
+    verifyThatUserIsDeactivated(user.getLogin(), false);
     assertThat(es.client().prepareSearch(UserIndexDefinition.TYPE_USER)
       .setQuery(boolQuery()
         .must(termQuery(FIELD_UUID, user.getUuid()))
@@ -242,7 +244,32 @@ public class DeactivateActionTest {
   }
 
   @Test
-  public void cannot_deactivate_self() {
+  public void user_can_deactivate_itself_on_sonarcloud() {
+    WsActionTester customWs = newSonarCloudWs();
+
+    UserDto user = db.users().insertUser();
+    userSession.logIn(user.getLogin());
+
+    deactivate(customWs, user.getLogin());
+
+    verifyThatUserIsDeactivated(user.getLogin(), true);
+  }
+
+  @Test
+  public void user_cannot_deactivate_another_user_on_sonarcloud() {
+    WsActionTester customWs = newSonarCloudWs();
+
+    UserDto user = db.users().insertUser();
+    userSession.logIn(user.getLogin());
+
+    expectedException.expect(ForbiddenException.class);
+    expectedException.expectMessage("Insufficient privilege");
+
+    deactivate(customWs, "other user");
+  }
+
+  @Test
+  public void user_cannot_deactivate_itself_on_sonarqube() {
     UserDto user = db.users().insertUser();
     userSession.logIn(user.getLogin()).setSystemAdministrator();
 
@@ -263,7 +290,7 @@ public class DeactivateActionTest {
   }
 
   @Test
-  public void deactivation_requires_administrator_permission() {
+  public void deactivation_requires_administrator_permission_on_sonarqube() {
     userSession.logIn();
 
     expectedException.expect(ForbiddenException.class);
@@ -346,7 +373,7 @@ public class DeactivateActionTest {
 
     deactivate(admin.getLogin());
 
-    verifyThatUserIsDeactivated(admin.getLogin());
+    verifyThatUserIsDeactivated(admin.getLogin(), false);
     verifyThatUserExists(anotherAdmin.getLogin());
   }
 
@@ -377,6 +404,10 @@ public class DeactivateActionTest {
   }
 
   private TestResponse deactivate(@Nullable String login) {
+    return deactivate(ws, login);
+  }
+
+  private TestResponse deactivate(WsActionTester ws, @Nullable String login) {
     TestRequest request = ws.newRequest()
       .setMethod("POST");
     Optional.ofNullable(login).ifPresent(t -> request.setParam("login", login));
@@ -387,11 +418,20 @@ public class DeactivateActionTest {
     assertThat(db.users().selectUserByLogin(login)).isPresent();
   }
 
-  private void verifyThatUserIsDeactivated(String login) {
+  private void verifyThatUserIsDeactivated(String login, boolean isSonarCloud) {
     Optional<UserDto> user = db.users().selectUserByLogin(login);
     assertThat(user).isPresent();
     assertThat(user.get().isActive()).isFalse();
     assertThat(user.get().getEmail()).isNull();
     assertThat(user.get().getScmAccountsAsList()).isEmpty();
+    if (isSonarCloud) {
+      assertThat(user.get().getName()).isNull();
+    }
+  }
+
+  private WsActionTester newSonarCloudWs() {
+    settings.setProperty(SONARCLOUD_ENABLED.getKey(), true);
+    return new WsActionTester(new DeactivateAction(dbClient, userIndexer, userSession,
+      new UserJsonWriter(userSession), defaultOrganizationProvider, settings.asConfig()));
   }
 }
index 12445f7a341d2082f36eb3a00717ad91c1890372..8595dd72aa31ea894e1efdf61d2f882c97a77808 100644 (file)
@@ -119,24 +119,6 @@ public class UpdateLoginActionTest {
     assertThat(userReloaded.getSalt()).isNull();
   }
 
-  @Test
-  public void update_personal_organization_when_updating_login() {
-    userSession.logIn().setSystemAdministrator();
-    String oldLogin = "old_login";
-    OrganizationDto personalOrganization = db.organizations().insert(o -> o.setKey(oldLogin).setGuarded(true));
-    UserDto user = db.users().insertUser(u -> u.setLogin(oldLogin).setOrganizationUuid(personalOrganization.getUuid()));
-
-    ws.newRequest()
-      .setParam("login", oldLogin)
-      .setParam("newLogin", "new_login")
-      .execute();
-
-    UserDto userReloaded = db.getDbClient().userDao().selectByUuid(db.getSession(), user.getUuid());
-    assertThat(userReloaded.getLogin()).isEqualTo("new_login");
-    OrganizationDto organizationReloaded = db.getDbClient().organizationDao().selectByUuid(db.getSession(), personalOrganization.getUuid()).get();
-    assertThat(organizationReloaded.getKey()).isEqualTo("new_login");
-  }
-
   @Test
   public void fail_with_IAE_when_new_login_is_already_used() {
     userSession.logIn().setSystemAdministrator();
index 660f59caed913c4ad27962931dd471bf535482db..6f5aeccd46d345538d00653a476ce5faf527ec80 100644 (file)
@@ -41,7 +41,7 @@
     "regenerator-runtime": "0.13.2",
     "remark-custom-blocks": "2.3.0",
     "remark-slug": "5.1.0",
-    "sonar-ui-common": "0.0.10",
+    "sonar-ui-common": "0.0.11",
     "unist-util-visit": "1.4.0",
     "valid-url": "1.0.9",
     "whatwg-fetch": "2.0.4"
index b2a5c56082535015358a25f11e625aab27e2b521..c8ce98cf84d261c871b3b78bfc56c73c9866b9b0 100644 (file)
@@ -26,7 +26,7 @@ export interface IssueResponse {
   components?: Array<{ key: string; name: string }>;
   issue: RawIssue;
   rules?: Array<{}>;
-  users?: Array<{ login: string }>;
+  users?: Array<T.UserBase>;
 }
 
 interface IssuesResponse {
@@ -37,13 +37,9 @@ interface IssuesResponse {
     values: { count: number; val: string }[];
   }>;
   issues: RawIssue[];
-  paging: {
-    pageIndex: number;
-    pageSize: number;
-    total: number;
-  };
+  paging: T.Paging;
   rules?: Array<{}>;
-  users?: { login: string }[];
+  users?: Array<T.UserBase>;
 }
 
 type FacetName =
@@ -109,8 +105,8 @@ export function searchIssueTags(data: {
     .catch(throwGlobalError);
 }
 
-export function getIssueChangelog(issue: string): Promise<any> {
-  return getJSON('/api/issues/changelog', { issue }).then(r => r.changelog, throwGlobalError);
+export function getIssueChangelog(issue: string): Promise<{ changelog: T.IssueChangelog[] }> {
+  return getJSON('/api/issues/changelog', { issue }).catch(throwGlobalError);
 }
 
 export function getIssueFilters() {
index 1ce52b9344a225e193be996ec4081acfa14d7277..191d100af5483c3b31c596d51931403ce0c28132 100644 (file)
@@ -52,6 +52,12 @@ export function getOrganizationNavigation(key: string): Promise<GetOrganizationN
   );
 }
 
+export function getOrganizationsThatPreventDeletion(): Promise<{
+  organizations: T.Organization[];
+}> {
+  return getJSON('/api/organizations/prevent_user_deletion').catch(throwGlobalError);
+}
+
 export function createOrganization(
   data: T.OrganizationBase & { installationId?: string }
 ): Promise<T.Organization> {
index e0f1c948aa979b4ccb22154fe5ae9f983ebd91bd..150276ecb2176917ea323a4e6c23649a4a2f12e7 100644 (file)
@@ -194,13 +194,8 @@ export interface SearchUsersGroupsParameters {
   selected?: 'all' | 'selected' | 'deselected';
 }
 
-export interface SearchUsersResponse {
-  users: Array<{
-    avatar?: string;
-    login: string;
-    name: string;
-    selected?: boolean;
-  }>;
+interface SearchUsersResponse {
+  users: T.UserSelected[];
   paging: T.Paging;
 }
 
index 14d3900f942b770936c25945208c69d58cbac405..7a3c88b0d3306e7f57b252349b84fbaa63773a8c 100644 (file)
@@ -30,12 +30,6 @@ export function searchUsersGroups(data: {
   return getJSON('/api/user_groups/search', data).catch(throwGlobalError);
 }
 
-export interface GroupUser {
-  login: string;
-  name: string;
-  selected: boolean;
-}
-
 export function getUsersInGroup(data: {
   id?: number;
   name?: string;
@@ -44,7 +38,7 @@ export function getUsersInGroup(data: {
   ps?: number;
   q?: string;
   selected?: string;
-}): Promise<T.Paging & { users: GroupUser[] }> {
+}): Promise<T.Paging & { users: T.UserSelected[] }> {
   return getJSON('/api/user_groups/users', data).catch(throwGlobalError);
 }
 
diff --git a/server/sonar-web/src/main/js/app/components/AccountDeleted.tsx b/server/sonar-web/src/main/js/app/components/AccountDeleted.tsx
new file mode 100644 (file)
index 0000000..d91059b
--- /dev/null
@@ -0,0 +1,53 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { Link } from 'react-router';
+import { Alert } from 'sonar-ui-common/components/ui/Alert';
+import { translate } from 'sonar-ui-common/helpers/l10n';
+
+export default function AccountDeleted() {
+  return (
+    <div className="page-wrapper-simple display-flex-column">
+      <Alert className="huge-spacer-bottom" variant="success">
+        {translate('my_profile.delete_account.success')}
+      </Alert>
+
+      <div className="page-simple text-center">
+        <p className="big-spacer-bottom">
+          <h1>{translate('my_profile.delete_account.feedback.reason.explanation')}</h1>
+        </p>
+        <p className="spacer-bottom">
+          <FormattedMessage
+            defaultMessage={translate('my_profile.delete_account.feedback.call_to_action')}
+            id="my_profile.delete_account.feedback.call_to_action"
+            values={{
+              link: <Link to="/about/contact">{translate('footer.contact_us')}</Link>
+            }}
+          />
+        </p>
+        <p>
+          <Link to="/">{translate('go_back_to_homepage')}</Link>
+        </p>
+      </div>
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/app/components/__tests__/AccountDeleted-test.tsx b/server/sonar-web/src/main/js/app/components/__tests__/AccountDeleted-test.tsx
new file mode 100644 (file)
index 0000000..911ce7a
--- /dev/null
@@ -0,0 +1,27 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 AccountDeleted from '../AccountDeleted';
+
+it('should render correctly', () => {
+  expect(shallow(<AccountDeleted />)).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/AccountDeleted-test.tsx.snap b/server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/AccountDeleted-test.tsx.snap
new file mode 100644 (file)
index 0000000..8bffcc4
--- /dev/null
@@ -0,0 +1,53 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<div
+  className="page-wrapper-simple display-flex-column"
+>
+  <Alert
+    className="huge-spacer-bottom"
+    variant="success"
+  >
+    my_profile.delete_account.success
+  </Alert>
+  <div
+    className="page-simple text-center"
+  >
+    <p
+      className="big-spacer-bottom"
+    >
+      <h1>
+        my_profile.delete_account.feedback.reason.explanation
+      </h1>
+    </p>
+    <p
+      className="spacer-bottom"
+    >
+      <FormattedMessage
+        defaultMessage="my_profile.delete_account.feedback.call_to_action"
+        id="my_profile.delete_account.feedback.call_to_action"
+        values={
+          Object {
+            "link": <Link
+              onlyActiveOnIndex={false}
+              style={Object {}}
+              to="/about/contact"
+            >
+              footer.contact_us
+            </Link>,
+          }
+        }
+      />
+    </p>
+    <p>
+      <Link
+        onlyActiveOnIndex={false}
+        style={Object {}}
+        to="/"
+      >
+        go_back_to_homepage
+      </Link>
+    </p>
+  </div>
+</div>
+`;
index 42d19d67d3253c525257b0f2068ee5bb072b292f..fe2e6c9cd525c9c90daac76ebc58d4617a1b4d21 100644 (file)
@@ -28,6 +28,10 @@ ul {
   padding-left: 40px;
 }
 
+.list-styled.no-padding {
+  padding-left: calc(var(--gridSize) * 2);
+}
+
 ul.list-styled {
   list-style: disc;
 }
index f1f0bc2e88ba9b969a1d922e546595fe058c1477..415a5f2e2762fd24505d8e611ba49bbf67071621 100644 (file)
@@ -239,12 +239,7 @@ declare namespace T {
     };
     projectKey: string;
     pending?: boolean;
-    user: {
-      active?: boolean;
-      email?: string;
-      login: string;
-      name: string;
-    };
+    user: T.UserBase;
     value: string;
     updatedAt?: string;
   }
@@ -333,7 +328,7 @@ declare namespace T {
   export interface Issue {
     actions: string[];
     assignee?: string;
-    assigneeActive?: string;
+    assigneeActive?: boolean;
     assigneeAvatar?: string;
     assigneeLogin?: string;
     assigneeName?: string;
@@ -374,6 +369,21 @@ declare namespace T {
     type: T.IssueType;
   }
 
+  export interface IssueChangelog {
+    avatar?: string;
+    creationDate: string;
+    diffs: IssueChangelogDiff[];
+    user: string;
+    isUserActive: boolean;
+    userName: string;
+  }
+
+  export interface IssueChangelogDiff {
+    key: string;
+    newValue?: string;
+    oldValue?: string;
+  }
+
   export interface IssueComment {
     author?: string;
     authorActive?: boolean;
@@ -423,17 +433,14 @@ declare namespace T {
     open?: boolean;
   }
 
-  export interface LoggedInUser extends CurrentUser {
-    avatar?: string;
-    email?: string;
+  export interface LoggedInUser extends CurrentUser, UserActive {
     externalIdentity?: string;
     externalProvider?: string;
     groups: string[];
     homepage?: HomePage;
     isLoggedIn: true;
     local?: boolean;
-    login: string;
-    name: string;
+    personalOrganization?: string;
     scmAccounts: string[];
     settings?: CurrentUserSetting[];
   }
@@ -527,10 +534,7 @@ declare namespace T {
     url?: string;
   }
 
-  export interface OrganizationMember {
-    login: string;
-    name: string;
-    avatar?: string;
+  export interface OrganizationMember extends UserActive {
     groupCount?: number;
   }
 
@@ -590,11 +594,7 @@ declare namespace T {
     permissions: string[];
   }
 
-  export interface PermissionUser {
-    avatar?: string;
-    email?: string;
-    login: string;
-    name: string;
+  export interface PermissionUser extends UserActive {
     permissions: string[];
   }
 
@@ -987,21 +987,33 @@ declare namespace T {
     endOffset: number;
   }
 
-  export interface User {
-    active: boolean;
-    avatar?: string;
-    email?: string;
+  export interface User extends UserBase {
     externalIdentity?: string;
     externalProvider?: string;
     groups?: string[];
     lastConnectionDate?: string;
     local: boolean;
-    login: string;
-    name: string;
     scmAccounts?: string[];
     tokensCount?: number;
   }
 
+  export interface UserActive extends UserBase {
+    active?: true;
+    name: string;
+  }
+
+  export interface UserBase {
+    active?: boolean;
+    avatar?: string;
+    email?: string;
+    login: string;
+    name?: string;
+  }
+
+  export interface UserSelected extends UserActive {
+    selected: boolean;
+  }
+
   export interface UserToken {
     name: string;
     createdAt: string;
index 714cba17a37479c43f0a35f1c02fba0b807f3ab8..6560bb4bef2a1579c221b8a5e606543e0c6ab6a4 100644 (file)
@@ -20,7 +20,7 @@
 /* eslint-disable react/jsx-sort-props */
 import * as React from 'react';
 import { render } from 'react-dom';
-import { Router, Route, IndexRoute, Redirect, RouteProps, RouteConfig } from 'react-router';
+import { IndexRoute, Redirect, Route, RouteConfig, RouteProps, Router } from 'react-router';
 import { Provider } from 'react-redux';
 import { IntlProvider } from 'react-intl';
 import { Location } from 'history';
@@ -202,6 +202,10 @@ export default function startReactApp(
                           import('../../apps/feedback/downgrade/DowngradeFeedback')
                         )}
                       />
+                      <Route
+                        path="account-deleted"
+                        component={lazyLoad(() => import('../components/AccountDeleted'))}
+                      />
                     </>
                   )}
                   <RouteWithChildRoutes path="organizations" childRoutes={organizationsRoutes} />
index b752f8fa8b3c3927655a8aa97e3ba7dcf328345f..941371b710eac09a7625893d4d527ddcd69df347 100644 (file)
@@ -20,6 +20,7 @@
 import * as React from 'react';
 import { translate } from 'sonar-ui-common/helpers/l10n';
 import UserExternalIdentity from './UserExternalIdentity';
+import UserDeleteAccount from './UserDeleteAccount';
 import UserGroups from './UserGroups';
 import UserScmAccounts from './UserScmAccounts';
 import { isSonarCloud } from '../../../helpers/system';
@@ -59,6 +60,14 @@ export function Profile({ currentUser }: Props) {
         <hr />
 
         <UserScmAccounts scmAccounts={currentUser.scmAccounts} user={currentUser} />
+
+        {isSonarCloud() && (
+          <>
+            <hr />
+
+            <UserDeleteAccount user={currentUser} />
+          </>
+        )}
       </div>
     </div>
   );
diff --git a/server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccount.tsx b/server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccount.tsx
new file mode 100644 (file)
index 0000000..38351f0
--- /dev/null
@@ -0,0 +1,125 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { translate } from 'sonar-ui-common/helpers/l10n';
+import { Button } from 'sonar-ui-common/components/controls/buttons';
+import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner';
+import UserDeleteAccountModal from './UserDeleteAccountModal';
+import UserDeleteAccountContent from './UserDeleteAccountContent';
+import { whenLoggedIn } from '../../../components/hoc/whenLoggedIn';
+import { withUserOrganizations } from '../../../components/hoc/withUserOrganizations';
+import { getOrganizationsThatPreventDeletion } from '../../../api/organizations';
+
+interface Props {
+  user: T.LoggedInUser;
+  userOrganizations: T.Organization[];
+}
+
+interface State {
+  loading: boolean;
+  organizationsToTransferOrDelete: T.Organization[];
+  showModal: boolean;
+}
+
+export class UserDeleteAccount extends React.PureComponent<Props, State> {
+  mounted = false;
+
+  state: State = {
+    loading: true,
+    organizationsToTransferOrDelete: [],
+    showModal: false
+  };
+
+  componentDidMount() {
+    this.mounted = true;
+    this.fetchOrganizationsThatPreventDeletion();
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  fetchOrganizationsThatPreventDeletion = () => {
+    getOrganizationsThatPreventDeletion().then(
+      ({ organizations }) => {
+        if (this.mounted) {
+          this.setState({
+            loading: false,
+            organizationsToTransferOrDelete: organizations
+          });
+        }
+      },
+      () => {}
+    );
+  };
+
+  toggleModal = () => {
+    if (this.mounted) {
+      this.setState(state => ({
+        showModal: !state.showModal
+      }));
+    }
+  };
+
+  render() {
+    const { user, userOrganizations } = this.props;
+    const { organizationsToTransferOrDelete, loading, showModal } = this.state;
+
+    const label = translate('my_profile.delete_account');
+
+    return (
+      <div>
+        <h2 className="spacer-bottom">{label}</h2>
+
+        <DeferredSpinner loading={loading} />
+
+        {!loading && (
+          <>
+            <UserDeleteAccountContent
+              className="list-styled no-padding big-spacer-top big-spacer-bottom"
+              organizationsSafeToDelete={userOrganizations}
+              organizationsToTransferOrDelete={organizationsToTransferOrDelete}
+            />
+
+            <Button
+              className="button-red"
+              disabled={organizationsToTransferOrDelete.length > 0}
+              onClick={this.toggleModal}
+              type="button">
+              {translate('delete')}
+            </Button>
+
+            {showModal && (
+              <UserDeleteAccountModal
+                label={label}
+                organizationsSafeToDelete={userOrganizations}
+                organizationsToTransferOrDelete={organizationsToTransferOrDelete}
+                toggleModal={this.toggleModal}
+                user={user}
+              />
+            )}
+          </>
+        )}
+      </div>
+    );
+  }
+}
+
+export default whenLoggedIn(withUserOrganizations(UserDeleteAccount));
diff --git a/server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccountContent.tsx b/server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccountContent.tsx
new file mode 100644 (file)
index 0000000..aaad598
--- /dev/null
@@ -0,0 +1,148 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { Link } from 'react-router';
+import { Alert } from 'sonar-ui-common/components/ui/Alert';
+import { translate } from 'sonar-ui-common/helpers/l10n';
+import { getOrganizationUrl } from '../../../helpers/urls';
+
+function getOrganizationLink(org: T.Organization, i: number, organizations: T.Organization[]) {
+  return (
+    <span key={org.key}>
+      <Link to={getOrganizationUrl(org.key)}>{org.name}</Link>
+      {i < organizations.length - 1 && ', '}
+    </span>
+  );
+}
+
+export function ShowOrganizationsToTransferOrDelete({
+  organizations
+}: {
+  organizations: T.Organization[];
+}) {
+  return (
+    <>
+      <p className="big-spacer-bottom">
+        <FormattedMessage
+          defaultMessage={translate('my_profile.delete_account.info.orgs_to_transfer_or_delete')}
+          id="my_profile.delete_account.info.orgs_to_transfer_or_delete"
+          values={{
+            organizations: <>{organizations.map(getOrganizationLink)}</>
+          }}
+        />
+      </p>
+
+      <Alert className="big-spacer-bottom" variant="warning">
+        <FormattedMessage
+          defaultMessage={translate(
+            'my_profile.delete_account.info.orgs_to_transfer_or_delete.info'
+          )}
+          id="my_profile.delete_account.info.orgs_to_transfer_or_delete.info"
+          values={{
+            link: (
+              <a
+                href="https://sieg.eu.ngrok.io/documentation/organizations/overview/#how-to-transfer-ownership-of-an-organization"
+                rel="noopener noreferrer"
+                target="_blank">
+                {translate('my_profile.delete_account.info.orgs_to_transfer_or_delete.info.link')}
+              </a>
+            )
+          }}
+        />
+      </Alert>
+    </>
+  );
+}
+
+export function ShowOrganizations({
+  className,
+  organizations
+}: {
+  className?: string;
+  organizations: T.Organization[];
+}) {
+  const organizationsIAdministrate = organizations.filter(o => o.actions && o.actions.admin);
+
+  return (
+    <ul className={className}>
+      <li className="spacer-bottom">{translate('my_profile.delete_account.info')}</li>
+
+      <li className="spacer-bottom">
+        <FormattedMessage
+          defaultMessage={translate('my_profile.delete_account.data.info')}
+          id="my_profile.delete_account.data.info"
+          values={{
+            help: (
+              <a
+                href="/documentation/user-guide/user-account/#delete-your-user-account"
+                rel="noopener noreferrer"
+                target="_blank">
+                {translate('learn_more')}
+              </a>
+            )
+          }}
+        />
+      </li>
+
+      {organizations.length > 0 && (
+        <li className="spacer-bottom">
+          <FormattedMessage
+            defaultMessage={translate('my_profile.delete_account.info.orgs.members')}
+            id="my_profile.delete_account.info.orgs.members"
+            values={{
+              organizations: <>{organizations.map(getOrganizationLink)}</>
+            }}
+          />
+        </li>
+      )}
+
+      {organizationsIAdministrate.length > 0 && (
+        <li className="spacer-bottom">
+          <FormattedMessage
+            defaultMessage={translate('my_profile.delete_account.info.orgs.administrators')}
+            id="my_profile.delete_account.info.orgs.administrators"
+            values={{
+              organizations: <>{organizationsIAdministrate.map(getOrganizationLink)}</>
+            }}
+          />
+        </li>
+      )}
+    </ul>
+  );
+}
+
+interface UserDeleteAccountContentProps {
+  className?: string;
+  organizationsSafeToDelete: T.Organization[];
+  organizationsToTransferOrDelete: T.Organization[];
+}
+
+export default function UserDeleteAccountContent({
+  className,
+  organizationsSafeToDelete,
+  organizationsToTransferOrDelete
+}: UserDeleteAccountContentProps) {
+  if (organizationsToTransferOrDelete.length > 0) {
+    return <ShowOrganizationsToTransferOrDelete organizations={organizationsToTransferOrDelete} />;
+  }
+
+  return <ShowOrganizations className={className} organizations={organizationsSafeToDelete} />;
+}
diff --git a/server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccountModal.tsx b/server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccountModal.tsx
new file mode 100644 (file)
index 0000000..566fad4
--- /dev/null
@@ -0,0 +1,150 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { FormikProps } from 'formik';
+import { connect } from 'react-redux';
+import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
+import { Alert } from 'sonar-ui-common/components/ui/Alert';
+import InputValidationField from 'sonar-ui-common/components/controls/InputValidationField';
+import UserDeleteAccountContent from './UserDeleteAccountContent';
+import RecentHistory from '../../../app/components/RecentHistory';
+import ValidationModal from '../../../components/controls/ValidationModal';
+import { deactivateUser } from '../../../api/users';
+import { Router, withRouter } from '../../../components/hoc/withRouter';
+import { doLogout } from '../../../store/rootActions';
+
+interface Values {
+  login: string;
+}
+
+interface DeleteModalProps {
+  doLogout: () => Promise<void>;
+  label: string;
+  organizationsSafeToDelete: T.Organization[];
+  organizationsToTransferOrDelete: T.Organization[];
+  router: Pick<Router, 'push'>;
+  toggleModal: VoidFunction;
+  user: T.LoggedInUser;
+}
+
+export class UserDeleteAccountModal extends React.PureComponent<DeleteModalProps> {
+  handleSubmit = () => {
+    const { user } = this.props;
+
+    return deactivateUser({ login: user.login })
+      .then(this.props.doLogout)
+      .then(() => {
+        RecentHistory.clear();
+        window.location.replace('/account-deleted');
+      });
+  };
+
+  handleValidate = ({ login }: Values) => {
+    const { user } = this.props;
+    const errors: { login?: string } = {};
+    const trimmedLogin = login.trim();
+
+    if (!trimmedLogin) {
+      errors.login = translate('my_profile.delete_account.login.required');
+    } else if (user.externalIdentity && trimmedLogin !== user.externalIdentity.trim()) {
+      errors.login = translate('my_profile.delete_account.login.wrong_value');
+    }
+
+    return errors;
+  };
+
+  render() {
+    const {
+      label,
+      organizationsSafeToDelete,
+      organizationsToTransferOrDelete,
+      toggleModal,
+      user
+    } = this.props;
+
+    return (
+      <ValidationModal
+        confirmButtonText={translate('delete')}
+        header={translateWithParameters(
+          'my_profile.delete_account.modal.header',
+          label,
+          user.externalIdentity || ''
+        )}
+        initialValues={{
+          login: ''
+        }}
+        isDestructive={true}
+        onClose={toggleModal}
+        onSubmit={this.handleSubmit}
+        validate={this.handleValidate}>
+        {({
+          dirty,
+          errors,
+          handleBlur,
+          handleChange,
+          isSubmitting,
+          touched,
+          values
+        }: FormikProps<Values>) => (
+          <>
+            <Alert className="big-spacer-bottom" variant="error">
+              {translate('my_profile.warning_message')}
+            </Alert>
+
+            <UserDeleteAccountContent
+              className="list-styled no-padding big-spacer-bottom"
+              organizationsSafeToDelete={organizationsSafeToDelete}
+              organizationsToTransferOrDelete={organizationsToTransferOrDelete}
+            />
+
+            <InputValidationField
+              autoFocus={true}
+              dirty={dirty}
+              disabled={isSubmitting}
+              error={errors.login}
+              id="user-login"
+              label={
+                <label htmlFor="user-login">
+                  {translate('my_profile.delete_account.verify')}
+                  <em className="mandatory">*</em>
+                </label>
+              }
+              name="login"
+              onBlur={handleBlur}
+              onChange={handleChange}
+              touched={touched.login}
+              type="text"
+              value={values.login}
+            />
+          </>
+        )}
+      </ValidationModal>
+    );
+  }
+}
+
+const mapStateToProps = () => ({});
+
+const mapDispatchToProps = { doLogout: doLogout as any };
+
+export default connect(
+  mapStateToProps,
+  mapDispatchToProps
+)(withRouter(UserDeleteAccountModal));
diff --git a/server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccount-test.tsx b/server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccount-test.tsx
new file mode 100644 (file)
index 0000000..1e4d4b2
--- /dev/null
@@ -0,0 +1,87 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { click, waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
+import { mockLoggedInUser, mockOrganization } from '../../../../helpers/testMocks';
+import { UserDeleteAccount } from '../UserDeleteAccount';
+import { getOrganizationsThatPreventDeletion } from '../../../../api/organizations';
+
+jest.mock('../../../../api/organizations', () => ({
+  getOrganizationsThatPreventDeletion: jest.fn().mockResolvedValue({ organizations: [] })
+}));
+
+beforeEach(() => {
+  jest.clearAllMocks();
+});
+
+const organizationToTransferOrDelete = {
+  key: 'luke-leia',
+  name: 'Luke and Leia'
+};
+
+it('should render correctly', async () => {
+  const wrapper = shallowRender();
+  expect(wrapper).toMatchSnapshot();
+
+  await waitAndUpdate(wrapper);
+  expect(wrapper).toMatchSnapshot();
+
+  click(wrapper.find('Button'));
+  expect(wrapper).toMatchSnapshot();
+});
+
+it('should get some organizations', async () => {
+  (getOrganizationsThatPreventDeletion as jest.Mock).mockResolvedValue({
+    organizations: [organizationToTransferOrDelete]
+  });
+
+  const wrapper = shallowRender();
+
+  await waitAndUpdate(wrapper);
+
+  expect(wrapper.state('loading')).toBeFalsy();
+  expect(wrapper.state('organizationsToTransferOrDelete')).toEqual([
+    organizationToTransferOrDelete
+  ]);
+  expect(getOrganizationsThatPreventDeletion).toBeCalled();
+  expect(wrapper.find('Button').prop('disabled')).toBe(true);
+});
+
+it('should toggle modal', () => {
+  const wrapper = shallowRender();
+  wrapper.setState({ loading: false });
+  expect(wrapper.find('Connect(withRouter(UserDeleteAccountModal))').exists()).toBe(false);
+  click(wrapper.find('Button'));
+  expect(wrapper.find('Connect(withRouter(UserDeleteAccountModal))').exists()).toBe(true);
+});
+
+function shallowRender(props: Partial<UserDeleteAccount['props']> = {}) {
+  const user = mockLoggedInUser({ externalIdentity: 'luke' });
+
+  const userOrganizations = [
+    mockOrganization({ key: 'luke-leia', name: 'Luke and Leia' }),
+    mockOrganization({ key: 'luke', name: 'Luke Skywalker' })
+  ];
+
+  return shallow<UserDeleteAccount>(
+    <UserDeleteAccount user={user} userOrganizations={userOrganizations} {...props} />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccountContent-test.tsx b/server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccountContent-test.tsx
new file mode 100644 (file)
index 0000000..c9e5e33
--- /dev/null
@@ -0,0 +1,71 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 UserDeleteAccountContent, {
+  ShowOrganizations,
+  ShowOrganizationsToTransferOrDelete
+} from '../UserDeleteAccountContent';
+
+const organizationSafeToDelete = {
+  key: 'luke',
+  name: 'Luke Skywalker'
+};
+
+const organizationToTransferOrDelete = {
+  key: 'luke-leia',
+  name: 'Luke and Leia'
+};
+
+it('should render content correctly', () => {
+  expect(
+    shallow(
+      <UserDeleteAccountContent
+        className="my-class"
+        organizationsSafeToDelete={[organizationSafeToDelete]}
+        organizationsToTransferOrDelete={[organizationToTransferOrDelete]}
+      />
+    )
+  ).toMatchSnapshot();
+
+  expect(
+    shallow(
+      <UserDeleteAccountContent
+        className="my-class"
+        organizationsSafeToDelete={[organizationSafeToDelete]}
+        organizationsToTransferOrDelete={[]}
+      />
+    )
+  ).toMatchSnapshot();
+});
+
+it('should render correctly ShowOrganizationsToTransferOrDelete', () => {
+  expect(
+    shallow(
+      <ShowOrganizationsToTransferOrDelete organizations={[organizationToTransferOrDelete]} />
+    )
+  ).toMatchSnapshot();
+});
+
+it('should render correctly ShowOrganizations', () => {
+  expect(
+    shallow(<ShowOrganizations organizations={[organizationSafeToDelete]} />)
+  ).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccountModal-test.tsx b/server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccountModal-test.tsx
new file mode 100644 (file)
index 0000000..e820b6c
--- /dev/null
@@ -0,0 +1,86 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
+import { mockLoggedInUser, mockRouter } from '../../../../helpers/testMocks';
+import { UserDeleteAccountModal } from '../UserDeleteAccountModal';
+import { deactivateUser } from '../../../../api/users';
+
+jest.mock('../../../../api/users', () => ({
+  deactivateUser: jest.fn()
+}));
+
+const organizationSafeToDelete = {
+  key: 'luke',
+  name: 'Luke Skywalker'
+};
+
+const organizationToTransferOrDelete = {
+  key: 'luke-leia',
+  name: 'Luke and Leia'
+};
+
+it('should render modal correctly', () => {
+  expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should handle submit', async () => {
+  (deactivateUser as jest.Mock).mockResolvedValue(true);
+  window.location.replace = jest.fn();
+
+  const wrapper = shallowRender();
+  const instance = wrapper.instance();
+
+  instance.handleSubmit();
+  await waitAndUpdate(wrapper);
+
+  expect(deactivateUser).toBeCalled();
+  expect(window.location.replace).toHaveBeenCalledWith('/account-deleted');
+});
+
+it('should validate user input', () => {
+  const wrapper = shallowRender();
+  const instance = wrapper.instance();
+  const { handleValidate } = instance;
+
+  expect(handleValidate({ login: '' }).login).toBe('my_profile.delete_account.login.required');
+  expect(handleValidate({ login: 'abc' }).login).toBe(
+    'my_profile.delete_account.login.wrong_value'
+  );
+  expect(handleValidate({ login: 'luke' }).login).toBeUndefined();
+});
+
+function shallowRender(props: Partial<UserDeleteAccountModal['props']> = {}) {
+  const user = mockLoggedInUser({ externalIdentity: 'luke' });
+
+  return shallow<UserDeleteAccountModal>(
+    <UserDeleteAccountModal
+      doLogout={jest.fn().mockResolvedValue(true)}
+      label="label"
+      organizationsSafeToDelete={[organizationSafeToDelete]}
+      organizationsToTransferOrDelete={[organizationToTransferOrDelete]}
+      router={mockRouter()}
+      toggleModal={jest.fn()}
+      user={user}
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccount-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccount-test.tsx.snap
new file mode 100644 (file)
index 0000000..05a4397
--- /dev/null
@@ -0,0 +1,118 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<div>
+  <h2
+    className="spacer-bottom"
+  >
+    my_profile.delete_account
+  </h2>
+  <DeferredSpinner
+    loading={true}
+    timeout={100}
+  />
+</div>
+`;
+
+exports[`should render correctly 2`] = `
+<div>
+  <h2
+    className="spacer-bottom"
+  >
+    my_profile.delete_account
+  </h2>
+  <DeferredSpinner
+    loading={false}
+    timeout={100}
+  />
+  <UserDeleteAccountContent
+    className="list-styled no-padding big-spacer-top big-spacer-bottom"
+    organizationsSafeToDelete={
+      Array [
+        Object {
+          "key": "luke-leia",
+          "name": "Luke and Leia",
+        },
+        Object {
+          "key": "luke",
+          "name": "Luke Skywalker",
+        },
+      ]
+    }
+    organizationsToTransferOrDelete={Array []}
+  />
+  <Button
+    className="button-red"
+    disabled={false}
+    onClick={[Function]}
+    type="button"
+  >
+    delete
+  </Button>
+</div>
+`;
+
+exports[`should render correctly 3`] = `
+<div>
+  <h2
+    className="spacer-bottom"
+  >
+    my_profile.delete_account
+  </h2>
+  <DeferredSpinner
+    loading={false}
+    timeout={100}
+  />
+  <UserDeleteAccountContent
+    className="list-styled no-padding big-spacer-top big-spacer-bottom"
+    organizationsSafeToDelete={
+      Array [
+        Object {
+          "key": "luke-leia",
+          "name": "Luke and Leia",
+        },
+        Object {
+          "key": "luke",
+          "name": "Luke Skywalker",
+        },
+      ]
+    }
+    organizationsToTransferOrDelete={Array []}
+  />
+  <Button
+    className="button-red"
+    disabled={false}
+    onClick={[Function]}
+    type="button"
+  >
+    delete
+  </Button>
+  <Connect(withRouter(UserDeleteAccountModal))
+    label="my_profile.delete_account"
+    organizationsSafeToDelete={
+      Array [
+        Object {
+          "key": "luke-leia",
+          "name": "Luke and Leia",
+        },
+        Object {
+          "key": "luke",
+          "name": "Luke Skywalker",
+        },
+      ]
+    }
+    organizationsToTransferOrDelete={Array []}
+    toggleModal={[Function]}
+    user={
+      Object {
+        "externalIdentity": "luke",
+        "groups": Array [],
+        "isLoggedIn": true,
+        "login": "luke",
+        "name": "Skywalker",
+        "scmAccounts": Array [],
+      }
+    }
+  />
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccountContent-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccountContent-test.tsx.snap
new file mode 100644 (file)
index 0000000..56ac708
--- /dev/null
@@ -0,0 +1,128 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render content correctly 1`] = `
+<ShowOrganizationsToTransferOrDelete
+  organizations={
+    Array [
+      Object {
+        "key": "luke-leia",
+        "name": "Luke and Leia",
+      },
+    ]
+  }
+/>
+`;
+
+exports[`should render content correctly 2`] = `
+<ShowOrganizations
+  className="my-class"
+  organizations={
+    Array [
+      Object {
+        "key": "luke",
+        "name": "Luke Skywalker",
+      },
+    ]
+  }
+/>
+`;
+
+exports[`should render correctly ShowOrganizations 1`] = `
+<ul>
+  <li
+    className="spacer-bottom"
+  >
+    my_profile.delete_account.info
+  </li>
+  <li
+    className="spacer-bottom"
+  >
+    <FormattedMessage
+      defaultMessage="my_profile.delete_account.data.info"
+      id="my_profile.delete_account.data.info"
+      values={
+        Object {
+          "help": <a
+            href="/documentation/user-guide/user-account/#delete-your-user-account"
+            rel="noopener noreferrer"
+            target="_blank"
+          >
+            learn_more
+          </a>,
+        }
+      }
+    />
+  </li>
+  <li
+    className="spacer-bottom"
+  >
+    <FormattedMessage
+      defaultMessage="my_profile.delete_account.info.orgs.members"
+      id="my_profile.delete_account.info.orgs.members"
+      values={
+        Object {
+          "organizations": <React.Fragment>
+            <span>
+              <Link
+                onlyActiveOnIndex={false}
+                style={Object {}}
+                to="/organizations/luke"
+              >
+                Luke Skywalker
+              </Link>
+            </span>
+          </React.Fragment>,
+        }
+      }
+    />
+  </li>
+</ul>
+`;
+
+exports[`should render correctly ShowOrganizationsToTransferOrDelete 1`] = `
+<Fragment>
+  <p
+    className="big-spacer-bottom"
+  >
+    <FormattedMessage
+      defaultMessage="my_profile.delete_account.info.orgs_to_transfer_or_delete"
+      id="my_profile.delete_account.info.orgs_to_transfer_or_delete"
+      values={
+        Object {
+          "organizations": <React.Fragment>
+            <span>
+              <Link
+                onlyActiveOnIndex={false}
+                style={Object {}}
+                to="/organizations/luke-leia"
+              >
+                Luke and Leia
+              </Link>
+            </span>
+          </React.Fragment>,
+        }
+      }
+    />
+  </p>
+  <Alert
+    className="big-spacer-bottom"
+    variant="warning"
+  >
+    <FormattedMessage
+      defaultMessage="my_profile.delete_account.info.orgs_to_transfer_or_delete.info"
+      id="my_profile.delete_account.info.orgs_to_transfer_or_delete.info"
+      values={
+        Object {
+          "link": <a
+            href="https://sieg.eu.ngrok.io/documentation/organizations/overview/#how-to-transfer-ownership-of-an-organization"
+            rel="noopener noreferrer"
+            target="_blank"
+          >
+            my_profile.delete_account.info.orgs_to_transfer_or_delete.info.link
+          </a>,
+        }
+      }
+    />
+  </Alert>
+</Fragment>
+`;
diff --git a/server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccountModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccountModal-test.tsx.snap
new file mode 100644 (file)
index 0000000..5ba1d10
--- /dev/null
@@ -0,0 +1,19 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render modal correctly 1`] = `
+<ValidationModal
+  confirmButtonText="delete"
+  header="my_profile.delete_account.modal.header.label.luke"
+  initialValues={
+    Object {
+      "login": "",
+    }
+  }
+  isDestructive={true}
+  onClose={[MockFunction]}
+  onSubmit={[Function]}
+  validate={[Function]}
+>
+  <Component />
+</ValidationModal>
+`;
index b7ceeab374d7569956fcfdda7cc67ac7776fe21c..92ec1433518b8c1e85da2611cf3ee5f728b0b246 100644 (file)
@@ -18,7 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import { translate } from 'sonar-ui-common/helpers/l10n';
+import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
 import Tooltip from 'sonar-ui-common/components/controls/Tooltip';
 import { formatMeasure } from 'sonar-ui-common/helpers/measures';
 import ActionsDropdown, {
@@ -28,6 +28,7 @@ import ActionsDropdown, {
 import DeleteForm from './DeleteForm';
 import Form from './Form';
 import MeasureDate from './MeasureDate';
+import { isUserActive } from '../../../helpers/users';
 
 interface Props {
   measure: T.CustomMeasure;
@@ -114,7 +115,11 @@ export default class Item extends React.PureComponent<Props, State> {
 
         <td>
           <MeasureDate measure={measure} /> {translate('by_')}{' '}
-          <span className="js-custom-measure-user">{measure.user.name}</span>
+          <span className="js-custom-measure-user">
+            {isUserActive(measure.user)
+              ? measure.user.name || measure.user.login
+              : translateWithParameters('user.x_deleted', measure.user.login)}
+          </span>
         </td>
 
         <td className="thin nowrap">
index 90fb5c29edc490fdd9e15398a85424b2db4567fb..667d1f38a88986dd15e7b137951bb6f8471b63bd 100644 (file)
@@ -33,14 +33,12 @@ const measure = {
 };
 
 it('should render', () => {
-  expect(
-    shallow(<Item measure={measure} onDelete={jest.fn()} onEdit={jest.fn()} />)
-  ).toMatchSnapshot();
+  expect(shallowRender()).toMatchSnapshot();
 });
 
 it('should edit metric', () => {
   const onEdit = jest.fn();
-  const wrapper = shallow(<Item measure={measure} onDelete={jest.fn()} onEdit={onEdit} />);
+  const wrapper = shallowRender({ onEdit });
 
   click(wrapper.find('.js-custom-measure-update'));
   wrapper.update();
@@ -55,7 +53,7 @@ it('should edit metric', () => {
 
 it('should delete custom measure', () => {
   const onDelete = jest.fn();
-  const wrapper = shallow(<Item measure={measure} onDelete={onDelete} onEdit={jest.fn()} />);
+  const wrapper = shallowRender({ onDelete });
 
   click(wrapper.find('.js-custom-measure-delete'));
   wrapper.update();
@@ -63,3 +61,13 @@ it('should delete custom measure', () => {
   wrapper.find('DeleteForm').prop<Function>('onSubmit')();
   expect(onDelete).toBeCalledWith('1');
 });
+
+it('should render correctly for deleted user', () => {
+  expect(
+    shallowRender({ measure: { ...measure, user: { active: false, login: 'user' } } })
+  ).toMatchSnapshot();
+});
+
+function shallowRender(props: Partial<Item['props']> = {}) {
+  return shallow(<Item measure={measure} onDelete={jest.fn()} onEdit={jest.fn()} {...props} />);
+}
index 225ca0c587c5b79dc1544cfbad6878c8656e271e..a763f649723b39a675738c1730bfcef38267fb3b 100644 (file)
@@ -87,3 +87,90 @@ exports[`should render 1`] = `
   </td>
 </tr>
 `;
+
+exports[`should render correctly for deleted user 1`] = `
+<tr
+  data-metric="custom"
+>
+  <td
+    className="nowrap"
+  >
+    <div>
+      <span
+        className="js-custom-measure-metric-name"
+      >
+        custom-metric
+      </span>
+    </div>
+    <span
+      className="js-custom-measure-domain note"
+    />
+  </td>
+  <td
+    className="nowrap"
+  >
+    <strong
+      className="js-custom-measure-value"
+    >
+      custom-value
+    </strong>
+  </td>
+  <td>
+    <span
+      className="js-custom-measure-description"
+    >
+      my custom measure
+    </span>
+  </td>
+  <td>
+    <MeasureDate
+      measure={
+        Object {
+          "createdAt": "2017-01-01",
+          "description": "my custom measure",
+          "id": "1",
+          "metric": Object {
+            "key": "custom",
+            "name": "custom-metric",
+            "type": "STRING",
+          },
+          "projectKey": "foo",
+          "user": Object {
+            "active": false,
+            "login": "user",
+          },
+          "value": "custom-value",
+        }
+      }
+    />
+     
+    by_
+     
+    <span
+      className="js-custom-measure-user"
+    >
+      user.x_deleted.user
+    </span>
+  </td>
+  <td
+    className="thin nowrap"
+  >
+    <ActionsDropdown>
+      <ActionsDropdownItem
+        className="js-custom-measure-update"
+        onClick={[Function]}
+      >
+        update_verb
+      </ActionsDropdownItem>
+      <ActionsDropdownDivider />
+      <ActionsDropdownItem
+        className="js-custom-measure-delete"
+        destructive={true}
+        onClick={[Function]}
+      >
+        delete
+      </ActionsDropdownItem>
+    </ActionsDropdown>
+  </td>
+</tr>
+`;
index a0f00cf6d6ef80a56ca49cba5aed78274b49867f..712206e016f2669b3e3cf8b65af6ed2012e90fde 100644 (file)
@@ -24,12 +24,7 @@ import { ResetButtonLink } from 'sonar-ui-common/components/controls/buttons';
 import Modal from 'sonar-ui-common/components/controls/Modal';
 import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner';
 import SelectList, { Filter } from '../../../components/SelectList/SelectList';
-import {
-  addUserToGroup,
-  getUsersInGroup,
-  GroupUser,
-  removeUserFromGroup
-} from '../../../api/user_groups';
+import { addUserToGroup, getUsersInGroup, removeUserFromGroup } from '../../../api/user_groups';
 
 interface Props {
   group: T.Group;
@@ -50,7 +45,7 @@ interface State {
   lastSearchParams: SearchParams;
   listHasBeenTouched: boolean;
   loading: boolean;
-  users: GroupUser[];
+  users: T.UserSelected[];
   usersTotalCount?: number;
   selectedUsers: string[];
 }
index d4482a05dbf6429917c93b0f203734cf96a1cb28..4d3262d3fec772145653e957a5664e50092d74cc 100644 (file)
@@ -66,7 +66,6 @@ import {
   ReferencedComponent,
   ReferencedLanguage,
   ReferencedRule,
-  ReferencedUser,
   saveMyIssues,
   serializeQuery,
   STANDARDS,
@@ -96,7 +95,7 @@ interface FetchIssuesPromise {
   languages: ReferencedLanguage[];
   paging: T.Paging;
   rules: ReferencedRule[];
-  users: ReferencedUser[];
+  users: T.UserBase[];
 }
 
 interface Props {
@@ -136,7 +135,7 @@ export interface State {
   referencedComponentsByKey: T.Dict<ReferencedComponent>;
   referencedLanguages: T.Dict<ReferencedLanguage>;
   referencedRules: T.Dict<ReferencedRule>;
-  referencedUsers: T.Dict<ReferencedUser>;
+  referencedUsers: T.Dict<T.UserBase>;
   selected?: string;
   selectedFlowIndex?: number;
   selectedLocationIndex?: number;
index 9599069623a6169c356adb519f806fbd056e41eb..7a8fb72a71778786b62c6f0291f51bfcde2f8bff 100644 (file)
@@ -36,7 +36,7 @@ import Select from '../../../components/controls/Select';
 import SeverityHelper from '../../../components/shared/SeverityHelper';
 import throwGlobalError from '../../../app/utils/throwGlobalError';
 import { searchIssueTags, bulkChangeIssues } from '../../../api/issues';
-import { isLoggedIn } from '../../../helpers/users';
+import { isLoggedIn, isUserActive } from '../../../helpers/users';
 
 interface AssigneeOption {
   avatar?: string;
@@ -161,7 +161,11 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
 
   handleAssigneeSearch = (query: string) => {
     return searchAssignees(query, this.state.organization).then(({ results }) =>
-      results.map(r => ({ avatar: r.avatar, label: r.name, value: r.login }))
+      results.map(r => ({
+        avatar: r.avatar,
+        label: isUserActive(r) ? r.name : translateWithParameters('user.x_deleted', r.login),
+        value: r.login
+      }))
     );
   };
 
index caf1042496931e76840608bbe472bf7252a5fa10..95c4446ff8d23cc91acbe630a3f472a842188870 100644 (file)
 import * as React from 'react';
 import { omit, sortBy, without } from 'lodash';
 import { highlightTerm } from 'sonar-ui-common/helpers/search';
-import { translate } from 'sonar-ui-common/helpers/l10n';
-import { searchAssignees, Query, ReferencedUser, SearchedAssignee, Facet } from '../utils';
+import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
+import { searchAssignees, Query, Facet } from '../utils';
 import Avatar from '../../../components/ui/Avatar';
 import ListStyleFacet from '../../../components/facet/ListStyleFacet';
+import { isUserActive } from '../../../helpers/users';
 
-export interface Props {
+interface Props {
   assigned: boolean;
   assignees: string[];
   fetching: boolean;
@@ -36,7 +37,7 @@ export interface Props {
   organization: string | undefined;
   query: Query;
   stats: T.Dict<number> | undefined;
-  referencedUsers: T.Dict<ReferencedUser>;
+  referencedUsers: T.Dict<T.UserBase>;
 }
 
 export default class AssigneeFacet extends React.PureComponent<Props> {
@@ -71,11 +72,14 @@ export default class AssigneeFacet extends React.PureComponent<Props> {
       return translate('unassigned');
     } else {
       const user = this.props.referencedUsers[assignee];
-      return user ? user.name : assignee;
+      if (!user) {
+        return assignee;
+      }
+      return isUserActive(user) ? user.name : translateWithParameters('user.x_deleted', user.login);
     }
   };
 
-  loadSearchResultCount = (assignees: SearchedAssignee[]) => {
+  loadSearchResultCount = (assignees: T.UserBase[]) => {
     return this.props.loadSearchResultCount('assignees', {
       assigned: undefined,
       assignees: assignees.map(assignee => assignee.login)
@@ -99,28 +103,35 @@ export default class AssigneeFacet extends React.PureComponent<Props> {
     }
 
     const user = this.props.referencedUsers[assignee];
+
     return user ? (
       <>
-        <Avatar className="little-spacer-right" hash={user.avatar} name={user.name} size={16} />
-        {user.name}
+        <Avatar
+          className="little-spacer-right"
+          hash={user.avatar}
+          name={user.name || user.login}
+          size={16}
+        />
+        {isUserActive(user) ? user.name : translateWithParameters('user.x_deleted', user.login)}
       </>
     ) : (
       assignee
     );
   };
 
-  renderSearchResult = (result: SearchedAssignee, query: string) => {
+  renderSearchResult = (result: T.UserBase, query: string) => {
+    const displayName = isUserActive(result)
+      ? result.name
+      : translateWithParameters('user.x_deleted', result.login);
     return (
       <>
-        {result.avatar !== undefined && (
-          <Avatar
-            className="little-spacer-right"
-            hash={result.avatar}
-            name={result.name}
-            size={16}
-          />
-        )}
-        {highlightTerm(result.name, query)}
+        <Avatar
+          className="little-spacer-right"
+          hash={result.avatar}
+          name={result.name || result.login}
+          size={16}
+        />
+        {highlightTerm(displayName, query)}
       </>
     );
   };
@@ -132,12 +143,12 @@ export default class AssigneeFacet extends React.PureComponent<Props> {
     }
 
     return (
-      <ListStyleFacet<SearchedAssignee>
+      <ListStyleFacet<T.UserBase>
         facetHeader={translate('issues.facet.assignees')}
         fetching={this.props.fetching}
         getFacetItemText={this.getAssigneeName}
         getSearchResultKey={user => user.login}
-        getSearchResultText={user => user.name}
+        getSearchResultText={user => user.name || user.login}
         // put "not assigned" item first
         getSortedItems={this.getSortedItems}
         loadSearchResultCount={this.loadSearchResultCount}
index 95bcfd24158a911f3d72782c5ea12d6f3fcdc5f0..ade6f7080b855666dc278b2ff2da72bb04745c80 100644 (file)
@@ -32,14 +32,7 @@ import StandardFacet from './StandardFacet';
 import StatusFacet from './StatusFacet';
 import TagFacet from './TagFacet';
 import TypeFacet from './TypeFacet';
-import {
-  Query,
-  Facet,
-  ReferencedComponent,
-  ReferencedUser,
-  ReferencedLanguage,
-  ReferencedRule
-} from '../utils';
+import { Query, Facet, ReferencedComponent, ReferencedLanguage, ReferencedRule } from '../utils';
 
 export interface Props {
   component: T.Component | undefined;
@@ -57,7 +50,7 @@ export interface Props {
   referencedComponentsByKey: T.Dict<ReferencedComponent>;
   referencedLanguages: T.Dict<ReferencedLanguage>;
   referencedRules: T.Dict<ReferencedRule>;
-  referencedUsers: T.Dict<ReferencedUser>;
+  referencedUsers: T.Dict<T.UserBase>;
 }
 
 export default class Sidebar extends React.PureComponent<Props> {
index 6bab64ec2a1fb40068d5b5dbc29b842d86254135..4ce377cd1be6e84d54b591ab4c97652024293241 100644 (file)
  */
 import * as React from 'react';
 import { shallow } from 'enzyme';
-import AssigneeFacet, { Props } from '../AssigneeFacet';
+import AssigneeFacet from '../AssigneeFacet';
 import { Query } from '../../utils';
 
 jest.mock('../../../../store/rootReducer', () => ({}));
 
 it('should render', () => {
-  expect(renderAssigneeFacet({ assignees: ['foo'] })).toMatchSnapshot();
+  expect(shallowRender({ assignees: ['foo'] })).toMatchSnapshot();
 });
 
 it('should select unassigned', () => {
   expect(
-    renderAssigneeFacet({ assigned: false })
+    shallowRender({ assigned: false })
       .find('ListStyleFacet')
       .prop('values')
   ).toEqual(['']);
@@ -38,7 +38,7 @@ it('should select unassigned', () => {
 
 it('should call onChange', () => {
   const onChange = jest.fn();
-  const wrapper = renderAssigneeFacet({ assignees: ['foo'], onChange });
+  const wrapper = shallowRender({ assignees: ['foo'], onChange });
   const itemOnClick = wrapper.find('ListStyleFacet').prop<Function>('onItemClick');
 
   itemOnClick('');
@@ -51,8 +51,39 @@ it('should call onChange', () => {
   expect(onChange).lastCalledWith({ assigned: true, assignees: ['baz', 'foo'] });
 });
 
-function renderAssigneeFacet(props?: Partial<Props>) {
-  return shallow(
+describe('test behavior', () => {
+  const instance = shallowRender({
+    assignees: ['foo', 'baz'],
+    referencedUsers: {
+      foo: { active: false, login: 'foo' },
+      baz: { active: true, login: 'baz', name: 'Name Baz' }
+    }
+  }).instance();
+
+  it('should correctly render assignee name', () => {
+    expect(instance.getAssigneeName('')).toBe('unassigned');
+    expect(instance.getAssigneeName('bar')).toBe('bar');
+    expect(instance.getAssigneeName('baz')).toBe('Name Baz');
+    expect(instance.getAssigneeName('foo')).toBe('user.x_deleted.foo');
+  });
+
+  it('should correctly render facet item', () => {
+    expect(instance.renderFacetItem('')).toBe('unassigned');
+    expect(instance.renderFacetItem('bar')).toBe('bar');
+    expect(instance.renderFacetItem('baz')).toMatchSnapshot();
+    expect(instance.renderFacetItem('foo')).toMatchSnapshot();
+  });
+
+  it('should correctly render search result correctly', () => {
+    expect(
+      instance.renderSearchResult({ active: true, login: 'bar', name: 'Name Bar' }, 'ba')
+    ).toMatchSnapshot();
+    expect(instance.renderSearchResult({ active: false, login: 'foo' }, 'fo')).toMatchSnapshot();
+  });
+});
+
+function shallowRender(props?: Partial<AssigneeFacet['props']>) {
+  return shallow<AssigneeFacet>(
     <AssigneeFacet
       assigned={true}
       assignees={[]}
@@ -63,7 +94,7 @@ function renderAssigneeFacet(props?: Partial<Props>) {
       open={true}
       organization={undefined}
       query={{} as Query}
-      referencedUsers={{ foo: { avatar: 'avatart-foo', name: 'name-foo' } }}
+      referencedUsers={{ foo: { avatar: 'avatart-foo', login: 'name-foo', name: 'Name Foo' } }}
       stats={{ '': 5, foo: 13, bar: 7, baz: 6 }}
       {...props}
     />
index 7dea30a849c67ca10539d530b89f8d1c3be44ff4..6e5f27e4e47a21738aee5f57f7230a9476cb08d5 100644 (file)
@@ -38,3 +38,59 @@ exports[`should render 1`] = `
   }
 />
 `;
+
+exports[`test behavior should correctly render facet item 1`] = `
+<React.Fragment>
+  <Connect(Avatar)
+    className="little-spacer-right"
+    name="Name Baz"
+    size={16}
+  />
+  Name Baz
+</React.Fragment>
+`;
+
+exports[`test behavior should correctly render facet item 2`] = `
+<React.Fragment>
+  <Connect(Avatar)
+    className="little-spacer-right"
+    name="foo"
+    size={16}
+  />
+  user.x_deleted.foo
+</React.Fragment>
+`;
+
+exports[`test behavior should correctly render search result correctly 1`] = `
+<React.Fragment>
+  <Connect(Avatar)
+    className="little-spacer-right"
+    name="Name Bar"
+    size={16}
+  />
+  <React.Fragment>
+    Name 
+    <mark>
+      Ba
+    </mark>
+    r
+  </React.Fragment>
+</React.Fragment>
+`;
+
+exports[`test behavior should correctly render search result correctly 2`] = `
+<React.Fragment>
+  <Connect(Avatar)
+    className="little-spacer-right"
+    name="foo"
+    size={16}
+  />
+  <React.Fragment>
+    user.x_deleted.
+    <mark>
+      fo
+    </mark>
+    o
+  </React.Fragment>
+</React.Fragment>
+`;
index a6c644f645ef606282a3c290c838e72b1602e8b7..8e98b7919cb0e30ea0c96e28d22711a66e73add5 100644 (file)
@@ -199,11 +199,6 @@ export interface ReferencedComponent {
   uuid: string;
 }
 
-export interface ReferencedUser {
-  avatar: string;
-  name: string;
-}
-
 export interface ReferencedLanguage {
   name: string;
 }
@@ -213,17 +208,11 @@ export interface ReferencedRule {
   name: string;
 }
 
-export interface SearchedAssignee {
-  avatar?: string;
-  login: string;
-  name: string;
-}
-
 export const searchAssignees = (
   query: string,
   organization: string | undefined,
   page = 1
-): Promise<{ paging: T.Paging; results: SearchedAssignee[] }> => {
+): Promise<{ paging: T.Paging; results: T.UserBase[] }> => {
   return organization
     ? searchMembers({ organization, p: page, ps: 50, q: query }).then(({ paging, users }) => ({
         paging,
index 7132c0bd98bdbf773cfea5c9239f867da4a9d56d..02a75616768e2c87ac83709453fcd659d784ff72 100644 (file)
@@ -50,20 +50,25 @@ export function NoFavoriteProjects(props: StateProps & OwnProps) {
               {translate('provisioning.analyze_new_project')}
             </Button>
 
-            <Dropdown
-              className="display-inline-block big-spacer-left"
-              overlay={
-                <ul className="menu">
-                  {sortBy(props.organizations, org => org.name.toLowerCase()).map(organization => (
-                    <OrganizationListItem key={organization.key} organization={organization} />
-                  ))}
-                </ul>
-              }>
-              <a className="button" href="#">
-                {translate('projects.no_favorite_projects.favorite_projects_from_orgs')}
-                <DropdownIcon className="little-spacer-left" />
-              </a>
-            </Dropdown>
+            {props.organizations.length > 0 && (
+              <Dropdown
+                className="display-inline-block big-spacer-left"
+                overlay={
+                  <ul className="menu">
+                    {sortBy(props.organizations, org => org.name.toLowerCase()).map(
+                      organization => (
+                        <OrganizationListItem key={organization.key} organization={organization} />
+                      )
+                    )}
+                  </ul>
+                }>
+                <a className="button" href="#">
+                  {translate('projects.no_favorite_projects.favorite_projects_from_orgs')}
+                  <DropdownIcon className="little-spacer-left" />
+                </a>
+              </Dropdown>
+            )}
+
             <Link className="button big-spacer-left" to="/explore/projects">
               {translate('projects.no_favorite_projects.favorite_public_projects')}
             </Link>
index 8cdc9b4a4e711ed61840b99a8998d4f6adb2941e..0f8947329a584612026669b31539fbb3f7f4e8f8 100644 (file)
@@ -31,7 +31,14 @@ it('renders', () => {
   ).toMatchSnapshot();
 });
 
-it('renders for SonarCloud', () => {
+it('renders for SonarCloud without organizations', () => {
+  (isSonarCloud as jest.Mock).mockImplementation(() => true);
+  expect(
+    shallow(<NoFavoriteProjects openProjectOnboarding={jest.fn()} organizations={[]} />)
+  ).toMatchSnapshot();
+});
+
+it('renders for SonarCloud with organizations', () => {
   (isSonarCloud as jest.Mock).mockImplementation(() => true);
   const organizations: T.Organization[] = [
     { actions: { admin: true }, key: 'org1', name: 'org1', projectVisibility: 'public' },
index d1b0a82b9c60e990e1f0f8b9470cd6d3425ee02d..0aa5d44907e1bc7b03edbc59f35f7e9b23b66403 100644 (file)
@@ -29,7 +29,7 @@ exports[`renders 1`] = `
 </div>
 `;
 
-exports[`renders for SonarCloud 1`] = `
+exports[`renders for SonarCloud with organizations 1`] = `
 <div
   className="projects-empty-list"
 >
@@ -105,3 +105,37 @@ exports[`renders for SonarCloud 1`] = `
   </div>
 </div>
 `;
+
+exports[`renders for SonarCloud without organizations 1`] = `
+<div
+  className="projects-empty-list"
+>
+  <h3>
+    projects.no_favorite_projects
+  </h3>
+  <div
+    className="spacer-top"
+  >
+    <p>
+      projects.no_favorite_projects.how_to_add_projects
+    </p>
+    <div
+      className="huge-spacer-top"
+    >
+      <Button
+        onClick={[MockFunction]}
+      >
+        provisioning.analyze_new_project
+      </Button>
+      <Link
+        className="button big-spacer-left"
+        onlyActiveOnIndex={false}
+        style={Object {}}
+        to="/explore/projects"
+      >
+        projects.no_favorite_projects.favorite_public_projects
+      </Link>
+    </div>
+  </div>
+</div>
+`;
index 29b60956fca0f68ae1f14bb3044887d8c4197a7f..7de9677ea73cd72385338dc282b79e202035e561 100644 (file)
@@ -31,7 +31,7 @@ import Suggestions from '../../app/components/embed-docs-modal/Suggestions';
 import { getComponents, Project } from '../../api/components';
 
 export interface Props {
-  currentUser: { login: string };
+  currentUser: Pick<T.LoggedInUser, 'login'>;
   hasProvisionPermission?: boolean;
   onOrganizationUpgrade: () => void;
   onVisibilityChange: (visibility: T.Visibility) => void;
index 1273e30ffd377dbd167abfee05fd8d58045ae8ac..4e99b7f715b1e43a40b3df6d2abdeb0084f21ca1 100644 (file)
@@ -27,7 +27,7 @@ import DateTooltipFormatter from '../../components/intl/DateTooltipFormatter';
 import { Project } from '../../api/components';
 
 interface Props {
-  currentUser: { login: string };
+  currentUser: Pick<T.LoggedInUser, 'login'>;
   onProjectCheck: (project: Project, checked: boolean) => void;
   organization: string | undefined;
   project: Project;
index abff099d1eea21d4180e31cbef618b934156e1cd..a2578797b24bc96e643ed79f7955577ed54a3363 100644 (file)
@@ -29,7 +29,7 @@ import { getComponentNavigation } from '../../api/nav';
 import { getComponentPermissionsUrl } from '../../helpers/urls';
 
 export interface Props {
-  currentUser: { login: string };
+  currentUser: Pick<T.LoggedInUser, 'login'>;
   organization: string | undefined;
   project: Project;
 }
index d644c5410d2c169b04aa9ca61e402e5f77f3c7eb..5c89aaac9b1fd06d77861fd510c6db9f4a37c1e2 100644 (file)
@@ -24,7 +24,7 @@ import ProjectRow from './ProjectRow';
 import { Project } from '../../api/components';
 
 interface Props {
-  currentUser: { login: string };
+  currentUser: Pick<T.LoggedInUser, 'login'>;
   onProjectDeselected: (project: string) => void;
   onProjectSelected: (project: string) => void;
   organization: T.Organization;
index 89fc396db7d7eea453cdab4633bd8e7ca2c8a01f..ad11a0f3ded0c3e8e519e44c8adae1c34f856636 100644 (file)
@@ -26,7 +26,7 @@ import { grantPermissionToUser } from '../../api/permissions';
 import { Project } from '../../api/components';
 
 interface Props {
-  currentUser: { login: string };
+  currentUser: Pick<T.LoggedInUser, 'login'>;
   onClose: () => void;
   onRestoreAccess: () => void;
   project: Project;
index a9816e9d4bea869f072c58f5719ca6378ce55d8c..dc919c071df55c13a7ac1dbadd330729cf4b761f 100644 (file)
@@ -31,12 +31,6 @@ import {
 } from '../../../api/quality-profiles';
 import { Profile } from '../types';
 
-export interface User {
-  avatar?: string;
-  login: string;
-  name: string;
-}
-
 export interface Group {
   name: string;
 }
@@ -50,7 +44,7 @@ interface State {
   addUserForm: boolean;
   groups?: Group[];
   loading: boolean;
-  users?: User[];
+  users?: T.UserSelected[];
 }
 
 export default class ProfilePermissions extends React.PureComponent<Props, State> {
@@ -112,7 +106,7 @@ export default class ProfilePermissions extends React.PureComponent<Props, State
     }
   };
 
-  handleUserAdd = (addedUser: User) => {
+  handleUserAdd = (addedUser: T.UserSelected) => {
     if (this.mounted) {
       this.setState((state: State) => ({
         addUserForm: false,
@@ -121,7 +115,7 @@ export default class ProfilePermissions extends React.PureComponent<Props, State
     }
   };
 
-  handleUserDelete = (removedUser: User) => {
+  handleUserDelete = (removedUser: T.UserSelected) => {
     if (this.mounted) {
       this.setState((state: State) => ({
         users: state.users && state.users.filter(user => user !== removedUser)
index 574063aaa4a12af0f08611898b869051cbbcf35a..4e93e4f12d4711ae7a62194910c29ee7415fcfaa 100644 (file)
@@ -21,8 +21,8 @@ import * as React from 'react';
 import { translate } from 'sonar-ui-common/helpers/l10n';
 import { SubmitButton, ResetButtonLink } from 'sonar-ui-common/components/controls/buttons';
 import Modal from 'sonar-ui-common/components/controls/Modal';
-import { User, Group } from './ProfilePermissions';
 import ProfilePermissionsFormSelect from './ProfilePermissionsFormSelect';
+import { Group } from './ProfilePermissions';
 import {
   searchUsers,
   searchGroups,
@@ -34,13 +34,13 @@ import {
 interface Props {
   onClose: () => void;
   onGroupAdd: (group: Group) => void;
-  onUserAdd: (user: User) => void;
+  onUserAdd: (user: T.UserSelected) => void;
   organization?: string;
   profile: { language: string; name: string };
 }
 
 interface State {
-  selected?: User | Group;
+  selected?: T.UserSelected | Group;
   submitting: boolean;
 }
 
@@ -62,7 +62,7 @@ export default class ProfilePermissionsForm extends React.PureComponent<Props, S
     }
   };
 
-  handleUserAdd = (user: User) =>
+  handleUserAdd = (user: T.UserSelected) =>
     addUser({
       language: this.props.profile.language,
       login: user.login,
@@ -83,8 +83,8 @@ export default class ProfilePermissionsForm extends React.PureComponent<Props, S
     const { selected } = this.state;
     if (selected) {
       this.setState({ submitting: true });
-      if ((selected as User).login !== undefined) {
-        this.handleUserAdd(selected as User);
+      if ((selected as T.UserSelected).login !== undefined) {
+        this.handleUserAdd(selected as T.UserSelected);
       } else {
         this.handleGroupAdd(selected as Group);
       }
@@ -105,7 +105,7 @@ export default class ProfilePermissionsForm extends React.PureComponent<Props, S
     );
   };
 
-  handleValueChange = (selected: User | Group) => {
+  handleValueChange = (selected: T.UserSelected | Group) => {
     this.setState({ selected });
   };
 
index 3599277ea521314bb26a23eb2ba53b525e1f66e1..7d3ef0b6a1d69d87906be02257e2e17e86497192 100644 (file)
@@ -21,11 +21,11 @@ import * as React from 'react';
 import GroupIcon from 'sonar-ui-common/components/icons/GroupIcon';
 import { debounce, identity } from 'lodash';
 import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
-import { User, Group } from './ProfilePermissions';
+import { Group } from './ProfilePermissions';
 import Select from '../../../components/controls/Select';
 import Avatar from '../../../components/ui/Avatar';
 
-type Option = User | Group;
+type Option = T.UserSelected | Group;
 type OptionWithValue = Option & { value: string };
 
 interface Props {
@@ -112,8 +112,8 @@ export default class ProfilePermissionsFormSelect extends React.PureComponent<Pr
   }
 }
 
-function isUser(option: Option): option is User {
-  return (option as User).login !== undefined;
+function isUser(option: Option): option is T.UserSelected {
+  return (option as T.UserSelected).login !== undefined;
 }
 
 function getStringValue(option: Option) {
index 196ba45d7e98fb0308fc4c920029545136349957..5498efdc9b244f0d42b3b662376b827d5681e914 100644 (file)
@@ -26,15 +26,14 @@ import {
   ResetButtonLink
 } from 'sonar-ui-common/components/controls/buttons';
 import SimpleModal, { ChildrenProps } from 'sonar-ui-common/components/controls/SimpleModal';
-import { User } from './ProfilePermissions';
 import { removeUser } from '../../../api/quality-profiles';
 import Avatar from '../../../components/ui/Avatar';
 
 interface Props {
-  onDelete: (user: User) => void;
+  onDelete: (user: T.UserSelected) => void;
   organization?: string;
   profile: { language: string; name: string };
-  user: User;
+  user: T.UserSelected;
 }
 
 interface State {
index 027390687f172e9f1389d59938c7525a85b8f14a..e70e777f7d60883130cfa4f25bbb5adbfbe24e6e 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-/* eslint-disable import/first, import/order */
-jest.mock('../../../../api/quality-profiles', () => ({
-  removeUser: jest.fn(() => Promise.resolve())
-}));
-
 import * as React from 'react';
 import { shallow } from 'enzyme';
-import ProfilePermissionsUser from '../ProfilePermissionsUser';
 import { click } from 'sonar-ui-common/helpers/testUtils';
+import ProfilePermissionsUser from '../ProfilePermissionsUser';
+import { removeUser } from '../../../../api/quality-profiles';
 
-const removeUser = require('../../../../api/quality-profiles').removeUser as jest.Mock<any>;
+jest.mock('../../../../api/quality-profiles', () => ({
+  removeUser: jest.fn(() => Promise.resolve())
+}));
 
 const profile = { language: 'js', name: 'Sonar way' };
-const user = { login: 'luke', name: 'Luke Skywalker' };
+const user: T.UserSelected = { login: 'luke', name: 'Luke Skywalker', selected: true };
 
 beforeEach(() => {
-  removeUser.mockClear();
+  jest.clearAllMocks();
 });
 
 it('renders', () => {
index 0ebfab0259964834c2fdace70d3298ac528bd6d4..0bba08201fe4bc207809d92ea39dd83cba1ed02a 100644 (file)
@@ -29,12 +29,12 @@ interface Props {
   doLogout: () => Promise<void>;
 }
 
-class Logout extends React.PureComponent<Props> {
+export class Logout extends React.PureComponent<Props> {
   componentDidMount() {
     this.props.doLogout().then(
       () => {
         RecentHistory.clear();
-        window.location.href = getBaseUrl() + '/';
+        window.location.replace(getBaseUrl() + '/');
       },
       () => {}
     );
diff --git a/server/sonar-web/src/main/js/apps/sessions/components/__tests__/Logout-test.tsx b/server/sonar-web/src/main/js/apps/sessions/components/__tests__/Logout-test.tsx
new file mode 100644 (file)
index 0000000..dcd7df7
--- /dev/null
@@ -0,0 +1,50 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
+import { Logout } from '../Logout';
+
+it('should logout correctly', async () => {
+  const doLogout = jest.fn().mockResolvedValue(true);
+  window.location.replace = jest.fn();
+
+  const wrapper = shallowRender({ doLogout });
+  await waitAndUpdate(wrapper);
+
+  expect(doLogout).toHaveBeenCalled();
+  expect(window.location.replace).toHaveBeenCalledWith('/');
+});
+
+it('should not redirect if logout fails', async () => {
+  const doLogout = jest.fn().mockRejectedValue(false);
+  window.location.replace = jest.fn();
+
+  const wrapper = shallowRender({ doLogout });
+  await waitAndUpdate(wrapper);
+
+  expect(doLogout).toHaveBeenCalled();
+  expect(window.location.replace).not.toHaveBeenCalled();
+  expect(wrapper).toMatchSnapshot();
+});
+
+function shallowRender(props: Partial<Logout['props']> = {}) {
+  return shallow(<Logout doLogout={jest.fn()} {...props} />);
+}
diff --git a/server/sonar-web/src/main/js/apps/sessions/components/__tests__/__snapshots__/Logout-test.tsx.snap b/server/sonar-web/src/main/js/apps/sessions/components/__tests__/__snapshots__/Logout-test.tsx.snap
new file mode 100644 (file)
index 0000000..f510e29
--- /dev/null
@@ -0,0 +1,14 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should not redirect if logout fails 1`] = `
+<div
+  className="page page-limited"
+>
+  <Connect(GlobalMessages) />
+  <div
+    className="text-center"
+  >
+    logging_out
+  </div>
+</div>
+`;
index bf7ea8f38d1802ee3c25c3966d37ac6fd37dc6a4..a193e71bcceda20d794035291c35c6b6d2de27e2 100644 (file)
@@ -26,7 +26,7 @@ import { deactivateUser } from '../../../api/users';
 export interface Props {
   onClose: () => void;
   onUpdateUsers: () => void;
-  user: T.User;
+  user: T.UserActive;
 }
 
 interface State {
index 43b4273c75c736c8a0fcc11274024227bfd8e2ff..d42480142146bbde46ce53cdb872bf66cc555ac9 100644 (file)
@@ -26,6 +26,7 @@ import ActionsDropdown, {
 import DeactivateForm from './DeactivateForm';
 import PasswordForm from './PasswordForm';
 import UserForm from './UserForm';
+import { isUserActive } from '../../../helpers/users';
 
 interface Props {
   isCurrentUser: boolean;
@@ -40,10 +41,21 @@ interface State {
 export default class UserActions extends React.PureComponent<Props, State> {
   state: State = {};
 
-  handleOpenDeactivateForm = () => this.setState({ openForm: 'deactivate' });
-  handleOpenPasswordForm = () => this.setState({ openForm: 'password' });
-  handleOpenUpdateForm = () => this.setState({ openForm: 'update' });
-  handleCloseForm = () => this.setState({ openForm: undefined });
+  handleOpenDeactivateForm = () => {
+    this.setState({ openForm: 'deactivate' });
+  };
+
+  handleOpenPasswordForm = () => {
+    this.setState({ openForm: 'password' });
+  };
+
+  handleOpenUpdateForm = () => {
+    this.setState({ openForm: 'update' });
+  };
+
+  handleCloseForm = () => {
+    this.setState({ openForm: undefined });
+  };
 
   renderActions = () => {
     const { user } = this.props;
@@ -60,12 +72,14 @@ export default class UserActions extends React.PureComponent<Props, State> {
           </ActionsDropdownItem>
         )}
         <ActionsDropdownDivider />
-        <ActionsDropdownItem
-          className="js-user-deactivate"
-          destructive={true}
-          onClick={this.handleOpenDeactivateForm}>
-          {translate('users.deactivate')}
-        </ActionsDropdownItem>
+        {isUserActive(user) && (
+          <ActionsDropdownItem
+            className="js-user-deactivate"
+            destructive={true}
+            onClick={this.handleOpenDeactivateForm}>
+            {translate('users.deactivate')}
+          </ActionsDropdownItem>
+        )}
       </ActionsDropdown>
     );
   };
@@ -77,7 +91,7 @@ export default class UserActions extends React.PureComponent<Props, State> {
     return (
       <>
         {this.renderActions()}
-        {openForm === 'deactivate' && (
+        {openForm === 'deactivate' && isUserActive(user) && (
           <DeactivateForm
             onClose={this.handleCloseForm}
             onUpdateUsers={onUpdateUsers}
index 06e487685e66ff91974027e7ef7b4ac4869d28c7..584095c3779e855f650ee9773fa0fa868fc61dba 100644 (file)
@@ -22,11 +22,11 @@ import { uniq } from 'lodash';
 import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
 import { parseError } from 'sonar-ui-common/helpers/request';
 import { Button, ResetButtonLink, SubmitButton } from 'sonar-ui-common/components/controls/buttons';
-import SimpleModal from 'sonar-ui-common/components/controls/SimpleModal';
 import { Alert } from 'sonar-ui-common/components/ui/Alert';
+import SimpleModal from 'sonar-ui-common/components/controls/SimpleModal';
 import UserScmAccountInput from './UserScmAccountInput';
-import { createUser, updateUser } from '../../../api/users';
 import throwGlobalError from '../../../app/utils/throwGlobalError';
+import { createUser, updateUser } from '../../../api/users';
 
 export interface Props {
   onClose: () => void;
@@ -41,7 +41,6 @@ interface State {
   name: string;
   password: string;
   scmAccounts: string[];
-  submitting: boolean;
 }
 
 export default class UserForm extends React.PureComponent<Props, State> {
@@ -54,10 +53,9 @@ export default class UserForm extends React.PureComponent<Props, State> {
       this.state = {
         email: user.email || '',
         login: user.login,
-        name: user.name,
+        name: user.name || '',
         password: '',
-        scmAccounts: user.scmAccounts || [],
-        submitting: false
+        scmAccounts: user.scmAccounts || []
       };
     } else {
       this.state = {
@@ -65,8 +63,7 @@ export default class UserForm extends React.PureComponent<Props, State> {
         login: '',
         name: '',
         password: '',
-        scmAccounts: [],
-        submitting: false
+        scmAccounts: []
       };
     }
   }
@@ -84,7 +81,7 @@ export default class UserForm extends React.PureComponent<Props, State> {
       return throwGlobalError(error);
     } else {
       return parseError(error).then(
-        errorMsg => this.setState({ error: errorMsg, submitting: false }),
+        errorMsg => this.setState({ error: errorMsg }),
         throwGlobalError
       );
     }
@@ -103,8 +100,7 @@ export default class UserForm extends React.PureComponent<Props, State> {
     this.setState({ password: event.currentTarget.value });
 
   handleCreateUser = () => {
-    this.setState({ submitting: true });
-    createUser({
+    return createUser({
       email: this.state.email || undefined,
       login: this.state.login,
       name: this.state.name,
@@ -118,9 +114,7 @@ export default class UserForm extends React.PureComponent<Props, State> {
 
   handleUpdateUser = () => {
     const { user } = this.props;
-
-    this.setState({ submitting: true });
-    updateUser({
+    return updateUser({
       email: user!.local ? this.state.email : undefined,
       login: this.state.login,
       name: user!.local ? this.state.name : undefined,
index 6002475bbbe723c7a52065f7a4d1617e2ff85db9..58d7916807539faec3df09d6638beda6d8b2109b 100644 (file)
@@ -34,23 +34,37 @@ it('should render correctly', () => {
   expect(getWrapper()).toMatchSnapshot();
 });
 
-it('should display change password action', () => {
+it('should open the update form', () => {
+  const wrapper = getWrapper();
+  click(wrapper.find('.js-user-update'));
   expect(
-    getWrapper({ user: { ...user, local: true } })
-      .find('.js-user-change-password')
+    wrapper
+      .first()
+      .find('UserForm')
       .exists()
-  ).toBeTruthy();
+  ).toBe(true);
 });
 
-it('should open the update form', () => {
+it('should open the password form', () => {
+  const wrapper = getWrapper({ user: { ...user, local: true } });
+  click(wrapper.find('.js-user-change-password'));
+  expect(
+    wrapper
+      .first()
+      .find('PasswordForm')
+      .exists()
+  ).toBe(true);
+});
+
+it('should open the deactivate form', () => {
   const wrapper = getWrapper();
-  click(wrapper.find('.js-user-update'));
+  click(wrapper.find('.js-user-deactivate'));
   expect(
     wrapper
       .first()
-      .find('UserForm')
+      .find('DeactivateForm')
       .exists()
-  ).toBeTruthy();
+  ).toBe(true);
 });
 
 function getWrapper(props = {}) {
index 110bbc842373fc176a323e378c7e053337a71bc5..841b3592be103d2ccb8fffaaf099a9507d213a99 100644 (file)
@@ -29,6 +29,7 @@ interface Props<V> extends ModalProps {
   confirmButtonText: string;
   header: string;
   initialValues: V;
+  isDestructive?: boolean;
   isInitialValid?: boolean;
   onClose: () => void;
   onSubmit: (data: V) => Promise<void>;
@@ -64,7 +65,9 @@ export default class ValidationModal<V> extends React.PureComponent<Props<V>> {
 
               <footer className="modal-foot">
                 <DeferredSpinner className="spacer-right" loading={props.isSubmitting} />
-                <SubmitButton disabled={props.isSubmitting || !props.isValid || !props.dirty}>
+                <SubmitButton
+                  className={this.props.isDestructive ? 'button-red' : undefined}
+                  disabled={props.isSubmitting || !props.isValid || !props.dirty}>
                   {this.props.confirmButtonText}
                 </SubmitButton>
                 <ResetButtonLink disabled={props.isSubmitting} onClick={this.props.onClose}>
index c09219ef13b2700ed40c9e197b2e8c222c277b18..f4af7f14294df7f60e27de4a25e1ed1841de7379 100644 (file)
@@ -27,6 +27,7 @@ it('should render correctly', () => {
       confirmButtonText="confirm"
       header="title"
       initialValues={{ field: 'foo' }}
+      isDestructive={true}
       isInitialValid={true}
       onClose={jest.fn()}
       onSubmit={jest.fn()}
index c13bf63ea1b5ad1770de6c449998e4c350dcce5a..544456a265c4836f6bfe5bc25c10735c6a4e03d9 100644 (file)
@@ -19,7 +19,7 @@
  */
 import * as React from 'react';
 import DropdownIcon from 'sonar-ui-common/components/icons/DropdownIcon';
-import { translate } from 'sonar-ui-common/helpers/l10n';
+import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
 import { ButtonLink } from 'sonar-ui-common/components/controls/buttons';
 import Toggler from 'sonar-ui-common/components/controls/Toggler';
 import Avatar from '../../ui/Avatar';
@@ -27,7 +27,10 @@ import SetAssigneePopup from '../popups/SetAssigneePopup';
 
 interface Props {
   isOpen: boolean;
-  issue: Pick<T.Issue, 'assignee' | 'assigneeAvatar' | 'assigneeName' | 'projectOrganization'>;
+  issue: Pick<
+    T.Issue,
+    'assignee' | 'assigneeActive' | 'assigneeAvatar' | 'assigneeName' | 'projectOrganization'
+  >;
   canAssign: boolean;
   onAssign: (login: string) => void;
   togglePopup: (popup: string, show?: boolean) => void;
@@ -44,9 +47,12 @@ export default class IssueAssign extends React.PureComponent<Props> {
 
   renderAssignee() {
     const { issue } = this.props;
-    return (
-      <span>
-        {issue.assignee && (
+    const assignee =
+      issue.assigneeActive !== false ? issue.assigneeName || issue.assignee : issue.assignee;
+
+    if (assignee) {
+      return (
+        <>
           <span className="text-top">
             <Avatar
               className="little-spacer-right"
@@ -55,12 +61,16 @@ export default class IssueAssign extends React.PureComponent<Props> {
               size={16}
             />
           </span>
-        )}
-        <span className="issue-meta-label">
-          {issue.assignee ? issue.assigneeName || issue.assignee : translate('unassigned')}
-        </span>
-      </span>
-    );
+          <span className="issue-meta-label">
+            {issue.assigneeActive === false
+              ? translateWithParameters('user.x_deleted', assignee)
+              : assignee}
+          </span>
+        </>
+      );
+    }
+
+    return <span className="issue-meta-label">{translate('unassigned')}</span>;
   }
 
   render() {
index a4e84cbf7a930bfbee384c2cb2101807fc45db93..1f1cccfd6b855cf3eeb72fb06d599ebfb0aaf70f 100644 (file)
@@ -21,14 +21,8 @@ import * as React from 'react';
 import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
 import { formatMeasure } from 'sonar-ui-common/helpers/measures';
 
-export interface ChangelogDiff {
-  key: string;
-  newValue?: string;
-  oldValue?: string;
-}
-
 interface Props {
-  diff: ChangelogDiff;
+  diff: T.IssueChangelogDiff;
 }
 
 export default function IssueChangelogDiff({ diff }: Props) {
index 117697c19a700c2b94db5df94844d52c0cf96b3a..5d351a94347a1f248f9b6649ba4dbf67e529aa25 100644 (file)
@@ -20,8 +20,9 @@
 import * as React from 'react';
 import { sanitize } from 'dompurify';
 import { EditButton, DeleteButton } from 'sonar-ui-common/components/controls/buttons';
-import Toggler from 'sonar-ui-common/components/controls/Toggler';
 import { PopupPlacement } from 'sonar-ui-common/components/ui/popups';
+import { translateWithParameters } from 'sonar-ui-common/helpers/l10n';
+import Toggler from 'sonar-ui-common/components/controls/Toggler';
 import Avatar from '../../ui/Avatar';
 import CommentDeletePopup from '../popups/CommentDeletePopup';
 import CommentPopup from '../popups/CommentPopup';
@@ -77,16 +78,21 @@ export default class IssueCommentLine extends React.PureComponent<Props, State>
 
   render() {
     const { comment } = this.props;
+    const author = comment.authorName || comment.author;
+    const displayName =
+      comment.authorActive === false && author
+        ? translateWithParameters('user.x_deleted', author)
+        : author;
     return (
       <div className="issue-comment">
-        <div className="issue-comment-author" title={comment.authorName}>
+        <div className="issue-comment-author" title={displayName}>
           <Avatar
             className="little-spacer-right"
             hash={comment.authorAvatar}
-            name={comment.authorName || comment.author}
+            name={author}
             size={16}
           />
-          {comment.authorName || comment.author}
+          {displayName}
         </div>
         <div
           className="issue-comment-text markdown"
index 14f487b2950099738f16514db9831460c790dd81..b2c3ac6eddb6acb97ef8fcc41338049522e43b17 100644 (file)
@@ -23,6 +23,8 @@ import { click } from 'sonar-ui-common/helpers/testUtils';
 import IssueCommentLine from '../IssueCommentLine';
 
 const comment: T.IssueComment = {
+  author: 'john.doe',
+  authorActive: true,
   authorAvatar: 'gravatarhash',
   authorName: 'John Doe',
   createdAt: '2017-03-01T09:36:01+0100',
@@ -32,32 +34,34 @@ const comment: T.IssueComment = {
   updatable: true
 };
 
-it('should render correctly a comment that is not updatable', () => {
-  const element = shallow(
-    <IssueCommentLine
-      comment={{ ...comment, updatable: false }}
-      onDelete={jest.fn()}
-      onEdit={jest.fn()}
-    />
-  );
-  expect(element).toMatchSnapshot();
+it('should render correctly a comment that is updatable', () => {
+  expect(shallowRender()).toMatchSnapshot();
 });
 
-it('should render correctly a comment that is updatable', () => {
-  const element = shallow(
-    <IssueCommentLine comment={comment} onDelete={jest.fn()} onEdit={jest.fn()} />
-  );
-  expect(element).toMatchSnapshot();
+it('should render correctly a comment that is not updatable', () => {
+  expect(shallowRender({ comment: { ...comment, updatable: false } })).toMatchSnapshot();
 });
 
 it('should open the right popups when the buttons are clicked', () => {
-  const element = shallow(
-    <IssueCommentLine comment={comment} onDelete={jest.fn()} onEdit={jest.fn()} />
-  );
-  click(element.find('.js-issue-comment-edit'));
-  expect(element.state()).toMatchSnapshot();
-  click(element.find('.js-issue-comment-delete'));
-  expect(element.state()).toMatchSnapshot();
-  element.update();
-  expect(element).toMatchSnapshot();
+  const wrapper = shallowRender();
+  click(wrapper.find('.js-issue-comment-edit'));
+  expect(wrapper.state()).toMatchSnapshot();
+  click(wrapper.find('.js-issue-comment-delete'));
+  expect(wrapper.state()).toMatchSnapshot();
+  wrapper.update();
+  expect(wrapper).toMatchSnapshot();
+});
+
+it('should render correctly a comment with a deleted author', () => {
+  expect(
+    shallowRender({
+      comment: { ...comment, authorActive: false, authorName: undefined }
+    }).find('.issue-comment-author')
+  ).toMatchSnapshot();
 });
+
+function shallowRender(props: Partial<IssueCommentLine['props']> = {}) {
+  return shallow(
+    <IssueCommentLine comment={comment} onDelete={jest.fn()} onEdit={jest.fn()} {...props} />
+  );
+}
index 0b8921f3cd4eb11f5b0f61a361f7d48c01f66471..2e2ccd87499da594daec37ad0edeeca7ca506e70 100644 (file)
@@ -18,7 +18,7 @@ exports[`should open the popup when the button is clicked 2`] = `
     onRequestClose={[Function]}
     open={true}
     overlay={
-      <Connect(SetAssigneePopup)
+      <Connect(withCurrentUser(SetAssigneePopup))
         issue={
           Object {
             "assignee": "john",
@@ -35,22 +35,20 @@ exports[`should open the popup when the button is clicked 2`] = `
       className="issue-action issue-action-with-options js-issue-assign"
       onClick={[Function]}
     >
-      <span>
-        <span
-          className="text-top"
-        >
-          <Connect(Avatar)
-            className="little-spacer-right"
-            hash="gravatarhash"
-            name="John Doe"
-            size={16}
-          />
-        </span>
-        <span
-          className="issue-meta-label"
-        >
-          John Doe
-        </span>
+      <span
+        className="text-top"
+      >
+        <Connect(Avatar)
+          className="little-spacer-right"
+          hash="gravatarhash"
+          name="John Doe"
+          size={16}
+        />
+      </span>
+      <span
+        className="issue-meta-label"
+      >
+        John Doe
       </span>
       <DropdownIcon
         className="little-spacer-left"
@@ -69,7 +67,7 @@ exports[`should render with the action 1`] = `
     onRequestClose={[Function]}
     open={false}
     overlay={
-      <Connect(SetAssigneePopup)
+      <Connect(withCurrentUser(SetAssigneePopup))
         issue={
           Object {
             "assignee": "john",
@@ -86,22 +84,20 @@ exports[`should render with the action 1`] = `
       className="issue-action issue-action-with-options js-issue-assign"
       onClick={[Function]}
     >
-      <span>
-        <span
-          className="text-top"
-        >
-          <Connect(Avatar)
-            className="little-spacer-right"
-            hash="gravatarhash"
-            name="John Doe"
-            size={16}
-          />
-        </span>
-        <span
-          className="issue-meta-label"
-        >
-          John Doe
-        </span>
+      <span
+        className="text-top"
+      >
+        <Connect(Avatar)
+          className="little-spacer-right"
+          hash="gravatarhash"
+          name="John Doe"
+          size={16}
+        />
+      </span>
+      <span
+        className="issue-meta-label"
+      >
+        John Doe
       </span>
       <DropdownIcon
         className="little-spacer-left"
@@ -112,7 +108,7 @@ exports[`should render with the action 1`] = `
 `;
 
 exports[`should render without the action when the correct rights are missing 1`] = `
-<span>
+<Fragment>
   <span
     className="text-top"
   >
@@ -128,5 +124,5 @@ exports[`should render without the action when the correct rights are missing 1`
   >
     John Doe
   </span>
-</span>
+</Fragment>
 `;
index 2b347f6d7429a89c51db9c8a116f686188be32c3..1f5b5388ac9fa3ccb22f7c7fbda85ab32b4815d3 100644 (file)
@@ -57,6 +57,8 @@ exports[`should open the right popups when the buttons are clicked 3`] = `
           <CommentPopup
             comment={
               Object {
+                "author": "john.doe",
+                "authorActive": true,
                 "authorAvatar": "gravatarhash",
                 "authorName": "John Doe",
                 "createdAt": "2017-03-01T09:36:01+0100",
@@ -183,6 +185,8 @@ exports[`should render correctly a comment that is updatable 1`] = `
           <CommentPopup
             comment={
               Object {
+                "author": "john.doe",
+                "authorActive": true,
                 "authorAvatar": "gravatarhash",
                 "authorName": "John Doe",
                 "createdAt": "2017-03-01T09:36:01+0100",
@@ -226,3 +230,18 @@ exports[`should render correctly a comment that is updatable 1`] = `
   </div>
 </div>
 `;
+
+exports[`should render correctly a comment with a deleted author 1`] = `
+<div
+  className="issue-comment-author"
+  title="user.x_deleted.john.doe"
+>
+  <Connect(Avatar)
+    className="little-spacer-right"
+    hash="gravatarhash"
+    name="john.doe"
+    size={16}
+  />
+  user.x_deleted.john.doe
+</div>
+`;
index 2a023866e2aa5302bcbc8b78e1cea7e1d13770fb..2393284a645f2ea111d0e444f5162f520e8600b9 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import { translate } from 'sonar-ui-common/helpers/l10n';
+import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
 import { DropdownOverlay } from 'sonar-ui-common/components/controls/Dropdown';
 import { PopupPlacement } from 'sonar-ui-common/components/ui/popups';
 import { getIssueChangelog } from '../../../api/issues';
 import Avatar from '../../ui/Avatar';
 import DateTimeFormatter from '../../intl/DateTimeFormatter';
-import IssueChangelogDiff, { ChangelogDiff } from '../components/IssueChangelogDiff';
-
-interface Changelog {
-  avatar?: string;
-  creationDate: string;
-  diffs: ChangelogDiff[];
-  user: string;
-  userName: string;
-}
+import IssueChangelogDiff from '../components/IssueChangelogDiff';
 
 interface Props {
   issue: Pick<T.Issue, 'author' | 'creationDate' | 'key'>;
 }
 
 interface State {
-  changelogs: Changelog[];
+  changelog: T.IssueChangelog[];
 }
 
 export default class ChangelogPopup extends React.PureComponent<Props, State> {
   mounted = false;
-  state: State = {
-    changelogs: []
-  };
+  state: State = { changelog: [] };
 
   componentDidMount() {
     this.mounted = true;
@@ -59,9 +49,9 @@ export default class ChangelogPopup extends React.PureComponent<Props, State> {
 
   loadChangelog() {
     getIssueChangelog(this.props.issue.key).then(
-      changelogs => {
+      ({ changelog }) => {
         if (this.mounted) {
-          this.setState({ changelogs });
+          this.setState({ changelog });
         }
       },
       () => {}
@@ -85,23 +75,23 @@ export default class ChangelogPopup extends React.PureComponent<Props, State> {
                 </td>
               </tr>
 
-              {this.state.changelogs.map((item, idx) => (
+              {this.state.changelog.map((item, idx) => (
                 <tr key={idx}>
                   <td className="thin text-left text-top nowrap">
                     <DateTimeFormatter date={item.creationDate} />
                   </td>
                   <td className="text-left text-top">
-                    {item.userName && (
-                      <p>
-                        <Avatar
-                          className="little-spacer-right"
-                          hash={item.avatar}
-                          name={item.userName}
-                          size={16}
-                        />
-                        {item.userName}
-                      </p>
-                    )}
+                    <p>
+                      <Avatar
+                        className="little-spacer-right"
+                        hash={item.avatar}
+                        name={(item.isUserActive && item.userName) || item.user}
+                        size={16}
+                      />
+                      {item.isUserActive
+                        ? item.userName || item.user
+                        : translateWithParameters('user.x_deleted', item.user)}
+                    </p>
                     {item.diffs.map(diff => (
                       <IssueChangelogDiff diff={diff} key={diff.key} />
                     ))}
index 52ed2d9d674cef6c595c51dce9887454f3f2c4aa..f019d02f673d1a54a3dcf1cde99e9b22d58a1d83 100644 (file)
  */
 import * as React from 'react';
 import { map } from 'lodash';
-import { connect } from 'react-redux';
 import { translate } from 'sonar-ui-common/helpers/l10n';
 import SearchBox from 'sonar-ui-common/components/controls/SearchBox';
 import { DropdownOverlay } from 'sonar-ui-common/components/controls/Dropdown';
 import Avatar from '../../ui/Avatar';
 import SelectList from '../../common/SelectList';
 import SelectListItem from '../../common/SelectListItem';
+import { withCurrentUser } from '../../hoc/withCurrentUser';
 import { searchMembers } from '../../../api/organizations';
 import { searchUsers } from '../../../api/users';
-import { getCurrentUser, Store } from '../../../store/rootReducer';
+import { isLoggedIn, isUserActive } from '../../../helpers/users';
 import { isSonarCloud } from '../../../helpers/system';
-import { isLoggedIn } from '../../../helpers/users';
-
-interface User {
-  avatar?: string;
-  email?: string;
-  login: string;
-  name: string;
-}
 
 interface Props {
   currentUser: T.CurrentUser;
@@ -48,13 +40,13 @@ interface Props {
 interface State {
   currentUser: string;
   query: string;
-  users: User[];
+  users: T.UserActive[];
 }
 
 const LIST_SIZE = 10;
 
-class SetAssigneePopup extends React.PureComponent<Props, State> {
-  defaultUsersArray: User[];
+export class SetAssigneePopup extends React.PureComponent<Props, State> {
+  defaultUsersArray: T.UserActive[];
 
   constructor(props: Props) {
     super(props);
@@ -83,10 +75,11 @@ class SetAssigneePopup extends React.PureComponent<Props, State> {
     searchUsers({ q: query, ps: LIST_SIZE }).then(this.handleSearchResult, () => {});
   };
 
-  handleSearchResult = (response: { users: T.OrganizationMember[] }) => {
+  handleSearchResult = ({ users }: { users: T.UserBase[] }) => {
+    const activeUsers = users.filter(isUserActive);
     this.setState({
-      users: response.users,
-      currentUser: response.users.length > 0 ? response.users[0].login : ''
+      users: activeUsers,
+      currentUser: activeUsers.length > 0 ? activeUsers[0].login : ''
     });
   };
 
@@ -130,7 +123,7 @@ class SetAssigneePopup extends React.PureComponent<Props, State> {
                 {!!user.login && (
                   <Avatar className="spacer-right" hash={user.avatar} name={user.name} size={16} />
                 )}
-                <span className="text-middle" style={{ marginLeft: !user.login ? 24 : undefined }}>
+                <span className="text-middle" style={{ marginLeft: user.login ? 24 : undefined }}>
                   {user.name}
                 </span>
               </SelectListItem>
@@ -142,8 +135,4 @@ class SetAssigneePopup extends React.PureComponent<Props, State> {
   }
 }
 
-const mapStateToProps = (state: Store) => ({
-  currentUser: getCurrentUser(state)
-});
-
-export default connect(mapStateToProps)(SetAssigneePopup);
+export default withCurrentUser(SetAssigneePopup);
index 2b4c067d78fb75a040107deacf28c020a6b56501..baa824dedcf259a726740486fad7e4c7ceaa6153 100644 (file)
@@ -22,7 +22,7 @@ import IssueTypeIcon from 'sonar-ui-common/components/icons/IssueTypeIcon';
 import QualifierIcon from 'sonar-ui-common/components/icons/QualifierIcon';
 import TagsIcon from 'sonar-ui-common/components/icons/TagsIcon';
 import { fileFromPath, limitComponentName } from 'sonar-ui-common/helpers/path';
-import { translate } from 'sonar-ui-common/helpers/l10n';
+import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
 import { DropdownOverlay } from 'sonar-ui-common/components/controls/Dropdown';
 import Avatar from '../../ui/Avatar';
 import SelectList from '../../common/SelectList';
@@ -56,6 +56,8 @@ export default class SimilarIssuesPopup extends React.PureComponent<Props> {
       'file'
     ].filter(item => item) as string[];
 
+    const assignee = issue.assigneeName || issue.assignee;
+
     return (
       <DropdownOverlay noPadding={true}>
         <header className="menu-search">
@@ -87,16 +89,18 @@ export default class SimilarIssuesPopup extends React.PureComponent<Props> {
           </SelectListItem>
 
           <SelectListItem item="assignee">
-            {issue.assignee != null ? (
+            {assignee ? (
               <span>
                 {translate('assigned_to')}
                 <Avatar
                   className="little-spacer-left little-spacer-right"
                   hash={issue.assigneeAvatar}
-                  name={issue.assigneeName || issue.assignee}
+                  name={assignee}
                   size={16}
                 />
-                {issue.assigneeName || issue.assignee}
+                {issue.assigneeActive === false
+                  ? translateWithParameters('user.x_deleted', assignee)
+                  : assignee}
               </span>
             ) : (
               translate('unassigned')
index 358b5c3d1a3b5b090872cf63b6ad72dec727604b..8045eef9d530bf47f98211449d64f575e91004f3 100644 (file)
  */
 import * as React from 'react';
 import { shallow } from 'enzyme';
+import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
 import ChangelogPopup from '../ChangelogPopup';
+import { getIssueChangelog } from '../../../../api/issues';
 
-it('should render the changelog popup correctly', () => {
-  const element = shallow(
-    <ChangelogPopup
-      issue={{
-        key: 'issuekey',
-        author: 'john.david.dalton@gmail.com',
-        creationDate: '2017-03-01T09:36:01+0100'
-      }}
-    />
-  );
-  element.setState({
-    changelogs: [
+jest.mock('../../../../api/issues', () => ({
+  getIssueChangelog: jest.fn().mockResolvedValue({
+    changelog: [
       {
         creationDate: '2017-03-01T09:36:01+0100',
-        userName: 'john.doe',
+        user: 'john.doe',
+        isUserActive: true,
+        userName: 'John Doe',
         avatar: 'gravatarhash',
         diffs: [{ key: 'severity', newValue: 'MINOR', oldValue: 'CRITICAL' }]
       }
     ]
+  })
+}));
+
+beforeEach(() => {
+  jest.clearAllMocks();
+});
+
+it('should render the changelog popup correctly', async () => {
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+  expect(getIssueChangelog).toBeCalledWith('issuekey');
+  expect(wrapper).toMatchSnapshot();
+});
+
+it('should render the changelog popup when we have a deleted user', async () => {
+  (getIssueChangelog as jest.Mock).mockResolvedValueOnce({
+    changelog: [
+      {
+        creationDate: '2017-03-01T09:36:01+0100',
+        user: 'john.doe',
+        isUserActive: false,
+        diffs: [{ key: 'severity', newValue: 'MINOR', oldValue: 'CRITICAL' }]
+      }
+    ]
   });
-  expect(element).toMatchSnapshot();
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+  expect(wrapper).toMatchSnapshot();
 });
+
+function shallowRender(props: Partial<ChangelogPopup['props']> = {}) {
+  return shallow(
+    <ChangelogPopup
+      issue={{
+        key: 'issuekey',
+        author: 'john.david.dalton@gmail.com',
+        creationDate: '2017-03-01T09:36:01+0100'
+      }}
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/components/issue/popups/__tests__/SetAssigneePopup-test.tsx b/server/sonar-web/src/main/js/components/issue/popups/__tests__/SetAssigneePopup-test.tsx
new file mode 100644 (file)
index 0000000..6eb8153
--- /dev/null
@@ -0,0 +1,81 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
+import { SetAssigneePopup } from '../SetAssigneePopup';
+import { mockLoggedInUser, mockUser } from '../../../../helpers/testMocks';
+import { searchMembers } from '../../../../api/organizations';
+import { searchUsers } from '../../../../api/users';
+import { isSonarCloud } from '../../../../helpers/system';
+
+jest.mock('../../../../helpers/system', () => ({
+  isSonarCloud: jest.fn().mockReturnValue(false)
+}));
+
+jest.mock('../../../../api/organizations', () => {
+  const { mockUser } = jest.requireActual('../../../../helpers/testMocks');
+  return {
+    searchMembers: jest.fn().mockResolvedValue({
+      users: [mockUser(), mockUser({ active: false, login: 'foo', name: undefined })]
+    })
+  };
+});
+
+jest.mock('../../../../api/users', () => {
+  const { mockUser } = jest.requireActual('../../../../helpers/testMocks');
+  return { searchUsers: jest.fn().mockResolvedValue({ users: [mockUser()] }) };
+});
+
+beforeEach(() => {
+  jest.clearAllMocks();
+});
+
+it('should render correctly', () => {
+  expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should allow to search for a user on SQ', async () => {
+  const wrapper = shallowRender();
+  wrapper.find('SearchBox').prop<Function>('onChange')('o');
+  await waitAndUpdate(wrapper);
+  expect(searchUsers).toBeCalledWith({ q: 'o', ps: 10 });
+  expect(wrapper.state('users')).toEqual([mockUser()]);
+});
+
+it('should allow to search for a user on SC', async () => {
+  (isSonarCloud as jest.Mock).mockReturnValueOnce(true);
+  const wrapper = shallowRender();
+  wrapper.find('SearchBox').prop<Function>('onChange')('o');
+  await waitAndUpdate(wrapper);
+  expect(searchMembers).toBeCalledWith({ organization: 'foo', q: 'o', ps: 10 });
+  expect(wrapper.state('users')).toEqual([mockUser()]);
+});
+
+function shallowRender(props: Partial<SetAssigneePopup['props']> = {}) {
+  return shallow(
+    <SetAssigneePopup
+      currentUser={mockLoggedInUser()}
+      issue={{ projectOrganization: 'foo' }}
+      onSelect={jest.fn()}
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/components/issue/popups/__tests__/SimilarIssuesPopup-test.tsx b/server/sonar-web/src/main/js/components/issue/popups/__tests__/SimilarIssuesPopup-test.tsx
new file mode 100644 (file)
index 0000000..962e2d0
--- /dev/null
@@ -0,0 +1,59 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 SimilarIssuesPopup from '../SimilarIssuesPopup';
+import { mockIssue } from '../../../../helpers/testMocks';
+
+it('should render correctly', () => {
+  expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should render correctly when assigned', () => {
+  expect(
+    shallowRender({
+      issue: mockIssue(false, { assignee: 'luke', assigneeName: 'Luke Skywalker' })
+    }).find('SelectListItem[item="assignee"]')
+  ).toMatchSnapshot();
+
+  expect(
+    shallowRender({ issue: mockIssue(false, { assignee: 'luke', assigneeActive: false }) }).find(
+      'SelectListItem[item="assignee"]'
+    )
+  ).toMatchSnapshot();
+});
+
+it('should filter properly', () => {
+  const issue = mockIssue();
+  const onFilter = jest.fn();
+  const wrapper = shallowRender({ issue, onFilter });
+  wrapper.find('SelectList').prop<Function>('onSelect')('assignee');
+  expect(onFilter).toBeCalledWith('assignee', issue);
+});
+
+function shallowRender(props: Partial<SimilarIssuesPopup['props']> = {}) {
+  return shallow(
+    <SimilarIssuesPopup
+      issue={mockIssue(false, { subProject: 'foo', subProjectName: 'Foo', tags: ['test-tag'] })}
+      onFilter={jest.fn()}
+      {...props}
+    />
+  );
+}
index eb4ed4d2aedd171966b42659c8b923b2bc030834..2bf516e280b4f72a03427ef2ec4c8228ef5b360d 100644 (file)
@@ -42,10 +42,74 @@ exports[`should render the changelog popup correctly 1`] = `
               <Connect(Avatar)
                 className="little-spacer-right"
                 hash="gravatarhash"
+                name="John Doe"
+                size={16}
+              />
+              John Doe
+            </p>
+            <IssueChangelogDiff
+              diff={
+                Object {
+                  "key": "severity",
+                  "newValue": "MINOR",
+                  "oldValue": "CRITICAL",
+                }
+              }
+              key="severity"
+            />
+          </td>
+        </tr>
+      </tbody>
+    </table>
+  </div>
+</DropdownOverlay>
+`;
+
+exports[`should render the changelog popup when we have a deleted user 1`] = `
+<DropdownOverlay
+  placement="bottom-right"
+>
+  <div
+    className="menu is-container issue-changelog"
+  >
+    <table
+      className="spaced"
+    >
+      <tbody>
+        <tr>
+          <td
+            className="thin text-left text-top nowrap"
+          >
+            <DateTimeFormatter
+              date="2017-03-01T09:36:01+0100"
+            />
+          </td>
+          <td
+            className="text-left text-top"
+          >
+            created_by john.david.dalton@gmail.com
+          </td>
+        </tr>
+        <tr
+          key="0"
+        >
+          <td
+            className="thin text-left text-top nowrap"
+          >
+            <DateTimeFormatter
+              date="2017-03-01T09:36:01+0100"
+            />
+          </td>
+          <td
+            className="text-left text-top"
+          >
+            <p>
+              <Connect(Avatar)
+                className="little-spacer-right"
                 name="john.doe"
                 size={16}
               />
-              john.doe
+              user.x_deleted.john.doe
             </p>
             <IssueChangelogDiff
               diff={
diff --git a/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetAssigneePopup-test.tsx.snap b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetAssigneePopup-test.tsx.snap
new file mode 100644 (file)
index 0000000..b539ed1
--- /dev/null
@@ -0,0 +1,70 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<DropdownOverlay
+  noPadding={true}
+>
+  <div
+    className="multi-select"
+  >
+    <div
+      className="menu-search"
+    >
+      <SearchBox
+        autoFocus={true}
+        className="little-spacer-top"
+        minLength={2}
+        onChange={[Function]}
+        placeholder="search.search_for_users"
+        value=""
+      />
+    </div>
+    <SelectList
+      currentItem="luke"
+      items={
+        Array [
+          "luke",
+          "",
+        ]
+      }
+      onSelect={[MockFunction]}
+    >
+      <SelectListItem
+        item="luke"
+        key="luke"
+      >
+        <Connect(Avatar)
+          className="spacer-right"
+          name="Skywalker"
+          size={16}
+        />
+        <span
+          className="text-middle"
+          style={
+            Object {
+              "marginLeft": 24,
+            }
+          }
+        >
+          Skywalker
+        </span>
+      </SelectListItem>
+      <SelectListItem
+        item=""
+        key=""
+      >
+        <span
+          className="text-middle"
+          style={
+            Object {
+              "marginLeft": undefined,
+            }
+          }
+        >
+          unassigned
+        </span>
+      </SelectListItem>
+    </SelectList>
+  </div>
+</DropdownOverlay>
+`;
diff --git a/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SimilarIssuesPopup-test.tsx.snap b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SimilarIssuesPopup-test.tsx.snap
new file mode 100644 (file)
index 0000000..5907065
--- /dev/null
@@ -0,0 +1,151 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<DropdownOverlay
+  noPadding={true}
+>
+  <header
+    className="menu-search"
+  >
+    <h6>
+      issue.filter_similar_issues
+    </h6>
+  </header>
+  <SelectList
+    className="issues-similar-issues-menu"
+    currentItem="type"
+    items={
+      Array [
+        "type",
+        "severity",
+        "status",
+        "resolution",
+        "assignee",
+        "rule",
+        "tag###test-tag",
+        "project",
+        "module",
+        "file",
+      ]
+    }
+    onSelect={[Function]}
+  >
+    <SelectListItem
+      item="type"
+    >
+      <IssueTypeIcon
+        className="little-spacer-right"
+        query="BUG"
+      />
+      issue.type.BUG
+    </SelectListItem>
+    <SelectListItem
+      item="severity"
+    >
+      <SeverityHelper
+        severity="MAJOR"
+      />
+    </SelectListItem>
+    <SelectListItem
+      item="status"
+    >
+      <StatusHelper
+        status="OPEN"
+      />
+    </SelectListItem>
+    <SelectListItem
+      item="resolution"
+    >
+      unresolved
+    </SelectListItem>
+    <SelectListItem
+      item="assignee"
+    >
+      unassigned
+    </SelectListItem>
+    <li
+      className="divider"
+    />
+    <SelectListItem
+      item="rule"
+    >
+      foo
+    </SelectListItem>
+    <SelectListItem
+      item="tag###test-tag"
+      key="tag###test-tag"
+    >
+      <TagsIcon
+        className="icon-half-transparent little-spacer-right text-middle"
+      />
+      <span
+        className="text-middle"
+      >
+        test-tag
+      </span>
+    </SelectListItem>
+    <li
+      className="divider"
+    />
+    <SelectListItem
+      item="project"
+    >
+      <QualifierIcon
+        className="little-spacer-right"
+        qualifier="TRK"
+      />
+      Foo
+    </SelectListItem>
+    <SelectListItem
+      item="module"
+    >
+      <QualifierIcon
+        className="little-spacer-right"
+        qualifier="BRC"
+      />
+      Foo
+    </SelectListItem>
+    <SelectListItem
+      item="file"
+    >
+      <QualifierIcon
+        className="little-spacer-right"
+        qualifier="FIL"
+      />
+      main.js
+    </SelectListItem>
+  </SelectList>
+</DropdownOverlay>
+`;
+
+exports[`should render correctly when assigned 1`] = `
+<SelectListItem
+  item="assignee"
+>
+  <span>
+    assigned_to
+    <Connect(Avatar)
+      className="little-spacer-left little-spacer-right"
+      name="Luke Skywalker"
+      size={16}
+    />
+    Luke Skywalker
+  </span>
+</SelectListItem>
+`;
+
+exports[`should render correctly when assigned 2`] = `
+<SelectListItem
+  item="assignee"
+>
+  <span>
+    assigned_to
+    <Connect(Avatar)
+      className="little-spacer-left little-spacer-right"
+      name="luke"
+      size={16}
+    />
+    user.x_deleted.luke
+  </span>
+</SelectListItem>
+`;
index d0f90ebeb030e06a0bff627f6dc2d9aacfa79fc6..e7757004281e18e63b424e701add9259a43ff4e2 100644 (file)
@@ -25,10 +25,6 @@ interface Comment {
   [x: string]: any;
 }
 
-interface User {
-  login: string;
-}
-
 interface Rule {}
 
 interface Component {
@@ -83,7 +79,7 @@ function injectRelational(
   return newFields;
 }
 
-function injectCommentsRelational(issue: RawIssue, users?: User[]) {
+function injectCommentsRelational(issue: RawIssue, users?: T.UserBase[]) {
   if (!issue.comments) {
     return {};
   }
@@ -158,7 +154,7 @@ function orderLocations(locations: T.FlowLocation[]) {
 export function parseIssueFromResponse(
   issue: RawIssue,
   components?: Component[],
-  users?: User[],
+  users?: T.UserBase[],
   rules?: Rule[]
 ): T.Issue {
   const { secondaryLocations, flows } = splitFlows(issue, components);
index a12fe60f13a41b68548e2409f1735c1a639a9f01..cf2cc974c182e8f468d5b0d3fe58113e8ca05d22 100644 (file)
@@ -27,3 +27,7 @@ export function hasGlobalPermission(user: T.CurrentUser, permission: string): bo
 export function isLoggedIn(user: T.CurrentUser): user is T.LoggedInUser {
   return user.isLoggedIn;
 }
+
+export function isUserActive(user: T.UserBase): user is T.UserActive {
+  return user.active !== false && Boolean(user.name);
+}
index bf9daccf676cc56d91c369753e12c576b39e47bf..d12b6eb2844a7518e7820e7d28c90640d1e10f6c 100644 (file)
@@ -9280,10 +9280,10 @@ sockjs@0.3.19:
     faye-websocket "^0.10.0"
     uuid "^3.0.1"
 
-sonar-ui-common@0.0.10:
-  version "0.0.10"
-  resolved "https://repox.jfrog.io/repox/api/npm/npm/sonar-ui-common/-/sonar-ui-common-0.0.10.tgz#c51643ff14232d7ef8669bb553a4c694bd2b7c0c"
-  integrity sha1-xRZD/xQjLX74Zpu1U6TGlL0rfAw=
+sonar-ui-common@0.0.11:
+  version "0.0.11"
+  resolved "https://registry.yarnpkg.com/sonar-ui-common/-/sonar-ui-common-0.0.11.tgz#b9aee225da16564d5b823ca2fb103f6d913db719"
+  integrity sha512-Q3Umwf+nVnH0J6XUR104PRpLN8aGAlqZHOA2oxaJVzhwT26o80A6i0i6PZAkzrbqQE4jhTRBQ396oalihI0s2g==
   dependencies:
     classnames "2.2.6"
     clipboard "2.0.4"
index 4bb08dcb93c30a939c7448041c770f6f6d559077..5db376440ccfa3e557a5e03777b67d5a94e67a44 100644 (file)
@@ -1470,6 +1470,7 @@ alert.tooltip.info=This is an info message.
 #------------------------------------------------------------------------------
 user.password_doesnt_match_confirmation=Password doesn't match confirmation.
 user.login_or_email_used_as_scm_account=Login and email are automatically considered as SCM accounts
+user.x_deleted={0} (deleted)
 
 login.login_to_sonarqube=Log In to SonarQube
 login.login_or_signup_to_sonarcloud=Log in or Sign up to SonarCloud
@@ -1501,6 +1502,21 @@ groups.remove.confirmation=Are you sure you want to remove group "{user}"?
 # MY PROFILE & MY ACCOUNT
 #
 #------------------------------------------------------------------------------
+my_profile.delete_account=Delete your user account
+my_profile.delete_account.success=Account successfully deleted
+my_profile.delete_account.feedback.reason.explanation=We are sorry to see you leave.
+my_profile.delete_account.feedback.call_to_action={link} to help improve our product or offer.
+my_profile.delete_account.info=We will immediately delete your account.
+my_profile.delete_account.data.info=All your data will be removed except your login. {help}
+my_profile.delete_account.info.orgs.members=You will be removed from the members of: {organizations}.
+my_profile.delete_account.info.orgs.administrators=You will be removed from the administrators of: {organizations}.
+my_profile.delete_account.info.orgs_to_transfer_or_delete=Your account is the only administrator for the following organization(s): {organizations}.
+my_profile.delete_account.info.orgs_to_transfer_or_delete.info=You must transfer administration permissions or delete these organizations before you can delete your SonarCloud account. {link}.
+my_profile.delete_account.info.orgs_to_transfer_or_delete.info.link=See Organization Admin Guide
+my_profile.delete_account.modal.header={0}: {1}
+my_profile.delete_account.login.required=Login is required
+my_profile.delete_account.login.wrong_value=Please type your login to confirm
+my_profile.delete_account.verify=To verify, please type your user account name below
 my_profile.email=Email
 my_profile.groups=Groups
 my_profile.scm_accounts=SCM Accounts
@@ -1515,6 +1531,7 @@ my_profile.overall_notifications.title=Overall notifications
 my_profile.sonarcloud_feature_notifications.title=SonarCloud new feature notifications
 my_profile.sonarcloud_feature_notifications.description=Display a notification in the header when new features are deployed
 my_profile.per_project_notifications.title=Notifications per project
+my_profile.warning_message=This is a definitive action. No account recovery will be possible.
 
 my_account.page=My Account
 my_account.notifications=Notifications
index d74b89ded46849046ae75bc2636b525fb650c834..4ba95043bb468ffdb134e814b662f19492f8799f 100644 (file)
@@ -248,6 +248,7 @@ message ChangelogWsResponse {
     optional string creationDate = 4;
     repeated Diff diffs = 5;
     optional string avatar = 6;
+    optional bool isUserActive = 7;
 
     message Diff {
       optional string key = 1;
index 7a468f3b8fea480cd53fe674206de5a9bb22470d..f16c6b7a40f7fb8b4a06ff242197a9659167ed52 100644 (file)
@@ -53,6 +53,16 @@ message AddMemberWsResponse {
   optional User user = 1;
 }
 
+// WS api/organizations/prevent_user_deletion
+message PreventUserDeletionWsResponse {
+  repeated Organization organizations = 1;
+
+  message Organization {
+    optional string key = 1;
+    optional string name = 2;
+  }
+}
+
 message Organization {
   optional string key = 1;
   optional string name = 2;