aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBenoit <benoit.gianinetti@sonarsource.com>2019-07-12 14:06:47 +0200
committerSonarTech <sonartech@sonarsource.com>2019-07-12 20:21:16 +0200
commit7f1afd8ce4723dad04762837efec3b4b2525dff5 (patch)
tree868fce46d90be48623e237c195a64e9aff091696
parentc2d9ced3637a5aa08422427e6435aaecaf659feb (diff)
downloadsonarqube-7f1afd8ce4723dad04762837efec3b4b2525dff5.tar.gz
sonarqube-7f1afd8ce4723dad04762837efec3b4b2525dff5.zip
MMF-769 User can close their account (#1861)
-rw-r--r--server/sonar-db-core/src/test/java/org/sonar/db/DefaultOrganizationTesting.java1
-rw-r--r--server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationDto.java14
-rw-r--r--server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationHelper.java47
-rw-r--r--server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationQuery.java42
-rw-r--r--server/sonar-db-dao/src/main/java/org/sonar/db/user/UserDao.java4
-rw-r--r--server/sonar-db-dao/src/main/java/org/sonar/db/user/UserDto.java11
-rw-r--r--server/sonar-db-dao/src/main/java/org/sonar/db/user/UserMapper.java2
-rw-r--r--server/sonar-db-dao/src/main/resources/org/sonar/db/organization/OrganizationMapper.xml27
-rw-r--r--server/sonar-db-dao/src/main/resources/org/sonar/db/user/UserMapper.xml20
-rw-r--r--server/sonar-db-dao/src/test/java/org/sonar/db/organization/OrganizationDaoTest.java69
-rw-r--r--server/sonar-db-dao/src/test/java/org/sonar/db/organization/OrganizationHelperTest.java141
-rw-r--r--server/sonar-db-dao/src/test/java/org/sonar/db/organization/OrganizationQueryTest.java67
-rw-r--r--server/sonar-db-dao/src/test/java/org/sonar/db/user/UserDaoTest.java49
-rw-r--r--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.sql2
-rw-r--r--server/sonar-docs/src/pages/sonarcloud/organizations/index.md29
-rw-r--r--server/sonar-docs/src/pages/user-guide/user-account.md37
-rw-r--r--server/sonar-docs/static/SonarCloudNavigationTree.json1
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/authentication/SafeModeUserSession.java5
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/issue/ws/ChangelogAction.java3
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/organization/OrganizationUpdater.java2
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/organization/ws/AddMemberAction.java2
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/organization/ws/DeleteEmptyPersonalOrgsAction.java77
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/organization/ws/OrganizationsWsModule.java4
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/organization/ws/PreventUserDeletionAction.java87
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/organization/ws/SearchMembersAction.java4
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/qualityprofile/ws/SearchUsersAction.java2
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/user/DoPrivileged.java5
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/user/ServerUserSession.java5
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/user/ThreadLocalUserSession.java5
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/user/UserSession.java5
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/user/UserUpdater.java7
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/user/ws/CreateAction.java3
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/user/ws/DeactivateAction.java64
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/user/ws/UpdateLoginAction.java15
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/user/ws/UserJsonWriter.java3
-rw-r--r--server/sonar-server/src/main/resources/org/sonar/server/issue/ws/changelog-example.json1
-rw-r--r--server/sonar-server/src/main/resources/org/sonar/server/organization/ws/prevent_user_deletion-example.json12
-rw-r--r--server/sonar-server/src/test/java/org/sonar/server/issue/ws/ChangelogActionTest.java20
-rw-r--r--server/sonar-server/src/test/java/org/sonar/server/organization/OrganizationUpdaterImplTest.java7
-rw-r--r--server/sonar-server/src/test/java/org/sonar/server/organization/ws/DeleteEmptyPersonalOrgsActionTest.java141
-rw-r--r--server/sonar-server/src/test/java/org/sonar/server/organization/ws/PreventUserDeletionActionTest.java208
-rw-r--r--server/sonar-server/src/test/java/org/sonar/server/organization/ws/SearchActionTest.java22
-rw-r--r--server/sonar-server/src/test/java/org/sonar/server/tester/AnonymousMockUserSession.java5
-rw-r--r--server/sonar-server/src/test/java/org/sonar/server/tester/MockUserSession.java4
-rw-r--r--server/sonar-server/src/test/java/org/sonar/server/tester/UserSessionRule.java5
-rw-r--r--server/sonar-server/src/test/java/org/sonar/server/ui/ws/OrganizationActionTest.java4
-rw-r--r--server/sonar-server/src/test/java/org/sonar/server/user/ServerUserSessionTest.java3
-rw-r--r--server/sonar-server/src/test/java/org/sonar/server/user/TestUserSessionFactory.java5
-rw-r--r--server/sonar-server/src/test/java/org/sonar/server/user/ws/DeactivateActionTest.java56
-rw-r--r--server/sonar-server/src/test/java/org/sonar/server/user/ws/UpdateLoginActionTest.java18
-rw-r--r--server/sonar-web/package.json2
-rw-r--r--server/sonar-web/src/main/js/api/issues.ts14
-rw-r--r--server/sonar-web/src/main/js/api/organizations.ts6
-rw-r--r--server/sonar-web/src/main/js/api/quality-profiles.ts9
-rw-r--r--server/sonar-web/src/main/js/api/user_groups.ts8
-rw-r--r--server/sonar-web/src/main/js/app/components/AccountDeleted.tsx53
-rw-r--r--server/sonar-web/src/main/js/app/components/__tests__/AccountDeleted-test.tsx27
-rw-r--r--server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/AccountDeleted-test.tsx.snap53
-rw-r--r--server/sonar-web/src/main/js/app/styles/init/lists.css4
-rw-r--r--server/sonar-web/src/main/js/app/types.d.ts66
-rw-r--r--server/sonar-web/src/main/js/app/utils/startReactApp.tsx6
-rw-r--r--server/sonar-web/src/main/js/apps/account/profile/Profile.tsx9
-rw-r--r--server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccount.tsx125
-rw-r--r--server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccountContent.tsx148
-rw-r--r--server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccountModal.tsx150
-rw-r--r--server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccount-test.tsx87
-rw-r--r--server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccountContent-test.tsx71
-rw-r--r--server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccountModal-test.tsx86
-rw-r--r--server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccount-test.tsx.snap118
-rw-r--r--server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccountContent-test.tsx.snap128
-rw-r--r--server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccountModal-test.tsx.snap19
-rw-r--r--server/sonar-web/src/main/js/apps/custom-measures/components/Item.tsx9
-rw-r--r--server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/Item-test.tsx18
-rw-r--r--server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/__snapshots__/Item-test.tsx.snap87
-rw-r--r--server/sonar-web/src/main/js/apps/groups/components/EditMembersModal.tsx9
-rw-r--r--server/sonar-web/src/main/js/apps/issues/components/App.tsx5
-rw-r--r--server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx8
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx51
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx11
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.tsx45
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.tsx.snap56
-rw-r--r--server/sonar-web/src/main/js/apps/issues/utils.ts13
-rw-r--r--server/sonar-web/src/main/js/apps/projects/components/NoFavoriteProjects.tsx33
-rw-r--r--server/sonar-web/src/main/js/apps/projects/components/__tests__/NoFavoriteProjects-test.tsx9
-rw-r--r--server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/NoFavoriteProjects-test.tsx.snap36
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/App.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/ProjectRowActions.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/Projects.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/RestoreAccessModal.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissions.tsx12
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsForm.tsx14
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsFormSelect.tsx8
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsUser.tsx5
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsUser-test.tsx16
-rw-r--r--server/sonar-web/src/main/js/apps/sessions/components/Logout.tsx4
-rw-r--r--server/sonar-web/src/main/js/apps/sessions/components/__tests__/Logout-test.tsx50
-rw-r--r--server/sonar-web/src/main/js/apps/sessions/components/__tests__/__snapshots__/Logout-test.tsx.snap14
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/UserActions.tsx36
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/UserForm.tsx22
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/__tests__/UserActions-test.tsx30
-rw-r--r--server/sonar-web/src/main/js/components/controls/ValidationModal.tsx5
-rw-r--r--server/sonar-web/src/main/js/components/controls/__tests__/ValidationModal-test.tsx1
-rw-r--r--server/sonar-web/src/main/js/components/issue/components/IssueAssign.tsx32
-rw-r--r--server/sonar-web/src/main/js/components/issue/components/IssueChangelogDiff.tsx8
-rw-r--r--server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.tsx14
-rw-r--r--server/sonar-web/src/main/js/components/issue/components/__tests__/IssueCommentLine-test.tsx50
-rw-r--r--server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueAssign-test.tsx.snap68
-rw-r--r--server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueCommentLine-test.tsx.snap19
-rw-r--r--server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.tsx46
-rw-r--r--server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.tsx33
-rw-r--r--server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.tsx12
-rw-r--r--server/sonar-web/src/main/js/components/issue/popups/__tests__/ChangelogPopup-test.tsx62
-rw-r--r--server/sonar-web/src/main/js/components/issue/popups/__tests__/SetAssigneePopup-test.tsx81
-rw-r--r--server/sonar-web/src/main/js/components/issue/popups/__tests__/SimilarIssuesPopup-test.tsx59
-rw-r--r--server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/ChangelogPopup-test.tsx.snap66
-rw-r--r--server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetAssigneePopup-test.tsx.snap70
-rw-r--r--server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SimilarIssuesPopup-test.tsx.snap151
-rw-r--r--server/sonar-web/src/main/js/helpers/issues.ts8
-rw-r--r--server/sonar-web/src/main/js/helpers/users.ts4
-rw-r--r--server/sonar-web/yarn.lock8
-rw-r--r--sonar-core/src/main/resources/org/sonar/l10n/core.properties17
-rw-r--r--sonar-ws/src/main/protobuf/ws-issues.proto1
-rw-r--r--sonar-ws/src/main/protobuf/ws-organizations.proto10
125 files changed, 3000 insertions, 999 deletions
diff --git a/server/sonar-db-core/src/test/java/org/sonar/db/DefaultOrganizationTesting.java b/server/sonar-db-core/src/test/java/org/sonar/db/DefaultOrganizationTesting.java
index 1931be9e68f..f2bb100e27c 100644
--- a/server/sonar-db-core/src/test/java/org/sonar/db/DefaultOrganizationTesting.java
+++ b/server/sonar-db-core/src/test/java/org/sonar/db/DefaultOrganizationTesting.java
@@ -46,7 +46,6 @@ public class DefaultOrganizationTesting {
"UUID", uuid,
"KEE", uuid,
"NAME", uuid,
- "GUARDED", String.valueOf(false),
"CREATED_AT", "1000",
"UPDATED_AT", "1000");
}
diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationDto.java b/server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationDto.java
index f82bd6588a8..33a703a0be9 100644
--- a/server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationDto.java
+++ b/server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationDto.java
@@ -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
index 00000000000..ef304882a3a
--- /dev/null
+++ b/server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationHelper.java
@@ -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;
+ }
+}
diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationQuery.java b/server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationQuery.java
index da63b59d512..899bcba33cb 100644
--- a/server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationQuery.java
+++ b/server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationQuery.java
@@ -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);
}
diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserDao.java b/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserDao.java
index 7fe0e24e73d..744249a2ce6 100644
--- a/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserDao.java
+++ b/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserDao.java
@@ -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());
}
diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserDto.java b/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserDto.java
index 2110735f660..c8a414f934a 100644
--- a/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserDto.java
+++ b/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserDto.java
@@ -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.
@@ -273,16 +272,6 @@ public class UserDto {
}
@CheckForNull
- public String getOrganizationUuid() {
- return organizationUuid;
- }
-
- public UserDto setOrganizationUuid(@Nullable String organizationUuid) {
- this.organizationUuid = organizationUuid;
- return this;
- }
-
- @CheckForNull
public Long getLastConnectionDate() {
return lastConnectionDate;
}
diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserMapper.java b/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserMapper.java
index 8f1a77c7b86..8c83ba012c3 100644
--- a/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserMapper.java
+++ b/server/sonar-db-dao/src/main/java/org/sonar/db/user/UserMapper.java
@@ -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);
diff --git a/server/sonar-db-dao/src/main/resources/org/sonar/db/organization/OrganizationMapper.xml b/server/sonar-db-dao/src/main/resources/org/sonar/db/organization/OrganizationMapper.xml
index a806a01d78a..b23b1b46960 100644
--- a/server/sonar-db-dao/src/main/resources/org/sonar/db/organization/OrganizationMapper.xml
+++ b/server/sonar-db-dao/src/main/resources/org/sonar/db/organization/OrganizationMapper.xml
@@ -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"
@@ -121,22 +120,6 @@
#{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
@@ -147,14 +130,6 @@
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
@@ -246,7 +221,6 @@
description,
url,
avatar_url,
- guarded,
new_project_private,
default_quality_gate_uuid,
subscription,
@@ -261,7 +235,6 @@
#{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},
diff --git a/server/sonar-db-dao/src/main/resources/org/sonar/db/user/UserMapper.xml b/server/sonar-db-dao/src/main/resources/org/sonar/db/user/UserMapper.xml
index bf0ee3a5d81..d6c6c9b059f 100644
--- a/server/sonar-db-dao/src/main/resources/org/sonar/db/user/UserMapper.xml
+++ b/server/sonar-db-dao/src/main/resources/org/sonar/db/user/UserMapper.xml
@@ -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"
@@ -173,8 +172,7 @@
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,
@@ -182,6 +180,19 @@
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>
@@ -233,7 +244,6 @@
onboarded,
homepage_type,
homepage_parameter,
- organization_uuid,
created_at,
updated_at
) values (
@@ -254,7 +264,6 @@
#{user.onboarded,jdbcType=BOOLEAN},
#{user.homepageType,jdbcType=VARCHAR},
#{user.homepageParameter,jdbcType=VARCHAR},
- #{user.organizationUuid,jdbcType=VARCHAR},
#{user.createdAt,jdbcType=BIGINT},
#{user.updatedAt,jdbcType=BIGINT}
)
@@ -277,7 +286,6 @@
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
diff --git a/server/sonar-db-dao/src/test/java/org/sonar/db/organization/OrganizationDaoTest.java b/server/sonar-db-dao/src/test/java/org/sonar/db/organization/OrganizationDaoTest.java
index ef389bc5223..debde182ede 100644
--- a/server/sonar-db-dao/src/test/java/org/sonar/db/organization/OrganizationDaoTest.java
+++ b/server/sonar-db-dao/src/test/java/org/sonar/db/organization/OrganizationDaoTest.java
@@ -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,7 +144,6 @@ 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();
@@ -156,14 +151,6 @@ public class OrganizationDaoTest {
}
@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);
insertOrganization(copyOf(ORGANIZATION_DTO_1).setDescription(null).setUrl(null).setAvatarUrl(null));
@@ -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);
@@ -585,20 +563,6 @@ public class OrganizationDaoTest {
}
@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)))
.isEmpty();
@@ -630,25 +594,6 @@ public class OrganizationDaoTest {
}
@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
index 00000000000..bc5d738366c
--- /dev/null
+++ b/server/sonar-db-dao/src/test/java/org/sonar/db/organization/OrganizationHelperTest.java
@@ -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
index 74f60141463..00000000000
--- a/server/sonar-db-dao/src/test/java/org/sonar/db/organization/OrganizationQueryTest.java
+++ /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();
- }
-}
diff --git a/server/sonar-db-dao/src/test/java/org/sonar/db/user/UserDaoTest.java b/server/sonar-db-dao/src/test/java/org/sonar/db/user/UserDaoTest.java
index e739b57e69a..1f02bd04a34 100644
--- a/server/sonar-db-dao/src/test/java/org/sonar/db/user/UserDaoTest.java
+++ b/server/sonar-db-dao/src/test/java/org/sonar/db/user/UserDaoTest.java
@@ -19,12 +19,15 @@
*/
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();
diff --git a/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 b/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
index 6337884e8cf..0ca9ff069c6 100644
--- a/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
+++ b/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
@@ -5,7 +5,7 @@ CREATE TABLE "ORGANIZATIONS" (
"DESCRIPTION" VARCHAR(256),
"URL" VARCHAR(256),
"AVATAR_URL" VARCHAR(256),
- "GUARDED" BOOLEAN NOT NULL,
+ "GUARDED" BOOLEAN,
"USER_ID" INTEGER,
"DEFAULT_PERM_TEMPLATE_PROJECT" VARCHAR(40),
"DEFAULT_PERM_TEMPLATE_VIEW" VARCHAR(40),
diff --git a/server/sonar-docs/src/pages/sonarcloud/organizations/index.md b/server/sonar-docs/src/pages/sonarcloud/organizations/index.md
index 22d57f1dee4..bb16634f7b0 100644
--- a/server/sonar-docs/src/pages/sonarcloud/organizations/index.md
+++ b/server/sonar-docs/src/pages/sonarcloud/organizations/index.md
@@ -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".
diff --git a/server/sonar-docs/src/pages/user-guide/user-account.md b/server/sonar-docs/src/pages/user-guide/user-account.md
index 636279f7e60..bdeef3cf68d 100644
--- a/server/sonar-docs/src/pages/user-guide/user-account.md
+++ b/server/sonar-docs/src/pages/user-guide/user-account.md
@@ -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 -->
diff --git a/server/sonar-docs/static/SonarCloudNavigationTree.json b/server/sonar-docs/static/SonarCloudNavigationTree.json
index 6e6f8d79ca4..1f50dc695b0 100644
--- a/server/sonar-docs/static/SonarCloudNavigationTree.json
+++ b/server/sonar-docs/static/SonarCloudNavigationTree.json
@@ -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/"
]
diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/SafeModeUserSession.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/SafeModeUserSession.java
index d1e7a9d7fa0..85828432ad1 100644
--- a/server/sonar-server/src/main/java/org/sonar/server/authentication/SafeModeUserSession.java
+++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/SafeModeUserSession.java
@@ -92,11 +92,6 @@ public class SafeModeUserSession extends AbstractUserSession {
}
@Override
- public Optional<String> getPersonalOrganizationUuid() {
- return Optional.empty();
- }
-
- @Override
public boolean isLoggedIn() {
return false;
}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/issue/ws/ChangelogAction.java b/server/sonar-server/src/main/java/org/sonar/server/issue/ws/ChangelogAction.java
index 11c91719c79..bbd294d475e 100644
--- a/server/sonar-server/src/main/java/org/sonar/server/issue/ws/ChangelogAction.java
+++ b/server/sonar-server/src/main/java/org/sonar/server/issue/ws/ChangelogAction.java
@@ -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()
diff --git a/server/sonar-server/src/main/java/org/sonar/server/organization/OrganizationUpdater.java b/server/sonar-server/src/main/java/org/sonar/server/organization/OrganizationUpdater.java
index 5bc4e3b6675..de4f15b2ee7 100644
--- a/server/sonar-server/src/main/java/org/sonar/server/organization/OrganizationUpdater.java
+++ b/server/sonar-server/src/main/java/org/sonar/server/organization/OrganizationUpdater.java
@@ -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:
diff --git a/server/sonar-server/src/main/java/org/sonar/server/organization/ws/AddMemberAction.java b/server/sonar-server/src/main/java/org/sonar/server/organization/ws/AddMemberAction.java
index 6e3dfb7d8dd..54a1c2e60d7 100644
--- a/server/sonar-server/src/main/java/org/sonar/server/organization/ws/AddMemberAction.java
+++ b/server/sonar-server/src/main/java/org/sonar/server/organization/ws/AddMemberAction.java
@@ -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
index d0fb9681f6b..00000000000
--- a/server/sonar-server/src/main/java/org/sonar/server/organization/ws/DeleteEmptyPersonalOrgsAction.java
+++ /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();
- }
-
-}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/organization/ws/OrganizationsWsModule.java b/server/sonar-server/src/main/java/org/sonar/server/organization/ws/OrganizationsWsModule.java
index a19b95ebff3..a3df5bf41d6 100644
--- a/server/sonar-server/src/main/java/org/sonar/server/organization/ws/OrganizationsWsModule.java
+++ b/server/sonar-server/src/main/java/org/sonar/server/organization/ws/OrganizationsWsModule.java
@@ -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
index 00000000000..7ff1feb859d
--- /dev/null
+++ b/server/sonar-server/src/main/java/org/sonar/server/organization/ws/PreventUserDeletionAction.java
@@ -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());
+ }
+}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/organization/ws/SearchMembersAction.java b/server/sonar-server/src/main/java/org/sonar/server/organization/ws/SearchMembersAction.java
index eee4c7a34ea..cc588949555 100644
--- a/server/sonar-server/src/main/java/org/sonar/server/organization/ws/SearchMembersAction.java
+++ b/server/sonar-server/src/main/java/org/sonar/server/organization/ws/SearchMembersAction.java
@@ -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;
})
diff --git a/server/sonar-server/src/main/java/org/sonar/server/qualityprofile/ws/SearchUsersAction.java b/server/sonar-server/src/main/java/org/sonar/server/qualityprofile/ws/SearchUsersAction.java
index 355a41caa7e..a60f5a40058 100644
--- a/server/sonar-server/src/main/java/org/sonar/server/qualityprofile/ws/SearchUsersAction.java
+++ b/server/sonar-server/src/main/java/org/sonar/server/qualityprofile/ws/SearchUsersAction.java
@@ -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();
diff --git a/server/sonar-server/src/main/java/org/sonar/server/user/DoPrivileged.java b/server/sonar-server/src/main/java/org/sonar/server/user/DoPrivileged.java
index d9bc5ee1a26..4f695b25f8d 100644
--- a/server/sonar-server/src/main/java/org/sonar/server/user/DoPrivileged.java
+++ b/server/sonar-server/src/main/java/org/sonar/server/user/DoPrivileged.java
@@ -113,11 +113,6 @@ public final class DoPrivileged {
}
@Override
- public Optional<String> getPersonalOrganizationUuid() {
- return Optional.empty();
- }
-
- @Override
protected boolean hasPermissionImpl(OrganizationPermission permission, String organizationUuid) {
return true;
}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/user/ServerUserSession.java b/server/sonar-server/src/main/java/org/sonar/server/user/ServerUserSession.java
index 2564c4c8e25..1485eca1891 100644
--- a/server/sonar-server/src/main/java/org/sonar/server/user/ServerUserSession.java
+++ b/server/sonar-server/src/main/java/org/sonar/server/user/ServerUserSession.java
@@ -133,11 +133,6 @@ public class ServerUserSession extends AbstractUserSession {
}
@Override
- public Optional<String> getPersonalOrganizationUuid() {
- return ofNullable(userDto).map(UserDto::getOrganizationUuid);
- }
-
- @Override
protected boolean hasPermissionImpl(OrganizationPermission permission, String organizationUuid) {
if (permissionsByOrganizationUuid == null) {
permissionsByOrganizationUuid = new HashMap<>();
diff --git a/server/sonar-server/src/main/java/org/sonar/server/user/ThreadLocalUserSession.java b/server/sonar-server/src/main/java/org/sonar/server/user/ThreadLocalUserSession.java
index 6a04149538b..3e5712bc5fa 100644
--- a/server/sonar-server/src/main/java/org/sonar/server/user/ThreadLocalUserSession.java
+++ b/server/sonar-server/src/main/java/org/sonar/server/user/ThreadLocalUserSession.java
@@ -96,11 +96,6 @@ public class ThreadLocalUserSession implements UserSession {
}
@Override
- public Optional<String> getPersonalOrganizationUuid() {
- return get().getPersonalOrganizationUuid();
- }
-
- @Override
public boolean isLoggedIn() {
return get().isLoggedIn();
}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/user/UserSession.java b/server/sonar-server/src/main/java/org/sonar/server/user/UserSession.java
index 85a09760052..3399e36cdcc 100644
--- a/server/sonar-server/src/main/java/org/sonar/server/user/UserSession.java
+++ b/server/sonar-server/src/main/java/org/sonar/server/user/UserSession.java
@@ -149,11 +149,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.
*/
boolean isLoggedIn();
diff --git a/server/sonar-server/src/main/java/org/sonar/server/user/UserUpdater.java b/server/sonar-server/src/main/java/org/sonar/server/user/UserUpdater.java
index bf54d87f49e..8a6da917e1c 100644
--- a/server/sonar-server/src/main/java/org/sonar/server/user/UserUpdater.java
+++ b/server/sonar-server/src/main/java/org/sonar/server/user/UserUpdater.java
@@ -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()
diff --git a/server/sonar-server/src/main/java/org/sonar/server/user/ws/CreateAction.java b/server/sonar-server/src/main/java/org/sonar/server/user/ws/CreateAction.java
index d88102d14b4..dfc25e18ef8 100644
--- a/server/sonar-server/src/main/java/org/sonar/server/user/ws/CreateAction.java
+++ b/server/sonar-server/src/main/java/org/sonar/server/user/ws/CreateAction.java
@@ -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 -> {}));
}
}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/user/ws/DeactivateAction.java b/server/sonar-server/src/main/java/org/sonar/server/user/ws/DeactivateAction.java
index c53c29885bd..b14463ecd7c 100644
--- a/server/sonar-server/src/main/java/org/sonar/server/user/ws/DeactivateAction.java
+++ b/server/sonar-server/src/main/java/org/sonar/server/user/ws/DeactivateAction.java
@@ -19,23 +19,26 @@
*/
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()));
- }
}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/user/ws/UpdateLoginAction.java b/server/sonar-server/src/main/java/org/sonar/server/user/ws/UpdateLoginAction.java
index e67093cb407..21410bf8a2f 100644
--- a/server/sonar-server/src/main/java/org/sonar/server/user/ws/UpdateLoginAction.java
+++ b/server/sonar-server/src/main/java/org/sonar/server/user/ws/UpdateLoginAction.java
@@ -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()));
- }
-
}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/user/ws/UserJsonWriter.java b/server/sonar-server/src/main/java/org/sonar/server/user/ws/UserJsonWriter.java
index 8ac76d0c030..a9925a1bf63 100644
--- a/server/sonar-server/src/main/java/org/sonar/server/user/ws/UserJsonWriter.java
+++ b/server/sonar-server/src/main/java/org/sonar/server/user/ws/UserJsonWriter.java
@@ -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);
diff --git a/server/sonar-server/src/main/resources/org/sonar/server/issue/ws/changelog-example.json b/server/sonar-server/src/main/resources/org/sonar/server/issue/ws/changelog-example.json
index 3b70838b670..45a0a287e07 100644
--- a/server/sonar-server/src/main/resources/org/sonar/server/issue/ws/changelog-example.json
+++ b/server/sonar-server/src/main/resources/org/sonar/server/issue/ws/changelog-example.json
@@ -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
index 00000000000..149a283b943
--- /dev/null
+++ b/server/sonar-server/src/main/resources/org/sonar/server/organization/ws/prevent_user_deletion-example.json
@@ -0,0 +1,12 @@
+{
+ "organizations": [
+ {
+ "key": "foo-company",
+ "name": "Foo Company"
+ },
+ {
+ "key": "bar-company",
+ "name": "Bar Company"
+ }
+ ]
+}
diff --git a/server/sonar-server/src/test/java/org/sonar/server/issue/ws/ChangelogActionTest.java b/server/sonar-server/src/test/java/org/sonar/server/issue/ws/ChangelogActionTest.java
index 22ab82cb47a..918feb3e52f 100644
--- a/server/sonar-server/src/test/java/org/sonar/server/issue/ws/ChangelogActionTest.java
+++ b/server/sonar-server/src/test/java/org/sonar/server/issue/ws/ChangelogActionTest.java
@@ -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"));
@@ -197,6 +198,25 @@ public class ChangelogActionTest {
}
@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();
IssueDto issueDto = db.issues().insertIssue(newIssue());
diff --git a/server/sonar-server/src/test/java/org/sonar/server/organization/OrganizationUpdaterImplTest.java b/server/sonar-server/src/test/java/org/sonar/server/organization/OrganizationUpdaterImplTest.java
index 2bcfb03b973..9eb1ed9f23b 100644
--- a/server/sonar-server/src/test/java/org/sonar/server/organization/OrganizationUpdaterImplTest.java
+++ b/server/sonar-server/src/test/java/org/sonar/server/organization/OrganizationUpdaterImplTest.java
@@ -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
index ccf960804c3..00000000000
--- a/server/sonar-server/src/test/java/org/sonar/server/organization/ws/DeleteEmptyPersonalOrgsActionTest.java
+++ /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
index 00000000000..060e845fcd6
--- /dev/null
+++ b/server/sonar-server/src/test/java/org/sonar/server/organization/ws/PreventUserDeletionActionTest.java
@@ -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);
+ }
+}
diff --git a/server/sonar-server/src/test/java/org/sonar/server/organization/ws/SearchActionTest.java b/server/sonar-server/src/test/java/org/sonar/server/organization/ws/SearchActionTest.java
index 7f141946b7a..9c229d7176a 100644
--- a/server/sonar-server/src/test/java/org/sonar/server/organization/ws/SearchActionTest.java
+++ b/server/sonar-server/src/test/java/org/sonar/server/organization/ws/SearchActionTest.java
@@ -100,22 +100,6 @@ public class SearchActionTest {
}
@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();
OrganizationDto groupProvisionOrganization = 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);
diff --git a/server/sonar-server/src/test/java/org/sonar/server/tester/AnonymousMockUserSession.java b/server/sonar-server/src/test/java/org/sonar/server/tester/AnonymousMockUserSession.java
index 0e4b7bbf1b4..3c5d4068c61 100644
--- a/server/sonar-server/src/test/java/org/sonar/server/tester/AnonymousMockUserSession.java
+++ b/server/sonar-server/src/test/java/org/sonar/server/tester/AnonymousMockUserSession.java
@@ -76,11 +76,6 @@ public class AnonymousMockUserSession extends AbstractMockUserSession<AnonymousM
}
@Override
- public Optional<String> getPersonalOrganizationUuid() {
- return Optional.empty();
- }
-
- @Override
public boolean hasMembershipImpl(OrganizationDto organizationDto) {
return false;
}
diff --git a/server/sonar-server/src/test/java/org/sonar/server/tester/MockUserSession.java b/server/sonar-server/src/test/java/org/sonar/server/tester/MockUserSession.java
index daff1a67c78..e59b0eda6f5 100644
--- a/server/sonar-server/src/test/java/org/sonar/server/tester/MockUserSession.java
+++ b/server/sonar-server/src/test/java/org/sonar/server/tester/MockUserSession.java
@@ -144,8 +144,4 @@ public class MockUserSession extends AbstractMockUserSession<MockUserSession> {
return Optional.ofNullable(externalIdentity);
}
- @Override
- public Optional<String> getPersonalOrganizationUuid() {
- return Optional.empty();
- }
}
diff --git a/server/sonar-server/src/test/java/org/sonar/server/tester/UserSessionRule.java b/server/sonar-server/src/test/java/org/sonar/server/tester/UserSessionRule.java
index 13f05371271..5e7cc4147d5 100644
--- a/server/sonar-server/src/test/java/org/sonar/server/tester/UserSessionRule.java
+++ b/server/sonar-server/src/test/java/org/sonar/server/tester/UserSessionRule.java
@@ -290,11 +290,6 @@ public class UserSessionRule implements TestRule, UserSession {
}
@Override
- public Optional<String> getPersonalOrganizationUuid() {
- return currentUserSession.getPersonalOrganizationUuid();
- }
-
- @Override
public boolean isLoggedIn() {
return currentUserSession.isLoggedIn();
}
diff --git a/server/sonar-server/src/test/java/org/sonar/server/ui/ws/OrganizationActionTest.java b/server/sonar-server/src/test/java/org/sonar/server/ui/ws/OrganizationActionTest.java
index 7d2e3f9905a..605560481b6 100644
--- a/server/sonar-server/src/test/java/org/sonar/server/ui/ws/OrganizationActionTest.java
+++ b/server/sonar-server/src/test/java/org/sonar/server/ui/ws/OrganizationActionTest.java
@@ -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);
diff --git a/server/sonar-server/src/test/java/org/sonar/server/user/ServerUserSessionTest.java b/server/sonar-server/src/test/java/org/sonar/server/user/ServerUserSessionTest.java
index 4af4d3c807b..af654c54493 100644
--- a/server/sonar-server/src/test/java/org/sonar/server/user/ServerUserSessionTest.java
+++ b/server/sonar-server/src/test/java/org/sonar/server/user/ServerUserSessionTest.java
@@ -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;
diff --git a/server/sonar-server/src/test/java/org/sonar/server/user/TestUserSessionFactory.java b/server/sonar-server/src/test/java/org/sonar/server/user/TestUserSessionFactory.java
index 3957e22959f..2dfa06ab569 100644
--- a/server/sonar-server/src/test/java/org/sonar/server/user/TestUserSessionFactory.java
+++ b/server/sonar-server/src/test/java/org/sonar/server/user/TestUserSessionFactory.java
@@ -98,11 +98,6 @@ public class TestUserSessionFactory implements UserSessionFactory {
}
@Override
- public Optional<String> getPersonalOrganizationUuid() {
- throw notImplemented();
- }
-
- @Override
public boolean isLoggedIn() {
return user != null;
}
diff --git a/server/sonar-server/src/test/java/org/sonar/server/user/ws/DeactivateActionTest.java b/server/sonar-server/src/test/java/org/sonar/server/user/ws/DeactivateActionTest.java
index 2f7bebc48fb..afb7691885b 100644
--- a/server/sonar-server/src/test/java/org/sonar/server/user/ws/DeactivateActionTest.java
+++ b/server/sonar-server/src/test/java/org/sonar/server/user/ws/DeactivateActionTest.java
@@ -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()));
}
}
diff --git a/server/sonar-server/src/test/java/org/sonar/server/user/ws/UpdateLoginActionTest.java b/server/sonar-server/src/test/java/org/sonar/server/user/ws/UpdateLoginActionTest.java
index 12445f7a341..8595dd72aa3 100644
--- a/server/sonar-server/src/test/java/org/sonar/server/user/ws/UpdateLoginActionTest.java
+++ b/server/sonar-server/src/test/java/org/sonar/server/user/ws/UpdateLoginActionTest.java
@@ -120,24 +120,6 @@ public class UpdateLoginActionTest {
}
@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();
UserDto user = db.users().insertUser();
diff --git a/server/sonar-web/package.json b/server/sonar-web/package.json
index 660f59caed9..6f5aeccd46d 100644
--- a/server/sonar-web/package.json
+++ b/server/sonar-web/package.json
@@ -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"
diff --git a/server/sonar-web/src/main/js/api/issues.ts b/server/sonar-web/src/main/js/api/issues.ts
index b2a5c560825..c8ce98cf84d 100644
--- a/server/sonar-web/src/main/js/api/issues.ts
+++ b/server/sonar-web/src/main/js/api/issues.ts
@@ -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() {
diff --git a/server/sonar-web/src/main/js/api/organizations.ts b/server/sonar-web/src/main/js/api/organizations.ts
index 1ce52b9344a..191d100af54 100644
--- a/server/sonar-web/src/main/js/api/organizations.ts
+++ b/server/sonar-web/src/main/js/api/organizations.ts
@@ -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> {
diff --git a/server/sonar-web/src/main/js/api/quality-profiles.ts b/server/sonar-web/src/main/js/api/quality-profiles.ts
index e0f1c948aa9..150276ecb21 100644
--- a/server/sonar-web/src/main/js/api/quality-profiles.ts
+++ b/server/sonar-web/src/main/js/api/quality-profiles.ts
@@ -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;
}
diff --git a/server/sonar-web/src/main/js/api/user_groups.ts b/server/sonar-web/src/main/js/api/user_groups.ts
index 14d3900f942..7a3c88b0d33 100644
--- a/server/sonar-web/src/main/js/api/user_groups.ts
+++ b/server/sonar-web/src/main/js/api/user_groups.ts
@@ -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
index 00000000000..d91059bf630
--- /dev/null
+++ b/server/sonar-web/src/main/js/app/components/AccountDeleted.tsx
@@ -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
index 00000000000..911ce7ae432
--- /dev/null
+++ b/server/sonar-web/src/main/js/app/components/__tests__/AccountDeleted-test.tsx
@@ -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
index 00000000000..8bffcc4ae6e
--- /dev/null
+++ b/server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/AccountDeleted-test.tsx.snap
@@ -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>
+`;
diff --git a/server/sonar-web/src/main/js/app/styles/init/lists.css b/server/sonar-web/src/main/js/app/styles/init/lists.css
index 42d19d67d32..fe2e6c9cd52 100644
--- a/server/sonar-web/src/main/js/app/styles/init/lists.css
+++ b/server/sonar-web/src/main/js/app/styles/init/lists.css
@@ -28,6 +28,10 @@ ul {
padding-left: 40px;
}
+.list-styled.no-padding {
+ padding-left: calc(var(--gridSize) * 2);
+}
+
ul.list-styled {
list-style: disc;
}
diff --git a/server/sonar-web/src/main/js/app/types.d.ts b/server/sonar-web/src/main/js/app/types.d.ts
index f1f0bc2e88b..415a5f2e276 100644
--- a/server/sonar-web/src/main/js/app/types.d.ts
+++ b/server/sonar-web/src/main/js/app/types.d.ts
@@ -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;
diff --git a/server/sonar-web/src/main/js/app/utils/startReactApp.tsx b/server/sonar-web/src/main/js/app/utils/startReactApp.tsx
index 714cba17a37..6560bb4bef2 100644
--- a/server/sonar-web/src/main/js/app/utils/startReactApp.tsx
+++ b/server/sonar-web/src/main/js/app/utils/startReactApp.tsx
@@ -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} />
diff --git a/server/sonar-web/src/main/js/apps/account/profile/Profile.tsx b/server/sonar-web/src/main/js/apps/account/profile/Profile.tsx
index b752f8fa8b3..941371b710e 100644
--- a/server/sonar-web/src/main/js/apps/account/profile/Profile.tsx
+++ b/server/sonar-web/src/main/js/apps/account/profile/Profile.tsx
@@ -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
index 00000000000..38351f002b4
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccount.tsx
@@ -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
index 00000000000..aaad598307b
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccountContent.tsx
@@ -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
index 00000000000..566fad469f6
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccountModal.tsx
@@ -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
index 00000000000..1e4d4b217e3
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccount-test.tsx
@@ -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
index 00000000000..c9e5e334d5e
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccountContent-test.tsx
@@ -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
index 00000000000..e820b6c0284
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccountModal-test.tsx
@@ -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
index 00000000000..05a4397a565
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccount-test.tsx.snap
@@ -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
index 00000000000..56ac708fb53
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccountContent-test.tsx.snap
@@ -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
index 00000000000..5ba1d10e4ad
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccountModal-test.tsx.snap
@@ -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>
+`;
diff --git a/server/sonar-web/src/main/js/apps/custom-measures/components/Item.tsx b/server/sonar-web/src/main/js/apps/custom-measures/components/Item.tsx
index b7ceeab374d..92ec1433518 100644
--- a/server/sonar-web/src/main/js/apps/custom-measures/components/Item.tsx
+++ b/server/sonar-web/src/main/js/apps/custom-measures/components/Item.tsx
@@ -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">
diff --git a/server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/Item-test.tsx b/server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/Item-test.tsx
index 90fb5c29edc..667d1f38a88 100644
--- a/server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/Item-test.tsx
+++ b/server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/Item-test.tsx
@@ -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} />);
+}
diff --git a/server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/__snapshots__/Item-test.tsx.snap b/server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/__snapshots__/Item-test.tsx.snap
index 225ca0c587c..a763f649723 100644
--- a/server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/__snapshots__/Item-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/__snapshots__/Item-test.tsx.snap
@@ -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>
+`;
diff --git a/server/sonar-web/src/main/js/apps/groups/components/EditMembersModal.tsx b/server/sonar-web/src/main/js/apps/groups/components/EditMembersModal.tsx
index a0f00cf6d6e..712206e016f 100644
--- a/server/sonar-web/src/main/js/apps/groups/components/EditMembersModal.tsx
+++ b/server/sonar-web/src/main/js/apps/groups/components/EditMembersModal.tsx
@@ -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[];
}
diff --git a/server/sonar-web/src/main/js/apps/issues/components/App.tsx b/server/sonar-web/src/main/js/apps/issues/components/App.tsx
index d4482a05dbf..4d3262d3fec 100644
--- a/server/sonar-web/src/main/js/apps/issues/components/App.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/components/App.tsx
@@ -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;
diff --git a/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx
index 9599069623a..7a8fb72a717 100644
--- a/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx
@@ -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
+ }))
);
};
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx
index caf10424969..95c4446ff8d 100644
--- a/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx
@@ -20,12 +20,13 @@
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}
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx
index 95bcfd24158..ade6f7080b8 100644
--- a/server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx
@@ -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> {
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.tsx
index 6bab64ec2a1..4ce377cd1be 100644
--- a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.tsx
@@ -19,18 +19,18 @@
*/
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}
/>
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.tsx.snap b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.tsx.snap
index 7dea30a849c..6e5f27e4e47 100644
--- a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.tsx.snap
@@ -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>
+`;
diff --git a/server/sonar-web/src/main/js/apps/issues/utils.ts b/server/sonar-web/src/main/js/apps/issues/utils.ts
index a6c644f645e..8e98b7919cb 100644
--- a/server/sonar-web/src/main/js/apps/issues/utils.ts
+++ b/server/sonar-web/src/main/js/apps/issues/utils.ts
@@ -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,
diff --git a/server/sonar-web/src/main/js/apps/projects/components/NoFavoriteProjects.tsx b/server/sonar-web/src/main/js/apps/projects/components/NoFavoriteProjects.tsx
index 7132c0bd98b..02a75616768 100644
--- a/server/sonar-web/src/main/js/apps/projects/components/NoFavoriteProjects.tsx
+++ b/server/sonar-web/src/main/js/apps/projects/components/NoFavoriteProjects.tsx
@@ -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>
diff --git a/server/sonar-web/src/main/js/apps/projects/components/__tests__/NoFavoriteProjects-test.tsx b/server/sonar-web/src/main/js/apps/projects/components/__tests__/NoFavoriteProjects-test.tsx
index 8cdc9b4a4e7..0f8947329a5 100644
--- a/server/sonar-web/src/main/js/apps/projects/components/__tests__/NoFavoriteProjects-test.tsx
+++ b/server/sonar-web/src/main/js/apps/projects/components/__tests__/NoFavoriteProjects-test.tsx
@@ -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' },
diff --git a/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/NoFavoriteProjects-test.tsx.snap b/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/NoFavoriteProjects-test.tsx.snap
index d1b0a82b9c6..0aa5d44907e 100644
--- a/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/NoFavoriteProjects-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/NoFavoriteProjects-test.tsx.snap
@@ -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>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/App.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/App.tsx
index 29b60956fca..7de9677ea73 100644
--- a/server/sonar-web/src/main/js/apps/projectsManagement/App.tsx
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/App.tsx
@@ -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;
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.tsx
index 1273e30ffd3..4e99b7f715b 100644
--- a/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.tsx
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.tsx
@@ -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;
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRowActions.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRowActions.tsx
index abff099d1ee..a2578797b24 100644
--- a/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRowActions.tsx
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRowActions.tsx
@@ -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;
}
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/Projects.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/Projects.tsx
index d644c5410d2..5c89aaac9b1 100644
--- a/server/sonar-web/src/main/js/apps/projectsManagement/Projects.tsx
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/Projects.tsx
@@ -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;
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/RestoreAccessModal.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/RestoreAccessModal.tsx
index 89fc396db7d..ad11a0f3ded 100644
--- a/server/sonar-web/src/main/js/apps/projectsManagement/RestoreAccessModal.tsx
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/RestoreAccessModal.tsx
@@ -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;
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissions.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissions.tsx
index a9816e9d4be..dc919c071df 100644
--- a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissions.tsx
+++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissions.tsx
@@ -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)
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsForm.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsForm.tsx
index 574063aaa4a..4e93e4f12d4 100644
--- a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsForm.tsx
+++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsForm.tsx
@@ -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 });
};
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsFormSelect.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsFormSelect.tsx
index 3599277ea52..7d3ef0b6a1d 100644
--- a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsFormSelect.tsx
+++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsFormSelect.tsx
@@ -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) {
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsUser.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsUser.tsx
index 196ba45d7e9..5498efdc9b2 100644
--- a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsUser.tsx
+++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsUser.tsx
@@ -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 {
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsUser-test.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsUser-test.tsx
index 027390687f1..e70e777f7d6 100644
--- a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsUser-test.tsx
+++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsUser-test.tsx
@@ -17,23 +17,21 @@
* 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', () => {
diff --git a/server/sonar-web/src/main/js/apps/sessions/components/Logout.tsx b/server/sonar-web/src/main/js/apps/sessions/components/Logout.tsx
index 0ebfab02599..0bba08201fe 100644
--- a/server/sonar-web/src/main/js/apps/sessions/components/Logout.tsx
+++ b/server/sonar-web/src/main/js/apps/sessions/components/Logout.tsx
@@ -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
index 00000000000..dcd7df7c9e4
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/sessions/components/__tests__/Logout-test.tsx
@@ -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
index 00000000000..f510e29fcf7
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/sessions/components/__tests__/__snapshots__/Logout-test.tsx.snap
@@ -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>
+`;
diff --git a/server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx b/server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx
index bf7ea8f38d1..a193e71bcce 100644
--- a/server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx
+++ b/server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx
@@ -26,7 +26,7 @@ import { deactivateUser } from '../../../api/users';
export interface Props {
onClose: () => void;
onUpdateUsers: () => void;
- user: T.User;
+ user: T.UserActive;
}
interface State {
diff --git a/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx b/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx
index 43b4273c75c..d4248014214 100644
--- a/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx
+++ b/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx
@@ -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}
diff --git a/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx b/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx
index 06e487685e6..584095c3779 100644
--- a/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx
+++ b/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx
@@ -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,
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/UserActions-test.tsx b/server/sonar-web/src/main/js/apps/users/components/__tests__/UserActions-test.tsx
index 6002475bbbe..58d79168075 100644
--- a/server/sonar-web/src/main/js/apps/users/components/__tests__/UserActions-test.tsx
+++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/UserActions-test.tsx
@@ -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 = {}) {
diff --git a/server/sonar-web/src/main/js/components/controls/ValidationModal.tsx b/server/sonar-web/src/main/js/components/controls/ValidationModal.tsx
index 110bbc84237..841b3592be1 100644
--- a/server/sonar-web/src/main/js/components/controls/ValidationModal.tsx
+++ b/server/sonar-web/src/main/js/components/controls/ValidationModal.tsx
@@ -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}>
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/ValidationModal-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/ValidationModal-test.tsx
index c09219ef13b..f4af7f14294 100644
--- a/server/sonar-web/src/main/js/components/controls/__tests__/ValidationModal-test.tsx
+++ b/server/sonar-web/src/main/js/components/controls/__tests__/ValidationModal-test.tsx
@@ -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()}
diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueAssign.tsx b/server/sonar-web/src/main/js/components/issue/components/IssueAssign.tsx
index c13bf63ea1b..544456a265c 100644
--- a/server/sonar-web/src/main/js/components/issue/components/IssueAssign.tsx
+++ b/server/sonar-web/src/main/js/components/issue/components/IssueAssign.tsx
@@ -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() {
diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueChangelogDiff.tsx b/server/sonar-web/src/main/js/components/issue/components/IssueChangelogDiff.tsx
index a4e84cbf7a9..1f1cccfd6b8 100644
--- a/server/sonar-web/src/main/js/components/issue/components/IssueChangelogDiff.tsx
+++ b/server/sonar-web/src/main/js/components/issue/components/IssueChangelogDiff.tsx
@@ -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) {
diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.tsx b/server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.tsx
index 117697c19a7..5d351a94347 100644
--- a/server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.tsx
+++ b/server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.tsx
@@ -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"
diff --git a/server/sonar-web/src/main/js/components/issue/components/__tests__/IssueCommentLine-test.tsx b/server/sonar-web/src/main/js/components/issue/components/__tests__/IssueCommentLine-test.tsx
index 14f487b2950..b2c3ac6eddb 100644
--- a/server/sonar-web/src/main/js/components/issue/components/__tests__/IssueCommentLine-test.tsx
+++ b/server/sonar-web/src/main/js/components/issue/components/__tests__/IssueCommentLine-test.tsx
@@ -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} />
+ );
+}
diff --git a/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueAssign-test.tsx.snap b/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueAssign-test.tsx.snap
index 0b8921f3cd4..2e2ccd87499 100644
--- a/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueAssign-test.tsx.snap
+++ b/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueAssign-test.tsx.snap
@@ -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>
`;
diff --git a/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueCommentLine-test.tsx.snap b/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueCommentLine-test.tsx.snap
index 2b347f6d742..1f5b5388ac9 100644
--- a/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueCommentLine-test.tsx.snap
+++ b/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueCommentLine-test.tsx.snap
@@ -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>
+`;
diff --git a/server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.tsx b/server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.tsx
index 2a023866e2a..2393284a645 100644
--- a/server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.tsx
+++ b/server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.tsx
@@ -18,35 +18,25 @@
* 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} />
))}
diff --git a/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.tsx b/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.tsx
index 52ed2d9d674..f019d02f673 100644
--- a/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.tsx
+++ b/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.tsx
@@ -19,25 +19,17 @@
*/
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);
diff --git a/server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.tsx b/server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.tsx
index 2b4c067d78f..baa824dedcf 100644
--- a/server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.tsx
+++ b/server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.tsx
@@ -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')
diff --git a/server/sonar-web/src/main/js/components/issue/popups/__tests__/ChangelogPopup-test.tsx b/server/sonar-web/src/main/js/components/issue/popups/__tests__/ChangelogPopup-test.tsx
index 358b5c3d1a3..8045eef9d53 100644
--- a/server/sonar-web/src/main/js/components/issue/popups/__tests__/ChangelogPopup-test.tsx
+++ b/server/sonar-web/src/main/js/components/issue/popups/__tests__/ChangelogPopup-test.tsx
@@ -19,27 +19,61 @@
*/
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
index 00000000000..6eb8153484f
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/issue/popups/__tests__/SetAssigneePopup-test.tsx
@@ -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
index 00000000000..962e2d0ca3a
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/issue/popups/__tests__/SimilarIssuesPopup-test.tsx
@@ -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}
+ />
+ );
+}
diff --git a/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/ChangelogPopup-test.tsx.snap b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/ChangelogPopup-test.tsx.snap
index eb4ed4d2aed..2bf516e280b 100644
--- a/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/ChangelogPopup-test.tsx.snap
+++ b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/ChangelogPopup-test.tsx.snap
@@ -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
index 00000000000..b539ed1acf0
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetAssigneePopup-test.tsx.snap
@@ -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
index 00000000000..59070654247
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SimilarIssuesPopup-test.tsx.snap
@@ -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>
+`;
diff --git a/server/sonar-web/src/main/js/helpers/issues.ts b/server/sonar-web/src/main/js/helpers/issues.ts
index d0f90ebeb03..e7757004281 100644
--- a/server/sonar-web/src/main/js/helpers/issues.ts
+++ b/server/sonar-web/src/main/js/helpers/issues.ts
@@ -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);
diff --git a/server/sonar-web/src/main/js/helpers/users.ts b/server/sonar-web/src/main/js/helpers/users.ts
index a12fe60f13a..cf2cc974c18 100644
--- a/server/sonar-web/src/main/js/helpers/users.ts
+++ b/server/sonar-web/src/main/js/helpers/users.ts
@@ -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);
+}
diff --git a/server/sonar-web/yarn.lock b/server/sonar-web/yarn.lock
index bf9daccf676..d12b6eb2844 100644
--- a/server/sonar-web/yarn.lock
+++ b/server/sonar-web/yarn.lock
@@ -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"
diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties
index 4bb08dcb93c..5db376440cc 100644
--- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties
+++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties
@@ -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
diff --git a/sonar-ws/src/main/protobuf/ws-issues.proto b/sonar-ws/src/main/protobuf/ws-issues.proto
index d74b89ded46..4ba95043bb4 100644
--- a/sonar-ws/src/main/protobuf/ws-issues.proto
+++ b/sonar-ws/src/main/protobuf/ws-issues.proto
@@ -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;
diff --git a/sonar-ws/src/main/protobuf/ws-organizations.proto b/sonar-ws/src/main/protobuf/ws-organizations.proto
index 7a468f3b8fe..f16c6b7a40f 100644
--- a/sonar-ws/src/main/protobuf/ws-organizations.proto
+++ b/sonar-ws/src/main/protobuf/ws-organizations.proto
@@ -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;