From 214b2f9670c5ea9ca5969a5794dd55ee0a19fee7 Mon Sep 17 00:00:00 2001 From: Daniel Mensinger Date: Wed, 9 Oct 2024 17:42:36 +0200 Subject: [PATCH] Add an option to restrict which locales a user can edit (#1004) This PR adds the option to restrict what locales a user can edit. This way, users that are not that well versed with mojito can't accidentally change the translations for wrong locales. --------- Co-authored-by: Jean Aurambault --- .../cli/command/UserDeleteCommandTest.java | 2 +- .../cli/command/UserUpdateCommandTest.java | 2 +- .../com/box/l10n/mojito/rest/entity/User.java | 20 +++++ .../l10n/mojito/rest/entity/UserLocale.java | 25 ++++++ .../com/box/l10n/mojito/FlyWayConfig.java | 4 +- .../mojito/entity/security/user/User.java | 29 ++++++- .../entity/security/user/UserLocale.java | 67 ++++++++++++++++ .../quartz/QuartzPollableTaskScheduler.java | 4 +- .../l10n/mojito/react/ReactAppController.java | 8 ++ .../com/box/l10n/mojito/react/ReactUser.java | 19 +++++ .../box/l10n/mojito/rest/security/UserWS.java | 13 ++++ .../l10n/mojito/rest/textunit/TextUnitWS.java | 36 +++++++++ .../security/user/UserLocaleRepository.java | 10 +++ .../service/security/user/UserService.java | 75 +++++++++++++++++- .../db/migration/V66__user_locales.sql | 10 +++ .../main/resources/properties/de.properties | 2 + .../main/resources/properties/en.properties | 2 + .../public/js/actions/users/LocaleActions.js | 13 ++++ .../js/actions/users/LocaleDataSource.js | 16 ++++ .../js/actions/users/UserModalActions.js | 5 ++ .../components/header/LocaleSelectorModal.js | 10 +-- .../js/components/users/UserMainPage.js | 5 +- .../public/js/components/users/UserModal.js | 77 +++++++++++++++++-- .../js/components/workbench/TextUnit.js | 15 ++-- .../resources/public/js/sdk/LocaleClient.js | 24 ++++++ .../resources/public/js/sdk/entity/User.js | 32 ++++++++ .../public/js/sdk/entity/UserLocale.js | 27 +++++++ .../public/js/stores/users/LocaleStore.js | 35 +++++++++ .../public/js/stores/users/UserModalStore.js | 48 ++++++++++++ .../js/stores/workbench/SearchResultsStore.js | 4 + .../public/js/utils/AuthorityService.js | 14 ++++ webapp/src/main/resources/sass/mojito.scss | 30 ++++++++ .../security/user/UserServiceTest.java | 2 +- .../l10n/mojito/service/tm/TMServiceTest.java | 2 +- 34 files changed, 655 insertions(+), 32 deletions(-) create mode 100644 restclient/src/main/java/com/box/l10n/mojito/rest/entity/UserLocale.java create mode 100644 webapp/src/main/java/com/box/l10n/mojito/entity/security/user/UserLocale.java create mode 100644 webapp/src/main/java/com/box/l10n/mojito/service/security/user/UserLocaleRepository.java create mode 100644 webapp/src/main/resources/db/migration/V66__user_locales.sql create mode 100644 webapp/src/main/resources/public/js/actions/users/LocaleActions.js create mode 100644 webapp/src/main/resources/public/js/actions/users/LocaleDataSource.js create mode 100644 webapp/src/main/resources/public/js/sdk/LocaleClient.js create mode 100644 webapp/src/main/resources/public/js/sdk/entity/UserLocale.js create mode 100644 webapp/src/main/resources/public/js/stores/users/LocaleStore.js diff --git a/cli/src/test/java/com/box/l10n/mojito/cli/command/UserDeleteCommandTest.java b/cli/src/test/java/com/box/l10n/mojito/cli/command/UserDeleteCommandTest.java index cab54419f3..0b5c76eaf6 100644 --- a/cli/src/test/java/com/box/l10n/mojito/cli/command/UserDeleteCommandTest.java +++ b/cli/src/test/java/com/box/l10n/mojito/cli/command/UserDeleteCommandTest.java @@ -34,7 +34,7 @@ public void testDelete() { String commonName = "Test Mojito"; userService.createUserWithRole( - username, password, Role.ROLE_USER, givenName, surname, commonName, false); + username, password, Role.ROLE_USER, givenName, surname, commonName, null, true, false); User user = userRepository.findByUsername(username); assertNotNull(user); diff --git a/cli/src/test/java/com/box/l10n/mojito/cli/command/UserUpdateCommandTest.java b/cli/src/test/java/com/box/l10n/mojito/cli/command/UserUpdateCommandTest.java index 14d1f13e05..a81e275c9d 100644 --- a/cli/src/test/java/com/box/l10n/mojito/cli/command/UserUpdateCommandTest.java +++ b/cli/src/test/java/com/box/l10n/mojito/cli/command/UserUpdateCommandTest.java @@ -131,7 +131,7 @@ private User createTestUserUsingUserService(String username, String rolename) { } User user = userService.createUserWithRole( - username, password, role, givenName, surname, commonName, false); + username, password, role, givenName, surname, commonName, null, true, false); return user; } } diff --git a/restclient/src/main/java/com/box/l10n/mojito/rest/entity/User.java b/restclient/src/main/java/com/box/l10n/mojito/rest/entity/User.java index 5524d90219..5b7dd27e81 100644 --- a/restclient/src/main/java/com/box/l10n/mojito/rest/entity/User.java +++ b/restclient/src/main/java/com/box/l10n/mojito/rest/entity/User.java @@ -25,8 +25,12 @@ public class User { private String commonName; + private boolean canTranslateAllLocales; + @JsonManagedReference Set authorities = new HashSet<>(); + @JsonManagedReference Set userLocales = new HashSet<>(); + public Long getId() { return id; } @@ -90,4 +94,20 @@ public Set getAuthorities() { public void setAuthorities(Set authorities) { this.authorities = authorities; } + + public boolean getCanTranslateAllLocales() { + return canTranslateAllLocales; + } + + public void setCanTranslateAllLocales(boolean canTranslateAllLocales) { + this.canTranslateAllLocales = canTranslateAllLocales; + } + + public Set getUserLocales() { + return userLocales; + } + + public void setUserLocales(Set userLocales) { + this.userLocales = userLocales; + } } diff --git a/restclient/src/main/java/com/box/l10n/mojito/rest/entity/UserLocale.java b/restclient/src/main/java/com/box/l10n/mojito/rest/entity/UserLocale.java new file mode 100644 index 0000000000..806cfeb022 --- /dev/null +++ b/restclient/src/main/java/com/box/l10n/mojito/rest/entity/UserLocale.java @@ -0,0 +1,25 @@ +package com.box.l10n.mojito.rest.entity; + +import com.fasterxml.jackson.annotation.JsonBackReference; + +public class UserLocale { + @JsonBackReference private User user; + + private Locale locale; + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public Locale getLocale() { + return locale; + } + + public void setLocale(Locale locale) { + this.locale = locale; + } +} diff --git a/webapp/src/main/java/com/box/l10n/mojito/FlyWayConfig.java b/webapp/src/main/java/com/box/l10n/mojito/FlyWayConfig.java index be4e0d999a..85684f4dad 100644 --- a/webapp/src/main/java/com/box/l10n/mojito/FlyWayConfig.java +++ b/webapp/src/main/java/com/box/l10n/mojito/FlyWayConfig.java @@ -154,8 +154,8 @@ void tryToMigrateIfMysql8Migration(Flyway flyway, FlywayException fe) { * since a failure to perform the query will show that the DB is not protected. * *

This is an extra check added to the settings: spring.flyway.clean-disabled=true (now default - * in Mojito) and l10n.flyway.clean=false (that is usually set manually, but can be wrongly enabled) - * and shouldn't be solely relied upon. + * in Mojito) and l10n.flyway.clean=false (that is usually set manually, but can be wrongly + * enabled) and shouldn't be solely relied upon. * *

For now this is enabled manually in the database with: CREATE TABLE * flyway_clean_protection(enabled boolean default true); INSERT INTO flyway_clean_protection diff --git a/webapp/src/main/java/com/box/l10n/mojito/entity/security/user/User.java b/webapp/src/main/java/com/box/l10n/mojito/entity/security/user/User.java index e3e0bea01c..7e90ff05fc 100644 --- a/webapp/src/main/java/com/box/l10n/mojito/entity/security/user/User.java +++ b/webapp/src/main/java/com/box/l10n/mojito/entity/security/user/User.java @@ -36,12 +36,16 @@ name = "User.legacy", attributeNodes = { @NamedAttributeNode("createdByUser"), + @NamedAttributeNode(value = "userLocales", subgraph = "User.legacy.userLocales"), @NamedAttributeNode(value = "authorities", subgraph = "User.legacy.authorities") }, subgraphs = { @NamedSubgraph( name = "User.legacy.authorities", - attributeNodes = {@NamedAttributeNode("createdByUser"), @NamedAttributeNode("user")}) + attributeNodes = {@NamedAttributeNode("createdByUser"), @NamedAttributeNode("user")}), + @NamedSubgraph( + name = "User.legacy.userLocales", + attributeNodes = {@NamedAttributeNode("user")}), }) public class User extends AuditableEntity implements Serializable { @@ -69,6 +73,9 @@ public class User extends AuditableEntity implements Serializable { @JsonView(View.IdAndName.class) String commonName; + @Column(name = "can_translate_all_locales", nullable = false) + boolean canTranslateAllLocales = true; + /** * Sets this flag if the user is created by a process that don't have all the information. Eg. * pushing an asset for a branch with an owner or header base authentication. If the owner is not @@ -80,6 +87,10 @@ public class User extends AuditableEntity implements Serializable { @Column(name = "partially_created") Boolean partiallyCreated = false; + @JsonManagedReference + @OneToMany(mappedBy = "user", fetch = FetchType.LAZY) + Set userLocales = new HashSet<>(); + @JsonManagedReference @OneToMany(mappedBy = "user", fetch = FetchType.LAZY) Set authorities = new HashSet<>(); @@ -156,6 +167,22 @@ public void setCommonName(String commonName) { this.commonName = commonName; } + public boolean getCanTranslateAllLocales() { + return canTranslateAllLocales; + } + + public void setCanTranslateAllLocales(boolean canTranslateAllLocales) { + this.canTranslateAllLocales = canTranslateAllLocales; + } + + public Set getUserLocales() { + return userLocales; + } + + public void setUserLocales(Set userLocales) { + this.userLocales = userLocales; + } + public Boolean getPartiallyCreated() { return partiallyCreated; } diff --git a/webapp/src/main/java/com/box/l10n/mojito/entity/security/user/UserLocale.java b/webapp/src/main/java/com/box/l10n/mojito/entity/security/user/UserLocale.java new file mode 100644 index 0000000000..f9913dfafe --- /dev/null +++ b/webapp/src/main/java/com/box/l10n/mojito/entity/security/user/UserLocale.java @@ -0,0 +1,67 @@ +package com.box.l10n.mojito.entity.security.user; + +import com.box.l10n.mojito.entity.BaseEntity; +import com.box.l10n.mojito.entity.Locale; +import com.box.l10n.mojito.rest.View; +import com.fasterxml.jackson.annotation.JsonBackReference; +import com.fasterxml.jackson.annotation.JsonView; +import jakarta.persistence.Entity; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.io.Serializable; +import org.hibernate.annotations.BatchSize; + +@Entity +@Table( + name = "user_locale", + indexes = { + @Index( + name = "UK__USER_LOCALE__USER_ID__LOCALE_ID", + columnList = "user_id, locale_id", + unique = true) + }) +@BatchSize(size = 1000) +public class UserLocale extends BaseEntity implements Serializable { + + @ManyToOne + @JsonBackReference + @JoinColumn( + name = "user_id", + foreignKey = @ForeignKey(name = "FK__USER_LOCALE__USER__ID"), + nullable = false) + User user; + + @JsonView(View.LocaleSummary.class) + @ManyToOne + @JoinColumn( + name = "locale_id", + foreignKey = @ForeignKey(name = "FK__USER_LOCALE__LOCALE__ID"), + nullable = false) + Locale locale; + + public UserLocale() {} + + public UserLocale(User user, Locale locale) { + this.user = user; + this.locale = locale; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public Locale getLocale() { + return locale; + } + + public void setLocale(Locale locale) { + this.locale = locale; + } +} diff --git a/webapp/src/main/java/com/box/l10n/mojito/quartz/QuartzPollableTaskScheduler.java b/webapp/src/main/java/com/box/l10n/mojito/quartz/QuartzPollableTaskScheduler.java index 9c85682f17..b5fadf1694 100644 --- a/webapp/src/main/java/com/box/l10n/mojito/quartz/QuartzPollableTaskScheduler.java +++ b/webapp/src/main/java/com/box/l10n/mojito/quartz/QuartzPollableTaskScheduler.java @@ -85,8 +85,8 @@ public PollableFuture scheduleJobWithCustomTimeout( * @param expectedSubTaskNumber set on the pollable task * @param triggerStartDate date at which the job should be started * @param uniqueId optional id used to generate the job keyname. If not provided the pollable task - * id is used. Pollable id keeps changing, unique id can be used for recurring jobs (eg. update - * stats of repository xyz) + * id is used. Pollable id keeps changing, unique id can be used for recurring jobs (eg. + * update stats of repository xyz) * @param inlineInput to inline the input in quartz data or save it in the blobstorage * @param * @param diff --git a/webapp/src/main/java/com/box/l10n/mojito/react/ReactAppController.java b/webapp/src/main/java/com/box/l10n/mojito/react/ReactAppController.java index 8fc3948b37..9084ded77a 100644 --- a/webapp/src/main/java/com/box/l10n/mojito/react/ReactAppController.java +++ b/webapp/src/main/java/com/box/l10n/mojito/react/ReactAppController.java @@ -1,6 +1,7 @@ package com.box.l10n.mojito.react; import com.box.l10n.mojito.entity.security.user.Authority; +import com.box.l10n.mojito.entity.security.user.UserLocale; import com.box.l10n.mojito.json.ObjectMapper; import com.box.l10n.mojito.mustache.MustacheBaseContext; import com.box.l10n.mojito.mustache.MustacheTemplateEngine; @@ -15,6 +16,7 @@ import java.nio.charset.StandardCharsets; import java.util.IllformedLocaleException; import java.util.Locale; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -134,6 +136,12 @@ ReactUser getReactUser() { reactUser.setGivenName(currentAuditor.getGivenName()); reactUser.setSurname(currentAuditor.getSurname()); reactUser.setCommonName(currentAuditor.getCommonName()); + reactUser.setCanTranslateAllLocales(currentAuditor.getCanTranslateAllLocales()); + reactUser.setUserLocales( + currentAuditor.getUserLocales().stream() + .map(UserLocale::getLocale) + .map(com.box.l10n.mojito.entity.Locale::getBcp47Tag) + .collect(Collectors.toList())); Role role = Role.ROLE_USER; Authority authority = authorityRepository.findByUser(currentAuditor); diff --git a/webapp/src/main/java/com/box/l10n/mojito/react/ReactUser.java b/webapp/src/main/java/com/box/l10n/mojito/react/ReactUser.java index cbe585bb2d..6867d907ae 100644 --- a/webapp/src/main/java/com/box/l10n/mojito/react/ReactUser.java +++ b/webapp/src/main/java/com/box/l10n/mojito/react/ReactUser.java @@ -1,6 +1,7 @@ package com.box.l10n.mojito.react; import com.box.l10n.mojito.security.Role; +import java.util.List; import org.springframework.stereotype.Component; @Component @@ -11,6 +12,8 @@ public class ReactUser { String surname; String commonName; Role role; + boolean canTranslateAllLocales; + List userLocales; public String getUsername() { return username; @@ -51,4 +54,20 @@ public Role getRole() { public void setRole(Role role) { this.role = role; } + + public boolean getCanTranslateAllLocales() { + return canTranslateAllLocales; + } + + public void setCanTranslateAllLocales(boolean canTranslateAllLocales) { + this.canTranslateAllLocales = canTranslateAllLocales; + } + + public List getUserLocales() { + return userLocales; + } + + public void setUserLocales(List userLocales) { + this.userLocales = userLocales; + } } diff --git a/webapp/src/main/java/com/box/l10n/mojito/rest/security/UserWS.java b/webapp/src/main/java/com/box/l10n/mojito/rest/security/UserWS.java index 2089f8db6a..979523d307 100644 --- a/webapp/src/main/java/com/box/l10n/mojito/rest/security/UserWS.java +++ b/webapp/src/main/java/com/box/l10n/mojito/rest/security/UserWS.java @@ -6,11 +6,14 @@ import static org.slf4j.LoggerFactory.getLogger; import static org.springframework.data.jpa.domain.Specification.where; +import com.box.l10n.mojito.entity.Locale; import com.box.l10n.mojito.entity.security.user.Authority; import com.box.l10n.mojito.entity.security.user.User; +import com.box.l10n.mojito.entity.security.user.UserLocale; import com.box.l10n.mojito.security.Role; import com.box.l10n.mojito.service.security.user.UserRepository; import com.box.l10n.mojito.service.security.user.UserService; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; @@ -98,6 +101,11 @@ public ResponseEntity createUser(@RequestBody User user) { user.getGivenName(), user.getSurname(), user.getCommonName(), + user.getUserLocales().stream() + .map(UserLocale::getLocale) + .map(Locale::getBcp47Tag) + .collect(Collectors.toSet()), + user.getCanTranslateAllLocales(), false); return new ResponseEntity<>(createdUser, HttpStatus.CREATED); @@ -151,6 +159,11 @@ public void updateUserByUserId(@PathVariable Long userId, @RequestBody User user user.getGivenName(), user.getSurname(), user.getCommonName(), + user.getUserLocales().stream() + .map(UserLocale::getLocale) + .map(Locale::getBcp47Tag) + .collect(Collectors.toSet()), + user.getCanTranslateAllLocales(), false); } diff --git a/webapp/src/main/java/com/box/l10n/mojito/rest/textunit/TextUnitWS.java b/webapp/src/main/java/com/box/l10n/mojito/rest/textunit/TextUnitWS.java index aa9aeb6407..f39cab94d1 100644 --- a/webapp/src/main/java/com/box/l10n/mojito/rest/textunit/TextUnitWS.java +++ b/webapp/src/main/java/com/box/l10n/mojito/rest/textunit/TextUnitWS.java @@ -2,13 +2,17 @@ import com.box.l10n.mojito.entity.Asset; import com.box.l10n.mojito.entity.AssetTextUnit; +import com.box.l10n.mojito.entity.BaseEntity; import com.box.l10n.mojito.entity.Locale; import com.box.l10n.mojito.entity.PollableTask; import com.box.l10n.mojito.entity.Repository; import com.box.l10n.mojito.entity.TMTextUnitCurrentVariant; import com.box.l10n.mojito.entity.TMTextUnitVariant; +import com.box.l10n.mojito.entity.security.user.User; +import com.box.l10n.mojito.entity.security.user.UserLocale; import com.box.l10n.mojito.json.ObjectMapper; import com.box.l10n.mojito.rest.View; +import com.box.l10n.mojito.security.AuditorAwareImpl; import com.box.l10n.mojito.service.NormalizationUtils; import com.box.l10n.mojito.service.asset.AssetPathNotFoundException; import com.box.l10n.mojito.service.asset.AssetRepository; @@ -20,6 +24,7 @@ import com.box.l10n.mojito.service.pollableTask.PollableFuture; import com.box.l10n.mojito.service.repository.RepositoryNameNotFoundException; import com.box.l10n.mojito.service.repository.RepositoryRepository; +import com.box.l10n.mojito.service.security.user.UserRepository; import com.box.l10n.mojito.service.tm.TMService; import com.box.l10n.mojito.service.tm.TMTextUnitCurrentVariantService; import com.box.l10n.mojito.service.tm.TMTextUnitHistoryService; @@ -37,12 +42,15 @@ import com.fasterxml.jackson.core.type.TypeReference; import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.Optional; import java.util.Set; import org.apache.commons.collections.CollectionUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; @@ -87,6 +95,10 @@ public class TextUnitWS { @Autowired AssetRepository assetRepository; + @Autowired AuditorAwareImpl auditorAwareImpl; + + @Autowired UserRepository userRepository; + /** * Gets the TextUnits that matches the search parameters. * @@ -247,6 +259,8 @@ String emptyOrString(String string, SearchType searchType) { @Transactional @RequestMapping(method = RequestMethod.POST, value = "/api/textunits") public TextUnitDTO addTextUnit(@RequestBody TextUnitDTO textUnitDTO) { + checkUserCanEditLocale(textUnitDTO.getLocaleId()); + logger.debug("Add TextUnit"); textUnitDTO.setTarget(NormalizationUtils.normalize(textUnitDTO.getTarget())); TMTextUnitCurrentVariant addTMTextUnitCurrentVariant = @@ -467,4 +481,26 @@ public PollableTask saveGitBlameWithUsages( gitBlameService.saveGitBlameWithUsages(gitBlameWithUsages); return pollableFuture.getPollableTask(); } + + private void checkUserCanEditLocale(Long localeId) { + // Fetch the User from the DB to ensure it is up to date + final Optional username = auditorAwareImpl.getCurrentAuditor().map(User::getUsername); + if (username.isEmpty() || localeId == null) { + return; + } + final User user = userRepository.findByUsername(username.get()); + + // Check if the user is allowed to edit the locale + if (!user.getCanTranslateAllLocales()) { + boolean canEditLocale = + user.getUserLocales().stream() + .map(UserLocale::getLocale) + .map(BaseEntity::getId) + .anyMatch(x -> Objects.equals(x, localeId)); + if (!canEditLocale) { + throw new AccessDeniedException( + "The user is not authorized to edit the locale with ID: " + localeId); + } + } + } } diff --git a/webapp/src/main/java/com/box/l10n/mojito/service/security/user/UserLocaleRepository.java b/webapp/src/main/java/com/box/l10n/mojito/service/security/user/UserLocaleRepository.java new file mode 100644 index 0000000000..4ff15b0a12 --- /dev/null +++ b/webapp/src/main/java/com/box/l10n/mojito/service/security/user/UserLocaleRepository.java @@ -0,0 +1,10 @@ +package com.box.l10n.mojito.service.security.user; + +import com.box.l10n.mojito.entity.security.user.UserLocale; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.rest.core.annotation.RepositoryRestResource; + +@RepositoryRestResource(exported = false) +public interface UserLocaleRepository + extends JpaRepository, JpaSpecificationExecutor {} diff --git a/webapp/src/main/java/com/box/l10n/mojito/service/security/user/UserService.java b/webapp/src/main/java/com/box/l10n/mojito/service/security/user/UserService.java index 177ee0938b..1a2d5d5fd4 100644 --- a/webapp/src/main/java/com/box/l10n/mojito/service/security/user/UserService.java +++ b/webapp/src/main/java/com/box/l10n/mojito/service/security/user/UserService.java @@ -1,16 +1,22 @@ package com.box.l10n.mojito.service.security.user; +import static java.util.Locale.ROOT; import static org.slf4j.LoggerFactory.getLogger; +import com.box.l10n.mojito.entity.Locale; import com.box.l10n.mojito.entity.security.user.Authority; import com.box.l10n.mojito.entity.security.user.User; +import com.box.l10n.mojito.entity.security.user.UserLocale; import com.box.l10n.mojito.security.AuditorAwareImpl; import com.box.l10n.mojito.security.Role; +import com.box.l10n.mojito.service.locale.LocaleService; import com.google.common.base.Preconditions; import com.google.common.collect.Sets; +import java.util.HashSet; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.StringUtils; import org.hibernate.Hibernate; @@ -41,6 +47,10 @@ public class UserService { @Autowired AuditorAwareImpl auditorAwareImpl; + @Autowired UserLocaleRepository userLocaleRepository; + + @Autowired LocaleService localeService; + /** * Allow PMs and ADMINs to create / edit users. However, a PM user can not create / edit ADMIN * users. @@ -91,6 +101,8 @@ public User createUserWithRole( String givenName, String surname, String commonName, + Set translatableLocales, + boolean canTranslateAllLocales, boolean partiallyCreated) { logger.debug("Creating user entry for: {}", username); Preconditions.checkNotNull(password, "password must not be null"); @@ -104,7 +116,16 @@ public User createUserWithRole( user.setEnabled(true); user.setUsername(username); - return saveUserWithRole(user, password, role, givenName, surname, commonName, partiallyCreated); + return saveUserWithRole( + user, + password, + role, + givenName, + surname, + commonName, + translatableLocales, + canTranslateAllLocales, + partiallyCreated); } /** @@ -127,6 +148,8 @@ public User saveUserWithRole( String givenName, String surname, String commonName, + Set translatableLocales, + boolean canTranslateAllLocales, boolean partiallyCreated) { // Only PMs and ADMINs can edit users and PMs can not edit ADMIN users (privilege escalation) @@ -154,6 +177,39 @@ public User saveUserWithRole( user.setPassword(bCryptPasswordEncoder.encode(password)); } + if (translatableLocales != null) { + Set localesToAdd = + translatableLocales.stream().map(x -> x.toLowerCase(ROOT)).collect(Collectors.toSet()); + Set currentUserLocales = new HashSet<>(user.getUserLocales()); + Set newUserLocales = new HashSet<>(); + + // Check the existing locales + for (UserLocale ul : currentUserLocales) { + final String tag = ul.getLocale().getBcp47Tag().toLowerCase(ROOT); + if (localesToAdd.remove(tag)) { + // User locale is already set --> reuse it + newUserLocales.add(ul); + } else { + // The locale was not in the new set --> remove it + userLocaleRepository.delete(ul); + } + } + + // Add the missing locales + if (!localesToAdd.isEmpty()) { + // Ensure that the user exists before saving new UserLocales objects + userRepository.save(user); + } + for (String bcp47Tag : localesToAdd) { + Locale locale = localeService.findByBcp47Tag(bcp47Tag); + UserLocale userLocale = new UserLocale(user, locale); + userLocaleRepository.save(userLocale); + newUserLocales.add(userLocale); + } + user.setUserLocales(newUserLocales); + } + + user.setCanTranslateAllLocales(canTranslateAllLocales); user.setPartiallyCreated(partiallyCreated); userRepository.save(user); @@ -211,7 +267,7 @@ public User updatePassword(String currentPassword, String newPassword) { * @return The newly created user */ public User createUserWithRole(String username, String password, Role role) { - return createUserWithRole(username, password, role, null, null, null, false); + return createUserWithRole(username, password, role, null, null, null, null, true, false); } /** @@ -298,8 +354,9 @@ public User createBasicUser( givenName, surname, commonName, + null, + true, partiallyCreated); - logger.debug( "Manually setting created by user to system user because at this point, there isn't an authenticated user context"); updateCreatedByUserToSystemUser(userWithRole); @@ -325,7 +382,17 @@ public User createOrUpdateBasicUser( givenName, surname, commonName); - user = saveUserWithRole(user, null, null, givenName, surname, commonName, false); + user = + saveUserWithRole( + user, + null, + null, + givenName, + surname, + commonName, + null, + user.getCanTranslateAllLocales(), + false); } return user; diff --git a/webapp/src/main/resources/db/migration/V66__user_locales.sql b/webapp/src/main/resources/db/migration/V66__user_locales.sql new file mode 100644 index 0000000000..9f7c4622fa --- /dev/null +++ b/webapp/src/main/resources/db/migration/V66__user_locales.sql @@ -0,0 +1,10 @@ +create table user_locale ( + id bigint(20) NOT NULL AUTO_INCREMENT, + user_id bigint not null, + locale_id bigint not null, + primary key (id) +); +alter table user_locale add constraint FK__USER_LOCALE__USER__ID foreign key (user_id) references user (id); +alter table user_locale add constraint FK__USER_LOCALE__LOCALE__ID foreign key (locale_id) references locale (id); +create unique index UK__USER_LOCALE__USER_ID__LOCALE_ID on user_locale(user_id, locale_id); +alter table user add can_translate_all_locales bit not null default true; diff --git a/webapp/src/main/resources/properties/de.properties b/webapp/src/main/resources/properties/de.properties index e80c8176d8..f6c4985051 100644 --- a/webapp/src/main/resources/properties/de.properties +++ b/webapp/src/main/resources/properties/de.properties @@ -757,6 +757,8 @@ userEditModal.form.label.password=Passwort userEditModal.form.placeholder.currentPassword=Bitte geben Sie ihr aktuelles Passwort ein userEditModal.form.placeholder.password=Passwort eingeben userEditModal.form.placeholder.passwordValidation=Passwort nochmal eingeben +userEditModal.form.canTranslateAllLocales=Der Benutzer darf alle Sprachen übersetzen +userEditModal.form.localesDisabled=(Benutzer mit der Rolle "{role}" dürfen überhaupt nicht übersetzen) userEditModal.form.label.authority=Rolle userEditModal.form.select.placeholder=Rolle auswählen userEditModal.alertMessage=Kontrollieren Sie bitte die Eingabe diff --git a/webapp/src/main/resources/properties/en.properties b/webapp/src/main/resources/properties/en.properties index 2afeb54c5d..58bca97d29 100644 --- a/webapp/src/main/resources/properties/en.properties +++ b/webapp/src/main/resources/properties/en.properties @@ -828,6 +828,8 @@ userEditModal.form.label.password=Password userEditModal.form.placeholder.currentPassword=Enter your current password userEditModal.form.placeholder.password=Enter the password userEditModal.form.placeholder.passwordValidation=Re-enter the password +userEditModal.form.canTranslateAllLocales=The user can translate all locales +userEditModal.form.localesDisabled=(Users with role "{role}" are not allowed to translate at all) userEditModal.form.label.authority=Role userEditModal.form.select.placeholder=Select Role userEditModal.alertMessage=Please check your input diff --git a/webapp/src/main/resources/public/js/actions/users/LocaleActions.js b/webapp/src/main/resources/public/js/actions/users/LocaleActions.js new file mode 100644 index 0000000000..a4400e8c7d --- /dev/null +++ b/webapp/src/main/resources/public/js/actions/users/LocaleActions.js @@ -0,0 +1,13 @@ +import alt from "../../alt"; + +class LocaleActions { + constructor() { + this.generateActions( + "loadLocales", + "loadLocalesSuccess", + "loadLocalesError", + ); + } +} + +export default alt.createActions(LocaleActions); diff --git a/webapp/src/main/resources/public/js/actions/users/LocaleDataSource.js b/webapp/src/main/resources/public/js/actions/users/LocaleDataSource.js new file mode 100644 index 0000000000..79b0eda4db --- /dev/null +++ b/webapp/src/main/resources/public/js/actions/users/LocaleDataSource.js @@ -0,0 +1,16 @@ +import LocaleClient from "../../sdk/LocaleClient"; +import UserClient from "../../sdk/UserClient"; +import LocaleActions from "./LocaleActions"; + +const LocaleDataSource = { + loadLocales: { + remote(userStoreState) { + return LocaleClient.getAllLocales(); + }, + + success: LocaleActions.loadLocalesSuccess, + error: LocaleActions.loadLocalesError + }, +}; + +export default LocaleDataSource; diff --git a/webapp/src/main/resources/public/js/actions/users/UserModalActions.js b/webapp/src/main/resources/public/js/actions/users/UserModalActions.js index 785b6db130..deed25f0d1 100644 --- a/webapp/src/main/resources/public/js/actions/users/UserModalActions.js +++ b/webapp/src/main/resources/public/js/actions/users/UserModalActions.js @@ -12,8 +12,13 @@ class UserModalActions { "updatePasswordValidation", "updateRole", "toggleInfoAlert", + "showValueAlert", "checkUsernameTaken", "checkUsernameTakenSuccess", + "pushCurrentLocale", + "removeLocaleFromList", + "updateCanTranslateAllLocales", + "updateLocaleInput", ); } } diff --git a/webapp/src/main/resources/public/js/components/header/LocaleSelectorModal.js b/webapp/src/main/resources/public/js/components/header/LocaleSelectorModal.js index 4a3b6c622b..977a216a31 100644 --- a/webapp/src/main/resources/public/js/components/header/LocaleSelectorModal.js +++ b/webapp/src/main/resources/public/js/components/header/LocaleSelectorModal.js @@ -10,14 +10,14 @@ class LocaleSelectorModal extends React.Component { }; state = { - "selectedLocale": Locales.getCurrentLocale() + "localeInput": Locales.getCurrentLocale() }; /** * Changes the locale of the app by setting the locale cookie and reloading the page. */ onSaveClicked = () => { - document.cookie = 'locale=' + this.state.selectedLocale; + document.cookie = 'locale=' + this.state.localeInput; document.location.reload(true); }; @@ -34,7 +34,7 @@ class LocaleSelectorModal extends React.Component { * @returns {boolean} */ isNewLocaleSelected = () => { - return Locales.getCurrentLocale() !== this.state.selectedLocale; + return Locales.getCurrentLocale() !== this.state.localeInput; }; /** @@ -44,7 +44,7 @@ class LocaleSelectorModal extends React.Component { */ onLocaleClicked = (locale) => { this.setState({ - "selectedLocale": locale + "localeInput": locale }); }; @@ -56,7 +56,7 @@ class LocaleSelectorModal extends React.Component { getLocaleListGroupItem = (locale) => { let localeDisplayName = Locales.getNativeDispalyName(locale); - let active = locale === this.state.selectedLocale; + let active = locale === this.state.localeInput; return ( {this.renderPageBar()} {this.renderUsersTable()} - + diff --git a/webapp/src/main/resources/public/js/components/users/UserModal.js b/webapp/src/main/resources/public/js/components/users/UserModal.js index bb9ee100bd..71fe473869 100644 --- a/webapp/src/main/resources/public/js/components/users/UserModal.js +++ b/webapp/src/main/resources/public/js/components/users/UserModal.js @@ -1,10 +1,11 @@ import React from "react"; import {FormattedMessage, injectIntl} from "react-intl"; -import {Button, FormControl, Modal, Form, FormGroup, ControlLabel, Alert, Collapse, Glyphicon} from "react-bootstrap"; +import {Button, FormControl, Modal, Form, FormGroup, ControlLabel, Alert, Collapse, Glyphicon, Dropdown, MenuItem, DropdownButton} from "react-bootstrap"; import UserStatics from "../../utils/UserStatics"; import UserActions from "../../actions/users/UserActions"; import UserModalActions from "../../actions/users/UserModalActions"; import {roleToIntlKey} from "./UserRole"; +import AuthorityService from "../../utils/AuthorityService"; class UserModal extends React.Component{ @@ -28,6 +29,53 @@ class UserModal extends React.Component{ return 'success'; } + renderLocales() { + const options = []; + let freeTagList = [] + for (let tag of this.props.locales.allLocales.map((x) => x.bcp47Tag).toSorted()) { + if (this.props.modal.localeTags.includes(tag) || (this.props.modal.localeFilter && !tag.toLowerCase().includes(this.props.modal.localeFilter.toLowerCase()))) { + continue; + } + options.push(

+
+ {localeElements} +
+ UserModalActions.updateLocaleInput(e.target.value)} + type="text" + value={this.props.modal.localeInput} + style={{gridArea: 'new-select'}} + placeholder="de-DE" + id="new-locale-input" + list="new-locale-options" + /> + + {options} + +
+ +
+
+ ); + } + renderForm() { let usernameForm = ''; let optionalPasswordLabel = ''; @@ -63,10 +111,13 @@ class UserModal extends React.Component{ } let options = [] - for (let i of [UserStatics.authorityPm(), UserStatics.authorityTranslator(), UserStatics.authorityAdmin(), UserStatics.authorityUser()]) { + for (let i of [UserStatics.authorityUser(), UserStatics.authorityTranslator(), UserStatics.authorityPm(), UserStatics.authorityAdmin()]) { options.push() } + const roleCanTranslate = AuthorityService.canEditTransalationRoles().includes(this.props.modal.role); + const roleName = this.props.modal.role ? this.props.intl.formatMessage({id: roleToIntlKey(this.props.modal.role)}) : ''; + return (
{usernameForm} @@ -118,6 +169,20 @@ class UserModal extends React.Component{ placeholder={this.props.intl.formatMessage({ id: "userEditModal.form.placeholder.passwordValidation" })} /> +
+ + UserModalActions.updateCanTranslateAllLocales(e.target.checked)} + type="checkbox" + checked={this.props.modal.canTranslateAllLocales} + value={} + disabled={!roleCanTranslate} + /> + + {!roleCanTranslate &&
} + {!this.props.modal.canTranslateAllLocales && roleCanTranslate && this.renderLocales()} +

@@ -189,6 +254,8 @@ class UserModal extends React.Component{ "surname": (this.props.modal.surname || '').trim(), "commonName": (this.props.modal.commonName || '').trim(), "password": this.props.modal.password, + "canTranslateAllLocales": this.props.modal.canTranslateAllLocales, + "userLocales": this.props.modal.localeTags.map((x) => {return {"locale": {"bcp47Tag": x}};}), "authorities": [{ "authority": this.props.modal.role }], @@ -205,9 +272,7 @@ class UserModal extends React.Component{ break; } } else { - this.setState({ - alertCollapse: true - }); + UserModalActions.showValueAlert(); } } @@ -231,7 +296,7 @@ class UserModal extends React.Component{ renderValueAlert() { return ( - +

diff --git a/webapp/src/main/resources/public/js/components/workbench/TextUnit.js b/webapp/src/main/resources/public/js/components/workbench/TextUnit.js index f55ddae76d..deeae25400 100644 --- a/webapp/src/main/resources/public/js/components/workbench/TextUnit.js +++ b/webapp/src/main/resources/public/js/components/workbench/TextUnit.js @@ -433,6 +433,7 @@ let TextUnit = createReactClass({ let glyphType = "ok"; let glyphTitle = this.props.intl.formatMessage({id: "textUnit.reviewModal.accepted"}); + let canChange = AuthorityService.canTranslateLocale(this.props.textUnit.getTargetLocale()); if (!this.props.textUnit.isIncludedInLocalizedFile()) { @@ -451,7 +452,7 @@ let TextUnit = createReactClass({ } ui = ( + onClick={this.onTextUnitGlyphClicked} disabled={!canChange}/> ); } @@ -521,7 +522,7 @@ let TextUnit = createReactClass({ editStringClicked(e) { e.stopPropagation(); - if (!AuthorityService.canEditTranslations()) { + if (!AuthorityService.canTranslateLocale(this.props.textUnit.getTargetLocale())) { return; } this.setState({ @@ -539,7 +540,7 @@ let TextUnit = createReactClass({ */ getTargetStringUI() { let ui; - if (this.state.isEditMode && AuthorityService.canEditTranslations()) { + if (this.state.isEditMode && AuthorityService.canTranslateLocale(this.props.textUnit.getTargetLocale())) { ui = this.getUIForEditMode(); } else { let targetString = this.hasTargetChanged() ? this.state.translation : this.props.translation; @@ -549,7 +550,7 @@ let TextUnit = createReactClass({ let noTranslation = false; let targetClassName = "pts pls pbs textunit-string"; - if (AuthorityService.canEditTranslations()) { + if (AuthorityService.canTranslateLocale(this.props.textUnit.getTargetLocale())) { targetClassName += " textunit-target" } if (targetString == null) { @@ -706,7 +707,7 @@ let TextUnit = createReactClass({ * @param {SyntheticEvent} e */ onTextUnitClick(e) { - if (!AuthorityService.canEditTranslations()) { + if (!AuthorityService.canTranslateLocale(this.props.textUnit.getTargetLocale())) { return; } @@ -722,7 +723,7 @@ let TextUnit = createReactClass({ */ getTextUnitReviewModal() { let ui = ""; - if (this.state.isShowModal && AuthorityService.canEditTranslations()) { + if (this.state.isShowModal && AuthorityService.canTranslateLocale(this.props.textUnit.getTargetLocale())) { let textUnitArray = [this.getCloneOfTextUnitFromProps()]; ui = (
- +