diff --git a/identity-api/src/main/java/life/qbic/identity/api/AuthenticationToUserIdTranslator.java b/identity-api/src/main/java/life/qbic/identity/api/AuthenticationToUserIdTranslator.java new file mode 100644 index 000000000..9d29888f0 --- /dev/null +++ b/identity-api/src/main/java/life/qbic/identity/api/AuthenticationToUserIdTranslator.java @@ -0,0 +1,21 @@ +package life.qbic.identity.api; + +import java.util.Optional; +import org.springframework.security.core.Authentication; + +/** + * Translates an {@link Authentication} object into an identifier of QBiC + * + * @since 1.2.0 + */ +public interface AuthenticationToUserIdTranslator { + + /** + * Translates an authentication object to the associated userId + * + * @param authentication + * @return + */ + Optional translateToUserId(Authentication authentication); + +} diff --git a/identity-api/src/main/java/life/qbic/identity/api/UserInfo.java b/identity-api/src/main/java/life/qbic/identity/api/UserInfo.java index 74bdf1236..6f281c390 100644 --- a/identity-api/src/main/java/life/qbic/identity/api/UserInfo.java +++ b/identity-api/src/main/java/life/qbic/identity/api/UserInfo.java @@ -10,7 +10,6 @@ * @since 1.0.0 */ public record UserInfo(String id, String fullName, String emailAddress, String platformUserName, - String encryptedPassword, boolean isActive) implements Serializable { } diff --git a/identity-api/src/main/java/life/qbic/identity/api/UserInformationService.java b/identity-api/src/main/java/life/qbic/identity/api/UserInformationService.java index d767438c3..7a101f17a 100644 --- a/identity-api/src/main/java/life/qbic/identity/api/UserInformationService.java +++ b/identity-api/src/main/java/life/qbic/identity/api/UserInformationService.java @@ -42,7 +42,18 @@ public interface UserInformationService { * @return true, if the username is still available, false if not * @since 1.0.0 */ - boolean userNameAvailable(String userName); + boolean isUserNameAvailable(String userName); + + + /** + * Queries if a provided email is already in use by another user + * + * @param email the desired email address + * @return true, if the email is still available, false if not + */ + boolean isEmailAvailable(String email); + + Optional findByOidc(String oidcId, String oidcIssuer); List findAllActive(String filter, int offset, int limit, List sortOrders); } diff --git a/identity-api/src/main/java/life/qbic/identity/api/UserPassword.java b/identity-api/src/main/java/life/qbic/identity/api/UserPassword.java new file mode 100644 index 000000000..392c6c195 --- /dev/null +++ b/identity-api/src/main/java/life/qbic/identity/api/UserPassword.java @@ -0,0 +1,10 @@ +package life.qbic.identity.api; + +/** + * An encrypted password belonging to a user + * + * @since 1.1.0 + */ +public record UserPassword(String userId, String encryptedPassword) { + +} diff --git a/identity-api/src/main/java/life/qbic/identity/api/UserPasswordService.java b/identity-api/src/main/java/life/qbic/identity/api/UserPasswordService.java new file mode 100644 index 000000000..1973a706b --- /dev/null +++ b/identity-api/src/main/java/life/qbic/identity/api/UserPasswordService.java @@ -0,0 +1,18 @@ +package life.qbic.identity.api; + +import java.util.Optional; + +/** + * A service for retrieving encrypted user passwords. + * + * @since 1.2.0 + */ +public interface UserPasswordService { + + /** + * @param userId the identifier of the user in the datamanager user management + * @return the {@link UserPassword} for a user with the given identifier + */ + Optional findEncryptedPasswordForUser(String userId); + +} diff --git a/identity-infrastructure/src/main/java/life/qbic/identity/infrastructure/QbicUserRepo.java b/identity-infrastructure/src/main/java/life/qbic/identity/infrastructure/QbicUserRepo.java index 8973cc061..e96cfb73e 100644 --- a/identity-infrastructure/src/main/java/life/qbic/identity/infrastructure/QbicUserRepo.java +++ b/identity-infrastructure/src/main/java/life/qbic/identity/infrastructure/QbicUserRepo.java @@ -2,6 +2,7 @@ import java.util.List; +import java.util.Optional; import life.qbic.identity.domain.model.EmailAddress; import life.qbic.identity.domain.model.User; import life.qbic.identity.domain.model.UserId; @@ -50,4 +51,6 @@ public interface QbicUserRepo extends JpaRepository { User findUserByUserName(String userName); List findAllByUserNameContainingIgnoreCaseAndActiveTrue(String username, Pageable pageable); + + Optional findByOidcIdEqualsAndOidcIssuerEquals(String oidcId, String oidcIssuer); } diff --git a/identity-infrastructure/src/main/java/life/qbic/identity/infrastructure/UserJpaRepository.java b/identity-infrastructure/src/main/java/life/qbic/identity/infrastructure/UserJpaRepository.java index e60d4e398..18dbe3cf6 100644 --- a/identity-infrastructure/src/main/java/life/qbic/identity/infrastructure/UserJpaRepository.java +++ b/identity-infrastructure/src/main/java/life/qbic/identity/infrastructure/UserJpaRepository.java @@ -64,4 +64,9 @@ public List findByUserNameContainingIgnoreCaseAndActiveTrue(String userNam Pageable pageable) { return userRepo.findAllByUserNameContainingIgnoreCaseAndActiveTrue(userName, pageable); } + + @Override + public Optional findByOidcIdEqualsAndOidcIssuerEquals(String oidcId, String oidcIssuer) { + return userRepo.findByOidcIdEqualsAndOidcIssuerEquals(oidcId, oidcIssuer); + } } diff --git a/identity/src/main/java/life/qbic/identity/application/service/BasicUserInformationService.java b/identity/src/main/java/life/qbic/identity/application/service/BasicUserInformationService.java index b6cdd7998..131f7dc5b 100644 --- a/identity/src/main/java/life/qbic/identity/application/service/BasicUserInformationService.java +++ b/identity/src/main/java/life/qbic/identity/application/service/BasicUserInformationService.java @@ -9,6 +9,8 @@ import life.qbic.application.commons.SortOrder; import life.qbic.identity.api.UserInfo; import life.qbic.identity.api.UserInformationService; +import life.qbic.identity.api.UserPassword; +import life.qbic.identity.api.UserPasswordService; import life.qbic.identity.domain.model.EmailAddress; import life.qbic.identity.domain.model.EmailAddress.EmailValidationException; import life.qbic.identity.domain.model.User; @@ -26,7 +28,7 @@ * * @since 1.0.0 */ -public class BasicUserInformationService implements UserInformationService { +public class BasicUserInformationService implements UserInformationService, UserPasswordService { private static final Logger log = logger(BasicUserInformationService.class); @@ -59,10 +61,26 @@ public Optional findById(String userId) { } @Override - public boolean userNameAvailable(String userName) { + public boolean isUserNameAvailable(String userName) { return userRepository.findByUserName(userName).isEmpty(); } + @Override + public Optional findByOidc(String oidcId, String oidcIssuer) { + return userRepository.findByOidc(oidcId, oidcIssuer).map(this::convert); + } + + @Override + public boolean isEmailAvailable(String email) { + try { + var emailAddress = EmailAddress.from(email); + return userRepository.findByEmail(emailAddress).isEmpty(); + } catch (EmailValidationException e) { + log.error("Invalid email address %s".formatted(email), e); + return false; + } + } + @Override public List findAllActive(String filter, int offset, int limit, List sortOrders) { @@ -79,14 +97,21 @@ public List findAllActive(String filter, int offset, int limit, filter, new OffsetBasedRequest(offset, limit, Sort.by(orders))) .stream() .map(user -> new UserInfo(user.id().get(), user.fullName().get(), user.emailAddress().get(), - user.userName(), user.getEncryptedPassword().get(), user.isActive())) + user.userName(), user.isActive())) .toList(); } private UserInfo convert(User user) { return new UserInfo(user.id().get(), user.fullName().get(), user.emailAddress().get(), user.userName(), - user.getEncryptedPassword().get(), user.isActive()); } + + @Override + public Optional findEncryptedPasswordForUser(String userId) { + return userRepository.findById(UserId.from(userId)) + .map(user -> user.getEncryptedPassword() != null ? user : null) + .map(user -> new UserPassword(user.id().get(), user.getEncryptedPassword().get())); + } + } diff --git a/identity/src/main/java/life/qbic/identity/application/user/IdentityService.java b/identity/src/main/java/life/qbic/identity/application/user/IdentityService.java index 7e3fe4ff7..943e0f69b 100644 --- a/identity/src/main/java/life/qbic/identity/application/user/IdentityService.java +++ b/identity/src/main/java/life/qbic/identity/application/user/IdentityService.java @@ -88,6 +88,36 @@ public ApplicationResponse registerUser(final String fullName, String userName, return ApplicationResponse.successResponse(); } + public ApplicationResponse registerOpenIdUser(String fullName, String userName, String email, + String oidcIssuer, String oidcId) { + + var validationResponse = validateInputOidcInput(fullName, userName, email, oidcIssuer, oidcId); + if (validationResponse.hasFailures()) { + return validationResponse; + } + + var userDomainService = DomainRegistry.instance().userDomainService(); + if (userDomainService.isEmpty()) { + throw new RuntimeException("User registration failed."); + } + + var userEmail = EmailAddress.from(email); + var userFullName = FullName.from(fullName); + + if (userRepository.findByEmail(userEmail).isPresent()) { + return ApplicationResponse.failureResponse(new UserExistsException()); + } + + if (userRepository.findByUserName(userName).isPresent()) { + return ApplicationResponse.failureResponse(new UserNameNotAvailableException()); + } + + // Trigger the user creation in the domain service + userDomainService.get().createOidcUser(userFullName, userName, userEmail, oidcIssuer, oidcId); + + return ApplicationResponse.successResponse(); + } + private ApplicationResponse validateInput(String fullName, String userName, String email, char[] rawPassword) { List failures = new ArrayList<>(); @@ -118,6 +148,37 @@ private ApplicationResponse validateInput(String fullName, String userName, Stri return ApplicationResponse.failureResponse(failures.toArray(RuntimeException[]::new)); } + private ApplicationResponse validateInputOidcInput(String fullName, String userName, String email, + String oidcIssuer, String oidcId) { + List failures = new ArrayList<>(); + + try { + EmailAddress.from(email); + } catch (EmailValidationException e) { + failures.add(e); + } + try { + FullName.from(fullName); + } catch (FullNameValidationException e) { + failures.add(e); + } + if (isNull(userName) || userName.isBlank()) { + failures.add(new EmptyUserNameException()); + } + if (isNull(oidcIssuer) || oidcIssuer.isBlank()) { + failures.add(new EmptyOidcIssuerException()); + } + if (isNull(oidcId) || oidcId.isBlank()) { + failures.add(new EmptyOidcIdException()); + } + + if (failures.isEmpty()) { + return ApplicationResponse.successResponse(); + } + + return ApplicationResponse.failureResponse(failures.toArray(RuntimeException[]::new)); + } + /** * Requests a password reset for a user. * @@ -276,4 +337,12 @@ public static class UserNotActivatedException extends ApplicationException { super(message); } } + + public static class EmptyOidcIssuerException extends RuntimeException { + + } + + public static class EmptyOidcIdException extends RuntimeException { + + } } diff --git a/identity/src/main/java/life/qbic/identity/application/user/policy/EmailConfirmationLinkSupplier.java b/identity/src/main/java/life/qbic/identity/application/user/policy/EmailConfirmationLinkSupplier.java index 016a48e71..31e5e60da 100644 --- a/identity/src/main/java/life/qbic/identity/application/user/policy/EmailConfirmationLinkSupplier.java +++ b/identity/src/main/java/life/qbic/identity/application/user/policy/EmailConfirmationLinkSupplier.java @@ -30,8 +30,8 @@ public EmailConfirmationLinkSupplier( @Value("${service.host.name}") String host, @Value("${service.host.port}") int port, @Value("${server.servlet.context-path}") String contextPath, - @Value("${email-confirmation-endpoint}") String loginEndpoint, - @Value("${email-confirmation-parameter}") String emailConfirmationParameter) { + @Value("${routing.email-confirmation.endpoint}") String loginEndpoint, + @Value("${routing.email-confirmation.confirmation-parameter}") String emailConfirmationParameter) { this.protocol = protocol; this.host = host; this.port = port; diff --git a/identity/src/main/java/life/qbic/identity/application/user/policy/PasswordResetLinkSupplier.java b/identity/src/main/java/life/qbic/identity/application/user/policy/PasswordResetLinkSupplier.java index 176ecf499..a2ee3d1d9 100644 --- a/identity/src/main/java/life/qbic/identity/application/user/policy/PasswordResetLinkSupplier.java +++ b/identity/src/main/java/life/qbic/identity/application/user/policy/PasswordResetLinkSupplier.java @@ -32,8 +32,8 @@ public PasswordResetLinkSupplier( @Value("${service.host.name}") String host, @Value("${service.host.port}") int port, @Value("${server.servlet.context-path}") String contextPath, - @Value("${password-reset-endpoint}") String resetEndpoint, - @Value("${password-reset-parameter}") String passwordResetParameter) { + @Value("${routing.password-reset.endpoint}") String resetEndpoint, + @Value("${routing.password-reset.reset-parameter}") String passwordResetParameter) { this.protocol = protocol; this.host = host; this.port = port; diff --git a/identity/src/main/java/life/qbic/identity/application/user/registration/UserRegistrationException.java b/identity/src/main/java/life/qbic/identity/application/user/registration/UserRegistrationException.java deleted file mode 100644 index fc25ef7bf..000000000 --- a/identity/src/main/java/life/qbic/identity/application/user/registration/UserRegistrationException.java +++ /dev/null @@ -1,140 +0,0 @@ -package life.qbic.identity.application.user.registration; - -import java.io.Serial; -import java.util.Optional; -import life.qbic.application.commons.ApplicationException; -import life.qbic.identity.application.user.IdentityService.EmptyUserNameException; -import life.qbic.identity.application.user.IdentityService.UserExistsException; -import life.qbic.identity.application.user.IdentityService.UserNameNotAvailableException; -import life.qbic.identity.domain.model.EmailAddress.EmailValidationException; -import life.qbic.identity.domain.model.EncryptedPassword.PasswordValidationException; -import life.qbic.identity.domain.model.FullName.FullNameValidationException; - -/** - *

Exception that indicates violations during the user registration process

- * - *

This exception is supposed to be thrown, if the provided user credentials violate one or more - * policies - * during the registration process. It's intention is to contain the exceptions thrown for each of - * the vioolated credentials

- *

- * Example: A user provides a malformed mail value and an empty user name. Since this violates the - * established policies, the method will catch the individual ApplicationExceptions and add them to - * this Exception - * - * @since 1.0.0 - */ -public class UserRegistrationException extends ApplicationException { - - @Serial - private static final long serialVersionUID = 1026978635211901782L; - private final transient EmailValidationException emailFormatException; - private final transient PasswordValidationException invalidPasswordException; - private final transient FullNameValidationException fullNameException; - - private final transient UserNameNotAvailableException userNameNotAvailableException; - private final transient EmptyUserNameException emptyUserNameException; - - private final transient UserExistsException userExistsException; - private final transient RuntimeException unexpectedException; - - public static Builder builder() { - return new Builder(); - } - - public static class Builder { - - private EmailValidationException emailFormatException; - - private PasswordValidationException invalidPasswordException; - - private FullNameValidationException fullNameException; - - private UserExistsException userExistsException; - private RuntimeException unexpectedException; - private UserNameNotAvailableException userNameNotAvailableException; - private EmptyUserNameException emptyUserNameException; - - protected Builder() { - - } - - public Builder withEmailFormatException(EmailValidationException e) { - emailFormatException = e; - return this; - } - - public Builder withFullNameException(FullNameValidationException e) { - fullNameException = e; - return this; - } - - public Builder withInvalidPasswordException(PasswordValidationException e) { - invalidPasswordException = e; - return this; - } - - public Builder withUserExistsException(UserExistsException e) { - userExistsException = e; - return this; - } - - public Builder withUnexpectedException(RuntimeException e) { - unexpectedException = e; - return this; - } - - public UserRegistrationException build() { - return new UserRegistrationException(this); - } - - public Builder withUserNameNotAvailableException(UserNameNotAvailableException e) { - this.userNameNotAvailableException = e; - return this; - } - - public Builder withEmptyUserNameException(EmptyUserNameException emptyUserNameException) { - this.emptyUserNameException = emptyUserNameException; - return this; - } - } - - private UserRegistrationException(Builder builder) { - emailFormatException = builder.emailFormatException; - fullNameException = builder.fullNameException; - invalidPasswordException = builder.invalidPasswordException; - userExistsException = builder.userExistsException; - unexpectedException = builder.unexpectedException; - userNameNotAvailableException = builder.userNameNotAvailableException; - emptyUserNameException = builder.emptyUserNameException; - } - - public Optional emailFormatException() { - return Optional.ofNullable(emailFormatException); - } - - public Optional fullNameException() { - return Optional.ofNullable(fullNameException); - } - - public Optional passwordException() { - return Optional.ofNullable(invalidPasswordException); - } - - public Optional userExistsException() { - return Optional.ofNullable(userExistsException); - } - - public Optional userNameNotAvailableException() { - return Optional.ofNullable(userNameNotAvailableException); - } - - public Optional emptyUserNameException() { - return Optional.ofNullable(emptyUserNameException); - } - - public Optional unexpectedException() { - return Optional.ofNullable(unexpectedException); - } - -} diff --git a/identity/src/main/java/life/qbic/identity/domain/model/EmailFormatPolicy.java b/identity/src/main/java/life/qbic/identity/domain/model/EmailFormatPolicy.java index 9c44e8e19..d97dfbe2a 100644 --- a/identity/src/main/java/life/qbic/identity/domain/model/EmailFormatPolicy.java +++ b/identity/src/main/java/life/qbic/identity/domain/model/EmailFormatPolicy.java @@ -1,5 +1,7 @@ package life.qbic.identity.domain.model; +import static java.util.Objects.isNull; + import java.util.regex.Pattern; import life.qbic.identity.domain.model.policy.PolicyCheckReport; import life.qbic.identity.domain.model.policy.PolicyStatus; @@ -44,6 +46,9 @@ public PolicyCheckReport validate(String email) { } private static boolean honoursRFCSpec(String email) { + if (isNull(email)) { + return false; + } var pattern = Pattern.compile(FULL_ADDRESS_SPEC); var matcher = pattern.matcher(email); return matcher.matches(); diff --git a/identity/src/main/java/life/qbic/identity/domain/model/User.java b/identity/src/main/java/life/qbic/identity/domain/model/User.java index 17ce12cec..8157a0ad0 100644 --- a/identity/src/main/java/life/qbic/identity/domain/model/User.java +++ b/identity/src/main/java/life/qbic/identity/domain/model/User.java @@ -1,5 +1,8 @@ package life.qbic.identity.domain.model; +import static java.util.Objects.isNull; +import static java.util.Objects.nonNull; + import jakarta.persistence.Column; import jakarta.persistence.Convert; import jakarta.persistence.EmbeddedId; @@ -8,6 +11,7 @@ import java.io.Serial; import java.io.Serializable; import java.time.Instant; +import java.util.Optional; import life.qbic.domain.concepts.DomainEventDispatcher; import life.qbic.identity.domain.event.PasswordResetRequested; import life.qbic.identity.domain.event.UserActivated; @@ -49,16 +53,22 @@ public class User implements Serializable { @Convert(converter = PasswordConverter.class) private EncryptedPassword encryptedPassword; + private String oidcIssuer; + private String oidcId; + private boolean active = false; private User(UserId id, FullName fullName, EmailAddress emailAddress, - String userName, EncryptedPassword encryptedPassword, Instant registrationDate) { + String userName, EncryptedPassword encryptedPassword, Instant registrationDate, + String oidcIssuer, String oidcId) { this.id = id; this.fullName = fullName; this.emailAddress = emailAddress; this.encryptedPassword = encryptedPassword; this.userName = userName; this.registrationDate = registrationDate; + this.oidcIssuer = oidcIssuer; + this.oidcId = oidcId; } protected User() { @@ -85,12 +95,26 @@ public static User create(FullName fullName, EmailAddress emailAddress, String userName, EncryptedPassword encryptedPassword) { UserId id = UserId.create(); Instant registrationDate = Instant.now(); - var user = new User(id, fullName, emailAddress, userName, encryptedPassword, registrationDate); + var user = new User(id, fullName, emailAddress, userName, encryptedPassword, registrationDate, + null, null); user.active = false; return user; } + public static User createOidc(String fullName, String emailAddress, + String userName, String oidcIssuer, String oidcId) { + if (isNull(oidcIssuer) && nonNull(oidcId)) { + throw new IllegalStateException("OIDC issuer cannot be null if OIDC identifier is provided"); + } + UserId id = UserId.create(); + Instant registrationDate = Instant.now(); + var user = new User(id, FullName.from(fullName) + , EmailAddress.from(emailAddress), userName, null, registrationDate, oidcIssuer, oidcId); + user.active = false; + return user; + } + @Override public String toString() { return "User{" + @@ -120,6 +144,14 @@ public UserId id() { return this.id; } + public Optional getOidcIssuer() { + return Optional.ofNullable(oidcIssuer); + } + + public Optional getOidcId() { + return Optional.ofNullable(oidcId); + } + public EmailAddress emailAddress() { return this.emailAddress; } diff --git a/identity/src/main/java/life/qbic/identity/domain/model/UserId.java b/identity/src/main/java/life/qbic/identity/domain/model/UserId.java index 6d9974220..5e8df9de1 100644 --- a/identity/src/main/java/life/qbic/identity/domain/model/UserId.java +++ b/identity/src/main/java/life/qbic/identity/domain/model/UserId.java @@ -48,7 +48,7 @@ public static UserId from(String s) throws IllegalArgumentException { try { return new UserId(UUID.fromString(s)); } catch (IllegalArgumentException ignored) { - throw new IllegalArgumentException(s+ " has unknown user id format."); + throw new IllegalArgumentException(s + " has unknown user id format."); } } diff --git a/identity/src/main/java/life/qbic/identity/domain/model/translation/PasswordConverter.java b/identity/src/main/java/life/qbic/identity/domain/model/translation/PasswordConverter.java index 75fee51e6..b695b8eed 100644 --- a/identity/src/main/java/life/qbic/identity/domain/model/translation/PasswordConverter.java +++ b/identity/src/main/java/life/qbic/identity/domain/model/translation/PasswordConverter.java @@ -1,5 +1,7 @@ package life.qbic.identity.domain.model.translation; +import static java.util.Objects.isNull; + import jakarta.persistence.AttributeConverter; import life.qbic.identity.domain.model.EncryptedPassword; @@ -17,11 +19,17 @@ public class PasswordConverter implements AttributeConverter findByUserNameContainingIgnoreCaseAndActiveTrue(String username, Pageable pageable); + Optional findByOidcIdEqualsAndOidcIssuerEquals(String oidcId, String oidcIssuer); } diff --git a/identity/src/main/java/life/qbic/identity/domain/repository/UserRepository.java b/identity/src/main/java/life/qbic/identity/domain/repository/UserRepository.java index 374e15ae4..db3595ce0 100644 --- a/identity/src/main/java/life/qbic/identity/domain/repository/UserRepository.java +++ b/identity/src/main/java/life/qbic/identity/domain/repository/UserRepository.java @@ -115,6 +115,10 @@ public List findByUserNameContainingIgnoreCaseAndActiveTrue(String userNam return dataStorage.findByUserNameContainingIgnoreCaseAndActiveTrue(userName, pageable); } + public Optional findByOidc(String oidcId, String oidcIssuer) { + return dataStorage.findByOidcIdEqualsAndOidcIssuerEquals(oidcId, oidcIssuer); + } + /** * Updates a user in the repository. Publishes all domain events of the user if successful. If * unsuccessful, throws a {@link UserStorageException} diff --git a/identity/src/main/java/life/qbic/identity/domain/service/UserDomainService.java b/identity/src/main/java/life/qbic/identity/domain/service/UserDomainService.java index bec52258f..6650c874e 100644 --- a/identity/src/main/java/life/qbic/identity/domain/service/UserDomainService.java +++ b/identity/src/main/java/life/qbic/identity/domain/service/UserDomainService.java @@ -53,4 +53,17 @@ public void createUser(FullName fullName, String userName, EmailAddress emailAdd user.emailAddress().get()); DomainEventDispatcher.instance().dispatch(userCreatedEvent); } + + public void createOidcUser(FullName userFullName, String userName, EmailAddress userEmail, + String oidcIssuer, String oidcId) { + // Ensure idempotent behaviour of the service + if (userRepository.findByOidc(oidcId, oidcIssuer).isPresent()) { + return; + } + var user = User.createOidc(userFullName.get(), userEmail.get(), userName, oidcIssuer, oidcId); + userRepository.addUser(user); + var userCreatedEvent = UserRegistered.create(user.id().get(), user.fullName().get(), + user.emailAddress().get()); + DomainEventDispatcher.instance().dispatch(userCreatedEvent); + } } diff --git a/identity/src/test/groovy/life/qbic/identity/domain/usermanagement/registration/RegistrationSpec.groovy b/identity/src/test/groovy/life/qbic/identity/domain/usermanagement/registration/RegistrationSpec.groovy new file mode 100644 index 000000000..e69de29bb diff --git a/pom.xml b/pom.xml index 5aac01c5b..e275d4b4e 100644 --- a/pom.xml +++ b/pom.xml @@ -30,7 +30,7 @@ 17 - 24.2.11 + 24.3.12 17 17 3.1.0 diff --git a/project-management-infrastructure/pom.xml b/project-management-infrastructure/pom.xml index f3f6b0093..bf86921cf 100644 --- a/project-management-infrastructure/pom.xml +++ b/project-management-infrastructure/pom.xml @@ -51,6 +51,10 @@ org.springframework.data spring-data-jpa + + org.springframework.security + spring-security-oauth2-client + diff --git a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/project/ProjectRepositoryImpl.java b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/project/ProjectRepositoryImpl.java index b47339eac..92777e906 100644 --- a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/project/ProjectRepositoryImpl.java +++ b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/project/ProjectRepositoryImpl.java @@ -1,21 +1,20 @@ package life.qbic.projectmanagement.infrastructure.project; +import static java.util.Objects.requireNonNull; import static life.qbic.logging.service.LoggerFactory.logger; import java.time.Instant; import java.util.Optional; import life.qbic.logging.api.Logger; -import life.qbic.projectmanagement.application.authorization.QbicUserDetails; +import life.qbic.projectmanagement.application.AuthenticationToUserIdTranslationService; import life.qbic.projectmanagement.application.authorization.acl.ProjectAccessService; import life.qbic.projectmanagement.application.authorization.acl.ProjectAccessService.ProjectRole; -import life.qbic.projectmanagement.application.authorization.authorities.aspects.CanCreateProject; import life.qbic.projectmanagement.domain.model.project.Project; import life.qbic.projectmanagement.domain.model.project.ProjectCode; import life.qbic.projectmanagement.domain.model.project.ProjectId; import life.qbic.projectmanagement.domain.repository.ProjectRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @@ -42,17 +41,19 @@ public class ProjectRepositoryImpl implements ProjectRepository { private final QbicProjectRepo projectRepo; private final QbicProjectDataRepo projectDataRepo; private final ProjectAccessService projectAccessService; + private final AuthenticationToUserIdTranslationService userIdTranslator; @Autowired public ProjectRepositoryImpl(QbicProjectRepo projectRepo, - QbicProjectDataRepo projectDataRepo, ProjectAccessService projectAccessService) { + QbicProjectDataRepo projectDataRepo, ProjectAccessService projectAccessService, + AuthenticationToUserIdTranslationService userIdTranslator) { this.projectRepo = projectRepo; this.projectDataRepo = projectDataRepo; this.projectAccessService = projectAccessService; + this.userIdTranslator = requireNonNull(userIdTranslator, "userIdTranslator must not be null"); } @Override - @CanCreateProject public void add(Project project) { ProjectCode projectCode = project.getProjectCode(); if (doesProjectExistWithId(project.getId()) || projectDataRepo.projectExists(projectCode)) { @@ -60,8 +61,9 @@ public void add(Project project) { } try { var savedProject = projectRepo.save(project); - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - var userId = ((QbicUserDetails) authentication.getPrincipal()).getUserId(); + var userId = userIdTranslator.translateToUserId( + SecurityContextHolder.getContext().getAuthentication()) + .orElseThrow(); projectAccessService.initializeProject(savedProject.getId(), userId); projectAccessService.addAuthorityAccess(savedProject.getId(), "ROLE_ADMIN", ProjectAccessService.ProjectRole.ADMIN); diff --git a/project-management/pom.xml b/project-management/pom.xml index 37a570329..15c8e4a30 100644 --- a/project-management/pom.xml +++ b/project-management/pom.xml @@ -94,5 +94,9 @@ org.springframework spring-orm + + org.springframework.security + spring-security-oauth2-core + diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/AuthenticationToUserIdTranslationService.java b/project-management/src/main/java/life/qbic/projectmanagement/application/AuthenticationToUserIdTranslationService.java new file mode 100644 index 000000000..bbb57f53e --- /dev/null +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/AuthenticationToUserIdTranslationService.java @@ -0,0 +1,24 @@ +package life.qbic.projectmanagement.application; + +import java.util.Optional; +import life.qbic.identity.api.AuthenticationToUserIdTranslator; +import life.qbic.projectmanagement.application.authorization.QbicOidcUser; +import life.qbic.projectmanagement.application.authorization.QbicUserDetails; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +@Service(value = "userIdTranslator") +public class AuthenticationToUserIdTranslationService implements AuthenticationToUserIdTranslator { + + @Override + public Optional translateToUserId(Authentication authentication) { + var principal = authentication.getPrincipal(); + if (principal instanceof QbicUserDetails qbicUserDetails) { + return Optional.of(qbicUserDetails.getUserId()); + } + if (principal instanceof QbicOidcUser qbicOidcUser) { + return Optional.of(qbicOidcUser.getQbicUserId()); + } + return Optional.empty(); + } +} diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/ProjectInformationService.java b/project-management/src/main/java/life/qbic/projectmanagement/application/ProjectInformationService.java index 8eec31eaa..381453ba6 100644 --- a/project-management/src/main/java/life/qbic/projectmanagement/application/ProjectInformationService.java +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/ProjectInformationService.java @@ -1,17 +1,19 @@ package life.qbic.projectmanagement.application; +import static java.util.function.Predicate.not; + import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Optional; import life.qbic.application.commons.ApplicationException; import life.qbic.application.commons.SortOrder; +import life.qbic.identity.api.AuthenticationToUserIdTranslator; import life.qbic.logging.api.Logger; import life.qbic.logging.service.LoggerFactory; import life.qbic.projectmanagement.application.api.ProjectOverviewLookup; -import life.qbic.projectmanagement.application.authorization.QbicUserDetails; import life.qbic.projectmanagement.application.authorization.acl.ProjectAccessService; -import life.qbic.projectmanagement.application.authorization.authorities.Role; import life.qbic.projectmanagement.domain.model.project.Contact; import life.qbic.projectmanagement.domain.model.project.Funding; import life.qbic.projectmanagement.domain.model.project.Project; @@ -38,14 +40,17 @@ public class ProjectInformationService { private final ProjectOverviewLookup projectOverviewLookup; private final ProjectRepository projectRepository; private final ProjectAccessService projectAccessService; + private final AuthenticationToUserIdTranslator userIdTranslator; public ProjectInformationService(@Autowired ProjectOverviewLookup projectOverviewLookup, @Autowired ProjectRepository projectRepository, - @Autowired ProjectAccessService projectAccessService) { + @Autowired ProjectAccessService projectAccessService, + AuthenticationToUserIdTranslator userIdTranslator) { Objects.requireNonNull(projectOverviewLookup); this.projectOverviewLookup = projectOverviewLookup; this.projectRepository = projectRepository; this.projectAccessService = projectAccessService; + this.userIdTranslator = userIdTranslator; } /** @@ -71,13 +76,18 @@ public List queryOverview(String filter, int offset, int limit, */ private List retrieveAccessibleProjectIdsForUser() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - var principal = ((QbicUserDetails) authentication.getPrincipal()); - var userId = principal.getUserId(); - var userRole = principal.getAuthorities().stream() - .filter(grantedAuthority -> grantedAuthority instanceof Role).findFirst(); - var accessibleProjectIds = projectAccessService.getAccessibleProjectsForSid(userId); - userRole.ifPresent(grantedAuthority -> accessibleProjectIds.addAll( - projectAccessService.getAccessibleProjectsForSid(grantedAuthority.getAuthority()))); + Optional optionalUserId = userIdTranslator.translateToUserId(authentication); + if (optionalUserId.isEmpty()) { + return new ArrayList<>(); + } + var accessibleProjectIds = projectAccessService.getAccessibleProjectsForSid( + optionalUserId.get()); + List accessibleProjectsFromRoles = authentication.getAuthorities().stream() + .flatMap(it -> projectAccessService.getAccessibleProjectsForSid( + it.getAuthority()).stream()) + .filter(not(accessibleProjectIds::contains)) + .toList(); + accessibleProjectIds.addAll(accessibleProjectsFromRoles); return accessibleProjectIds; } @@ -93,9 +103,7 @@ public Optional find(String projectId) throws IllegalArgumentException } public boolean isProjectCodeUnique(String projectCode) throws IllegalArgumentException { - boolean isUnique = !projectRepository.existsProjectByProjectCode( - ProjectCode.parse(projectCode)); - return isUnique; + return !projectRepository.existsProjectByProjectCode(ProjectCode.parse(projectCode)); } @PreAuthorize("hasPermission(#projectId, 'life.qbic.projectmanagement.domain.model.project.Project','READ')") diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/authorization/QbicOidcUser.java b/project-management/src/main/java/life/qbic/projectmanagement/application/authorization/QbicOidcUser.java new file mode 100644 index 000000000..ca39404f1 --- /dev/null +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/authorization/QbicOidcUser.java @@ -0,0 +1,82 @@ +package life.qbic.projectmanagement.application.authorization; + +import static java.util.Objects.requireNonNull; + +import java.util.Collection; +import java.util.Objects; +import java.util.Optional; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; + +/** + * An Oidc user enriched with QBiC information + * + * @since 1.2.0 + */ +public class QbicOidcUser extends DefaultOidcUser { + + private final QbicUserInfo qbicUserInfo; + private final String originalAuthName; + + public record QbicUserInfo(String userId, String fullName, String email, boolean active) { + + } + + @Override + public final boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof QbicOidcUser that)) { + return false; + } + if (!super.equals(o)) { + return false; + } + + return Objects.equals(qbicUserInfo, that.qbicUserInfo); + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + Objects.hashCode(qbicUserInfo); + return result; + } + + public QbicOidcUser(Collection authorities, OidcIdToken idToken, + OidcUserInfo userInfo, QbicUserInfo qbicUserInfo) { + super(authorities, idToken, userInfo); + this.qbicUserInfo = requireNonNull(qbicUserInfo, "qbicUserInfo must not be null"); + this.originalAuthName = super.getName(); + } + + public String getQbicUserId() { + return qbicUserInfo.userId(); + } + + @Override + public String getFullName() { + return Optional.ofNullable(super.getFullName()).orElse(qbicUserInfo.fullName()); + } + + @Override + public String getEmail() { + return Optional.ofNullable(super.getEmail()).orElse(qbicUserInfo.email()); + } + + @Override + public String getName() { + return qbicUserInfo.userId(); //needed for ACL permission checks + } + + public String getOidcId() { + return originalAuthName; + } + + public boolean isActive() { + return qbicUserInfo.active(); + } +} diff --git a/project-management/src/test/groovy/life/qbic/projectmanagement/application/ProjectInformationServiceSpec.groovy b/project-management/src/test/groovy/life/qbic/projectmanagement/application/ProjectInformationServiceSpec.groovy index 0578085d1..5705bc453 100644 --- a/project-management/src/test/groovy/life/qbic/projectmanagement/application/ProjectInformationServiceSpec.groovy +++ b/project-management/src/test/groovy/life/qbic/projectmanagement/application/ProjectInformationServiceSpec.groovy @@ -1,5 +1,6 @@ package life.qbic.projectmanagement.application +import life.qbic.identity.api.AuthenticationToUserIdTranslator import life.qbic.projectmanagement.application.api.ProjectOverviewLookup import life.qbic.projectmanagement.application.authorization.acl.ProjectAccessService import life.qbic.projectmanagement.domain.model.project.* @@ -11,7 +12,8 @@ class ProjectInformationServiceSpec extends Specification { ProjectRepository projectRepository = Mock() ProjectOverviewLookup projectPreviewLookup = Mock() ProjectAccessService projectAccessService = Mock() - ProjectInformationService projectInformationService = new ProjectInformationService(projectPreviewLookup, projectRepository, projectAccessService) + AuthenticationToUserIdTranslator authenticationToUserIdTranslator = Mock() + ProjectInformationService projectInformationService = new ProjectInformationService(projectPreviewLookup, projectRepository, projectAccessService, authenticationToUserIdTranslator) def project = setupProject() diff --git a/user-interface/frontend/themes/datamanager/components/button.css b/user-interface/frontend/themes/datamanager/components/button.css index 23db319d9..6de2f303f 100644 --- a/user-interface/frontend/themes/datamanager/components/button.css +++ b/user-interface/frontend/themes/datamanager/components/button.css @@ -22,6 +22,15 @@ vaadin-button.primary { min-width: calc(var(--lumo-button-size) * 2.5); } +vaadin-button.danger { + background-color: var(--lumo-error-color, var(--lumo-error-color)); + color: var(--lumo-button-primary-color, var(--lumo-primary-contrast-color)); +} + +vaadin-button.danger[focus-ring] { + box-shadow: none !important; +} + /* Styles recreated from vaadin lumo theme */ vaadin-button[disabled] { background-color: var(--lumo-contrast-30pct); diff --git a/user-interface/frontend/themes/datamanager/components/dialog.css b/user-interface/frontend/themes/datamanager/components/dialog.css index 884b93234..845957371 100644 --- a/user-interface/frontend/themes/datamanager/components/dialog.css +++ b/user-interface/frontend/themes/datamanager/components/dialog.css @@ -42,6 +42,13 @@ vaadin-dialog-overlay::part(title) { margin: 0; } +/*Vaadin currently does not allow to style the flex items within the footer. +Since we want to remove the spacing between the cancel and confirm button we replace the flex setting with a grid layout*/ +.notification-dialog::part(footer) { + display: grid; + grid-template-columns: 0 15% 15%; +} + /* icon in header */ .notification-dialog > [slot="header"] vaadin-icon { width: 1.83rem; diff --git a/user-interface/frontend/themes/datamanager/components/div.css b/user-interface/frontend/themes/datamanager/components/div.css index 65c4cf08a..ef47a2e4b 100644 --- a/user-interface/frontend/themes/datamanager/components/div.css +++ b/user-interface/frontend/themes/datamanager/components/div.css @@ -129,3 +129,18 @@ gap: var(--lumo-space-xs); white-space: nowrap; } + +.user-registration-component { + background-color: var(--lumo-base-color); + border: var(--lumo-contrast-10pct); + border-radius: var(--lumo-border-radius-m); + box-shadow: var(--lumo-box-shadow-l); + font-size: var(--lumo-font-size-s); + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: var(--lumo-space-s); + width: clamp(300px, 300px, 15vw); + padding-bottom: var(--lumo-space-l); + padding-inline: var(--lumo-space-l); +} diff --git a/user-interface/frontend/themes/datamanager/components/main.css b/user-interface/frontend/themes/datamanager/components/main.css index d62ef618e..b6daa17e3 100644 --- a/user-interface/frontend/themes/datamanager/components/main.css +++ b/user-interface/frontend/themes/datamanager/components/main.css @@ -19,7 +19,8 @@ background-size: cover; background-position: center; background-repeat: no-repeat; - height: 100%; + /*Ensures that the landing page layout is always the full height of the content area*/ + min-height: 100%; } #landing-page-layout .landing-page-title-and-logo { @@ -76,6 +77,34 @@ "experimentdetails"; } +.main.email-confirmation { + grid-template-columns: auto; + grid-template-rows: auto; + justify-content: center; + /*Should be moved to the parent layout once Login and forgot password components are overhauled*/ + padding-bottom: var(--lumo-space-m); +} + +.main.email-confirmation .email-confirmation-component { + background-color: var(--lumo-base-color); + border: var(--lumo-contrast-10pct); + border-radius: var(--lumo-border-radius-m); + box-shadow: var(--lumo-box-shadow-l); + font-size: var(--lumo-font-size-s); + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: var(--lumo-space-l); + width: clamp(450px, 450px, 15vw); + padding-bottom: var(--lumo-space-xl); + padding-inline: var(--lumo-space-l); +} + +.main.email-confirmation .email-confirmation-component .resend-verification { + display: inline-flex; + gap: var(--lumo-space-s); +} + .main.experiment .experiment-details-component { grid-area: experimentdetails; } @@ -326,6 +355,14 @@ margin-top: auto; } +.main.user-registration { + grid-template-columns: auto; + grid-template-rows: auto; + justify-content: center; + /*Should be moved to the parent layout once Login and forgot password components are overhauled*/ + padding-bottom: var(--lumo-space-m); +} + .main.sample { grid-template-columns: minmax(min-content, 60%) minmax(min-content, 50%); grid-template-rows: minmax(min-content, 20%) minmax(min-content, 75%); diff --git a/user-interface/frontend/themes/datamanager/components/page-area.css b/user-interface/frontend/themes/datamanager/components/page-area.css index 51ae01c85..9e22c9b2e 100644 --- a/user-interface/frontend/themes/datamanager/components/page-area.css +++ b/user-interface/frontend/themes/datamanager/components/page-area.css @@ -196,6 +196,10 @@ white-space: nowrap; } +.tab-with-count { + gap: var(--lumo-space-s); +} + .ontology-lookup-component { display: flex; flex-direction: column; diff --git a/user-interface/frontend/themes/datamanager/components/span.css b/user-interface/frontend/themes/datamanager/components/span.css index 89d13809a..992b9e28a 100644 --- a/user-interface/frontend/themes/datamanager/components/span.css +++ b/user-interface/frontend/themes/datamanager/components/span.css @@ -37,6 +37,71 @@ padding-bottom: var(--lumo-space-s); font-size: small; } + +.link { + color: var(--lumo-primary-text-color); + cursor: pointer; +} + +.link:hover { + text-decoration: underline; +} + +.login-card { + display: inline-flex; + gap: var(--lumo-space-l); + align-items: center; + padding-inline: var(--lumo-space-m); + padding-top: var(--lumo-space-s); + padding-bottom: var(--lumo-space-s); + box-sizing: border-box; + background-color: var(--lumo-base-color); + border-radius: var(--lumo-border-radius-m); + border-style: solid; + border-color: var(--lumo-contrast-20pct); + border-width: 1px; + width: 100%; + cursor: pointer; +} + +.login-card .logo { + height: var(--lumo-icon-size-m); + width: var(--lumo-icon-size-m); +} + +.login-card .text { + font-weight: 500; + font-size: var(--lumo-font-size-m); +} + +.registration-section { + display: flex; + flex-direction: column; + gap: var(--lumo-space-s); + align-items: center; + width: 100%; +} + +.registration-section .link { + display: inline-flex; + gap: var(--lumo-space-s); + font-weight: 500; +} + +.registration-section .spacer { + display: inline-flex; + align-items: center; + white-space: nowrap; + width: 100% +} + +.registration-section .spacer::before, +.registration-section .spacer::after { + content: ''; + flex: 1; + border-bottom: 1px solid var(--lumo-contrast-20pct); +} + /* ontology-link styling */ .ontology-link { display: inline-flex; diff --git a/user-interface/pom.xml b/user-interface/pom.xml index ba2a3c84d..0df39104b 100644 --- a/user-interface/pom.xml +++ b/user-interface/pom.xml @@ -155,6 +155,10 @@ spring-boot-devtools true + + org.springframework.boot + spring-boot-starter-oauth2-client + org.springframework.boot spring-boot-starter-test diff --git a/user-interface/src/main/java/life/qbic/datamanager/AppConfig.java b/user-interface/src/main/java/life/qbic/datamanager/AppConfig.java index 4c68912b7..6997e54af 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/AppConfig.java +++ b/user-interface/src/main/java/life/qbic/datamanager/AppConfig.java @@ -5,6 +5,7 @@ import life.qbic.domain.concepts.SimpleEventStore; import life.qbic.domain.concepts.TemporaryEventRepository; import life.qbic.identity.api.UserInformationService; +import life.qbic.identity.api.UserPasswordService; import life.qbic.identity.application.communication.EmailService; import life.qbic.identity.application.communication.broadcasting.EventHub; import life.qbic.identity.application.notification.NotificationService; @@ -111,6 +112,11 @@ public UserInformationService userInformationService(UserRepository userReposito return new BasicUserInformationService(userRepository); } + @Bean + public UserPasswordService userPasswordService(UserRepository userRepository) { + return new BasicUserInformationService(userRepository); + } + @Bean public WhenUserRegisteredSendConfirmationEmail whenUserRegisteredSendConfirmationEmail( EmailService emailService, JobScheduler jobScheduler, UserRepository userRepository, diff --git a/user-interface/src/main/java/life/qbic/datamanager/DataManagerContextProvider.java b/user-interface/src/main/java/life/qbic/datamanager/DataManagerContextProvider.java index 2e1ff65bf..33e7141ba 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/DataManagerContextProvider.java +++ b/user-interface/src/main/java/life/qbic/datamanager/DataManagerContextProvider.java @@ -28,8 +28,8 @@ public DataManagerContextProvider( @Value("${service.host.name}") String host, @Value("${service.host.port}") int port, @Value("${server.servlet.context-path}") String contextPath, - @Value("${project-info-endpoint}") String projectEndpoint, - @Value("${project-samples-endpoint}") String samplesEndpoint) { + @Value("${routing.projects.info.endpoint}") String projectEndpoint, + @Value("${routing.projects.samples.enpoint}") String samplesEndpoint) { this.projectInfoEndpoint = projectEndpoint; this.samplesEndpoint = samplesEndpoint; try { diff --git a/user-interface/src/main/java/life/qbic/datamanager/MyVaadinSessionInitListener.java b/user-interface/src/main/java/life/qbic/datamanager/MyVaadinSessionInitListener.java index c55d9aeb8..c86058620 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/MyVaadinSessionInitListener.java +++ b/user-interface/src/main/java/life/qbic/datamanager/MyVaadinSessionInitListener.java @@ -1,60 +1,106 @@ package life.qbic.datamanager; +import static java.util.Objects.isNull; import static life.qbic.logging.service.LoggerFactory.logger; import com.vaadin.flow.component.UI; import com.vaadin.flow.component.page.Page.ExtendedClientDetailsReceiver; +import com.vaadin.flow.router.BeforeEnterEvent; +import com.vaadin.flow.server.ServiceDestroyEvent; import com.vaadin.flow.server.ServiceInitEvent; import com.vaadin.flow.server.SessionDestroyEvent; -import com.vaadin.flow.server.SessionDestroyListener; -import com.vaadin.flow.server.VaadinService; +import com.vaadin.flow.server.SessionInitEvent; +import com.vaadin.flow.server.UIInitEvent; import com.vaadin.flow.server.VaadinServiceInitListener; +import com.vaadin.flow.server.WrappedSession; import com.vaadin.flow.spring.annotation.SpringComponent; import life.qbic.datamanager.exceptionhandling.UiExceptionHandler; +import life.qbic.datamanager.security.LogoutService; +import life.qbic.datamanager.views.AppRoutes; +import life.qbic.datamanager.views.register.RegistrationOrcIdMain; import life.qbic.logging.api.Logger; +import life.qbic.projectmanagement.application.authorization.QbicOidcUser; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; /** * Adds listeners to vaadin sessions and ui initialization. */ @SpringComponent -public class MyVaadinSessionInitListener implements VaadinServiceInitListener, - SessionDestroyListener { +public class MyVaadinSessionInitListener implements VaadinServiceInitListener { private static final Logger log = logger(MyVaadinSessionInitListener.class); private final ExtendedClientDetailsReceiver clientDetailsReceiver; private final UiExceptionHandler uiExceptionHandler; + private final LogoutService logoutService; public MyVaadinSessionInitListener( @Autowired ExtendedClientDetailsReceiver clientDetailsProvider, - @Autowired UiExceptionHandler uiExceptionHandler) { + @Autowired UiExceptionHandler uiExceptionHandler, + @Autowired LogoutService logoutService) { this.clientDetailsReceiver = clientDetailsProvider; this.uiExceptionHandler = uiExceptionHandler; + this.logoutService = logoutService; } @Override public void serviceInit(ServiceInitEvent event) { - event.getSource().addSessionInitListener( - initEvent -> log.debug("A new Session has been initialized! Session " + initEvent.getSession().getSession().getId())); + event.getSource().addSessionInitListener(MyVaadinSessionInitListener::onSessionInit); + event.getSource().addServiceDestroyListener(MyVaadinSessionInitListener::onServiceDestroyed); + event.getSource().addSessionDestroyListener(MyVaadinSessionInitListener::onSessionDestroy); + event.getSource().addUIInitListener(this::onUiInit); + } + + private void onUiInit(UIInitEvent initEvent) { + log.debug("A new UI has been initialized! ui[%s] vaadin[%s] http[%s] ".formatted( + initEvent.getUI().getUIId(), initEvent.getUI().getSession().getPushId(), + initEvent.getUI().getSession().getSession().getId())); + UI ui = initEvent.getUI(); + ui.getPage().retrieveExtendedClientDetails(clientDetailsReceiver); + ui.getSession().setErrorHandler(errorEvent -> uiExceptionHandler.error(errorEvent, ui)); + ui.addBeforeEnterListener(this::ensureCompleteOidcRegistration); + } - event.getSource().addSessionDestroyListener(this); + private static void onSessionInit(SessionInitEvent initEvent) { + log.debug("A new Session has been initialized! vaadin[%s] http[%s] ".formatted( + initEvent.getSession().getPushId(), initEvent.getSession().getSession().getId())); + } - event.getSource().addUIInitListener( - initEvent -> { - log.debug("A new UI has been initialized! Session is " + initEvent.getUI().getSession().getSession().getId()); - UI ui = initEvent.getUI(); - ui.getPage().retrieveExtendedClientDetails(clientDetailsReceiver); - ui.getSession().setErrorHandler(errorEvent -> uiExceptionHandler.error(errorEvent, ui)); - }); + private static void onServiceDestroyed(ServiceDestroyEvent serviceDestroyEvent) { + log.debug("Destroying vaadin service [%s]".formatted(serviceDestroyEvent.getSource())); + } + public static void onSessionDestroy(SessionDestroyEvent event) { + WrappedSession wrappedSession = event.getSession().getSession(); + if (wrappedSession != null) { + wrappedSession.invalidate(); + log.debug("Invalidated HTTP session " + wrappedSession.getId()); + } else { + log.debug("Vaadin session [%s] does not wrap any HTTP session.".formatted( + event.getSession().getPushId())); + } + log.debug("Vaadin session destroyed [%s].".formatted(event.getSession().getPushId())); } - @Override - public void sessionDestroy(SessionDestroyEvent event) { - log.debug("Session destroyed."); - event.getSession().getSession().invalidate(); - log.debug("HTTP Session has been invalidated. Id is " + event.getSession().getSession().getId()); + private void ensureCompleteOidcRegistration(BeforeEnterEvent it) { + SecurityContext securityContext = SecurityContextHolder.getDeferredContext().get(); + Authentication authentication = securityContext.getAuthentication(); + if (isNull(authentication)) { + return; + } + var principal = authentication.getPrincipal(); + if (principal instanceof OidcUser && !(principal instanceof QbicOidcUser)) { + if (it.getNavigationTarget().equals(RegistrationOrcIdMain.class)) { + return; + } + log.warn("Incomplete OpenIdConnect registration. Logging out and forwarding to login."); + logoutService.logout(); + it.forwardTo(AppRoutes.LOGIN); + } } } diff --git a/user-interface/src/main/java/life/qbic/datamanager/exceptionhandling/UiExceptionHandler.java b/user-interface/src/main/java/life/qbic/datamanager/exceptionhandling/UiExceptionHandler.java index eada0c35c..95dcb588b 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/exceptionhandling/UiExceptionHandler.java +++ b/user-interface/src/main/java/life/qbic/datamanager/exceptionhandling/UiExceptionHandler.java @@ -51,9 +51,20 @@ public void error(ErrorEvent errorEvent, UI ui) { } private void displayUserFriendlyMessage(UI ui, ApplicationException exception) { - requireNonNull(ui); - requireNonNull(exception); - + requireNonNull(ui, "ui must not be null"); + requireNonNull(exception, "exception must not be null"); + if (ui.isClosing()) { + log.error( + "tried to show message on closing UI ui[%s] vaadin[%s] http[%s]".formatted(ui.getUIId(), + ui.getSession().getPushId(), ui.getSession().getSession().getId())); + return; + } + if (!ui.isAttached()) { + log.error( + "tried to show message on detached UI ui[%s] vaadin[%s] http[%s]".formatted(ui.getUIId(), + ui.getSession().getPushId(), ui.getSession().getSession().getId())); + return; + } UserFriendlyErrorMessage errorMessage = userMessageService.translate(exception, ui.getLocale()); ui.access(() -> showErrorDialog(errorMessage)); } diff --git a/user-interface/src/main/java/life/qbic/datamanager/exceptionhandling/routing/ErrorPage.java b/user-interface/src/main/java/life/qbic/datamanager/exceptionhandling/routing/ErrorPage.java index 42963d7f8..38beab33e 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/exceptionhandling/routing/ErrorPage.java +++ b/user-interface/src/main/java/life/qbic/datamanager/exceptionhandling/routing/ErrorPage.java @@ -42,7 +42,8 @@ default void showError(E error, Locale locale) { */ default void logError(E error) { Logger log = logger(getClass()); - log.error(error.getMessage(), error); + log.error(error.getMessage()); + log.debug(error.getMessage(), error); } /** diff --git a/user-interface/src/main/java/life/qbic/datamanager/exceptionhandling/routing/notfound/NotFoundPage.java b/user-interface/src/main/java/life/qbic/datamanager/exceptionhandling/routing/notfound/NotFoundPage.java index 84eb721ed..5fc482508 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/exceptionhandling/routing/notfound/NotFoundPage.java +++ b/user-interface/src/main/java/life/qbic/datamanager/exceptionhandling/routing/notfound/NotFoundPage.java @@ -57,6 +57,7 @@ public int getStatusCode() { @Override public int setErrorParameter(BeforeEnterEvent event, ErrorParameter parameter) { + ErrorPage.super.setErrorParameter(event, parameter); return getStatusCode(); } } diff --git a/user-interface/src/main/java/life/qbic/datamanager/security/OidcUserDetailsService.java b/user-interface/src/main/java/life/qbic/datamanager/security/OidcUserDetailsService.java new file mode 100644 index 000000000..4f7ee38cd --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/security/OidcUserDetailsService.java @@ -0,0 +1,54 @@ +package life.qbic.datamanager.security; + +import static java.util.Objects.requireNonNull; + +import java.util.Optional; +import life.qbic.identity.api.UserInfo; +import life.qbic.identity.api.UserInformationService; +import life.qbic.projectmanagement.application.authorization.QbicOidcUser; +import life.qbic.projectmanagement.application.authorization.QbicOidcUser.QbicUserInfo; +import life.qbic.projectmanagement.application.authorization.authorities.UserAuthorityProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Component; + +/** + * A details service loading user detais for OpenId Connect users known to the system. + */ +@Component +public class OidcUserDetailsService extends OidcUserService { + + private final UserAuthorityProvider userAuthorityProvider; + private final UserInformationService userInformationService; + + + public OidcUserDetailsService( + @Autowired UserAuthorityProvider userAuthorityProvider, + @Autowired UserInformationService userInformationService) { + this.userAuthorityProvider = requireNonNull(userAuthorityProvider, + "userAuthorityProvider must not be null"); + this.userInformationService = requireNonNull(userInformationService, + "userInformationService must not be null"); + } + + @Override + public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { + OidcUser defaultOidcUser = super.loadUser(userRequest); + + Optional localUser = userInformationService.findByOidc(defaultOidcUser.getName(), + defaultOidcUser.getIssuer().toString()); + if (localUser.isPresent()) { + var user = localUser.get(); + var authorities = userAuthorityProvider.getAuthoritiesByUserId( + user.id()); + QbicUserInfo qbicUserInfo = new QbicUserInfo(user.id(), user.fullName(), user.emailAddress(), + user.isActive()); + return new QbicOidcUser(authorities, userRequest.getIdToken(), + defaultOidcUser.getUserInfo(), qbicUserInfo); + } + return defaultOidcUser; + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/security/SecurityConfiguration.java b/user-interface/src/main/java/life/qbic/datamanager/security/SecurityConfiguration.java index d74de2f5f..55afcceec 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/security/SecurityConfiguration.java +++ b/user-interface/src/main/java/life/qbic/datamanager/security/SecurityConfiguration.java @@ -1,13 +1,19 @@ package life.qbic.datamanager.security; +import static java.util.Objects.requireNonNull; + +import com.vaadin.flow.spring.security.VaadinDefaultRequestCache; import com.vaadin.flow.spring.security.VaadinWebSecurity; import life.qbic.datamanager.views.login.LoginLayout; import life.qbic.identity.application.security.QBiCPasswordEncoder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @EnableWebSecurity @@ -15,42 +21,47 @@ //@Import({AclSecurityConfiguration.class}) // enable in case you need beans from the Acl config public class SecurityConfiguration extends VaadinWebSecurity { + final VaadinDefaultRequestCache defaultRequestCache; + + @Value("${routing.registration.oidc.orcid.endpoint}") + String registrationOrcidEndpoint; + + @Value("${routing.registration.error.pending-email-verification}") + String emailConfirmationEndpoint; + + public SecurityConfiguration( + @Autowired VaadinDefaultRequestCache defaultRequestCache) { + this.defaultRequestCache = requireNonNull(defaultRequestCache, + "defaultRequestCache must not be null"); + } + @Bean public PasswordEncoder passwordEncoder() { return new QBiCPasswordEncoder(); } -// @Autowired -// AclPermissionEvaluator permissionEvaluator; -// protected AuthorizationManager projectAuthorizationManager() { -// return (authorization, object) -> { -// String projectId = object.getVariables().get("projectId"); -// if (projectId == null) { -// return new AuthorizationDecision(true); -// } -// return new AuthorizationDecision( -// permissionEvaluator.hasPermission(authorization.get(), ProjectId.parse(projectId), -// Project.class.getName(), BasePermission.READ)); -// }; -// } + private AuthenticationSuccessHandler authenticationSuccessHandler() { + requireNonNull(registrationOrcidEndpoint, "openIdRegistrationEndpoint must not be null"); + StoredRequestAwareOidcAuthenticationSuccessHandler storedRequestAwareOidcAuthenticationSuccessHandler = new StoredRequestAwareOidcAuthenticationSuccessHandler( + registrationOrcidEndpoint, emailConfirmationEndpoint); + storedRequestAwareOidcAuthenticationSuccessHandler.setRequestCache(defaultRequestCache); + return storedRequestAwareOidcAuthenticationSuccessHandler; + } @Override protected void configure(HttpSecurity http) throws Exception { - http.authorizeHttpRequests() - .requestMatchers(new AntPathRequestMatcher("/images/*.png")) - .permitAll(); -// //vaadin ignores these configurations when navigating inside the app -// .and() -// .authorizeHttpRequests() -// .requestMatchers(new AntPathRequestMatcher("/projects/list")) -// .permitAll() -// .and() -// .authorizeHttpRequests() -// .requestMatchers(new AntPathRequestMatcher("/projects/{projectId}/**")) -// .access(projectAuthorizationManager()); + http.authorizeHttpRequests(v -> v.requestMatchers( + new AntPathRequestMatcher("/oauth2/authorization/orcid"), + new AntPathRequestMatcher("/oauth2/code/**"), new AntPathRequestMatcher("images/*.png")) + .permitAll()); + http.oauth2Login(oAuth2Login -> { + oAuth2Login.loginPage("/login").permitAll(); + oAuth2Login.defaultSuccessUrl("/"); + oAuth2Login.successHandler( + authenticationSuccessHandler()); + oAuth2Login.failureUrl("/login?errorOauth2=true&error"); + }); super.configure(http); setLoginView(http, LoginLayout.class); } - - } diff --git a/user-interface/src/main/java/life/qbic/datamanager/security/StoredRequestAwareOidcAuthenticationSuccessHandler.java b/user-interface/src/main/java/life/qbic/datamanager/security/StoredRequestAwareOidcAuthenticationSuccessHandler.java new file mode 100644 index 000000000..da734da20 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/security/StoredRequestAwareOidcAuthenticationSuccessHandler.java @@ -0,0 +1,55 @@ +package life.qbic.datamanager.security; + +import static java.util.Objects.requireNonNull; + +import com.vaadin.flow.spring.security.VaadinSavedRequestAwareAuthenticationSuccessHandler; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import life.qbic.projectmanagement.application.authorization.QbicOidcUser; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; + +/** + * Handles action on successful authentication with an OpenId Connect Provider. On successfull + * authentication, reroutes to registration if no user account was found. Else navigates to the + * saved request + * + * @see SavedRequestAwareAuthenticationSuccessHandler + * @since 1.1.0 + */ +public class StoredRequestAwareOidcAuthenticationSuccessHandler extends + VaadinSavedRequestAwareAuthenticationSuccessHandler { + + private final String openIdRegistrationEndpoint; + private final String emailConfirmationEndpoint; + + public StoredRequestAwareOidcAuthenticationSuccessHandler(String openIdRegistrationEndpoint, + String emailConfirmationEndpoint) { + this.openIdRegistrationEndpoint = requireNonNull(openIdRegistrationEndpoint, + "openIdRegistrationEndpoint must not be null"); + this.emailConfirmationEndpoint = requireNonNull(emailConfirmationEndpoint, + "emailConfirmationEndpoint must not be null"); + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws ServletException, IOException { + if (authentication.getPrincipal() instanceof QbicOidcUser qbicOidcUser) { + if (!qbicOidcUser.isActive()) { + getRedirectStrategy().sendRedirect(request, response, emailConfirmationEndpoint); + return; + } + super.onAuthenticationSuccess(request, response, authentication); + } else if (authentication.getPrincipal() instanceof OidcUser) { + getRedirectStrategy().sendRedirect(request, response, openIdRegistrationEndpoint); + } else { + //we do not redirect + logger.error( + "Authentication failure. Unsupported principal type: " + authentication.getPrincipal() + .getClass()); + } + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/security/UserDetailsServiceImpl.java b/user-interface/src/main/java/life/qbic/datamanager/security/UserDetailsServiceImpl.java index 930db21f3..172a023db 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/security/UserDetailsServiceImpl.java +++ b/user-interface/src/main/java/life/qbic/datamanager/security/UserDetailsServiceImpl.java @@ -1,8 +1,9 @@ package life.qbic.datamanager.security; import java.util.List; -import life.qbic.identity.api.UserInfo; import life.qbic.identity.api.UserInformationService; +import life.qbic.identity.api.UserPassword; +import life.qbic.identity.api.UserPasswordService; import life.qbic.projectmanagement.application.authorization.QbicUserDetails; import life.qbic.projectmanagement.application.authorization.User; import life.qbic.projectmanagement.application.authorization.authorities.UserAuthorityProvider; @@ -18,35 +19,36 @@ public class UserDetailsServiceImpl implements UserDetailsService { private final UserInformationService userInformationService; + private final UserPasswordService userPasswordService; + private final UserAuthorityProvider userAuthorityProvider; @Autowired UserDetailsServiceImpl(UserInformationService userInformationService, + UserPasswordService userPasswordService, UserAuthorityProvider userAuthorityProvider) { this.userInformationService = userInformationService; + this.userPasswordService = userPasswordService; this.userAuthorityProvider = userAuthorityProvider; } - @Override /** - * In our case the mail address is the username + * @param mailAddress the username identifying the user whose data is required. + * @return existing user details + * @throws UsernameNotFoundException if no user with this email exists */ + @Override public UserDetails loadUserByUsername(String mailAddress) throws UsernameNotFoundException { // Then search for a user with the provided mail address var userInfo = userInformationService.findByEmail(mailAddress) .orElseThrow(() -> new UsernameNotFoundException("Cannot find user")); + var encryptedPassword = userPasswordService.findEncryptedPasswordForUser(userInfo.id()) + .map(UserPassword::encryptedPassword); List authorities = userAuthorityProvider.getAuthoritiesByUserId( userInfo.id()); var user = new User(userInfo.id(), userInfo.fullName(), userInfo.platformUserName(), userInfo.emailAddress(), - userInfo.encryptedPassword(), userInfo.isActive()); + encryptedPassword.orElseGet(null), userInfo.isActive()); return new QbicUserDetails(user, authorities); } - - private User convert(UserInfo userInfo) { - return new User(userInfo.id(), userInfo.fullName(), userInfo.emailAddress(), - userInfo.platformUserName(), - userInfo.encryptedPassword(), - userInfo.isActive()); - } } diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/AppRoutes.java b/user-interface/src/main/java/life/qbic/datamanager/views/AppRoutes.java index c9a74a8fa..c2d209594 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/AppRoutes.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/AppRoutes.java @@ -31,6 +31,16 @@ public class AppRoutes { */ public static final String REGISTER = "registration"; + /** + * The route to enable new users to create a new account with their orcId + */ + public static final String REGISTER_OIDC = "register/oidc"; + + /** + * The route to inform user to confirm their email after account registration + */ + public static final String EMAIL_CONFIRMATION = "register/pending-email-confirmation"; + public static class Projects { /** diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/CancelConfirmationNotificationDialog.java b/user-interface/src/main/java/life/qbic/datamanager/views/CancelConfirmationNotificationDialog.java new file mode 100644 index 000000000..768ae92b5 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/views/CancelConfirmationNotificationDialog.java @@ -0,0 +1,47 @@ +package life.qbic.datamanager.views; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.VaadinIcon; +import life.qbic.datamanager.views.notifications.NotificationDialog; + +/** + * Warns the user that they are about to cancel a dialog and lose input data + *

+ * This dialog is to be shown when a dialog is canceled via the Esc key or the Cancel button + */ +public class CancelConfirmationNotificationDialog extends NotificationDialog { + + public CancelConfirmationNotificationDialog() { + customizeHeader(); + setCancelable(true); + Button redButton = new Button("Discard"); + redButton.addClassName("danger"); + setConfirmButton(redButton); + Button cancelButton = new Button("Continue"); + setCancelButton(cancelButton); + } + + public CancelConfirmationNotificationDialog withBodyText(String mainText) { + content.removeAll(); + content.add(new Span(mainText)); + return this; + } + + public CancelConfirmationNotificationDialog withConfirmText(String confirmText) { + setConfirmText(confirmText); + return this; + } + + public CancelConfirmationNotificationDialog withTitle(String headerText) { + setTitle(headerText); + return this; + } + + private void customizeHeader() { + Icon errorIcon = new Icon(VaadinIcon.WARNING); + errorIcon.setClassName("warning-icon"); + setHeaderIcon(errorIcon); + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/DataManagerLayout.java b/user-interface/src/main/java/life/qbic/datamanager/views/DataManagerLayout.java index 63e3a80bd..3ab007267 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/DataManagerLayout.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/DataManagerLayout.java @@ -7,7 +7,7 @@ import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.RouterLayout; import java.util.Objects; -import life.qbic.datamanager.views.general.footer.FooterComponent; +import life.qbic.datamanager.views.general.footer.FooterComponentFactory; /** * Data Manager Layout @@ -20,14 +20,14 @@ public class DataManagerLayout extends AppLayout implements RouterLayout { private final Div contentArea; - protected DataManagerLayout(FooterComponent footerComponent) { - Objects.requireNonNull(footerComponent); + protected DataManagerLayout(FooterComponentFactory footerComponentFactory) { + Objects.requireNonNull(footerComponentFactory); addClassName("data-manager-layout"); // Create content area contentArea = new Div(); contentArea.setId("content-area"); // Add content area and footer to the main layout - Div mainLayout = new Div(contentArea, footerComponent); + Div mainLayout = new Div(contentArea, footerComponentFactory.get()); mainLayout.setId("main-layout"); setContent(mainLayout); } diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/UserMainLayout.java b/user-interface/src/main/java/life/qbic/datamanager/views/UserMainLayout.java index d04907062..39740e9b0 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/UserMainLayout.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/UserMainLayout.java @@ -6,7 +6,7 @@ import life.qbic.datamanager.security.LogoutService; import life.qbic.datamanager.views.account.PersonalAccessTokenMain; import life.qbic.datamanager.views.general.DataManagerMenu; -import life.qbic.datamanager.views.general.footer.FooterComponent; +import life.qbic.datamanager.views.general.footer.FooterComponentFactory; import life.qbic.datamanager.views.projects.overview.ProjectOverviewMain; import life.qbic.identity.api.UserInformationService; import org.springframework.beans.factory.annotation.Autowired; @@ -21,17 +21,16 @@ @PageTitle("Data Manager") public class UserMainLayout extends DataManagerLayout { - private final DataManagerMenu dataManagerMenu; - private final Span navBarTitle = new Span("Data Manager"); - public UserMainLayout(@Autowired LogoutService logoutService, - UserInformationService userInformationService, @Autowired FooterComponent footerComponent) { - super(Objects.requireNonNull(footerComponent)); + UserInformationService userInformationService, + @Autowired FooterComponentFactory footerComponentFactory) { + super(Objects.requireNonNull(footerComponentFactory)); Objects.requireNonNull(logoutService); + Span navBarTitle = new Span("Data Manager"); navBarTitle.setClassName("navbar-title"); addClassName("user-main-layout"); Objects.requireNonNull(userInformationService); - dataManagerMenu = new DataManagerMenu(logoutService, userInformationService); + DataManagerMenu dataManagerMenu = new DataManagerMenu(logoutService); addToNavbar(navBarTitle, dataManagerMenu); } diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/account/PersonalAccessTokenMain.java b/user-interface/src/main/java/life/qbic/datamanager/views/account/PersonalAccessTokenMain.java index d8f7e88a6..802d0f8eb 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/account/PersonalAccessTokenMain.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/account/PersonalAccessTokenMain.java @@ -1,5 +1,7 @@ package life.qbic.datamanager.views.account; +import static java.util.Objects.requireNonNull; + import com.vaadin.flow.router.BeforeEnterEvent; import com.vaadin.flow.router.BeforeEnterObserver; import com.vaadin.flow.router.Route; @@ -10,7 +12,6 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; -import java.util.Objects; import java.util.stream.Collectors; import life.qbic.datamanager.views.UserMainLayout; import life.qbic.datamanager.views.account.PersonalAccessTokenComponent.AddTokenEvent; @@ -22,9 +23,7 @@ import life.qbic.identity.api.RawToken; import life.qbic.logging.api.Logger; import life.qbic.logging.service.LoggerFactory; -import life.qbic.projectmanagement.application.authorization.QbicUserDetails; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.core.Authentication; +import life.qbic.projectmanagement.application.AuthenticationToUserIdTranslationService; import org.springframework.security.core.context.SecurityContextHolder; /** @@ -46,13 +45,17 @@ public class PersonalAccessTokenMain extends Main implements BeforeEnterObserver private static final Logger log = LoggerFactory.logger(PersonalAccessTokenMain.class); private final PersonalAccessTokenComponent personalAccessTokenComponent; private final PersonalAccessTokenService personalAccessTokenService; + private final AuthenticationToUserIdTranslationService userIdTranslator; public PersonalAccessTokenMain(PersonalAccessTokenService personalAccessTokenService, - @Autowired PersonalAccessTokenComponent personalAccessTokenComponent) { - Objects.requireNonNull(personalAccessTokenService); - Objects.requireNonNull(personalAccessTokenComponent); - this.personalAccessTokenService = personalAccessTokenService; - this.personalAccessTokenComponent = personalAccessTokenComponent; + PersonalAccessTokenComponent personalAccessTokenComponent, + AuthenticationToUserIdTranslationService userIdTranslator) { + this.personalAccessTokenService = requireNonNull(personalAccessTokenService, + "personalAccessTokenService must not be null"); + this.personalAccessTokenComponent = requireNonNull(personalAccessTokenComponent, + "personalAccessTokenComponent must not be null"); + this.userIdTranslator = requireNonNull(userIdTranslator, "userIdTranslator must not be null"); + addClassName("personal-access-token"); add(personalAccessTokenComponent); personalAccessTokenComponent.addTokenListener(this::onAddTokenClicked); @@ -68,9 +71,10 @@ private void onDeleteTokenClicked(DeleteTokenEvent deleteTokenEvent) { AccessTokenDeletionConfirmationNotification tokenDeletionConfirmationNotification = new AccessTokenDeletionConfirmationNotification(); tokenDeletionConfirmationNotification.open(); tokenDeletionConfirmationNotification.addConfirmListener(event -> { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - QbicUserDetails details = (QbicUserDetails) authentication.getPrincipal(); - personalAccessTokenService.delete(deleteTokenEvent.tokenId(), details.getUserId()); + var userId = userIdTranslator.translateToUserId( + SecurityContextHolder.getContext().getAuthentication()) + .orElseThrow(); + personalAccessTokenService.delete(deleteTokenEvent.tokenId(), userId); loadGeneratedPersonalAccessTokens(); tokenDeletionConfirmationNotification.close(); }); @@ -85,9 +89,10 @@ private void onAddTokenClicked(AddTokenEvent addTokenEvent) { /*Reload the tokens to ensure that if multiple tokens are generated the previously generated tokens are shown within the list*/ addPersonalAccessTokenDialog.addConfirmListener(event -> loadGeneratedPersonalAccessTokens()); addPersonalAccessTokenDialog.addConfirmListener(event -> { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - QbicUserDetails details = (QbicUserDetails) authentication.getPrincipal(); - RawToken createdToken = personalAccessTokenService.create(details.getUserId(), + var userId = userIdTranslator.translateToUserId( + SecurityContextHolder.getContext().getAuthentication()) + .orElseThrow(); + RawToken createdToken = personalAccessTokenService.create(userId, event.personalAccessTokenDTO() .tokenDescription(), event.personalAccessTokenDTO().expirationDate()); personalAccessTokenComponent.showCreatedToken(createdToken); @@ -107,10 +112,11 @@ public void beforeEnter(BeforeEnterEvent event) { } private void loadGeneratedPersonalAccessTokens() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - QbicUserDetails details = (QbicUserDetails) authentication.getPrincipal(); + var userId = userIdTranslator.translateToUserId( + SecurityContextHolder.getContext().getAuthentication()) + .orElseThrow(); Collection personalAccessTokens = personalAccessTokenService.findAll( - details.getUserId()); + userId); List personalAccessTokenFrontendBeans = personalAccessTokens.stream() .map(PersonalAccessTokenFrontendBean::from) .collect(Collectors.toCollection(ArrayList::new)); diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/account/UserProfileComponent.java b/user-interface/src/main/java/life/qbic/datamanager/views/account/UserProfileComponent.java index 6f8896d21..07a3f62b2 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/account/UserProfileComponent.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/account/UserProfileComponent.java @@ -85,6 +85,7 @@ public static class ChangeUserDetailsDialog extends DialogWindow { public ChangeUserDetailsDialog(String currentUserName) { super(); + setHeaderTitle("Change username"); add(platformUserNameField); setConfirmButtonLabel("Save"); diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/account/UserProfileMain.java b/user-interface/src/main/java/life/qbic/datamanager/views/account/UserProfileMain.java index 0bbbe0b4c..43f524aed 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/account/UserProfileMain.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/account/UserProfileMain.java @@ -15,7 +15,7 @@ import life.qbic.identity.domain.model.UserId; import life.qbic.logging.api.Logger; import life.qbic.logging.service.LoggerFactory; -import life.qbic.projectmanagement.application.authorization.QbicUserDetails; +import life.qbic.projectmanagement.application.AuthenticationToUserIdTranslationService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -40,13 +40,16 @@ public class UserProfileMain extends Main implements BeforeEnterObserver { private static final Logger log = LoggerFactory.logger(UserProfileMain.class); private final UserProfileComponent userProfileComponent; private final transient UserInformationService userInformationService; + private final transient AuthenticationToUserIdTranslationService userIdTranslator; public UserProfileMain(@Autowired UserProfileComponent userProfileComponent, - @Autowired UserInformationService userInformationService) { + @Autowired UserInformationService userInformationService, + AuthenticationToUserIdTranslationService userIdTranslator) { this.userInformationService = requireNonNull(userInformationService, "userInformationService must not be null"); this.userProfileComponent = requireNonNull(userProfileComponent, "userProfileComponent must not be null"); + this.userIdTranslator = requireNonNull(userIdTranslator, "userIdTranslator must not be null"); addClassName("user-profile"); add(userProfileComponent); log.debug(String.format( @@ -65,8 +68,8 @@ public UserProfileMain(@Autowired UserProfileComponent userProfileComponent, @Override public void beforeEnter(BeforeEnterEvent event) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - QbicUserDetails details = (QbicUserDetails) authentication.getPrincipal(); - var userInfo = userInformationService.findById(details.getUserId()).orElseThrow(); + var userId = userIdTranslator.translateToUserId(authentication).orElseThrow(); + var userInfo = userInformationService.findById(userId).orElseThrow(); userProfileComponent.showForUser(userInfo); } diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/DataManagerMenu.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/DataManagerMenu.java index fead18e90..74bcaa1c6 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/general/DataManagerMenu.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/general/DataManagerMenu.java @@ -13,10 +13,9 @@ import life.qbic.datamanager.views.account.UserAvatar; import life.qbic.datamanager.views.account.UserProfileMain; import life.qbic.datamanager.views.projects.overview.ProjectOverviewMain; -import life.qbic.identity.api.UserInformationService; +import life.qbic.projectmanagement.application.authorization.QbicOidcUser; import life.qbic.projectmanagement.application.authorization.QbicUserDetails; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; /** @@ -29,14 +28,11 @@ public class DataManagerMenu extends Div { private final transient LogoutService logoutService; - private final transient UserInformationService userInformationService; MenuBar projectMenu = new MenuBar(); UserAvatar userAvatar = new UserAvatar(); - public DataManagerMenu(@Autowired LogoutService logoutService, - @Autowired UserInformationService userInformationService) { + public DataManagerMenu(@Autowired LogoutService logoutService) { this.logoutService = Objects.requireNonNull(logoutService); - this.userInformationService = Objects.requireNonNull(userInformationService); initializeHomeMenuItem(); initializeUserSubMenuItems(); add(projectMenu); @@ -60,12 +56,15 @@ private void initializeUserSubMenuItems() { } private void initializeAvatar() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - QbicUserDetails details = (QbicUserDetails) authentication.getPrincipal(); - /*Since users can change their detailsInformation, the variable information in the user session may not be up to date, - which is why a we retrieve the current state from the database */ - var userInfo = userInformationService.findById(details.getUserId()).orElseThrow(); - userAvatar.setUserId(userInfo.id()); + var principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + var userId = ""; + if (principal instanceof QbicUserDetails qbicUserDetails) { + userId = qbicUserDetails.getUserId(); + } + if (principal instanceof QbicOidcUser qbicOidcUser) { + userId = qbicOidcUser.getQbicUserId(); + } + userAvatar.setUserId(userId); } private void routeTo(Class mainComponent) { diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/DialogWindow.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/DialogWindow.java index fd7d9cdf8..d3a4ae921 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/general/DialogWindow.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/general/DialogWindow.java @@ -1,8 +1,12 @@ package life.qbic.datamanager.views.general; import com.vaadin.flow.component.ClickEvent; +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.Shortcuts; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.dialog.Dialog; +import com.vaadin.flow.server.Command; /** * Dialog to create something @@ -29,6 +33,13 @@ protected DialogWindow() { confirmButton.addClickListener(this::onConfirmClicked); } + protected void specifyCancelShortcuts(Command onCreationCanceled) { + setCloseOnOutsideClick(false); + setCloseOnEsc(false); + Shortcuts.addShortcutListener(this, + onCreationCanceled, Key.ESCAPE); + } + /** * Overwrite to change what happens on confirm button clicked * diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/contact/AutocompleteContactField.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/contact/AutocompleteContactField.java index 0810874ae..0fd51a582 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/general/contact/AutocompleteContactField.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/general/contact/AutocompleteContactField.java @@ -14,8 +14,9 @@ import java.util.ArrayList; import java.util.List; import life.qbic.datamanager.views.general.HasBinderValidation; +import life.qbic.projectmanagement.application.authorization.QbicOidcUser; import life.qbic.projectmanagement.application.authorization.QbicUserDetails; -import org.springframework.security.core.Authentication; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; import org.springframework.security.core.context.SecurityContextHolder; /** @@ -97,11 +98,22 @@ public AutocompleteContactField(String label, String shortName) { clear(); } - private void onSelfSelected(ComponentValueChangeEvent checkboxvalueChangeEvent) { - if(checkboxvalueChangeEvent.getValue().booleanValue()) { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - QbicUserDetails details = (QbicUserDetails) authentication.getPrincipal(); - Contact userAsContact = new Contact(details.fullName(), details.getEmailAddress()); + private void onSelfSelected( + ComponentValueChangeEvent checkboxvalueChangeEvent) { + if (Boolean.TRUE.equals(checkboxvalueChangeEvent.getValue())) { + var principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + String fullName; + String emailAddress; + if (principal instanceof QbicUserDetails qbicUserDetails) { + fullName = qbicUserDetails.fullName(); + emailAddress = qbicUserDetails.getEmailAddress(); + } else if (principal instanceof QbicOidcUser qbicOidcUser) { + fullName = qbicOidcUser.getFullName(); + emailAddress = qbicOidcUser.getEmail(); + } else { + throw new AuthenticationCredentialsNotFoundException("Unknown authentication principal"); + } + Contact userAsContact = new Contact(fullName, emailAddress); setContact(userAsContact); } } diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/footer/DataPrivacyAgreement.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/footer/DataPrivacyAgreement.java index 82cb27ae2..273213209 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/general/footer/DataPrivacyAgreement.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/general/footer/DataPrivacyAgreement.java @@ -1,11 +1,10 @@ package life.qbic.datamanager.views.general.footer; import com.vaadin.flow.component.Html; +import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; import com.vaadin.flow.router.RouteConfiguration; import com.vaadin.flow.server.auth.AnonymousAllowed; -import com.vaadin.flow.spring.annotation.SpringComponent; -import com.vaadin.flow.spring.annotation.UIScope; import java.io.IOException; import java.io.InputStream; import java.io.Serial; @@ -21,10 +20,9 @@ * Main area showing the relevant legal information for the data handling and orotection performed * within the data-manager application */ -@SpringComponent -@UIScope @Route(value = "data-privacy-agreement", layout = DataManagerLayout.class) @AnonymousAllowed +@PageTitle("Impressum / Data Privacy Agreement") public class DataPrivacyAgreement extends Main { @Serial diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/footer/FooterComponent.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/footer/FooterComponent.java index 926a4eef3..a0ad7ffcb 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/general/footer/FooterComponent.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/general/footer/FooterComponent.java @@ -4,12 +4,7 @@ import com.vaadin.flow.component.html.AnchorTarget; import com.vaadin.flow.component.html.Footer; import com.vaadin.flow.router.ParentLayout; -import com.vaadin.flow.router.RouterLink; -import com.vaadin.flow.spring.annotation.SpringComponent; -import com.vaadin.flow.spring.annotation.UIScope; import life.qbic.datamanager.views.DataManagerLayout; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; /** * Footer Component @@ -17,25 +12,22 @@ * Basic Footer Component routing the user to main components with additional information legal or * otherwise such as {@link LegalNotice} and {@link DataPrivacyAgreement} */ -@SpringComponent -@UIScope @ParentLayout(DataManagerLayout.class) +//@SessionScope public class FooterComponent extends Footer { - private final RouterLink legalNoticeLink = new RouterLink("LegalNotice", LegalNotice.class); - private final RouterLink dataPrivacyAgreement = new RouterLink("Data Privacy Agreement", - DataPrivacyAgreement.class); - - @Autowired - public FooterComponent( - @Value("${qbic.communication.data-manager.source-code.url}") String sourceCodeUrl, - @Value("${qbic.communication.documentation.url}") String documentationUrl, - @Value("${qbic.communication.api.url}") String apiUrl, - @Value("${qbic.communication.contact.email}") String contactEmail, - @Value("${qbic.communication.contact.subject}") String contactSubject) { + FooterComponent( + String sourceCodeUrl, + String documentationUrl, + String apiUrl, + String contactEmail, + String contactSubject, + String legalNoticeHref, + String dataPrivacyAgreementHref) { setId("data-manager-footer"); - add(new Anchor(dataPrivacyAgreement.getHref(), "Data Privacy Agreement", AnchorTarget.BLANK), - new Anchor(legalNoticeLink.getHref(), "Legal Notice", AnchorTarget.BLANK), + + add(new Anchor(dataPrivacyAgreementHref, "Data Privacy Agreement", AnchorTarget.BLANK), + new Anchor(legalNoticeHref, "Legal Notice", AnchorTarget.BLANK), new Anchor(documentationUrl, "Documentation", AnchorTarget.BLANK), new Anchor(apiUrl, "API", AnchorTarget.BLANK), new Anchor(sourceCodeUrl, "Source", AnchorTarget.BLANK), diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/footer/FooterComponentFactory.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/footer/FooterComponentFactory.java new file mode 100644 index 000000000..25a8745d0 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/views/general/footer/FooterComponentFactory.java @@ -0,0 +1,42 @@ +package life.qbic.datamanager.views.general.footer; + +import com.vaadin.flow.router.RouterLink; +import com.vaadin.flow.spring.annotation.SpringComponent; +import java.util.function.Supplier; +import org.springframework.beans.factory.annotation.Value; + +/** + * A factory bean to create page footers. This is needed as footers can not be added to multiple state/page trees. Thus a new instance is required for every view. + * The factory solves the issue where a view is created before a session is initialized. + */ +@SpringComponent +public class FooterComponentFactory implements Supplier { + + private final String sourceCodeUrl; + private final String documentationUrl; + private final String apiUrl; + private final String contactEmail; + private final String contactSubject; + + public FooterComponentFactory( + @Value("${qbic.communication.data-manager.source-code.url}") String sourceCodeUrl, + @Value("${qbic.communication.documentation.url}") String documentationUrl, + @Value("${qbic.communication.api.url}") String apiUrl, + @Value("${qbic.communication.contact.email}") String contactEmail, + @Value("${qbic.communication.contact.subject}") String contactSubject) { + this.sourceCodeUrl = sourceCodeUrl; + this.documentationUrl = documentationUrl; + this.apiUrl = apiUrl; + this.contactEmail = contactEmail; + this.contactSubject = contactSubject; + } + + @Override + public FooterComponent get() { + final RouterLink legalNoticeLink = new RouterLink("LegalNotice", LegalNotice.class); + final RouterLink dataPrivacyAgreement = new RouterLink("Data Privacy Agreement", + DataPrivacyAgreement.class); + return new FooterComponent(sourceCodeUrl, documentationUrl, apiUrl, contactEmail, + contactSubject, legalNoticeLink.getHref(), dataPrivacyAgreement.getHref()); + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/general/footer/LegalNotice.java b/user-interface/src/main/java/life/qbic/datamanager/views/general/footer/LegalNotice.java index 7d7ac4dd4..46e7ab350 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/general/footer/LegalNotice.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/general/footer/LegalNotice.java @@ -1,11 +1,10 @@ package life.qbic.datamanager.views.general.footer; import com.vaadin.flow.component.Html; +import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; import com.vaadin.flow.router.RouteConfiguration; import com.vaadin.flow.server.auth.AnonymousAllowed; -import com.vaadin.flow.spring.annotation.SpringComponent; -import com.vaadin.flow.spring.annotation.UIScope; import java.io.IOException; import java.io.InputStream; import java.io.Serial; @@ -20,9 +19,8 @@ *

* Legal Notice Main Component showing the relevant legal information for the data-manager application */ -@SpringComponent -@UIScope -@Route(value = "legalnotice", layout = DataManagerLayout.class) +@Route(value = "legal-notice", layout = DataManagerLayout.class) +@PageTitle("Impressum / Legal Notice") @AnonymousAllowed public class LegalNotice extends Main { diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/landing/LandingPageLayout.java b/user-interface/src/main/java/life/qbic/datamanager/views/landing/LandingPageLayout.java index 088533acf..c5dffef3f 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/landing/LandingPageLayout.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/landing/LandingPageLayout.java @@ -13,7 +13,7 @@ import java.util.Objects; import life.qbic.datamanager.views.DataManagerLayout; import life.qbic.datamanager.views.LandingPageTitleAndLogo; -import life.qbic.datamanager.views.general.footer.FooterComponent; +import life.qbic.datamanager.views.general.footer.FooterComponentFactory; import org.springframework.beans.factory.annotation.Autowired; /** @@ -32,8 +32,8 @@ public class LandingPageLayout extends DataManagerLayout implements RouterLayout public Button login; public LandingPageLayout(@Autowired LandingPageHandlerInterface handlerInterface, @Autowired - FooterComponent footerComponent) { - super(Objects.requireNonNull(footerComponent)); + FooterComponentFactory footerComponentFactory) { + super(Objects.requireNonNull(footerComponentFactory)); Objects.requireNonNull(handlerInterface); setId("landing-page-layout"); //CSS class hosting the background image for all our landing pages diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/login/LoginHandler.java b/user-interface/src/main/java/life/qbic/datamanager/views/login/LoginHandler.java index d1c2999e9..c15949a85 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/login/LoginHandler.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/login/LoginHandler.java @@ -22,7 +22,8 @@ public class LoginHandler { private final String emailConfirmationParameter; @Autowired - LoginHandler(@Value("${EMAIL_CONFIRMATION_PARAMETER:confirm-email}") String emailConfirmationParameter) { + LoginHandler( + @Value("${EMAIL_CONFIRMATION_PARAMETER:confirm-email}") String emailConfirmationParameter) { this.emailConfirmationParameter = Objects.requireNonNull(emailConfirmationParameter); } diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/login/LoginLayout.java b/user-interface/src/main/java/life/qbic/datamanager/views/login/LoginLayout.java index da521bff0..ad793b818 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/login/LoginLayout.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/login/LoginLayout.java @@ -1,12 +1,16 @@ package life.qbic.datamanager.views.login; +import static java.util.Objects.requireNonNull; import static life.qbic.logging.service.LoggerFactory.logger; import com.vaadin.flow.component.ComponentEventListener; import com.vaadin.flow.component.Text; +import com.vaadin.flow.component.UI; import com.vaadin.flow.component.dependency.CssImport; import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.Image; +import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.login.AbstractLogin.ForgotPasswordEvent; import com.vaadin.flow.component.login.AbstractLogin.LoginEvent; import com.vaadin.flow.component.orderedlayout.FlexComponent; @@ -17,21 +21,23 @@ import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; import com.vaadin.flow.router.RouterLink; +import com.vaadin.flow.server.AbstractStreamResource; +import com.vaadin.flow.server.StreamResource; import com.vaadin.flow.server.auth.AnonymousAllowed; import com.vaadin.flow.spring.annotation.UIScope; import java.util.List; import java.util.Map; -import java.util.Objects; import life.qbic.datamanager.views.AppRoutes; import life.qbic.datamanager.views.AppRoutes.Projects; import life.qbic.datamanager.views.landing.LandingPageLayout; import life.qbic.datamanager.views.notifications.ErrorMessage; import life.qbic.datamanager.views.notifications.InformationMessage; -import life.qbic.datamanager.views.register.UserRegistrationLayout; +import life.qbic.datamanager.views.register.UserRegistrationMain; import life.qbic.identity.application.user.IdentityService; import life.qbic.identity.application.user.UserNotFoundException; import life.qbic.logging.api.Logger; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; /** * Defines the layout and look of the login view. @@ -52,29 +58,34 @@ public class LoginLayout extends VerticalLayout implements HasUrlParameter getClass().getClassLoader().getResourceAsStream(ORCID_LOGO_PATH)); } private void styleNotificationLayout() { @@ -190,12 +212,13 @@ public void handle(BeforeEvent beforeEvent) { } if (queryParams.containsKey(emailConfirmationParameter)) { String userId = queryParams.get(emailConfirmationParameter).iterator().next(); - try { + try { identityService.confirmUserEmail(userId); onEmailConfirmationSuccess(); } catch (UserNotFoundException e) { log.error("User %s not found!".formatted(userId), e); - onEmailConfirmationFailure("Unknown user for request. If the issue persists, please contact our helpdesk."); + onEmailConfirmationFailure( + "Unknown user for request. If the issue persists, please contact our helpdesk."); } } @@ -211,4 +234,20 @@ public void onEmailConfirmationSuccess() { public void onEmailConfirmationFailure(String reason) { showError("Email confirmation failed", reason); } + + private static class LoginCard extends Span { + + private final Span text = new Span(); + private final Image logo = new Image(); + + public LoginCard(AbstractStreamResource imageResource, String description, String url) { + logo.addClassName("logo"); + text.setText(description); + text.addClassName("text"); + logo.setSrc(imageResource); + add(logo, text); + addClassName("login-card"); + addClickListener(event -> UI.getCurrent().getPage().open(url, "_self")); + } + } } diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/login/newpassword/NewPasswordHandler.java b/user-interface/src/main/java/life/qbic/datamanager/views/login/newpassword/NewPasswordHandler.java index 2f6b0c592..ec4f3b7f0 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/login/newpassword/NewPasswordHandler.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/login/newpassword/NewPasswordHandler.java @@ -17,7 +17,7 @@ public class NewPasswordHandler { private final String passwordResetQueryParameter; @Autowired - NewPasswordHandler(@Value("${password-reset-parameter}") String newPasswordParam) { + NewPasswordHandler(@Value("${routing.password-reset.reset-parameter}") String newPasswordParam) { this.passwordResetQueryParameter = newPasswordParam; } diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/login/newpassword/NewPasswordLayout.java b/user-interface/src/main/java/life/qbic/datamanager/views/login/newpassword/NewPasswordLayout.java index 56ecaa359..b2c741e74 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/login/newpassword/NewPasswordLayout.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/login/newpassword/NewPasswordLayout.java @@ -149,13 +149,14 @@ public void handle(BeforeEvent beforeEvent) { private void addClickListeners() { sendButton().addClickListener(buttonClickEvent -> { - Result applicationResponse = identityService.newUserPassword(currentUserId, - newPassword().getValue().toCharArray()); - if (applicationResponse.isError()) { - handleNewPasswordError(applicationResponse); + Result applicationResponse = identityService.newUserPassword( + currentUserId, + newPassword().getValue().toCharArray()); + if (applicationResponse.isError()) { + handleNewPasswordError(applicationResponse); + } + handleSuccess(); } - handleSuccess(); - } ); sendButton().addClickShortcut(Key.ENTER); diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/login/newpassword/NewPasswordSetLayout.java b/user-interface/src/main/java/life/qbic/datamanager/views/login/newpassword/NewPasswordSetLayout.java index 5accdfb09..fde1646e8 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/login/newpassword/NewPasswordSetLayout.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/login/newpassword/NewPasswordSetLayout.java @@ -9,7 +9,7 @@ * * @since 1.0.0 */ -class NewPasswordSetLayout extends BoxLayout { +public class NewPasswordSetLayout extends BoxLayout { private Button loginButton; diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/login/passwordreset/LinkSentLayout.java b/user-interface/src/main/java/life/qbic/datamanager/views/login/passwordreset/LinkSentLayout.java index e889d60c0..059169c42 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/login/passwordreset/LinkSentLayout.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/login/passwordreset/LinkSentLayout.java @@ -9,7 +9,7 @@ * * @since 1.0.0 */ -class LinkSentLayout extends BoxLayout { +public class LinkSentLayout extends BoxLayout { public Button loginButton; diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/login/passwordreset/ResetPasswordLayout.java b/user-interface/src/main/java/life/qbic/datamanager/views/login/passwordreset/ResetPasswordLayout.java index 10940bd3a..9c422693b 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/login/passwordreset/ResetPasswordLayout.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/login/passwordreset/ResetPasswordLayout.java @@ -19,7 +19,7 @@ import life.qbic.datamanager.views.landing.LandingPageLayout; import life.qbic.datamanager.views.layouts.BoxLayout; import life.qbic.datamanager.views.notifications.ErrorMessage; -import life.qbic.datamanager.views.register.UserRegistrationLayout; +import life.qbic.datamanager.views.register.UserRegistrationMain; import life.qbic.identity.application.user.IdentityService; import life.qbic.identity.application.user.UserNotFoundException; import life.qbic.identity.domain.model.EmailAddress; @@ -91,8 +91,9 @@ private void styleLayout() { } private void createSpan() { - RouterLink link = new RouterLink("REGISTER", UserRegistrationLayout.class); - registerSpan = new Span(new Text("Need an account? "), link); + RouterLink routerLink = new RouterLink("Register", UserRegistrationMain.class); + registerSpan = new Span(new Text("Don't have an account? "), routerLink); + registerSpan.addClassName("registration-link"); } private void createSendButton() { diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/create/AddProjectDialog.java b/user-interface/src/main/java/life/qbic/datamanager/views/projects/create/AddProjectDialog.java index 1c6ec414f..f44237bd6 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/create/AddProjectDialog.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/projects/create/AddProjectDialog.java @@ -7,6 +7,8 @@ import com.vaadin.flow.component.ComponentEvent; import com.vaadin.flow.component.ComponentEventListener; import com.vaadin.flow.component.HasValidation; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.Shortcuts; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.dialog.Dialog; import com.vaadin.flow.component.html.Div; @@ -21,6 +23,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import life.qbic.datamanager.views.CancelConfirmationNotificationDialog; import life.qbic.datamanager.views.general.HasBinderValidation; import life.qbic.datamanager.views.general.Stepper; import life.qbic.datamanager.views.general.Stepper.StepIndicator; @@ -72,6 +75,9 @@ public AddProjectDialog(ProjectInformationService projectInformationService, OntologyLookupService ontologyLookupService, ContactRepository contactRepository) { super(); + + initCancelShortcuts(); + addClassName("add-project-dialog"); requireNonNull(projectInformationService, "project information service must not be null"); requireNonNull(financeService, "financeService must not be null"); @@ -142,6 +148,27 @@ public AddProjectDialog(ProjectInformationService projectInformationService, adaptFooterButtons(stepper.getFirstStep()); } + private void initCancelShortcuts() { + setCloseOnOutsideClick(false); + setCloseOnEsc(false); + Shortcuts.addShortcutListener(this, + this::onCreationCanceled, Key.ESCAPE); + } + + private void onCreationCanceled() { + CancelConfirmationNotificationDialog cancelDialog = new CancelConfirmationNotificationDialog() + .withBodyText("You will lose all the information entered for this project.") + .withConfirmText("Discard project creation") + .withTitle("Discard new project creation?"); + cancelDialog.open(); + cancelDialog.addConfirmListener(event -> { + cancelDialog.close(); + fireEvent(new CancelEvent(this, true)); + }); + cancelDialog.addCancelListener( + event -> cancelDialog.close()); + } + /** * Allows user to search the offer database to prefill some project information */ @@ -150,7 +177,7 @@ public void enableOfferSearch() { } private void onCancelClicked(ClickEvent