Add dates to users and user tokenstags/7.7
@@ -523,6 +523,7 @@ CREATE TABLE "USERS" ( | |||
"HOMEPAGE_TYPE" VARCHAR(40), | |||
"HOMEPAGE_PARAMETER" VARCHAR(40), | |||
"ORGANIZATION_UUID" VARCHAR(40), | |||
"LAST_CONNECTION_DATE" BIGINT, | |||
"CREATED_AT" BIGINT, | |||
"UPDATED_AT" BIGINT | |||
); | |||
@@ -785,6 +786,7 @@ CREATE TABLE "USER_TOKENS" ( | |||
"USER_UUID" VARCHAR(255) NOT NULL, | |||
"NAME" VARCHAR(100) NOT NULL, | |||
"TOKEN_HASH" VARCHAR(255) NOT NULL, | |||
"LAST_CONNECTION_DATE" BIGINT, | |||
"CREATED_AT" BIGINT NOT NULL | |||
); | |||
CREATE UNIQUE INDEX "USER_TOKENS_TOKEN_HASH" ON "USER_TOKENS" ("TOKEN_HASH"); |
@@ -178,10 +178,6 @@ public class UserDao implements Dao { | |||
return mapper(dbSession).selectByExternalLoginAndIdentityProvider(externalLogin, externalIdentityProvider); | |||
} | |||
public List<UserDto> selectByExternalIdentityProvider(DbSession dbSession, String externalIdentityProvider) { | |||
return mapper(dbSession).selectByExternalIdentityProvider(externalIdentityProvider); | |||
} | |||
public void scrollByUuids(DbSession dbSession, Collection<String> uuids, Consumer<UserDto> consumer) { | |||
UserMapper mapper = mapper(dbSession); | |||
@@ -56,6 +56,14 @@ public class UserDto { | |||
private boolean root = false; | |||
private boolean onboarded = false; | |||
private String organizationUuid; | |||
/** | |||
* Date of the last time the user has accessed to the server. | |||
* Can be null when user has never been authenticated, or has not been authenticated since the creation of the column in SonarQube 7.7. | |||
*/ | |||
@Nullable | |||
private Long lastConnectionDate; | |||
private Long createdAt; | |||
private Long updatedAt; | |||
@@ -274,6 +282,16 @@ public class UserDto { | |||
return this; | |||
} | |||
@CheckForNull | |||
public Long getLastConnectionDate() { | |||
return lastConnectionDate; | |||
} | |||
public UserDto setLastConnectionDate(@Nullable Long lastConnectionDate) { | |||
this.lastConnectionDate = lastConnectionDate; | |||
return this; | |||
} | |||
public Long getCreatedAt() { | |||
return createdAt; | |||
} |
@@ -64,8 +64,6 @@ public interface UserMapper { | |||
UserDto selectByExternalLoginAndIdentityProvider(@Param("externalLogin") String externalLogin, @Param("externalIdentityProvider") String externalExternalIdentityProvider); | |||
List<UserDto> selectByExternalIdentityProvider(@Param("externalIdentityProvider") String externalExternalIdentityProvider); | |||
void scrollAll(ResultHandler<UserDto> handler); | |||
/** |
@@ -31,10 +31,15 @@ import static org.sonar.core.util.stream.MoreCollectors.toList; | |||
import static org.sonar.db.DatabaseUtils.executeLargeInputs; | |||
public class UserTokenDao implements Dao { | |||
public void insert(DbSession dbSession, UserTokenDto userTokenDto) { | |||
mapper(dbSession).insert(userTokenDto); | |||
} | |||
public void update(DbSession dbSession, UserTokenDto userTokenDto) { | |||
mapper(dbSession).update(userTokenDto); | |||
} | |||
@CheckForNull | |||
public UserTokenDto selectByTokenHash(DbSession dbSession, String tokenHash) { | |||
return mapper(dbSession).selectByTokenHash(tokenHash); |
@@ -19,12 +19,23 @@ | |||
*/ | |||
package org.sonar.db.user; | |||
import javax.annotation.CheckForNull; | |||
import javax.annotation.Nullable; | |||
import static org.sonar.db.user.UserTokenValidator.checkTokenHash; | |||
public class UserTokenDto { | |||
private String userUuid; | |||
private String name; | |||
private String tokenHash; | |||
/** | |||
* Date of the last time this token has been used. | |||
* Can be null when user has never been used it. | |||
*/ | |||
private Long lastConnectionDate; | |||
private Long createdAt; | |||
public String getUserUuid() { | |||
@@ -54,6 +65,16 @@ public class UserTokenDto { | |||
return this; | |||
} | |||
@CheckForNull | |||
public Long getLastConnectionDate() { | |||
return lastConnectionDate; | |||
} | |||
public UserTokenDto setLastConnectionDate(@Nullable Long lastConnectionDate) { | |||
this.lastConnectionDate = lastConnectionDate; | |||
return this; | |||
} | |||
public Long getCreatedAt() { | |||
return createdAt; | |||
} |
@@ -23,8 +23,11 @@ import java.util.List; | |||
import org.apache.ibatis.annotations.Param; | |||
public interface UserTokenMapper { | |||
void insert(UserTokenDto userToken); | |||
void update(UserTokenDto userToken); | |||
UserTokenDto selectByTokenHash(String tokenHash); | |||
UserTokenDto selectByUserUuidAndName(@Param("userUuid") String userUuid, @Param("name") String name); |
@@ -23,6 +23,7 @@ | |||
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" | |||
</sql> | |||
@@ -150,13 +151,6 @@ | |||
WHERE u.external_login=#{externalLogin} AND u.external_identity_provider=#{externalIdentityProvider, jdbcType=VARCHAR} | |||
</select> | |||
<select id="selectByExternalIdentityProvider" parameterType="map" resultType="User"> | |||
SELECT | |||
<include refid="userColumns"/> | |||
FROM users u | |||
WHERE u.external_identity_provider=#{externalIdentityProvider, jdbcType=VARCHAR} | |||
</select> | |||
<select id="countRootUsersButLogin" parameterType="String" resultType="long"> | |||
select | |||
count(1) | |||
@@ -175,6 +169,7 @@ | |||
scm_accounts = null, | |||
salt = null, | |||
crypted_password = null, | |||
last_connection_date = null, | |||
updated_at = #{now, jdbcType=BIGINT} | |||
where | |||
login = #{login, jdbcType=VARCHAR} | |||
@@ -272,6 +267,7 @@ | |||
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 | |||
uuid = #{user.uuid, jdbcType=VARCHAR} |
@@ -7,6 +7,7 @@ | |||
t.user_uuid as "userUuid", | |||
t.name as "name", | |||
t.token_hash as "tokenHash", | |||
t.last_connection_date as "lastConnectionDate", | |||
t.created_at as "createdAt" | |||
</sql> | |||
@@ -17,32 +18,40 @@ | |||
token_hash, | |||
created_at | |||
) values ( | |||
#{userUuid,jdbcType=VARCHAR}, | |||
#{name,jdbcType=VARCHAR}, | |||
#{tokenHash,jdbcType=VARCHAR}, | |||
#{createdAt,jdbcType=BIGINT} | |||
#{userUuid, jdbcType=VARCHAR}, | |||
#{name, jdbcType=VARCHAR}, | |||
#{tokenHash, jdbcType=VARCHAR}, | |||
#{createdAt, jdbcType=BIGINT} | |||
) | |||
</insert> | |||
<insert id="update" parameterType="UserToken"> | |||
UPDATE user_tokens SET | |||
last_connection_date = #{lastConnectionDate, jdbcType=BIGINT} | |||
WHERE | |||
user_uuid = #{userUuid, jdbcType=VARCHAR} | |||
AND name = #{name, jdbcType=VARCHAR} | |||
</insert> | |||
<select id="selectByTokenHash" parameterType="String" resultType="UserToken"> | |||
SELECT | |||
<include refid="userTokensColumns"/> | |||
FROM user_tokens t | |||
WHERE t.token_hash=#{tokenHash} | |||
WHERE t.token_hash=#{tokenHash, jdbcType=VARCHAR} | |||
</select> | |||
<select id="selectByUserUuidAndName" parameterType="map" resultType="UserToken"> | |||
SELECT | |||
<include refid="userTokensColumns"/> | |||
FROM user_tokens t | |||
WHERE t.user_uuid=#{userUuid} and t.name=#{name} | |||
WHERE t.user_uuid=#{userUuid, jdbcType=VARCHAR} and t.name=#{name, jdbcType=VARCHAR} | |||
</select> | |||
<select id="selectByUserUuid" parameterType="map" resultType="UserToken"> | |||
SELECT | |||
<include refid="userTokensColumns"/> | |||
FROM user_tokens t | |||
WHERE t.user_uuid=#{userUuid} | |||
WHERE t.user_uuid=#{userUuid, jdbcType=VARCHAR} | |||
</select> | |||
<select id="countTokensByUserUuids" parameterType="map" resultType="UserTokenCount"> | |||
@@ -50,17 +59,17 @@ | |||
FROM user_tokens t | |||
WHERE t.user_uuid in | |||
<foreach collection="userUuids" open="(" close=")" item="userUuid" separator=","> | |||
#{userUuid} | |||
#{userUuid, jdbcType=VARCHAR} | |||
</foreach> | |||
GROUP BY t.user_uuid | |||
</select> | |||
<delete id="deleteByUserUuid"> | |||
DELETE FROM user_tokens WHERE user_uuid=#{userUuid} | |||
DELETE FROM user_tokens WHERE user_uuid=#{userUuid, jdbcType=VARCHAR} | |||
</delete> | |||
<delete id="deleteByUserUuidAndName"> | |||
DELETE FROM user_tokens WHERE user_uuid=#{userUuid} and name=#{name} | |||
DELETE FROM user_tokens WHERE user_uuid=#{userUuid, jdbcType=VARCHAR} and name=#{name, jdbcType=VARCHAR} | |||
</delete> | |||
</mapper> |
@@ -363,6 +363,17 @@ public class UserDaoTest { | |||
assertThat(user.getOrganizationUuid()).isEqualTo("ORG_UUID"); | |||
} | |||
@Test | |||
public void insert_user_does_not_set_last_connection_date() { | |||
UserDto user = newUserDto().setLastConnectionDate(10_000_000_000L); | |||
underTest.insert(db.getSession(), user); | |||
db.getSession().commit(); | |||
UserDto reloaded = underTest.selectByUuid(db.getSession(), user.getUuid()); | |||
assertThat(reloaded.getLastConnectionDate()).isNull(); | |||
} | |||
@Test | |||
public void update_user() { | |||
UserDto user = db.users().insertUser(u -> u | |||
@@ -391,7 +402,8 @@ public class UserDaoTest { | |||
.setLocal(false) | |||
.setHomepageType("project") | |||
.setHomepageParameter("OB1") | |||
.setOrganizationUuid("ORG_UUID")); | |||
.setOrganizationUuid("ORG_UUID") | |||
.setLastConnectionDate(10_000_000_000L)); | |||
UserDto reloaded = underTest.selectByUuid(db.getSession(), user.getUuid()); | |||
assertThat(reloaded).isNotNull(); | |||
@@ -413,6 +425,7 @@ public class UserDaoTest { | |||
assertThat(reloaded.getHomepageType()).isEqualTo("project"); | |||
assertThat(reloaded.getHomepageParameter()).isEqualTo("OB1"); | |||
assertThat(reloaded.getOrganizationUuid()).isEqualTo("ORG_UUID"); | |||
assertThat(reloaded.getLastConnectionDate()).isEqualTo(10_000_000_000L); | |||
} | |||
@Test | |||
@@ -420,6 +433,7 @@ public class UserDaoTest { | |||
UserDto user = insertActiveUser(); | |||
insertUserGroup(user); | |||
UserDto otherUser = insertActiveUser(); | |||
underTest.update(db.getSession(), user.setLastConnectionDate(10_000_000_000L)); | |||
session.commit(); | |||
underTest.deactivateUser(session, user); | |||
@@ -438,6 +452,7 @@ public class UserDaoTest { | |||
assertThat(userReloaded.getUpdatedAt()).isEqualTo(NOW); | |||
assertThat(userReloaded.getHomepageType()).isNull(); | |||
assertThat(userReloaded.getHomepageParameter()).isNull(); | |||
assertThat(userReloaded.getLastConnectionDate()).isNull(); | |||
assertThat(underTest.selectUserById(session, otherUser.getId())).isNotNull(); | |||
} | |||
@@ -107,6 +107,12 @@ public class UserDbTester { | |||
return user; | |||
} | |||
public UserDto updateLastConnectionDate(UserDto user, long lastConnectionDate) { | |||
db.getDbClient().userDao().update(db.getSession(), user.setLastConnectionDate(lastConnectionDate)); | |||
db.getSession().commit(); | |||
return user; | |||
} | |||
public Optional<UserDto> selectUserByLogin(String login) { | |||
return Optional.ofNullable(dbClient.userDao().selectByLogin(db.getSession(), login)); | |||
} |
@@ -55,6 +55,21 @@ public class UserTokenDaoTest { | |||
assertThat(userTokenFromDb.getUserUuid()).isEqualTo(userToken.getUserUuid()); | |||
} | |||
@Test | |||
public void update_last_connection_date() { | |||
UserDto user1 = db.users().insertUser(); | |||
UserTokenDto userToken1 = db.users().insertToken(user1); | |||
UserTokenDto userToken2 = db.users().insertToken(user1); | |||
assertThat(underTest.selectByTokenHash(dbSession, userToken1.getTokenHash()).getLastConnectionDate()).isNull(); | |||
underTest.update(dbSession, userToken1.setLastConnectionDate(10_000_000_000L)); | |||
UserTokenDto userTokenReloaded = underTest.selectByTokenHash(dbSession, userToken1.getTokenHash()); | |||
assertThat(userTokenReloaded.getLastConnectionDate()).isEqualTo(10_000_000_000L); | |||
assertThat(userTokenReloaded.getTokenHash()).isEqualTo(userToken1.getTokenHash()); | |||
assertThat(userTokenReloaded.getCreatedAt()).isEqualTo(userToken1.getCreatedAt()); | |||
} | |||
@Test | |||
public void select_by_token_hash() { | |||
UserDto user = db.users().insertUser(); |
@@ -0,0 +1,46 @@ | |||
/* | |||
* 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.platform.db.migration.version.v77; | |||
import java.sql.SQLException; | |||
import org.sonar.db.Database; | |||
import org.sonar.server.platform.db.migration.SupportsBlueGreen; | |||
import org.sonar.server.platform.db.migration.def.BigIntegerColumnDef; | |||
import org.sonar.server.platform.db.migration.sql.AddColumnsBuilder; | |||
import org.sonar.server.platform.db.migration.step.DdlChange; | |||
@SupportsBlueGreen | |||
public class AddLastConnectionDateToUserTokens extends DdlChange { | |||
public AddLastConnectionDateToUserTokens(Database db) { | |||
super(db); | |||
} | |||
@Override | |||
public void execute(Context context) throws SQLException { | |||
context.execute(new AddColumnsBuilder(getDialect(), "user_tokens") | |||
.addColumn(BigIntegerColumnDef.newBigIntegerColumnDefBuilder() | |||
.setColumnName("last_connection_date") | |||
.setIsNullable(true) | |||
.build()) | |||
.build()); | |||
} | |||
} |
@@ -0,0 +1,46 @@ | |||
/* | |||
* 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.platform.db.migration.version.v77; | |||
import java.sql.SQLException; | |||
import org.sonar.db.Database; | |||
import org.sonar.server.platform.db.migration.SupportsBlueGreen; | |||
import org.sonar.server.platform.db.migration.def.BigIntegerColumnDef; | |||
import org.sonar.server.platform.db.migration.sql.AddColumnsBuilder; | |||
import org.sonar.server.platform.db.migration.step.DdlChange; | |||
@SupportsBlueGreen | |||
public class AddLastConnectionDateToUsers extends DdlChange { | |||
public AddLastConnectionDateToUsers(Database db) { | |||
super(db); | |||
} | |||
@Override | |||
public void execute(Context context) throws SQLException { | |||
context.execute(new AddColumnsBuilder(getDialect(), "users") | |||
.addColumn(BigIntegerColumnDef.newBigIntegerColumnDefBuilder() | |||
.setColumnName("last_connection_date") | |||
.setIsNullable(true) | |||
.build()) | |||
.build()); | |||
} | |||
} |
@@ -28,6 +28,8 @@ public class DbVersion77 implements DbVersion { | |||
public void addSteps(MigrationStepRegistry registry) { | |||
registry | |||
.add(2600, "Drop elasticsearch index 'tests'", DropElasticsearchIndexTests.class) | |||
.add(2601, "Delete lines with DATA_TYPE='TEST' from table FILES_SOURCE", DeleteTestDataTypeFromFileSources.class); | |||
.add(2601, "Delete lines with DATA_TYPE='TEST' from table FILES_SOURCE", DeleteTestDataTypeFromFileSources.class) | |||
.add(2602, "Add column LAST_CONNECTION_DATE to USERS table", AddLastConnectionDateToUsers.class) | |||
.add(2603, "Add column LAST_USED_DATE to USER_TOKENS table", AddLastConnectionDateToUserTokens.class); | |||
} | |||
} |
@@ -0,0 +1,57 @@ | |||
/* | |||
* 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.platform.db.migration.version.v77; | |||
import java.sql.SQLException; | |||
import java.sql.Types; | |||
import org.junit.Rule; | |||
import org.junit.Test; | |||
import org.junit.rules.ExpectedException; | |||
import org.sonar.db.CoreDbTester; | |||
public class AddLastConnectionDateToUserTokensTest { | |||
private static final String TABLE = "user_tokens"; | |||
@Rule | |||
public final CoreDbTester db = CoreDbTester.createForSchema(AddLastConnectionDateToUserTokensTest.class, "user_tokens.sql"); | |||
@Rule | |||
public ExpectedException expectedException = ExpectedException.none(); | |||
private AddLastConnectionDateToUserTokens underTest = new AddLastConnectionDateToUserTokens(db.database()); | |||
@Test | |||
public void add_column() throws SQLException { | |||
underTest.execute(); | |||
db.assertColumnDefinition(TABLE, "last_connection_date", Types.BIGINT, null); | |||
} | |||
@Test | |||
public void migration_is_not_re_entrant() throws SQLException { | |||
underTest.execute(); | |||
expectedException.expect(IllegalStateException.class); | |||
underTest.execute(); | |||
} | |||
} |
@@ -0,0 +1,57 @@ | |||
/* | |||
* 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.platform.db.migration.version.v77; | |||
import java.sql.SQLException; | |||
import java.sql.Types; | |||
import org.junit.Rule; | |||
import org.junit.Test; | |||
import org.junit.rules.ExpectedException; | |||
import org.sonar.db.CoreDbTester; | |||
public class AddLastConnectionDateToUsersTest { | |||
private static final String TABLE = "users"; | |||
@Rule | |||
public final CoreDbTester db = CoreDbTester.createForSchema(AddLastConnectionDateToUsersTest.class, "users.sql"); | |||
@Rule | |||
public ExpectedException expectedException = ExpectedException.none(); | |||
private AddLastConnectionDateToUsers underTest = new AddLastConnectionDateToUsers(db.database()); | |||
@Test | |||
public void add_column() throws SQLException { | |||
underTest.execute(); | |||
db.assertColumnDefinition(TABLE, "last_connection_date", Types.BIGINT, null); | |||
} | |||
@Test | |||
public void migration_is_not_re_entrant() throws SQLException { | |||
underTest.execute(); | |||
expectedException.expect(IllegalStateException.class); | |||
underTest.execute(); | |||
} | |||
} |
@@ -36,7 +36,7 @@ public class DbVersion77Test { | |||
@Test | |||
public void verify_migration_count() { | |||
verifyMigrationCount(underTest, 2); | |||
verifyMigrationCount(underTest, 4); | |||
} | |||
} |
@@ -0,0 +1,9 @@ | |||
CREATE TABLE "USER_TOKENS" ( | |||
"ID" INTEGER NOT NULL GENERATED BY DEFAULT AS IDENTITY (START WITH 1, INCREMENT BY 1), | |||
"USER_UUID" VARCHAR(255) NOT NULL, | |||
"NAME" VARCHAR(100) NOT NULL, | |||
"TOKEN_HASH" VARCHAR(255) NOT NULL, | |||
"CREATED_AT" BIGINT NOT NULL | |||
); | |||
CREATE UNIQUE INDEX "USER_TOKENS_TOKEN_HASH" ON "USER_TOKENS" ("TOKEN_HASH"); | |||
CREATE UNIQUE INDEX "USER_TOKENS_USER_UUID_NAME" ON "USER_TOKENS" ("USER_UUID", "NAME"); |
@@ -0,0 +1,28 @@ | |||
CREATE TABLE "USERS" ( | |||
"ID" INTEGER NOT NULL GENERATED BY DEFAULT AS IDENTITY (START WITH 1, INCREMENT BY 1), | |||
"UUID" VARCHAR(255) NOT NULL, | |||
"LOGIN" VARCHAR(255) NOT NULL, | |||
"NAME" VARCHAR(200), | |||
"EMAIL" VARCHAR(100), | |||
"CRYPTED_PASSWORD" VARCHAR(100), | |||
"SALT" VARCHAR(40), | |||
"HASH_METHOD" VARCHAR(10), | |||
"ACTIVE" BOOLEAN DEFAULT TRUE, | |||
"SCM_ACCOUNTS" VARCHAR(4000), | |||
"EXTERNAL_ID" VARCHAR(255) NOT NULL, | |||
"EXTERNAL_LOGIN" VARCHAR(255) NOT NULL, | |||
"EXTERNAL_IDENTITY_PROVIDER" VARCHAR(100) NOT NULL, | |||
"IS_ROOT" BOOLEAN NOT NULL, | |||
"USER_LOCAL" BOOLEAN, | |||
"ONBOARDED" BOOLEAN NOT NULL, | |||
"HOMEPAGE_TYPE" VARCHAR(40), | |||
"HOMEPAGE_PARAMETER" VARCHAR(40), | |||
"ORGANIZATION_UUID" VARCHAR(40), | |||
"CREATED_AT" BIGINT, | |||
"UPDATED_AT" BIGINT | |||
); | |||
CREATE UNIQUE INDEX "USERS_UUID" ON "USERS" ("UUID"); | |||
CREATE UNIQUE INDEX "USERS_LOGIN" ON "USERS" ("LOGIN"); | |||
CREATE UNIQUE INDEX "UNIQ_EXTERNAL_ID" ON "USERS" ("EXTERNAL_IDENTITY_PROVIDER", "EXTERNAL_ID"); | |||
CREATE UNIQUE INDEX "UNIQ_EXTERNAL_LOGIN" ON "USERS" ("EXTERNAL_IDENTITY_PROVIDER", "EXTERNAL_LOGIN"); | |||
CREATE INDEX "USERS_UPDATED_AT" ON "USERS" ("UPDATED_AT"); |
@@ -52,6 +52,7 @@ public class AuthenticationModule extends Module { | |||
BasicAuthentication.class, | |||
ValidateAction.class, | |||
HttpHeadersAuthentication.class, | |||
RequestAuthenticatorImpl.class); | |||
RequestAuthenticatorImpl.class, | |||
UserLastConnectionDatesUpdaterImpl.class); | |||
} | |||
} |
@@ -0,0 +1,31 @@ | |||
/* | |||
* 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.authentication; | |||
import org.sonar.db.user.UserDto; | |||
import org.sonar.db.user.UserTokenDto; | |||
public interface UserLastConnectionDatesUpdater { | |||
void updateLastConnectionDateIfNeeded(UserDto user); | |||
void updateLastConnectionDateIfNeeded(UserTokenDto userToken); | |||
} |
@@ -0,0 +1,73 @@ | |||
/* | |||
* 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.authentication; | |||
import javax.annotation.Nullable; | |||
import org.sonar.api.utils.System2; | |||
import org.sonar.db.DbClient; | |||
import org.sonar.db.DbSession; | |||
import org.sonar.db.user.UserDto; | |||
import org.sonar.db.user.UserTokenDto; | |||
public class UserLastConnectionDatesUpdaterImpl implements UserLastConnectionDatesUpdater { | |||
private static final long ONE_HOUR_IN_MILLISECONDS = 60 * 60 * 1000L; | |||
private final DbClient dbClient; | |||
private final System2 system2; | |||
public UserLastConnectionDatesUpdaterImpl(DbClient dbClient, System2 system2) { | |||
this.dbClient = dbClient; | |||
this.system2 = system2; | |||
} | |||
@Override | |||
public void updateLastConnectionDateIfNeeded(UserDto user) { | |||
Long lastConnectionDate = user.getLastConnectionDate(); | |||
long now = system2.now(); | |||
if (doesNotRequireUpdate(lastConnectionDate, now)) { | |||
return; | |||
} | |||
try (DbSession dbSession = dbClient.openSession(false)) { | |||
dbClient.userDao().update(dbSession, user.setLastConnectionDate(now)); | |||
dbSession.commit(); | |||
} | |||
} | |||
@Override | |||
public void updateLastConnectionDateIfNeeded(UserTokenDto userToken) { | |||
Long lastConnectionDate = userToken.getLastConnectionDate(); | |||
long now = system2.now(); | |||
if (doesNotRequireUpdate(lastConnectionDate, now)) { | |||
return; | |||
} | |||
try (DbSession dbSession = dbClient.openSession(false)) { | |||
dbClient.userTokenDao().update(dbSession, userToken.setLastConnectionDate(now)); | |||
userToken.setLastConnectionDate(now); | |||
dbSession.commit(); | |||
} | |||
} | |||
private static boolean doesNotRequireUpdate(@Nullable Long lastConnectionDate, long now) { | |||
// Update date only once per hour in order to decrease pressure on DB | |||
return lastConnectionDate != null && (now - lastConnectionDate) < ONE_HOUR_IN_MILLISECONDS; | |||
} | |||
} |
@@ -22,6 +22,7 @@ package org.sonar.server.user; | |||
import org.sonar.api.server.ServerSide; | |||
import org.sonar.db.DbClient; | |||
import org.sonar.db.user.UserDto; | |||
import org.sonar.server.authentication.UserLastConnectionDatesUpdater; | |||
import org.sonar.server.organization.DefaultOrganizationProvider; | |||
import org.sonar.server.organization.OrganizationFlags; | |||
@@ -33,17 +34,20 @@ public class UserSessionFactoryImpl implements UserSessionFactory { | |||
private final DbClient dbClient; | |||
private final DefaultOrganizationProvider defaultOrganizationProvider; | |||
private final OrganizationFlags organizationFlags; | |||
private final UserLastConnectionDatesUpdater userLastConnectionDatesUpdater; | |||
public UserSessionFactoryImpl(DbClient dbClient, DefaultOrganizationProvider defaultOrganizationProvider, | |||
OrganizationFlags organizationFlags) { | |||
OrganizationFlags organizationFlags, UserLastConnectionDatesUpdater userLastConnectionDatesUpdater) { | |||
this.dbClient = dbClient; | |||
this.defaultOrganizationProvider = defaultOrganizationProvider; | |||
this.organizationFlags = organizationFlags; | |||
this.userLastConnectionDatesUpdater = userLastConnectionDatesUpdater; | |||
} | |||
@Override | |||
public ServerUserSession create(UserDto user) { | |||
requireNonNull(user, "UserDto must not be null"); | |||
userLastConnectionDatesUpdater.updateLastConnectionDateIfNeeded(user); | |||
return new ServerUserSession(dbClient, organizationFlags, defaultOrganizationProvider, user); | |||
} | |||
@@ -24,6 +24,7 @@ import java.util.ArrayList; | |||
import java.util.Collection; | |||
import java.util.List; | |||
import java.util.Map; | |||
import java.util.Objects; | |||
import java.util.function.Function; | |||
import javax.annotation.CheckForNull; | |||
import javax.annotation.Nullable; | |||
@@ -48,10 +49,12 @@ import org.sonarqube.ws.Users.SearchWsResponse; | |||
import static com.google.common.base.MoreObjects.firstNonNull; | |||
import static com.google.common.base.Preconditions.checkArgument; | |||
import static com.google.common.base.Strings.emptyToNull; | |||
import static java.util.Optional.ofNullable; | |||
import static org.sonar.api.server.ws.WebService.Param.FIELDS; | |||
import static org.sonar.api.server.ws.WebService.Param.PAGE; | |||
import static org.sonar.api.server.ws.WebService.Param.PAGE_SIZE; | |||
import static org.sonar.api.server.ws.WebService.Param.TEXT_QUERY; | |||
import static org.sonar.api.utils.DateUtils.formatDateTime; | |||
import static org.sonar.api.utils.Paging.forPageIndex; | |||
import static org.sonar.core.util.stream.MoreCollectors.toList; | |||
import static org.sonar.server.es.SearchOptions.MAX_LIMIT; | |||
@@ -91,11 +94,19 @@ public class SearchAction implements UsersWsAction { | |||
public void define(WebService.NewController controller) { | |||
WebService.NewAction action = controller.createAction("search") | |||
.setDescription("Get a list of active users. <br/>" + | |||
"Administer System permission is required to show the 'groups' field.<br/>" + | |||
"Field 'tokensCount' is only accessible to System Administrator and logged in user.<br/>" + | |||
"When accessed anonymously, only logins and names are returned.") | |||
"The following fields are only returned when user has Administer System permission or for logged-in in user :" + | |||
"<ul>" + | |||
" <li>'email'</li>" + | |||
" <li>'externalIdentity'</li>" + | |||
" <li>'externalProvider'</li>" + | |||
" <li>'groups'</li>" + | |||
" <li>'lastConnectionDate'</li>" + | |||
" <li>'tokensCount'</li>" + | |||
"</ul>" + | |||
"Field 'lastConnectionDate' is only updated every hour, so it may not be accurate, for instance when a user authenticates many times in less than one hour.") | |||
.setSince("3.6") | |||
.setChangelog( | |||
new Change("7.7", "New field 'lastConnectionDate' is added to response"), | |||
new Change("7.4", "External identity is only returned to system administrators"), | |||
new Change("6.4", "Paging response fields moved to a Paging object"), | |||
new Change("6.4", "Avatar has been added to the response"), | |||
@@ -153,16 +164,16 @@ public class SearchAction implements UsersWsAction { | |||
setIfNeeded(FIELD_ACTIVE, fields, user.isActive(), userBuilder::setActive); | |||
setIfNeeded(FIELD_LOCAL, fields, user.isLocal(), userBuilder::setLocal); | |||
setIfNeeded(FIELD_EXTERNAL_PROVIDER, fields, user.getExternalIdentityProvider(), userBuilder::setExternalProvider); | |||
setIfNeeded(isNeeded(FIELD_TOKENS_COUNT, fields) && user.getLogin().equals(userSession.getLogin()), tokensCount, userBuilder::setTokensCount); | |||
setIfNeeded(isNeeded(FIELD_SCM_ACCOUNTS, fields) && !user.getScmAccountsAsList().isEmpty(), user.getScmAccountsAsList(), | |||
scm -> userBuilder.setScmAccounts(ScmAccounts.newBuilder().addAllScmAccounts(scm))); | |||
} | |||
if (userSession.isSystemAdministrator()) { | |||
if (userSession.isSystemAdministrator() || Objects.equals(userSession.getUuid(), user.getUuid())) { | |||
setIfNeeded(FIELD_EMAIL, fields, user.getEmail(), userBuilder::setEmail); | |||
setIfNeeded(isNeeded(FIELD_GROUPS, fields) && !groups.isEmpty(), groups, | |||
g -> userBuilder.setGroups(Groups.newBuilder().addAllGroups(g))); | |||
setIfNeeded(FIELD_EXTERNAL_IDENTITY, fields, user.getExternalLogin(), userBuilder::setExternalIdentity); | |||
setIfNeeded(FIELD_TOKENS_COUNT, fields, tokensCount, userBuilder::setTokensCount); | |||
ofNullable(user.getLastConnectionDate()).ifPresent(date -> userBuilder.setLastConnectionDate(formatDateTime(date))); | |||
} | |||
return userBuilder.build(); | |||
} |
@@ -23,6 +23,7 @@ import java.util.Optional; | |||
import org.sonar.db.DbClient; | |||
import org.sonar.db.DbSession; | |||
import org.sonar.db.user.UserTokenDto; | |||
import org.sonar.server.authentication.UserLastConnectionDatesUpdater; | |||
import static java.util.Optional.empty; | |||
import static java.util.Optional.of; | |||
@@ -30,10 +31,12 @@ import static java.util.Optional.of; | |||
public class UserTokenAuthentication { | |||
private final TokenGenerator tokenGenerator; | |||
private final DbClient dbClient; | |||
private final UserLastConnectionDatesUpdater userLastConnectionDatesUpdater; | |||
public UserTokenAuthentication(TokenGenerator tokenGenerator, DbClient dbClient) { | |||
public UserTokenAuthentication(TokenGenerator tokenGenerator, DbClient dbClient, UserLastConnectionDatesUpdater userLastConnectionDatesUpdater) { | |||
this.tokenGenerator = tokenGenerator; | |||
this.dbClient = dbClient; | |||
this.userLastConnectionDatesUpdater = userLastConnectionDatesUpdater; | |||
} | |||
/** | |||
@@ -48,6 +51,7 @@ public class UserTokenAuthentication { | |||
if (userToken == null) { | |||
return empty(); | |||
} | |||
userLastConnectionDatesUpdater.updateLastConnectionDateIfNeeded(userToken); | |||
return of(userToken.getUserUuid()); | |||
} | |||
} |
@@ -20,6 +20,7 @@ | |||
package org.sonar.server.usertoken.ws; | |||
import java.util.List; | |||
import org.sonar.api.server.ws.Change; | |||
import org.sonar.api.server.ws.Request; | |||
import org.sonar.api.server.ws.Response; | |||
import org.sonar.api.server.ws.WebService; | |||
@@ -29,6 +30,7 @@ import org.sonar.db.user.UserDto; | |||
import org.sonar.db.user.UserTokenDto; | |||
import org.sonarqube.ws.UserTokens.SearchWsResponse; | |||
import static java.util.Optional.ofNullable; | |||
import static org.sonar.api.utils.DateUtils.formatDateTime; | |||
import static org.sonar.server.usertoken.ws.UserTokenSupport.ACTION_SEARCH; | |||
import static org.sonar.server.usertoken.ws.UserTokenSupport.PARAM_LOGIN; | |||
@@ -49,7 +51,9 @@ public class SearchAction implements UserTokensWsAction { | |||
WebService.NewAction action = context.createAction(ACTION_SEARCH) | |||
.setDescription("List the access tokens of a user.<br>" + | |||
"The login must exist and active.<br>" + | |||
"Field 'lastConnectionDate' is only updated every hour, so it may not be accurate, for instance when a user is using a token many times in less than one hour.<br/" + | |||
"It requires administration permissions to specify a 'login' and list the tokens of another user. Otherwise, tokens for the current user are listed.") | |||
.setChangelog(new Change("7.7", "New field 'lastConnectionDate' is added to response")) | |||
.setResponseExample(getClass().getResource("search-example.json")) | |||
.setSince("5.3") | |||
.setHandler(this); | |||
@@ -82,6 +86,7 @@ public class SearchAction implements UserTokensWsAction { | |||
.clear() | |||
.setName(userTokenDto.getName()) | |||
.setCreatedAt(formatDateTime(userTokenDto.getCreatedAt())); | |||
ofNullable(userTokenDto.getLastConnectionDate()).ifPresent(date -> userTokenBuilder.setLastConnectionDate(formatDateTime(date))); | |||
searchWsResponse.addUserTokens(userTokenBuilder); | |||
} | |||
@@ -30,7 +30,7 @@ public class AuthenticationModuleTest { | |||
public void verify_count_of_added_components() { | |||
ComponentContainer container = new ComponentContainer(); | |||
new AuthenticationModule().configure(container); | |||
assertThat(container.size()).isEqualTo(2 + 23); | |||
assertThat(container.size()).isEqualTo(2 + 24); | |||
} | |||
} |
@@ -0,0 +1,115 @@ | |||
/* | |||
* 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.authentication; | |||
import org.junit.Rule; | |||
import org.junit.Test; | |||
import org.sonar.api.utils.System2; | |||
import org.sonar.api.utils.internal.TestSystem2; | |||
import org.sonar.db.DbTester; | |||
import org.sonar.db.user.UserDto; | |||
import org.sonar.db.user.UserTokenDto; | |||
import static org.assertj.core.api.AssertionsForClassTypes.assertThat; | |||
public class UserLastConnectionDatesUpdaterImplTest { | |||
private static final long NOW = 10_000_000_000L; | |||
private static final long ONE_MINUTE = 60_000L; | |||
private static final long ONE_HOUR = ONE_MINUTE * 60L; | |||
private static final long TWO_HOUR = ONE_HOUR * 2L; | |||
@Rule | |||
public DbTester db = DbTester.create(); | |||
private System2 system2 = new TestSystem2().setNow(NOW); | |||
private UserLastConnectionDatesUpdaterImpl underTest = new UserLastConnectionDatesUpdaterImpl(db.getDbClient(), system2); | |||
@Test | |||
public void update_last_connection_date_from_user_when_last_connection_was_more_than_one_hour() { | |||
UserDto user = db.users().insertUser(); | |||
db.users().updateLastConnectionDate(user, NOW - TWO_HOUR); | |||
underTest.updateLastConnectionDateIfNeeded(user); | |||
UserDto userReloaded = db.getDbClient().userDao().selectByUuid(db.getSession(), user.getUuid()); | |||
assertThat(userReloaded.getLastConnectionDate()).isEqualTo(NOW); | |||
} | |||
@Test | |||
public void update_last_connection_date_from_user_when_no_last_connection_date() { | |||
UserDto user = db.users().insertUser(); | |||
underTest.updateLastConnectionDateIfNeeded(user); | |||
UserDto userReloaded = db.getDbClient().userDao().selectByUuid(db.getSession(), user.getUuid()); | |||
assertThat(userReloaded.getLastConnectionDate()).isEqualTo(NOW); | |||
} | |||
@Test | |||
public void do_not_update_when_last_connection_from_user_was_less_than_one_hour() { | |||
UserDto user = db.users().insertUser(); | |||
db.users().updateLastConnectionDate(user, NOW - ONE_MINUTE); | |||
underTest.updateLastConnectionDateIfNeeded(user); | |||
UserDto userReloaded = db.getDbClient().userDao().selectByUuid(db.getSession(), user.getUuid()); | |||
assertThat(userReloaded.getLastConnectionDate()).isEqualTo(NOW - ONE_MINUTE); | |||
} | |||
@Test | |||
public void update_last_connection_date_from_user_token_when_last_connection_was_more_than_one_hour() { | |||
UserDto user = db.users().insertUser(); | |||
UserTokenDto userToken = db.users().insertToken(user); | |||
db.getDbClient().userTokenDao().update(db.getSession(), userToken.setLastConnectionDate(NOW - TWO_HOUR)); | |||
db.commit(); | |||
underTest.updateLastConnectionDateIfNeeded(userToken); | |||
UserTokenDto userTokenReloaded = db.getDbClient().userTokenDao().selectByTokenHash(db.getSession(), userToken.getTokenHash()); | |||
assertThat(userTokenReloaded.getLastConnectionDate()).isEqualTo(NOW); | |||
} | |||
@Test | |||
public void update_last_connection_date_from_user_token_when_no_last_connection_date() { | |||
UserDto user = db.users().insertUser(); | |||
UserTokenDto userToken = db.users().insertToken(user); | |||
underTest.updateLastConnectionDateIfNeeded(userToken); | |||
UserTokenDto userTokenReloaded = db.getDbClient().userTokenDao().selectByTokenHash(db.getSession(), userToken.getTokenHash()); | |||
assertThat(userTokenReloaded.getLastConnectionDate()).isEqualTo(NOW); | |||
} | |||
@Test | |||
public void do_not_update_when_last_connection_from_user_token_was_less_than_one_hour() { | |||
UserDto user = db.users().insertUser(); | |||
UserTokenDto userToken = db.users().insertToken(user); | |||
db.getDbClient().userTokenDao().update(db.getSession(), userToken.setLastConnectionDate(NOW - ONE_MINUTE)); | |||
db.commit(); | |||
underTest.updateLastConnectionDateIfNeeded(userToken); | |||
UserTokenDto userTokenReloaded = db.getDbClient().userTokenDao().selectByTokenHash(db.getSession(), userToken.getTokenHash()); | |||
assertThat(userTokenReloaded.getLastConnectionDate()).isEqualTo(NOW - ONE_MINUTE); | |||
} | |||
} |
@@ -42,6 +42,7 @@ import static java.util.Collections.emptyList; | |||
import static java.util.Collections.singletonList; | |||
import static org.assertj.core.api.Assertions.assertThat; | |||
import static org.assertj.core.api.Assertions.tuple; | |||
import static org.sonar.api.utils.DateUtils.formatDateTime; | |||
import static org.sonar.test.JsonAssert.assertJson; | |||
public class SearchActionTest { | |||
@@ -132,26 +133,6 @@ public class SearchActionTest { | |||
.containsExactlyInAnyOrder(tuple(user.getLogin(), asList("john1", "john2"))); | |||
} | |||
@Test | |||
public void return_tokens_count_for_logged_user() { | |||
UserDto user = db.users().insertUser(); | |||
db.users().insertToken(user); | |||
db.users().insertToken(user); | |||
userIndexer.indexOnStartup(null); | |||
userSession.logIn(); | |||
assertThat(ws.newRequest() | |||
.executeProtobuf(SearchWsResponse.class).getUsersList()) | |||
.extracting(User::getLogin, User::hasTokensCount) | |||
.containsExactlyInAnyOrder(tuple(user.getLogin(), false)); | |||
userSession.logIn(user); | |||
assertThat(ws.newRequest() | |||
.executeProtobuf(SearchWsResponse.class).getUsersList()) | |||
.extracting(User::getLogin, User::getTokensCount) | |||
.containsExactlyInAnyOrder(tuple(user.getLogin(), 2)); | |||
} | |||
@Test | |||
public void return_tokens_count_when_system_administer() { | |||
UserDto user = db.users().insertUser(); | |||
@@ -167,7 +148,7 @@ public class SearchActionTest { | |||
userSession.logIn(); | |||
assertThat(ws.newRequest() | |||
.executeProtobuf(SearchWsResponse.class).getUsersList()) | |||
.executeProtobuf(SearchWsResponse.class).getUsersList()) | |||
.extracting(User::getLogin, User::hasTokensCount) | |||
.containsExactlyInAnyOrder(tuple(user.getLogin(), false)); | |||
} | |||
@@ -249,14 +230,14 @@ public class SearchActionTest { | |||
userSession.logIn().setSystemAdministrator(); | |||
assertThat(ws.newRequest() | |||
.executeProtobuf(SearchWsResponse.class).getUsersList()) | |||
.extracting(User::getLogin, User::getExternalIdentity) | |||
.containsExactlyInAnyOrder(tuple(user.getLogin(), user.getExternalLogin())); | |||
.extracting(User::getLogin, User::getExternalIdentity) | |||
.containsExactlyInAnyOrder(tuple(user.getLogin(), user.getExternalLogin())); | |||
userSession.logIn(); | |||
assertThat(ws.newRequest() | |||
.executeProtobuf(SearchWsResponse.class).getUsersList()) | |||
.extracting(User::getLogin, User::hasExternalIdentity) | |||
.containsExactlyInAnyOrder(tuple(user.getLogin(), false)); | |||
.extracting(User::getLogin, User::hasExternalIdentity) | |||
.containsExactlyInAnyOrder(tuple(user.getLogin(), false)); | |||
} | |||
@Test | |||
@@ -276,6 +257,52 @@ public class SearchActionTest { | |||
.containsExactlyInAnyOrder(tuple(user.getLogin(), user.getName(), false, false, false, false)); | |||
} | |||
@Test | |||
public void return_last_connection_date_when_system_administer() { | |||
UserDto userWithLastConnectionDate = db.users().insertUser(); | |||
db.users().updateLastConnectionDate(userWithLastConnectionDate, 10_000_000_000L); | |||
UserDto userWithoutLastConnectionDate = db.users().insertUser(); | |||
userIndexer.indexOnStartup(null); | |||
userSession.logIn().setSystemAdministrator(); | |||
SearchWsResponse response = ws.newRequest() | |||
.executeProtobuf(SearchWsResponse.class); | |||
assertThat(response.getUsersList()) | |||
.extracting(User::getLogin, User::hasLastConnectionDate, User::getLastConnectionDate) | |||
.containsExactlyInAnyOrder( | |||
tuple(userWithLastConnectionDate.getLogin(), true, formatDateTime(10_000_000_000L)), | |||
tuple(userWithoutLastConnectionDate.getLogin(), false, "")); | |||
} | |||
@Test | |||
public void return_all_fields_for_logged_user() { | |||
UserDto user = db.users().insertUser(); | |||
db.users().updateLastConnectionDate(user, 10_000_000_000L); | |||
db.users().insertToken(user); | |||
db.users().insertToken(user); | |||
GroupDto group = db.users().insertGroup(); | |||
db.users().insertMember(group, user); | |||
UserDto otherUser = db.users().insertUser(); | |||
userIndexer.indexOnStartup(null); | |||
userSession.logIn(user); | |||
assertThat(ws.newRequest().setParam("q", user.getLogin()) | |||
.executeProtobuf(SearchWsResponse.class).getUsersList()) | |||
.extracting(User::getLogin, User::getName, User::getEmail, User::getExternalIdentity, User::getExternalProvider, | |||
User::hasScmAccounts, User::hasAvatar, User::hasGroups, User::getTokensCount, User::hasLastConnectionDate) | |||
.containsExactlyInAnyOrder( | |||
tuple(user.getLogin(), user.getName(), user.getEmail(), user.getExternalLogin(), user.getExternalIdentityProvider(), true, true, true, 2, true)); | |||
userSession.logIn(otherUser); | |||
assertThat(ws.newRequest().setParam("q", user.getLogin()) | |||
.executeProtobuf(SearchWsResponse.class).getUsersList()) | |||
.extracting(User::getLogin, User::getName, User::hasEmail, User::hasExternalIdentity, User::hasExternalProvider, | |||
User::hasScmAccounts, User::hasAvatar, User::hasGroups, User::hasTokensCount, User::hasLastConnectionDate) | |||
.containsExactlyInAnyOrder( | |||
tuple(user.getLogin(), user.getName(), false, false, true, true, true, false, false, false)); | |||
} | |||
@Test | |||
public void search_with_fields() { | |||
UserDto user = db.users().insertUser(); |
@@ -26,9 +26,14 @@ import org.junit.rules.ExpectedException; | |||
import org.sonar.api.utils.System2; | |||
import org.sonar.db.DbTester; | |||
import org.sonar.db.user.UserDto; | |||
import org.sonar.db.user.UserTokenDto; | |||
import org.sonar.server.authentication.UserLastConnectionDatesUpdater; | |||
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.never; | |||
import static org.mockito.Mockito.verify; | |||
import static org.mockito.Mockito.when; | |||
public class UserTokenAuthenticationTest { | |||
@@ -40,8 +45,9 @@ public class UserTokenAuthenticationTest { | |||
public DbTester db = DbTester.create(System2.INSTANCE); | |||
private TokenGenerator tokenGenerator = mock(TokenGenerator.class); | |||
private UserLastConnectionDatesUpdater userLastConnectionDatesUpdater = mock(UserLastConnectionDatesUpdater.class); | |||
private UserTokenAuthentication underTest = new UserTokenAuthentication(tokenGenerator, db.getDbClient()); | |||
private UserTokenAuthentication underTest = new UserTokenAuthentication(tokenGenerator, db.getDbClient(), userLastConnectionDatesUpdater); | |||
@Test | |||
public void return_login_when_token_hash_found_in_db() { | |||
@@ -57,11 +63,14 @@ public class UserTokenAuthenticationTest { | |||
assertThat(login.isPresent()).isTrue(); | |||
assertThat(login.get()).isEqualTo(user1.getUuid()); | |||
verify(userLastConnectionDatesUpdater).updateLastConnectionDateIfNeeded(any(UserTokenDto.class)); | |||
} | |||
@Test | |||
public void return_absent_if_token_hash_is_not_found() { | |||
Optional<String> login = underTest.authenticate("unknown-token"); | |||
assertThat(login.isPresent()).isFalse(); | |||
verify(userLastConnectionDatesUpdater, never()).updateLastConnectionDateIfNeeded(any(UserTokenDto.class)); | |||
} | |||
} |
@@ -27,6 +27,7 @@ import org.sonar.api.utils.System2; | |||
import org.sonar.db.DbClient; | |||
import org.sonar.db.DbTester; | |||
import org.sonar.db.user.UserDto; | |||
import org.sonar.db.user.UserTokenDto; | |||
import org.sonar.server.exceptions.ForbiddenException; | |||
import org.sonar.server.exceptions.NotFoundException; | |||
import org.sonar.server.exceptions.UnauthorizedException; | |||
@@ -34,8 +35,11 @@ import org.sonar.server.tester.UserSessionRule; | |||
import org.sonar.server.ws.TestRequest; | |||
import org.sonar.server.ws.WsActionTester; | |||
import org.sonarqube.ws.UserTokens.SearchWsResponse; | |||
import org.sonarqube.ws.UserTokens.SearchWsResponse.UserToken; | |||
import static org.assertj.core.api.Assertions.assertThat; | |||
import static org.assertj.core.api.Assertions.tuple; | |||
import static org.sonar.api.utils.DateUtils.formatDateTime; | |||
import static org.sonar.server.usertoken.ws.UserTokenSupport.PARAM_LOGIN; | |||
import static org.sonar.test.JsonAssert.assertJson; | |||
@@ -79,6 +83,24 @@ public class SearchActionTest { | |||
assertThat(response.getUserTokensCount()).isEqualTo(1); | |||
} | |||
@Test | |||
public void return_last_connection_date() { | |||
UserDto user = db.users().insertUser(); | |||
UserTokenDto token1 = db.users().insertToken(user); | |||
UserTokenDto token2 = db.users().insertToken(user); | |||
db.getDbClient().userTokenDao().update(db.getSession(), token1.setLastConnectionDate(10_000_000_000L)); | |||
db.commit(); | |||
logInAsSystemAdministrator(); | |||
SearchWsResponse response = newRequest(user.getLogin()); | |||
assertThat(response.getUserTokensList()) | |||
.extracting(UserToken::getName, UserToken::hasLastConnectionDate, UserToken::getLastConnectionDate) | |||
.containsExactlyInAnyOrder( | |||
tuple(token1.getName(), true, formatDateTime(10_000_000_000L)), | |||
tuple(token2.getName(), false, "")); | |||
} | |||
@Test | |||
public void fail_when_login_does_not_exist() { | |||
logInAsSystemAdministrator(); |
@@ -20,29 +20,15 @@ | |||
import { getJSON, postJSON, post } from '../helpers/request'; | |||
import throwGlobalError from '../app/utils/throwGlobalError'; | |||
export interface UserToken { | |||
name: string; | |||
createdAt: string; | |||
} | |||
/** List tokens for given user login */ | |||
export function getTokens(login: string): Promise<UserToken[]> { | |||
export function getTokens(login: string): Promise<T.UserToken[]> { | |||
return getJSON('/api/user_tokens/search', { login }).then(r => r.userTokens, throwGlobalError); | |||
} | |||
export interface NewToken { | |||
createdAt: string; | |||
login: string; | |||
name: string; | |||
token: string; | |||
} | |||
/** Generate a user token */ | |||
export function generateToken(data: { name: string; login?: string }): Promise<NewToken> { | |||
export function generateToken(data: { name: string; login?: string }): Promise<T.NewUserToken> { | |||
return postJSON('/api/user_tokens/generate', data).catch(throwGlobalError); | |||
} | |||
/** Revoke a user token */ | |||
export function revokeToken(data: { name: string; login?: string }): Promise<void | Response> { | |||
export function revokeToken(data: { name: string; login?: string }) { | |||
return post('/api/user_tokens/revoke', data).catch(throwGlobalError); | |||
} |
@@ -859,6 +859,7 @@ declare namespace T { | |||
externalIdentity?: string; | |||
externalProvider?: string; | |||
groups?: string[]; | |||
lastConnectionDate?: string; | |||
local: boolean; | |||
login: string; | |||
name: string; | |||
@@ -866,6 +867,17 @@ declare namespace T { | |||
tokensCount?: number; | |||
} | |||
export interface UserToken { | |||
name: string; | |||
createdAt: string; | |||
lastConnectionDate?: string; | |||
} | |||
export interface NewUserToken extends UserToken { | |||
login: string; | |||
token: string; | |||
} | |||
export type Visibility = 'public' | 'private'; | |||
export interface Webhook { |
@@ -22,10 +22,10 @@ import { FormattedMessage } from 'react-intl'; | |||
import { Link } from 'react-router'; | |||
import * as classNames from 'classnames'; | |||
import Step from './Step'; | |||
import { getTokens, generateToken, revokeToken, UserToken } from '../../../api/user-tokens'; | |||
import AlertErrorIcon from '../../../components/icons-components/AlertErrorIcon'; | |||
import AlertSuccessIcon from '../../../components/icons-components/AlertSuccessIcon'; | |||
import { DeleteButton, SubmitButton, Button } from '../../../components/ui/buttons'; | |||
import { getTokens, generateToken, revokeToken } from '../../../api/user-tokens'; | |||
import { translate } from '../../../helpers/l10n'; | |||
interface Props { | |||
@@ -44,7 +44,7 @@ interface State { | |||
selection: string; | |||
tokenName?: string; | |||
token?: string; | |||
tokens?: UserToken[]; | |||
tokens?: T.UserToken[]; | |||
} | |||
export default class TokenStep extends React.PureComponent<Props, State> { | |||
@@ -85,7 +85,7 @@ export default class TokenStep extends React.PureComponent<Props, State> { | |||
getToken = () => | |||
this.state.selection === 'generate' ? this.state.token : this.state.existingToken; | |||
getUniqueTokenName = (tokens: UserToken[]) => { | |||
getUniqueTokenName = (tokens: T.UserToken[]) => { | |||
const { initialTokenName = '' } = this.props; | |||
const hasToken = (name: string) => tokens.find(token => token.name === name) !== undefined; | |||
@@ -46,6 +46,7 @@ export default function UsersList({ | |||
<th /> | |||
<th className="nowrap" /> | |||
<th className="nowrap">{translate('my_profile.scm_accounts')}</th> | |||
<th className="nowrap">{translate('users.last_connection')}</th> | |||
{!organizationsEnabled && <th className="nowrap">{translate('my_profile.groups')}</th>} | |||
<th className="nowrap">{translate('users.tokens')}</th> | |||
<th className="nowrap"> </th> |
@@ -19,6 +19,11 @@ exports[`should render correctly 1`] = ` | |||
> | |||
my_profile.scm_accounts | |||
</th> | |||
<th | |||
className="nowrap" | |||
> | |||
users.last_connection | |||
</th> | |||
<th | |||
className="nowrap" | |||
> |
@@ -20,9 +20,9 @@ | |||
import * as React from 'react'; | |||
import TokensFormItem from './TokensFormItem'; | |||
import TokensFormNewToken from './TokensFormNewToken'; | |||
import { getTokens, generateToken, UserToken } from '../../../api/user-tokens'; | |||
import DeferredSpinner from '../../../components/common/DeferredSpinner'; | |||
import { SubmitButton } from '../../../components/ui/buttons'; | |||
import { getTokens, generateToken } from '../../../api/user-tokens'; | |||
import { translate } from '../../../helpers/l10n'; | |||
interface Props { | |||
@@ -35,7 +35,7 @@ interface State { | |||
loading: boolean; | |||
newToken?: { name: string; token: string }; | |||
newTokenName: string; | |||
tokens: UserToken[]; | |||
tokens: T.UserToken[]; | |||
} | |||
export default class TokensForm extends React.PureComponent<Props, State> { | |||
@@ -103,7 +103,7 @@ export default class TokensForm extends React.PureComponent<Props, State> { | |||
} | |||
}; | |||
handleRevokeToken = (revokedToken: UserToken) => { | |||
handleRevokeToken = (revokedToken: T.UserToken) => { | |||
this.setState( | |||
state => ({ | |||
tokens: state.tokens.filter(token => token.name !== revokedToken.name) | |||
@@ -175,6 +175,7 @@ export default class TokensForm extends React.PureComponent<Props, State> { | |||
<thead> | |||
<tr> | |||
<th>{translate('name')}</th> | |||
<th>{translate('my_account.tokens_last_usage')}</th> | |||
<th className="text-right">{translate('created')}</th> | |||
<th /> | |||
</tr> |
@@ -18,18 +18,19 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as React from 'react'; | |||
import { revokeToken, UserToken } from '../../../api/user-tokens'; | |||
import DateFormatter from '../../../components/intl/DateFormatter'; | |||
import DateFromNowHourPrecision from '../../../components/intl/DateFromNowHourPrecision'; | |||
import DeferredSpinner from '../../../components/common/DeferredSpinner'; | |||
import Tooltip from '../../../components/controls/Tooltip'; | |||
import DateFormatter from '../../../components/intl/DateFormatter'; | |||
import { Button } from '../../../components/ui/buttons'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import { limitComponentName } from '../../../helpers/path'; | |||
import { revokeToken } from '../../../api/user-tokens'; | |||
import { translate } from '../../../helpers/l10n'; | |||
interface Props { | |||
login: string; | |||
onRevokeToken: (token: UserToken) => void; | |||
token: UserToken; | |||
onRevokeToken: (token: T.UserToken) => void; | |||
token: T.UserToken; | |||
} | |||
interface State { | |||
@@ -75,12 +76,15 @@ export default class TokensFormItem extends React.PureComponent<Props, State> { | |||
<span>{limitComponentName(token.name)}</span> | |||
</Tooltip> | |||
</td> | |||
<td className="nowrap"> | |||
<DateFromNowHourPrecision date={token.lastConnectionDate} /> | |||
</td> | |||
<td className="thin nowrap text-right"> | |||
<DateFormatter date={token.createdAt} long={true} /> | |||
</td> | |||
<td className="thin nowrap text-right"> | |||
<DeferredSpinner loading={loading}> | |||
<i className="spinner-placeholder " /> | |||
<i className="spinner-placeholder" /> | |||
</DeferredSpinner> | |||
<Button | |||
className="button-red input-small spacer-left" |
@@ -23,8 +23,9 @@ import UserActions from './UserActions'; | |||
import UserGroups from './UserGroups'; | |||
import UserListItemIdentity from './UserListItemIdentity'; | |||
import UserScmAccounts from './UserScmAccounts'; | |||
import BulletListIcon from '../../../components/icons-components/BulletListIcon'; | |||
import Avatar from '../../../components/ui/Avatar'; | |||
import BulletListIcon from '../../../components/icons-components/BulletListIcon'; | |||
import DateFromNowHourPrecision from '../../../components/intl/DateFromNowHourPrecision'; | |||
import { ButtonIcon } from '../../../components/ui/buttons'; | |||
import { translate } from '../../../helpers/l10n'; | |||
@@ -59,6 +60,9 @@ export default class UserListItem extends React.PureComponent<Props, State> { | |||
<td> | |||
<UserScmAccounts scmAccounts={user.scmAccounts || []} /> | |||
</td> | |||
<td> | |||
<DateFromNowHourPrecision date={user.lastConnectionDate} /> | |||
</td> | |||
{!organizationsEnabled && ( | |||
<td> | |||
<UserGroups groups={user.groups || []} onUpdateUsers={onUpdateUsers} user={user} /> |
@@ -0,0 +1,83 @@ | |||
/* | |||
* 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 TokensForm from '../TokensForm'; | |||
import { change, submit, waitAndUpdate } from '../../../../helpers/testUtils'; | |||
import { generateToken, getTokens } from '../../../../api/user-tokens'; | |||
jest.mock('../../../../api/user-tokens', () => ({ | |||
generateToken: jest.fn().mockResolvedValue({ | |||
name: 'baz', | |||
createdAt: '2019-01-21T08:06:00+0100', | |||
login: 'luke', | |||
token: 'token_value' | |||
}), | |||
getTokens: jest.fn().mockResolvedValue([ | |||
{ | |||
name: 'foo', | |||
createdAt: '2019-01-15T15:06:33+0100', | |||
lastConnectionDate: '2019-01-18T15:06:33+0100' | |||
}, | |||
{ name: 'bar', createdAt: '2019-01-18T15:06:33+0100' } | |||
]) | |||
})); | |||
beforeEach(() => { | |||
(generateToken as jest.Mock).mockClear(); | |||
(getTokens as jest.Mock).mockClear(); | |||
}); | |||
it('should render correctly', async () => { | |||
const wrapper = shallowRender(); | |||
expect(wrapper).toMatchSnapshot(); | |||
expect(getTokens).toHaveBeenCalledWith('luke'); | |||
await waitAndUpdate(wrapper); | |||
expect(wrapper).toMatchSnapshot(); | |||
}); | |||
it('should create new tokens', async () => { | |||
const wrapper = shallowRender(); | |||
await waitAndUpdate(wrapper); | |||
expect(wrapper.find('TokensFormItem')).toHaveLength(2); | |||
change(wrapper.find('input'), 'baz'); | |||
submit(wrapper.find('form')); | |||
await waitAndUpdate(wrapper); | |||
expect(generateToken).toHaveBeenCalledWith({ name: 'baz', login: 'luke' }); | |||
expect(wrapper.find('TokensFormItem')).toHaveLength(3); | |||
}); | |||
it('should revoke tokens', async () => { | |||
const updateTokensCount = jest.fn(); | |||
const wrapper = shallowRender({ updateTokensCount }); | |||
await waitAndUpdate(wrapper); | |||
expect(wrapper.find('TokensFormItem')).toHaveLength(2); | |||
wrapper.instance().handleRevokeToken({ createdAt: '2019-01-15T15:06:33+0100', name: 'foo' }); | |||
expect(updateTokensCount).toHaveBeenCalledWith('luke', 1); | |||
expect(wrapper.find('TokensFormItem')).toHaveLength(1); | |||
}); | |||
function shallowRender(props: Partial<TokensForm['props']> = {}) { | |||
return shallow<TokensForm>(<TokensForm login="luke" updateTokensCount={jest.fn()} {...props} />); | |||
} |
@@ -0,0 +1,65 @@ | |||
/* | |||
* 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 TokensFormItem from '../TokensFormItem'; | |||
import { revokeToken } from '../../../../api/user-tokens'; | |||
import { click, waitAndUpdate } from '../../../../helpers/testUtils'; | |||
jest.mock('../../../../components/intl/DateFormatter'); | |||
jest.mock('../../../../components/intl/DateFromNow'); | |||
jest.mock('../../../../components/intl/DateTimeFormatter'); | |||
jest.mock('../../../../api/user-tokens', () => ({ | |||
revokeToken: jest.fn().mockResolvedValue(undefined) | |||
})); | |||
const userToken: T.UserToken = { | |||
name: 'foo', | |||
createdAt: '2019-01-15T15:06:33+0100', | |||
lastConnectionDate: '2019-01-18T15:06:33+0100' | |||
}; | |||
beforeEach(() => { | |||
(revokeToken as jest.Mock).mockClear(); | |||
}); | |||
it('should render correctly', () => { | |||
expect(shallowRender()).toMatchSnapshot(); | |||
}); | |||
it('should revoke the token', async () => { | |||
const onRevokeToken = jest.fn(); | |||
const wrapper = shallowRender({ onRevokeToken }); | |||
expect(wrapper.find('Button')).toMatchSnapshot(); | |||
click(wrapper.find('Button')); | |||
expect(wrapper.find('Button')).toMatchSnapshot(); | |||
click(wrapper.find('Button')); | |||
expect(wrapper.find('DeferredSpinner').prop('loading')).toBe(true); | |||
await waitAndUpdate(wrapper); | |||
expect(revokeToken).toHaveBeenCalledWith({ login: 'luke', name: 'foo' }); | |||
expect(onRevokeToken).toHaveBeenCalledWith(userToken); | |||
}); | |||
function shallowRender(props: Partial<TokensFormItem['props']> = {}) { | |||
return shallow( | |||
<TokensFormItem login="luke" onRevokeToken={jest.fn()} token={userToken} {...props} /> | |||
); | |||
} |
@@ -22,33 +22,41 @@ import { shallow } from 'enzyme'; | |||
import { click } from '../../../../helpers/testUtils'; | |||
import UserListItem from '../UserListItem'; | |||
const user = { | |||
jest.mock('../../../../components/intl/DateFromNow'); | |||
jest.mock('../../../../components/intl/DateTimeFormatter'); | |||
const user: T.User = { | |||
active: true, | |||
lastConnectionDate: '2019-01-18T15:06:33+0100', | |||
local: false, | |||
login: 'obi', | |||
name: 'One', | |||
active: true, | |||
scmAccounts: [], | |||
local: false | |||
scmAccounts: [] | |||
}; | |||
it('should render correctly', () => { | |||
expect(getWrapper()).toMatchSnapshot(); | |||
expect(shallowRender()).toMatchSnapshot(); | |||
}); | |||
it('should render correctly without last connection date', () => { | |||
expect(shallowRender({})).toMatchSnapshot(); | |||
}); | |||
it('should display a change password button', () => { | |||
expect( | |||
getWrapper({ organizationsEnabled: true }) | |||
shallowRender({ organizationsEnabled: true }) | |||
.find('UserGroups') | |||
.exists() | |||
).toBeFalsy(); | |||
}); | |||
it('should open the correct forms', () => { | |||
const wrapper = getWrapper(); | |||
const wrapper = shallowRender(); | |||
click(wrapper.find('.js-user-tokens')); | |||
expect(wrapper.find('TokensFormModal').exists()).toBeTruthy(); | |||
}); | |||
function getWrapper(props = {}) { | |||
function shallowRender(props: Partial<UserListItem['props']> = {}) { | |||
return shallow( | |||
<UserListItem | |||
isCurrentUser={false} |
@@ -0,0 +1,168 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render correctly 1`] = ` | |||
<Fragment> | |||
<h3 | |||
className="spacer-bottom" | |||
> | |||
users.generate_tokens | |||
</h3> | |||
<form | |||
autoComplete="off" | |||
className="display-flex-center" | |||
id="generate-token-form" | |||
onSubmit={[Function]} | |||
> | |||
<input | |||
className="spacer-right" | |||
maxLength={100} | |||
onChange={[Function]} | |||
placeholder="users.enter_token_name" | |||
required={true} | |||
type="text" | |||
value="" | |||
/> | |||
<SubmitButton | |||
className="js-generate-token" | |||
disabled={true} | |||
> | |||
users.generate | |||
</SubmitButton> | |||
</form> | |||
<table | |||
className="data zebra big-spacer-top " | |||
> | |||
<thead> | |||
<tr> | |||
<th> | |||
name | |||
</th> | |||
<th> | |||
my_account.tokens_last_usage | |||
</th> | |||
<th | |||
className="text-right" | |||
> | |||
created | |||
</th> | |||
<th /> | |||
</tr> | |||
</thead> | |||
<tbody> | |||
<DeferredSpinner | |||
customSpinner={ | |||
<tr> | |||
<td> | |||
<i | |||
className="spinner" | |||
/> | |||
</td> | |||
</tr> | |||
} | |||
loading={true} | |||
timeout={100} | |||
> | |||
<tr> | |||
<td | |||
className="note" | |||
colSpan={3} | |||
> | |||
users.no_tokens | |||
</td> | |||
</tr> | |||
</DeferredSpinner> | |||
</tbody> | |||
</table> | |||
</Fragment> | |||
`; | |||
exports[`should render correctly 2`] = ` | |||
<Fragment> | |||
<h3 | |||
className="spacer-bottom" | |||
> | |||
users.generate_tokens | |||
</h3> | |||
<form | |||
autoComplete="off" | |||
className="display-flex-center" | |||
id="generate-token-form" | |||
onSubmit={[Function]} | |||
> | |||
<input | |||
className="spacer-right" | |||
maxLength={100} | |||
onChange={[Function]} | |||
placeholder="users.enter_token_name" | |||
required={true} | |||
type="text" | |||
value="" | |||
/> | |||
<SubmitButton | |||
className="js-generate-token" | |||
disabled={true} | |||
> | |||
users.generate | |||
</SubmitButton> | |||
</form> | |||
<table | |||
className="data zebra big-spacer-top " | |||
> | |||
<thead> | |||
<tr> | |||
<th> | |||
name | |||
</th> | |||
<th> | |||
my_account.tokens_last_usage | |||
</th> | |||
<th | |||
className="text-right" | |||
> | |||
created | |||
</th> | |||
<th /> | |||
</tr> | |||
</thead> | |||
<tbody> | |||
<DeferredSpinner | |||
customSpinner={ | |||
<tr> | |||
<td> | |||
<i | |||
className="spinner" | |||
/> | |||
</td> | |||
</tr> | |||
} | |||
loading={false} | |||
timeout={100} | |||
> | |||
<TokensFormItem | |||
key="foo" | |||
login="luke" | |||
onRevokeToken={[Function]} | |||
token={ | |||
Object { | |||
"createdAt": "2019-01-15T15:06:33+0100", | |||
"lastConnectionDate": "2019-01-18T15:06:33+0100", | |||
"name": "foo", | |||
} | |||
} | |||
/> | |||
<TokensFormItem | |||
key="bar" | |||
login="luke" | |||
onRevokeToken={[Function]} | |||
token={ | |||
Object { | |||
"createdAt": "2019-01-18T15:06:33+0100", | |||
"name": "bar", | |||
} | |||
} | |||
/> | |||
</DeferredSpinner> | |||
</tbody> | |||
</table> | |||
</Fragment> | |||
`; |
@@ -0,0 +1,69 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render correctly 1`] = ` | |||
<tr> | |||
<td> | |||
<Tooltip | |||
overlay="foo" | |||
> | |||
<span> | |||
foo | |||
</span> | |||
</Tooltip> | |||
</td> | |||
<td | |||
className="nowrap" | |||
> | |||
<DateFromNowHourPrecision | |||
date="2019-01-18T15:06:33+0100" | |||
/> | |||
</td> | |||
<td | |||
className="thin nowrap text-right" | |||
> | |||
<DateFormatter | |||
date="2019-01-15T15:06:33+0100" | |||
long={true} | |||
/> | |||
</td> | |||
<td | |||
className="thin nowrap text-right" | |||
> | |||
<DeferredSpinner | |||
loading={false} | |||
timeout={100} | |||
> | |||
<i | |||
className="spinner-placeholder" | |||
/> | |||
</DeferredSpinner> | |||
<Button | |||
className="button-red input-small spacer-left" | |||
disabled={false} | |||
onClick={[Function]} | |||
> | |||
users.tokens.revoke | |||
</Button> | |||
</td> | |||
</tr> | |||
`; | |||
exports[`should revoke the token 1`] = ` | |||
<Button | |||
className="button-red input-small spacer-left" | |||
disabled={false} | |||
onClick={[Function]} | |||
> | |||
users.tokens.revoke | |||
</Button> | |||
`; | |||
exports[`should revoke the token 2`] = ` | |||
<Button | |||
className="button-red input-small spacer-left" | |||
disabled={false} | |||
onClick={[Function]} | |||
> | |||
users.tokens.sure | |||
</Button> | |||
`; |
@@ -14,6 +14,7 @@ exports[`should render correctly 1`] = ` | |||
user={ | |||
Object { | |||
"active": true, | |||
"lastConnectionDate": "2019-01-18T15:06:33+0100", | |||
"local": false, | |||
"login": "obi", | |||
"name": "One", | |||
@@ -26,6 +27,89 @@ exports[`should render correctly 1`] = ` | |||
scmAccounts={Array []} | |||
/> | |||
</td> | |||
<td> | |||
<DateFromNowHourPrecision | |||
date="2019-01-18T15:06:33+0100" | |||
/> | |||
</td> | |||
<td> | |||
<UserGroups | |||
groups={Array []} | |||
onUpdateUsers={[MockFunction]} | |||
user={ | |||
Object { | |||
"active": true, | |||
"lastConnectionDate": "2019-01-18T15:06:33+0100", | |||
"local": false, | |||
"login": "obi", | |||
"name": "One", | |||
"scmAccounts": Array [], | |||
} | |||
} | |||
/> | |||
</td> | |||
<td> | |||
<ButtonIcon | |||
className="js-user-tokens spacer-left button-small" | |||
onClick={[Function]} | |||
tooltip="users.update_tokens" | |||
> | |||
<BulletListIcon /> | |||
</ButtonIcon> | |||
</td> | |||
<td | |||
className="thin nowrap text-right" | |||
> | |||
<UserActions | |||
isCurrentUser={false} | |||
onUpdateUsers={[MockFunction]} | |||
user={ | |||
Object { | |||
"active": true, | |||
"lastConnectionDate": "2019-01-18T15:06:33+0100", | |||
"local": false, | |||
"login": "obi", | |||
"name": "One", | |||
"scmAccounts": Array [], | |||
} | |||
} | |||
/> | |||
</td> | |||
</tr> | |||
`; | |||
exports[`should render correctly without last connection date 1`] = ` | |||
<tr> | |||
<td | |||
className="thin nowrap" | |||
> | |||
<Connect(Avatar) | |||
name="One" | |||
size={36} | |||
/> | |||
</td> | |||
<UserListItemIdentity | |||
user={ | |||
Object { | |||
"active": true, | |||
"lastConnectionDate": "2019-01-18T15:06:33+0100", | |||
"local": false, | |||
"login": "obi", | |||
"name": "One", | |||
"scmAccounts": Array [], | |||
} | |||
} | |||
/> | |||
<td> | |||
<UserScmAccounts | |||
scmAccounts={Array []} | |||
/> | |||
</td> | |||
<td> | |||
<DateFromNowHourPrecision | |||
date="2019-01-18T15:06:33+0100" | |||
/> | |||
</td> | |||
<td> | |||
<UserGroups | |||
groups={Array []} | |||
@@ -33,6 +117,7 @@ exports[`should render correctly 1`] = ` | |||
user={ | |||
Object { | |||
"active": true, | |||
"lastConnectionDate": "2019-01-18T15:06:33+0100", | |||
"local": false, | |||
"login": "obi", | |||
"name": "One", | |||
@@ -59,6 +144,7 @@ exports[`should render correctly 1`] = ` | |||
user={ | |||
Object { | |||
"active": true, | |||
"lastConnectionDate": "2019-01-18T15:06:33+0100", | |||
"local": false, | |||
"login": "obi", | |||
"name": "One", |
@@ -0,0 +1,56 @@ | |||
/* | |||
* 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 { DateSource } from 'react-intl'; | |||
import DateFromNow from './DateFromNow'; | |||
import DateTimeFormatter from './DateTimeFormatter'; | |||
import Tooltip from '../controls/Tooltip'; | |||
import { differenceInHours } from '../../helpers/dates'; | |||
import { translate } from '../../helpers/l10n'; | |||
interface Props { | |||
children?: (formattedDate: string) => React.ReactNode; | |||
date?: DateSource; | |||
} | |||
export default class DateFromNowHourPrecision extends React.PureComponent<Props> { | |||
render() { | |||
const { children, date } = this.props; | |||
let overrideDate: string | undefined; | |||
if (!date) { | |||
overrideDate = translate('never'); | |||
} else if (differenceInHours(Date.now(), date) < 1) { | |||
overrideDate = translate('less_than_1_hour_ago'); | |||
} | |||
if (overrideDate) { | |||
return children ? children(overrideDate) : overrideDate; | |||
} | |||
return ( | |||
<Tooltip overlay={<DateTimeFormatter date={date!} />}> | |||
<span> | |||
<DateFromNow date={date!}>{children}</DateFromNow> | |||
</span> | |||
</Tooltip> | |||
); | |||
} | |||
} |
@@ -18,6 +18,7 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as _differenceInDays from 'date-fns/difference_in_days'; | |||
import * as _differenceInHours from 'date-fns/difference_in_hours'; | |||
import * as _differenceInSeconds from 'date-fns/difference_in_seconds'; | |||
import * as _differenceInYears from 'date-fns/difference_in_years'; | |||
import * as _isSameDay from 'date-fns/is_same_day'; | |||
@@ -67,6 +68,10 @@ export function differenceInDays(dateLeft: ParsableDate, dateRight: ParsableDate | |||
return _differenceInDays(dateLeft, dateRight); | |||
} | |||
export function differenceInHours(dateLeft: ParsableDate, dateRight: ParsableDate): number { | |||
return _differenceInHours(dateLeft, dateRight); | |||
} | |||
export function differenceInSeconds(dateLeft: ParsableDate, dateRight: ParsableDate): number { | |||
return _differenceInSeconds(dateLeft, dateRight); | |||
} |
@@ -233,6 +233,7 @@ facet_might_have_more_results=There might be more results, try another set of fi | |||
false_positive=False positive | |||
go_back_to_homepage=Go back to the homepage | |||
last_analysis_before=Last analysis before | |||
less_than_1_hour_ago=< 1 hour ago | |||
logging_out=You're logging out, please wait... | |||
manage=Manage | |||
management=Management | |||
@@ -1500,6 +1501,7 @@ my_account.no_project_notifications=You have not set project notifications yet. | |||
my_account.profile=Profile | |||
my_account.security=Security | |||
my_account.tokens_description=If you want to enforce security by not providing credentials of a real {instance} user to run your code scan or to invoke web services, you can provide a User Token as a replacement of the user login. This will increase the security of your installation by not letting your analysis user's password going through your network. | |||
my_account.tokens_last_usage=Last usage | |||
my_account.projects=Projects | |||
my_account.projects.description=Those projects are the ones you are administering. | |||
my_account.projects.no_results=You are not administering any project yet. | |||
@@ -3002,6 +3004,7 @@ users.create_user=Create User | |||
users.update_user=Update User | |||
users.minimum_x_characters=Minimum {0} characters | |||
users.email=Email | |||
users.last_connection=Last connection | |||
users.update_groups=Update Groups | |||
users.update_tokens=Update Tokens | |||
users.add=Add user |
@@ -40,5 +40,6 @@ message SearchWsResponse { | |||
message UserToken { | |||
optional string name = 1; | |||
optional string createdAt = 2; | |||
optional string lastConnectionDate = 3; | |||
} | |||
} |
@@ -43,6 +43,7 @@ message SearchWsResponse { | |||
optional string externalIdentity = 9; | |||
optional string externalProvider = 10; | |||
optional string avatar = 11; | |||
optional string lastConnectionDate = 12; | |||
} | |||
message Groups { |