"UUID", uuid,
"KEE", uuid,
"NAME", uuid,
- "GUARDED", String.valueOf(false),
"CREATED_AT", "1000",
"UPDATED_AT", "1000");
}
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;
return this;
}
- public boolean isGuarded() {
- return guarded;
- }
-
- public OrganizationDto setGuarded(boolean guarded) {
- this.guarded = guarded;
- return this;
- }
-
@CheckForNull
public Integer getDefaultGroupId() {
return defaultGroupId;
", description='" + description + '\'' +
", url='" + url + '\'' +
", avatarUrl='" + avatarUrl + '\'' +
- ", guarded=" + guarded +
", defaultQualityGateUuid=" + defaultQualityGateUuid +
", subscription=" + subscription +
", createdAt=" + createdAt +
--- /dev/null
+/*
+ * 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;
+ }
+}
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
return userId;
}
- public boolean isOnlyTeam() {
- return onlyTeam;
- }
-
- public boolean isOnlyPersonal() {
- return onlyPersonal;
- }
-
public boolean isWithAnalyses() {
return withAnalyses;
}
return analyzedAfter;
}
- public boolean isWithoutProjects() {
- return withoutProjects;
- }
-
public static OrganizationQuery returnAll() {
return NO_FILTER;
}
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;
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;
return this;
}
- public Builder setWithoutProjects() {
- this.withoutProjects = true;
- return this;
- }
-
public OrganizationQuery build() {
return new OrganizationQuery(this);
}
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());
}
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.
return this;
}
- @CheckForNull
- public String getOrganizationUuid() {
- return organizationUuid;
- }
-
- public UserDto setOrganizationUuid(@Nullable String organizationUuid) {
- this.organizationUuid = organizationUuid;
- return this;
- }
-
@CheckForNull
public Long getLastConnectionDate() {
return lastConnectionDate;
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);
org.default_quality_gate_uuid as "defaultQualityGateUuid",
org.url as "url",
org.avatar_url as "avatarUrl",
- org.guarded as "guarded",
org.subscription as "subscription",
org.created_at as "createdAt",
org.updated_at as "updatedAt"
#{key, jdbcType=VARCHAR}
</foreach>
</if>
- <if test="query.onlyTeam">
- and not exists(
- select 1
- from users u
- where u.organization_uuid = org.uuid
- and u.active = ${_true}
- )
- </if>
- <if test="query.onlyPersonal">
- and exists(
- select 1
- from users u
- where u.organization_uuid = org.uuid
- and u.active = ${_true}
- )
- </if>
<if test="query.withAnalyses">
and exists(
select 1
and s.islast = ${_true}
)
</if>
- <if test="query.withoutProjects">
- and not exists(
- select 1
- from projects p
- where p.organization_uuid = org.uuid
- and p.enabled = ${_true}
- )
- </if>
<if test="query.analyzedAfter != null">
and exists(
select 1
description,
url,
avatar_url,
- guarded,
new_project_private,
default_quality_gate_uuid,
subscription,
#{organization.description, jdbcType=VARCHAR},
#{organization.url, jdbcType=VARCHAR},
#{organization.avatarUrl, jdbcType=VARCHAR},
- #{organization.guarded, jdbcType=BOOLEAN},
#{newProjectPrivate, jdbcType=BOOLEAN},
#{organization.defaultQualityGateUuid, jdbcType=VARCHAR},
#{organization.subscription, jdbcType=VARCHAR},
u.onboarded as "onboarded",
u.homepage_type as "homepageType",
u.homepage_parameter as "homepageParameter",
- u.organization_uuid as organizationUuid,
u.last_connection_date as "lastConnectionDate",
u.created_at as "createdAt",
u.updated_at as "updatedAt"
and u.login <> #{login}
</select>
- <update id="deactivateUser" parameterType="map">
- update users set
+ <sql id="deactivateUserUpdatedFields">
active = ${_false},
email = null,
scm_accounts = null,
crypted_password = null,
last_connection_date = null,
updated_at = #{now, jdbcType=BIGINT}
+ </sql>
+
+ <update id="deactivateUser" parameterType="map">
+ update users set
+ <include refid="deactivateUserUpdatedFields"/>
+ where
+ login = #{login, jdbcType=VARCHAR}
+ </update>
+
+ <update id="deactivateSonarCloudUser" parameterType="map">
+ update users set
+ name = null,
+ <include refid="deactivateUserUpdatedFields"/>
where
login = #{login, jdbcType=VARCHAR}
</update>
onboarded,
homepage_type,
homepage_parameter,
- organization_uuid,
created_at,
updated_at
) values (
#{user.onboarded,jdbcType=BOOLEAN},
#{user.homepageType,jdbcType=VARCHAR},
#{user.homepageParameter,jdbcType=VARCHAR},
- #{user.organizationUuid,jdbcType=VARCHAR},
#{user.createdAt,jdbcType=BIGINT},
#{user.updatedAt,jdbcType=BIGINT}
)
hash_method = #{user.hashMethod, jdbcType=VARCHAR},
homepage_type = #{user.homepageType, jdbcType=VARCHAR},
homepage_parameter = #{user.homepageParameter, jdbcType=VARCHAR},
- organization_uuid = #{user.organizationUuid, jdbcType=VARCHAR},
last_connection_date = #{user.lastConnectionDate,jdbcType=BIGINT},
updated_at = #{user.updatedAt,jdbcType=BIGINT}
where
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;
.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()
.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";
assertThat(row.get("avatarUrl")).isEqualTo(organization.getAvatarUrl());
assertThat(row.get("createdAt")).isEqualTo(organization.getCreatedAt());
assertThat(row.get("updatedAt")).isEqualTo(organization.getUpdatedAt());
- assertThat(row.get("guarded")).isEqualTo(toBool(organization.isGuarded()));
assertThat(row.get("subscription")).isEqualTo(organization.getSubscription().name());
assertThat(row.get("defaultTemplate")).isNull();
assertThat(row.get("projectDefaultTemplate")).isNull();
assertThat(row.get("viewDefaultTemplate")).isNull();
}
- @Test
- public void insert_persists_boolean_property_guarded_of_OrganizationDto() {
- insertOrganization(ORGANIZATION_DTO_2);
-
- Map<String, Object> row = selectSingleRow();
- assertThat(row.get("guarded")).isEqualTo(toBool(ORGANIZATION_DTO_2.isGuarded()));
- }
-
@Test
public void description_url_avatarUrl_and_userId_are_optional() {
when(system2.now()).thenReturn(SOME_DATE);
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);
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);
.doesNotContain(organizationWithoutKeyProvided.getUuid(), organizationWithoutMember.getUuid());
}
- @Test
- public void selectByQuery_filter_on_type() {
- OrganizationDto personalOrg1 = db.organizations().insert();
- db.users().insertUser(u -> u.setOrganizationUuid(personalOrg1.getUuid()));
- OrganizationDto personalOrg2 = db.organizations().insert();
- db.users().insertUser(u -> u.setOrganizationUuid(personalOrg2.getUuid()));
- OrganizationDto teamOrg1 = db.organizations().insert();
-
- assertThat(selectUuidsByQuery(q -> q.setOnlyPersonal(), forPage(1).andSize(100)))
- .containsExactlyInAnyOrder(personalOrg1.getUuid(), personalOrg2.getUuid());
- assertThat(selectUuidsByQuery(q -> q.setOnlyTeam(), forPage(1).andSize(100)))
- .containsExactlyInAnyOrder(teamOrg1.getUuid());
- }
-
@Test
public void selectByQuery_filter_on_withAnalyses() {
assertThat(selectUuidsByQuery(q -> q.setWithAnalyses(), forPage(1).andSize(100)))
.collect(Collectors.toList());
}
- @Test
- public void selectByQuery_filter_on_withoutProjects() {
- assertThat(selectUuidsByQuery(q -> q.setWithoutProjects(), forPage(1).andSize(100)))
- .isEmpty();
-
- // has projects
- OrganizationDto orgWithProjects = db.organizations().insert();
- db.components().insertPrivateProject(orgWithProjects);
- db.components().insertPrivateProject(orgWithProjects, p -> p.setEnabled(false));
- // has no projects
- OrganizationDto orgWithoutProjects = db.organizations().insert();
- // has only disabled projects
- OrganizationDto orgWithOnlyDisabledProjects = db.organizations().insert();
- db.components().insertPrivateProject(orgWithOnlyDisabledProjects, p -> p.setEnabled(false));
-
- assertThat(selectUuidsByQuery(q -> q.setWithoutProjects(), forPage(1).andSize(100)))
- .containsExactlyInAnyOrder(orgWithoutProjects.getUuid(), orgWithOnlyDisabledProjects.getUuid());
- }
-
@Test
public void getDefaultTemplates_returns_empty_when_table_is_empty() {
assertThat(underTest.getDefaultTemplates(dbSession, ORGANIZATION_DTO_1.getUuid())).isEmpty();
" default_perm_template_app," +
" default_perm_template_port," +
" new_project_private," +
- " guarded," +
" default_quality_gate_uuid," +
" subscription," +
" created_at," +
" ?," +
" ?," +
" ?," +
- " ?," +
" ?" +
" )")) {
preparedStatement.setString(1, organizationUuid);
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);
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());
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());
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\"," +
--- /dev/null
+/*
+ * 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);
+ }
+ }
+}
+++ /dev/null
-/*
- * 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();
- }
-}
*/
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;
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;
.setLocal(true)
.setHomepageType("project")
.setHomepageParameter("OB1")
- .setOrganizationUuid("ORG_UUID")
.setCreatedAt(date)
.setUpdatedAt(date);
underTest.insert(db.getSession(), userDto);
assertThat(user.isRoot()).isFalse();
assertThat(user.getHomepageType()).isEqualTo("project");
assertThat(user.getHomepageParameter()).isEqualTo("OB1");
- assertThat(user.getOrganizationUuid()).isEqualTo("ORG_UUID");
}
@Test
.setEmail("jo@hn.com")
.setActive(true)
.setLocal(true)
- .setOnboarded(false)
- .setOrganizationUuid("OLD_ORG_UUID"));
+ .setOnboarded(false));
underTest.update(db.getSession(), newUserDto()
.setUuid(user.getUuid())
.setLocal(false)
.setHomepageType("project")
.setHomepageParameter("OB1")
- .setOrganizationUuid("ORG_UUID")
.setLastConnectionDate(10_000_000_000L));
UserDto reloaded = underTest.selectByUuid(db.getSession(), user.getUuid());
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);
}
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();
"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),
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.
[[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".
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 -->
"/user-guide/visualizations/",
"/user-guide/sonarlint-notifications/",
"/user-guide/security-reports/",
+ "/user-guide/user-account/",
"/user-guide/user-token/",
"/user-guide/keyboard-shortcuts/"
]
return Optional.empty();
}
- @Override
- public Optional<String> getPersonalOrganizationUuid() {
- return Optional.empty();
- }
-
@Override
public boolean isLoggedIn() {
return false;
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()
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:
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();
}
+++ /dev/null
-/*
- * 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();
- }
-
-}
CreateAction.class,
OrganizationDeleter.class,
DeleteAction.class,
- DeleteEmptyPersonalOrgsAction.class,
RemoveMemberAction.class,
- UpdateAction.class);
+ UpdateAction.class,
+ PreventUserDeletionAction.class);
}
}
--- /dev/null
+/*
+ * 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());
+ }
+}
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;
})
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();
return Optional.empty();
}
- @Override
- public Optional<String> getPersonalOrganizationUuid() {
- return Optional.empty();
- }
-
@Override
protected boolean hasPermissionImpl(OrganizationPermission permission, String organizationUuid) {
return true;
return ofNullable(userDto).map(d -> computeIdentity(d).getExternalIdentity());
}
- @Override
- public Optional<String> getPersonalOrganizationUuid() {
- return ofNullable(userDto).map(UserDto::getOrganizationUuid);
- }
-
@Override
protected boolean hasPermissionImpl(OrganizationPermission permission, String organizationUuid) {
if (permissionsByOrganizationUuid == null) {
return get().getExternalIdentity();
}
- @Override
- public Optional<String> getPersonalOrganizationUuid() {
- return get().getPersonalOrganizationUuid();
- }
-
@Override
public boolean isLoggedIn() {
return get().isLoggedIn();
*/
Optional<ExternalIdentity> getExternalIdentity();
- /**
- * The UUID of the personal organization of the authenticated user.
- */
- Optional<String> getPersonalOrganizationUuid();
-
/**
* Whether the user is logged-in or anonymous.
*/
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)));
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()
}));
}
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 -> {}));
}
}
*/
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;
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;
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
@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);
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);
}
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()));
- }
}
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();
}
}
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()));
- }
-
}
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;
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);
{
"user": "john.smith",
"userName": "John Smith",
+ "isUserActive": true,
"avatar": "b0d8c6e5ea589e6fc3d3e08afb1873bb",
"creationDate": "2014-03-04T23:03:44+0100",
"diffs": [
--- /dev/null
+{
+ "organizations": [
+ {
+ "key": "foo-company",
+ "name": "Foo Company"
+ },
+ {
+ "key": "bar-company",
+ "name": "Bar Company"
+ }
+ ]
+}
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"));
assertThat(result.getChangelogList().get(0).getDiffsList()).isNotEmpty();
}
+ @Test
+ public void return_changelog_on_deactivated_user() {
+ UserDto user = db.users().insertDisabledUser();
+ IssueDto issueDto = db.issues().insertIssue(newIssue());
+ userSession.logIn("john")
+ .addMembership(db.getDefaultOrganization())
+ .addProjectPermission(USER, project, file);
+ db.issues().insertFieldDiffs(issueDto, new FieldDiffs().setUserUuid(user.getUuid()).setDiff("severity", "MAJOR", "BLOCKER").setCreationDate(new Date()));
+
+ ChangelogWsResponse result = call(issueDto.getKey());
+
+ assertThat(result.getChangelogList()).hasSize(1);
+ assertThat(result.getChangelogList().get(0).getUser()).isEqualTo(user.getLogin());
+ assertThat(result.getChangelogList().get(0).getIsUserActive()).isFalse();
+ assertThat(result.getChangelogList().get(0).getUserName()).isEqualTo(user.getName());
+ assertThat(result.getChangelogList().get(0).hasAvatar()).isFalse();
+ assertThat(result.getChangelogList().get(0).getDiffsList()).isNotEmpty();
+ }
+
@Test
public void return_multiple_diffs() {
UserDto user = insertUser();
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")
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();
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);
assertThat(organization.getDescription()).isNull();
assertThat(organization.getUrl()).isNull();
assertThat(organization.getAvatarUrl()).isNull();
- assertThat(organization.isGuarded()).isFalse();
}
@Test
+++ /dev/null
-/*
- * 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();
- }
-}
--- /dev/null
+/*
+ * 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);
+ }
+}
tuple(groupAdminOrganization.getKey(), true, true));
}
- @Test
- public void root_can_do_everything() {
- OrganizationDto organization = db.organizations().insert();
- OrganizationDto guardedOrganization = db.organizations().insert(dto -> dto.setGuarded(true));
- UserDto user = db.users().insertUser();
- userSession.logIn(user).setRoot();
-
- SearchWsResponse result = call(ws.newRequest());
-
- assertThat(result.getOrganizationsList())
- .extracting(Organization::getKey, o -> o.getActions().getAdmin(), o -> o.getActions().getDelete(), o -> o.getActions().getProvision())
- .containsExactlyInAnyOrder(
- tuple(organization.getKey(), true, true, true),
- tuple(guardedOrganization.getKey(), true, true, true));
- }
-
@Test
public void provision_action_available_for_each_organization() {
OrganizationDto userProvisionOrganization = db.organizations().insert();
.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")
.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);
return Optional.empty();
}
- @Override
- public Optional<String> getPersonalOrganizationUuid() {
- return Optional.empty();
- }
-
@Override
public boolean hasMembershipImpl(OrganizationDto organizationDto) {
return false;
return Optional.ofNullable(externalIdentity);
}
- @Override
- public Optional<String> getPersonalOrganizationUuid() {
- return Optional.empty();
- }
}
return currentUserSession.getExternalIdentity();
}
- @Override
- public Optional<String> getPersonalOrganizationUuid() {
- return currentUserSession.getPersonalOrganizationUuid();
- }
-
@Override
public boolean isLoggedIn() {
return currentUserSession.isLoggedIn();
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);
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);
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;
throw notImplemented();
}
- @Override
- public Optional<String> getPersonalOrganizationUuid() {
- throw notImplemented();
- }
-
@Override
public boolean isLoggedIn() {
return user != null;
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;
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;
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() {
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()))
}
@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();
}
@Test
- public void deactivation_requires_administrator_permission() {
+ public void deactivation_requires_administrator_permission_on_sonarqube() {
userSession.logIn();
expectedException.expect(ForbiddenException.class);
deactivate(admin.getLogin());
- verifyThatUserIsDeactivated(admin.getLogin());
+ verifyThatUserIsDeactivated(admin.getLogin(), false);
verifyThatUserExists(anotherAdmin.getLogin());
}
}
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));
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()));
}
}
assertThat(userReloaded.getSalt()).isNull();
}
- @Test
- public void update_personal_organization_when_updating_login() {
- userSession.logIn().setSystemAdministrator();
- String oldLogin = "old_login";
- OrganizationDto personalOrganization = db.organizations().insert(o -> o.setKey(oldLogin).setGuarded(true));
- UserDto user = db.users().insertUser(u -> u.setLogin(oldLogin).setOrganizationUuid(personalOrganization.getUuid()));
-
- ws.newRequest()
- .setParam("login", oldLogin)
- .setParam("newLogin", "new_login")
- .execute();
-
- UserDto userReloaded = db.getDbClient().userDao().selectByUuid(db.getSession(), user.getUuid());
- assertThat(userReloaded.getLogin()).isEqualTo("new_login");
- OrganizationDto organizationReloaded = db.getDbClient().organizationDao().selectByUuid(db.getSession(), personalOrganization.getUuid()).get();
- assertThat(organizationReloaded.getKey()).isEqualTo("new_login");
- }
-
@Test
public void fail_with_IAE_when_new_login_is_already_used() {
userSession.logIn().setSystemAdministrator();
"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"
components?: Array<{ key: string; name: string }>;
issue: RawIssue;
rules?: Array<{}>;
- users?: Array<{ login: string }>;
+ users?: Array<T.UserBase>;
}
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 =
.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() {
);
}
+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> {
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;
}
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;
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);
}
--- /dev/null
+/*
+ * 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>
+ );
+}
--- /dev/null
+/*
+ * 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();
+});
--- /dev/null
+// 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>
+`;
padding-left: 40px;
}
+.list-styled.no-padding {
+ padding-left: calc(var(--gridSize) * 2);
+}
+
ul.list-styled {
list-style: disc;
}
};
projectKey: string;
pending?: boolean;
- user: {
- active?: boolean;
- email?: string;
- login: string;
- name: string;
- };
+ user: T.UserBase;
value: string;
updatedAt?: string;
}
export interface Issue {
actions: string[];
assignee?: string;
- assigneeActive?: string;
+ assigneeActive?: boolean;
assigneeAvatar?: string;
assigneeLogin?: string;
assigneeName?: string;
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;
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[];
}
url?: string;
}
- export interface OrganizationMember {
- login: string;
- name: string;
- avatar?: string;
+ export interface OrganizationMember extends UserActive {
groupCount?: number;
}
permissions: string[];
}
- export interface PermissionUser {
- avatar?: string;
- email?: string;
- login: string;
- name: string;
+ export interface PermissionUser extends UserActive {
permissions: string[];
}
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;
/* 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';
import('../../apps/feedback/downgrade/DowngradeFeedback')
)}
/>
+ <Route
+ path="account-deleted"
+ component={lazyLoad(() => import('../components/AccountDeleted'))}
+ />
</>
)}
<RouteWithChildRoutes path="organizations" childRoutes={organizationsRoutes} />
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';
<hr />
<UserScmAccounts scmAccounts={currentUser.scmAccounts} user={currentUser} />
+
+ {isSonarCloud() && (
+ <>
+ <hr />
+
+ <UserDeleteAccount user={currentUser} />
+ </>
+ )}
</div>
</div>
);
--- /dev/null
+/*
+ * 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));
--- /dev/null
+/*
+ * 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} />;
+}
--- /dev/null
+/*
+ * 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));
--- /dev/null
+/*
+ * 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} />
+ );
+}
--- /dev/null
+/*
+ * 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();
+});
--- /dev/null
+/*
+ * 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}
+ />
+ );
+}
--- /dev/null
+// 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>
+`;
--- /dev/null
+// 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>
+`;
--- /dev/null
+// 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>
+`;
* 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, {
import DeleteForm from './DeleteForm';
import Form from './Form';
import MeasureDate from './MeasureDate';
+import { isUserActive } from '../../../helpers/users';
interface Props {
measure: T.CustomMeasure;
<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">
};
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();
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();
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} />);
+}
</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>
+`;
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;
lastSearchParams: SearchParams;
listHasBeenTouched: boolean;
loading: boolean;
- users: GroupUser[];
+ users: T.UserSelected[];
usersTotalCount?: number;
selectedUsers: string[];
}
ReferencedComponent,
ReferencedLanguage,
ReferencedRule,
- ReferencedUser,
saveMyIssues,
serializeQuery,
STANDARDS,
languages: ReferencedLanguage[];
paging: T.Paging;
rules: ReferencedRule[];
- users: ReferencedUser[];
+ users: T.UserBase[];
}
interface Props {
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;
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;
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
+ }))
);
};
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;
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> {
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)
}
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)}
</>
);
};
}
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}
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;
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> {
*/
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(['']);
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('');
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={[]}
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}
/>
}
/>
`;
+
+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>
+`;
uuid: string;
}
-export interface ReferencedUser {
- avatar: string;
- name: string;
-}
-
export interface ReferencedLanguage {
name: string;
}
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,
{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>
).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' },
</div>
`;
-exports[`renders for SonarCloud 1`] = `
+exports[`renders for SonarCloud with organizations 1`] = `
<div
className="projects-empty-list"
>
</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>
+`;
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;
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;
import { getComponentPermissionsUrl } from '../../helpers/urls';
export interface Props {
- currentUser: { login: string };
+ currentUser: Pick<T.LoggedInUser, 'login'>;
organization: string | undefined;
project: Project;
}
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;
import { Project } from '../../api/components';
interface Props {
- currentUser: { login: string };
+ currentUser: Pick<T.LoggedInUser, 'login'>;
onClose: () => void;
onRestoreAccess: () => void;
project: Project;
} from '../../../api/quality-profiles';
import { Profile } from '../types';
-export interface User {
- avatar?: string;
- login: string;
- name: string;
-}
-
export interface Group {
name: string;
}
addUserForm: boolean;
groups?: Group[];
loading: boolean;
- users?: User[];
+ users?: T.UserSelected[];
}
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,
}
};
- handleUserDelete = (removedUser: User) => {
+ handleUserDelete = (removedUser: T.UserSelected) => {
if (this.mounted) {
this.setState((state: State) => ({
users: state.users && state.users.filter(user => user !== removedUser)
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,
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;
}
}
};
- handleUserAdd = (user: User) =>
+ handleUserAdd = (user: T.UserSelected) =>
addUser({
language: this.props.profile.language,
login: user.login,
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);
}
);
};
- handleValueChange = (selected: User | Group) => {
+ handleValueChange = (selected: T.UserSelected | Group) => {
this.setState({ selected });
};
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 {
}
}
-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) {
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 {
* 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', () => {
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() + '/');
},
() => {}
);
--- /dev/null
+/*
+ * 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} />);
+}
--- /dev/null
+// 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>
+`;
export interface Props {
onClose: () => void;
onUpdateUsers: () => void;
- user: T.User;
+ user: T.UserActive;
}
interface State {
import DeactivateForm from './DeactivateForm';
import PasswordForm from './PasswordForm';
import UserForm from './UserForm';
+import { isUserActive } from '../../../helpers/users';
interface Props {
isCurrentUser: boolean;
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;
</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>
);
};
return (
<>
{this.renderActions()}
- {openForm === 'deactivate' && (
+ {openForm === 'deactivate' && isUserActive(user) && (
<DeactivateForm
onClose={this.handleCloseForm}
onUpdateUsers={onUpdateUsers}
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;
name: string;
password: string;
scmAccounts: string[];
- submitting: boolean;
}
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 = {
login: '',
name: '',
password: '',
- scmAccounts: [],
- submitting: false
+ scmAccounts: []
};
}
}
return throwGlobalError(error);
} else {
return parseError(error).then(
- errorMsg => this.setState({ error: errorMsg, submitting: false }),
+ errorMsg => this.setState({ error: errorMsg }),
throwGlobalError
);
}
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,
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,
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 = {}) {
confirmButtonText: string;
header: string;
initialValues: V;
+ isDestructive?: boolean;
isInitialValid?: boolean;
onClose: () => void;
onSubmit: (data: V) => Promise<void>;
<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}>
confirmButtonText="confirm"
header="title"
initialValues={{ field: 'foo' }}
+ isDestructive={true}
isInitialValid={true}
onClose={jest.fn()}
onSubmit={jest.fn()}
*/
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';
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;
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"
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() {
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) {
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';
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"
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',
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} />
+ );
+}
onRequestClose={[Function]}
open={true}
overlay={
- <Connect(SetAssigneePopup)
+ <Connect(withCurrentUser(SetAssigneePopup))
issue={
Object {
"assignee": "john",
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"
onRequestClose={[Function]}
open={false}
overlay={
- <Connect(SetAssigneePopup)
+ <Connect(withCurrentUser(SetAssigneePopup))
issue={
Object {
"assignee": "john",
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"
`;
exports[`should render without the action when the correct rights are missing 1`] = `
-<span>
+<Fragment>
<span
className="text-top"
>
>
John Doe
</span>
-</span>
+</Fragment>
`;
<CommentPopup
comment={
Object {
+ "author": "john.doe",
+ "authorActive": true,
"authorAvatar": "gravatarhash",
"authorName": "John Doe",
"createdAt": "2017-03-01T09:36:01+0100",
<CommentPopup
comment={
Object {
+ "author": "john.doe",
+ "authorActive": true,
"authorAvatar": "gravatarhash",
"authorName": "John Doe",
"createdAt": "2017-03-01T09:36:01+0100",
</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>
+`;
* 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;
loadChangelog() {
getIssueChangelog(this.props.issue.key).then(
- changelogs => {
+ ({ changelog }) => {
if (this.mounted) {
- this.setState({ changelogs });
+ this.setState({ changelog });
}
},
() => {}
</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} />
))}
*/
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;
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);
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 : ''
});
};
{!!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>
}
}
-const mapStateToProps = (state: Store) => ({
- currentUser: getCurrentUser(state)
-});
-
-export default connect(mapStateToProps)(SetAssigneePopup);
+export default withCurrentUser(SetAssigneePopup);
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';
'file'
].filter(item => item) as string[];
+ const assignee = issue.assigneeName || issue.assignee;
+
return (
<DropdownOverlay noPadding={true}>
<header className="menu-search">
</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')
*/
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}
+ />
+ );
+}
--- /dev/null
+/*
+ * 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}
+ />
+ );
+}
--- /dev/null
+/*
+ * 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}
+ />
+ );
+}
<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={
--- /dev/null
+// 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>
+`;
--- /dev/null
+// 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>
+`;
[x: string]: any;
}
-interface User {
- login: string;
-}
-
interface Rule {}
interface Component {
return newFields;
}
-function injectCommentsRelational(issue: RawIssue, users?: User[]) {
+function injectCommentsRelational(issue: RawIssue, users?: T.UserBase[]) {
if (!issue.comments) {
return {};
}
export function parseIssueFromResponse(
issue: RawIssue,
components?: Component[],
- users?: User[],
+ users?: T.UserBase[],
rules?: Rule[]
): T.Issue {
const { secondaryLocations, flows } = splitFlows(issue, components);
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);
+}
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"
#------------------------------------------------------------------------------
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
# 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
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
optional string creationDate = 4;
repeated Diff diffs = 5;
optional string avatar = 6;
+ optional bool isUserActive = 7;
message Diff {
optional string key = 1;
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;