diff --git a/backend/pom.xml b/backend/pom.xml index ab455c67e..dedbba4a2 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -192,6 +192,11 @@ org.springframework.boot spring-boot-starter-mail + + + org.springframework.security + spring-security-saml2-service-provider + org.springframework.boot spring-boot-starter-security diff --git a/backend/src/main/java/com/park/utmstack/config/SecurityConfiguration.java b/backend/src/main/java/com/park/utmstack/config/SecurityConfiguration.java index f113192fd..9252976f4 100644 --- a/backend/src/main/java/com/park/utmstack/config/SecurityConfiguration.java +++ b/backend/src/main/java/com/park/utmstack/config/SecurityConfiguration.java @@ -1,7 +1,5 @@ package com.park.utmstack.config; -import com.park.utmstack.loggin.api_key.ApiKeyUsageLoggingService; -import com.park.utmstack.loggin.filter.MdcCleanupFilter; import com.park.utmstack.repository.UserRepository; import com.park.utmstack.security.AuthoritiesConstants; import com.park.utmstack.security.api_key.ApiKeyConfigurer; @@ -9,13 +7,11 @@ import com.park.utmstack.security.internalApiKey.InternalApiKeyConfigurer; import com.park.utmstack.security.internalApiKey.InternalApiKeyProvider; import com.park.utmstack.security.jwt.JWTConfigurer; -import com.park.utmstack.security.jwt.JWTFilter; import com.park.utmstack.security.jwt.TokenProvider; -import com.park.utmstack.service.api_key.ApiKeyService; +import com.park.utmstack.security.saml.Saml2LoginFailureHandler; +import com.park.utmstack.security.saml.Saml2LoginSuccessHandler; import lombok.RequiredArgsConstructor; -import org.apache.commons.net.util.SubnetUtils; import org.springframework.beans.factory.BeanInitializationException; -import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; @@ -35,10 +31,10 @@ import org.springframework.web.filter.CorsFilter; import org.zalando.problem.spring.web.advice.security.SecurityProblemSupport; + import javax.annotation.PostConstruct; import javax.servlet.http.HttpServletResponse; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; + @Configuration @RequiredArgsConstructor @@ -53,6 +49,8 @@ public class SecurityConfiguration extends WebSecurityConfigurerAdapter { private final CorsFilter corsFilter; private final InternalApiKeyProvider internalApiKeyProvider; private final ApiKeyFilter apiKeyFilter; + private final UserRepository userRepository; + @PostConstruct public void init() { @@ -110,7 +108,9 @@ public void configure(HttpSecurity http) throws Exception { .antMatchers("/api/releaseInfo").permitAll() .antMatchers("/api/account/reset-password/init").permitAll() .antMatchers("/api/account/reset-password/finish").permitAll() + .antMatchers("/api/utm-providers").permitAll() .antMatchers("/api/images/all").permitAll() + .antMatchers("/api/info/version").permitAll() .antMatchers("/api/enrollment/**").hasAnyAuthority(AuthoritiesConstants.PRE_VERIFICATION_USER) .antMatchers("/api/tfa/verify-code").hasAnyAuthority(AuthoritiesConstants.PRE_VERIFICATION_USER, AuthoritiesConstants.USER, AuthoritiesConstants.ADMIN) .antMatchers("/api/tfa/refresh").hasAnyAuthority(AuthoritiesConstants.PRE_VERIFICATION_USER, AuthoritiesConstants.USER, AuthoritiesConstants.ADMIN) @@ -126,6 +126,12 @@ public void configure(HttpSecurity http) throws Exception { .antMatchers("/management/info").permitAll() .antMatchers("/management/**").hasAnyAuthority(AuthoritiesConstants.ADMIN, AuthoritiesConstants.USER) .and() + .saml2Login() + .successHandler(new Saml2LoginSuccessHandler(tokenProvider, + userRepository, + saml2LoginFailureHandler())) + .failureHandler(new Saml2LoginFailureHandler()) + .and() .apply(securityConfigurerAdapterForJwt()) .and() .apply(securityConfigurerAdapterForInternalApiKey()) @@ -147,4 +153,10 @@ private ApiKeyConfigurer securityConfigurerAdapterForApiKey() { return new ApiKeyConfigurer(apiKeyFilter); } + + @Bean + public Saml2LoginFailureHandler saml2LoginFailureHandler() { + return new Saml2LoginFailureHandler(); + } + } diff --git a/backend/src/main/java/com/park/utmstack/config/saml/OAuth2ClientConfig.java b/backend/src/main/java/com/park/utmstack/config/saml/OAuth2ClientConfig.java new file mode 100644 index 000000000..f5f6f00fb --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/config/saml/OAuth2ClientConfig.java @@ -0,0 +1,14 @@ +package com.park.utmstack.config.saml; + +import com.park.utmstack.repository.idp_provider.IdentityProviderConfigRepository; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OAuth2ClientConfig { + + @Bean + public SamlRelyingPartyRegistrationRepository clientRegistrationRepository(IdentityProviderConfigRepository repo) { + return new SamlRelyingPartyRegistrationRepository(repo); + } +} diff --git a/backend/src/main/java/com/park/utmstack/config/saml/ProviderChangeListener.java b/backend/src/main/java/com/park/utmstack/config/saml/ProviderChangeListener.java new file mode 100644 index 000000000..39e41fd12 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/config/saml/ProviderChangeListener.java @@ -0,0 +1,23 @@ +package com.park.utmstack.config.saml; + +import com.park.utmstack.repository.idp_provider.IdentityProviderConfigRepository; +import com.park.utmstack.util.events.ProviderChangedEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ProviderChangeListener { + + private final RelyingPartyRegistrationRepository repository; + private final IdentityProviderConfigRepository identityProviderConfigRepository; + + @EventListener + public void handleProviderChanged(ProviderChangedEvent event) { + if (repository instanceof SamlRelyingPartyRegistrationRepository customRepo) { + customRepo.reloadProviders(identityProviderConfigRepository); + } + } +} diff --git a/backend/src/main/java/com/park/utmstack/config/saml/SamlRelyingPartyRegistrationRepository.java b/backend/src/main/java/com/park/utmstack/config/saml/SamlRelyingPartyRegistrationRepository.java new file mode 100644 index 000000000..49c063cc6 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/config/saml/SamlRelyingPartyRegistrationRepository.java @@ -0,0 +1,61 @@ +package com.park.utmstack.config.saml; + +import com.park.utmstack.config.Constants; +import com.park.utmstack.domain.idp_provider.IdentityProviderConfig; +import com.park.utmstack.repository.idp_provider.IdentityProviderConfigRepository; +import com.park.utmstack.util.CipherUtil; +import com.park.utmstack.util.saml.PemUtils; +import org.springframework.security.saml2.core.Saml2X509Credential; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrations; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class SamlRelyingPartyRegistrationRepository implements RelyingPartyRegistrationRepository { + + private final Map registrations = new ConcurrentHashMap<>(); + + public SamlRelyingPartyRegistrationRepository(IdentityProviderConfigRepository jpaProviderRepository) { + loadProviders(jpaProviderRepository); + } + + @Override + public RelyingPartyRegistration findByRegistrationId(String registrationId) { + return registrations.get(registrationId); + } + + public void reloadProviders(IdentityProviderConfigRepository jpaProviderRepository) { + registrations.clear(); + loadProviders(jpaProviderRepository); + } + + private void loadProviders(IdentityProviderConfigRepository jpaProviderRepository) { + jpaProviderRepository.findAllByActiveTrue().forEach(entity -> { + RelyingPartyRegistration registration = buildRelyingPartyRegistration(entity); + registrations.put(entity.getProviderType().name().toLowerCase(), registration); + }); + } + + private RelyingPartyRegistration buildRelyingPartyRegistration(IdentityProviderConfig entity) { + + PrivateKey spKey = PemUtils.parsePrivateKey(CipherUtil.decrypt( + entity.getSpPrivateKeyPem(), + System.getenv(Constants.ENV_ENCRYPTION_KEY) + )); + X509Certificate spCert = PemUtils.parseCertificate(entity.getSpCertificatePem()); + + return RelyingPartyRegistrations + .fromMetadataLocation(entity.getMetadataUrl()) + .registrationId(entity.getName()) + .entityId(entity.getSpEntityId()) + .assertionConsumerServiceLocation(entity.getSpAcsUrl()) + .signingX509Credentials(c -> c.add(Saml2X509Credential.signing(spKey, spCert))) + .build(); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/park/utmstack/domain/idp_provider/IdentityProviderConfig.java b/backend/src/main/java/com/park/utmstack/domain/idp_provider/IdentityProviderConfig.java new file mode 100644 index 000000000..4690903c5 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/domain/idp_provider/IdentityProviderConfig.java @@ -0,0 +1,79 @@ +package com.park.utmstack.domain.idp_provider; + +import com.park.utmstack.domain.idp_provider.enums.ProviderType; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Type; + +import javax.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "utm_identity_provider_config") +@Data +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class IdentityProviderConfig { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @EqualsAndHashCode.Include + private Long id; + + @Column(nullable = false) + private String name; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ProviderType providerType; + + /** + * Metadata URL of the IdP (Keycloak, Okta, Azure, etc.) + * Example: https://localhost:8443/realms/UTMSTACK/protocol/saml/descriptor + */ + @Column(name = "metadata_url", nullable = false, length = 512) + private String metadataUrl; + + /** + * Service Provider private key in PEM format + * Used to sign AuthnRequests and other outgoing SAML messages + */ + @Type(type = "text") + @Column(name = "sp_private_key_pem", nullable = false, columnDefinition = "TEXT") + private String spPrivateKeyPem; + + /** + * Service Provider public certificate in PEM format + * Shared with IdP so it can validate signed requests from the SP + */ + @Type(type = "text") + @Column(name = "sp_certificate_pem", nullable = false, columnDefinition = "TEXT") + private String spCertificatePem; + + @Column(name = "sp_entity_id", nullable = false, length = 512) + private String spEntityId; + + @Column(name = "sp_acs_url", nullable = false, length = 512) + private String spAcsUrl; + + /** + * Flag to enable or disable this IdP configuration + */ + @Column(nullable = false) + private Boolean active; + + /** + * Timestamp when the record was created + */ + @Column(nullable = false) + private LocalDateTime createdAt; + + /** + * Timestamp when the record was last updated + */ + @Column(nullable = false) + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/park/utmstack/domain/idp_provider/enums/ProviderType.java b/backend/src/main/java/com/park/utmstack/domain/idp_provider/enums/ProviderType.java new file mode 100644 index 000000000..807874f8e --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/domain/idp_provider/enums/ProviderType.java @@ -0,0 +1,12 @@ +package com.park.utmstack.domain.idp_provider.enums; + +public enum ProviderType { + GOOGLE, + KEYCLOAK, + OKTA, + MICROSOFT; + + public static ProviderType from(String value) { + return ProviderType.valueOf(value.toUpperCase()); + } +} diff --git a/backend/src/main/java/com/park/utmstack/repository/idp_provider/IdentityProviderConfigRepository.java b/backend/src/main/java/com/park/utmstack/repository/idp_provider/IdentityProviderConfigRepository.java new file mode 100644 index 000000000..e4ef2e09e --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/repository/idp_provider/IdentityProviderConfigRepository.java @@ -0,0 +1,18 @@ +package com.park.utmstack.repository.idp_provider; + +import com.park.utmstack.domain.idp_provider.IdentityProviderConfig; +import com.park.utmstack.domain.idp_provider.enums.ProviderType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface IdentityProviderConfigRepository extends JpaRepository, JpaSpecificationExecutor { + + Optional findByProviderTypeAndActiveTrue(ProviderType providerType); + + List findAllByActiveTrue(); +} diff --git a/backend/src/main/java/com/park/utmstack/security/jwt/JWTConfigurer.java b/backend/src/main/java/com/park/utmstack/security/jwt/JWTConfigurer.java index e39a211ec..de7ef3b60 100644 --- a/backend/src/main/java/com/park/utmstack/security/jwt/JWTConfigurer.java +++ b/backend/src/main/java/com/park/utmstack/security/jwt/JWTConfigurer.java @@ -7,7 +7,7 @@ public class JWTConfigurer extends SecurityConfigurerAdapter { - private TokenProvider tokenProvider; + private final TokenProvider tokenProvider; public JWTConfigurer(TokenProvider tokenProvider) { this.tokenProvider = tokenProvider; diff --git a/backend/src/main/java/com/park/utmstack/security/saml/Saml2LoginFailureHandler.java b/backend/src/main/java/com/park/utmstack/security/saml/Saml2LoginFailureHandler.java new file mode 100644 index 000000000..5f7491bb7 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/security/saml/Saml2LoginFailureHandler.java @@ -0,0 +1,37 @@ +package com.park.utmstack.security.saml; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.web.util.UriComponentsBuilder; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URI; +import java.util.Objects; + +/** + * Failure handler for SAML2 login. + * Redirects the user to the frontend with an error parameter. + */ +@Slf4j +public class Saml2LoginFailureHandler implements AuthenticationFailureHandler { + + @Override + public void onAuthenticationFailure(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) throws IOException { + + String scheme = Objects.requireNonNullElse(request.getHeader("X-Forwarded-Proto"), request.getScheme()); + String host = Objects.requireNonNullElse(request.getHeader("Host"), request.getServerName()); + + String frontBaseUrl = scheme + "://" + host; + + URI redirectUri = UriComponentsBuilder.fromHttpUrl(frontBaseUrl) + .queryParam("error", "saml2") + .build().toUri(); + + response.sendRedirect(redirectUri.toString()); + } +} diff --git a/backend/src/main/java/com/park/utmstack/security/saml/Saml2LoginSuccessHandler.java b/backend/src/main/java/com/park/utmstack/security/saml/Saml2LoginSuccessHandler.java new file mode 100644 index 000000000..05a6f38f8 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/security/saml/Saml2LoginSuccessHandler.java @@ -0,0 +1,88 @@ +package com.park.utmstack.security.saml; + +import com.park.utmstack.repository.UserRepository; +import com.park.utmstack.security.jwt.TokenProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; +import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.web.util.UriComponentsBuilder; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URI; +import java.util.Collection; +import java.util.Objects; + +import static com.park.utmstack.config.Constants.FRONT_BASE_URL; + +/** + * Success handler for SAML2 login. + * Extracts NameID and attributes from the SAML assertion, + * generates a JWT, and redirects to the frontend with the token. + */ + +@RequiredArgsConstructor +@Slf4j +public class Saml2LoginSuccessHandler implements AuthenticationSuccessHandler { + + private final TokenProvider tokenProvider; + private final UserRepository userRepository; + private final Saml2LoginFailureHandler failureHandler; + + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException { + + String scheme = Objects.requireNonNullElse(request.getHeader("X-Forwarded-Proto"), request.getScheme()); + String host = Objects.requireNonNullElse(request.getHeader("Host"), request.getServerName()); + + String frontBaseUrl = scheme + "://" + host; + + Saml2AuthenticatedPrincipal samlUser = (Saml2AuthenticatedPrincipal) authentication.getPrincipal(); + var roles = samlUser.getAttribute("roles"); + + String username = samlUser.getName(); + + if (roles == null || ((Collection) roles).isEmpty() || userRepository.findOneByLogin(username).isEmpty()) { + log.error("{}: Attempted SAML2 login with invalid roles or non-existing user account.", username); + failureHandler.onAuthenticationFailure(request, response, + new BadCredentialsException("The provided credentials do not match any active user account or the account lacks required roles.")); + return; + } + + Collection authorities = Objects.requireNonNull(samlUser.getAttribute("roles")) + .stream() + .map(Objects::toString) + .filter(r -> r.startsWith("ROLE_")) + .map(SimpleGrantedAuthority::new) + .toList(); + + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(username, null, authorities); + + SecurityContextHolder.getContext().setAuthentication(auth); + + // Generate JWT + String token = tokenProvider.createToken(auth, false, true); + + // Redirect to frontend with token + URI redirectUri = UriComponentsBuilder.fromUriString(frontBaseUrl) + .path("/") + .queryParam("token", token) + .build() + .toUri(); + + response.sendRedirect(redirectUri.toString()); + } +} diff --git a/backend/src/main/java/com/park/utmstack/service/dto/idp_provider/dto/IdentityProviderConfigRequestDto.java b/backend/src/main/java/com/park/utmstack/service/dto/idp_provider/dto/IdentityProviderConfigRequestDto.java new file mode 100644 index 000000000..7290c2288 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/service/dto/idp_provider/dto/IdentityProviderConfigRequestDto.java @@ -0,0 +1,45 @@ +package com.park.utmstack.service.dto.idp_provider.dto; + +import com.park.utmstack.domain.idp_provider.enums.ProviderType; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.time.LocalDateTime; + +/** + * DTO for Identity Provider configuration requests. + * Extended for SAML providers. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class IdentityProviderConfigRequestDto { + + private Long id; + + @NotBlank + private String name; + + @NotNull + private ProviderType providerType; + + @NotBlank + private String metadataUrl; + + @NotBlank + private String spEntityId; + + @NotBlank + private String spAcsUrl; + + @NotNull + private Boolean active; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + +} diff --git a/backend/src/main/java/com/park/utmstack/service/dto/idp_provider/dto/IdentityProviderConfigResponseDto.java b/backend/src/main/java/com/park/utmstack/service/dto/idp_provider/dto/IdentityProviderConfigResponseDto.java new file mode 100644 index 000000000..62c3bb850 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/service/dto/idp_provider/dto/IdentityProviderConfigResponseDto.java @@ -0,0 +1,27 @@ +package com.park.utmstack.service.dto.idp_provider.dto; + +import com.park.utmstack.domain.idp_provider.enums.ProviderType; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Response DTO for Identity Provider configuration. + * Adapted for SAML providers only. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class IdentityProviderConfigResponseDto { + + private Long id; + private String name; + private ProviderType providerType; + private String metadataUrl; + private String spCertificatePem; + private Boolean active; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/park/utmstack/service/dto/idp_provider/dto/IdentityProviderCreateConfigDto.java b/backend/src/main/java/com/park/utmstack/service/dto/idp_provider/dto/IdentityProviderCreateConfigDto.java new file mode 100644 index 000000000..291e3ef0c --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/service/dto/idp_provider/dto/IdentityProviderCreateConfigDto.java @@ -0,0 +1,27 @@ +package com.park.utmstack.service.dto.idp_provider.dto; + +import com.park.utmstack.domain.idp_provider.enums.ProviderType; +import com.park.utmstack.validation.saml.ValidCertificate; +import com.park.utmstack.validation.saml.ValidPrivateKey; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +@EqualsAndHashCode(callSuper = true) +@Data +@NoArgsConstructor +@AllArgsConstructor +public class IdentityProviderCreateConfigDto extends IdentityProviderConfigRequestDto { + + @NotBlank + @ValidPrivateKey + private String spPrivateKeyPem; + + @NotBlank + private String spCertificatePem; + +} \ No newline at end of file diff --git a/backend/src/main/java/com/park/utmstack/service/dto/idp_provider/dto/IdentityProviderCriteria.java b/backend/src/main/java/com/park/utmstack/service/dto/idp_provider/dto/IdentityProviderCriteria.java new file mode 100644 index 000000000..47d643a02 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/service/dto/idp_provider/dto/IdentityProviderCriteria.java @@ -0,0 +1,29 @@ +package com.park.utmstack.service.dto.idp_provider.dto; + +import com.park.utmstack.domain.idp_provider.enums.ProviderType; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import tech.jhipster.service.filter.*; + +import java.io.Serializable; + +/** + * Criteria class for filtering IdentityProviderConfig entities. + * Adapted for SAML providers only. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class IdentityProviderCriteria implements Serializable { + private static final long serialVersionUID = 1L; + + public static class ProviderTypeFilter extends Filter { } + + private LongFilter id; + private StringFilter name; + private ProviderTypeFilter providerType; + private BooleanFilter active; + private InstantFilter createdDate; + private InstantFilter lastModifiedDate; +} diff --git a/backend/src/main/java/com/park/utmstack/service/dto/idp_provider/dto/IdentityProviderMapper.java b/backend/src/main/java/com/park/utmstack/service/dto/idp_provider/dto/IdentityProviderMapper.java new file mode 100644 index 000000000..46e9c5b83 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/service/dto/idp_provider/dto/IdentityProviderMapper.java @@ -0,0 +1,50 @@ +package com.park.utmstack.service.dto.idp_provider.dto; + +import com.park.utmstack.config.Constants; +import com.park.utmstack.domain.idp_provider.IdentityProviderConfig; +import com.park.utmstack.util.CipherUtil; +import com.park.utmstack.util.exceptions.FileProcessingException; +import lombok.extern.slf4j.Slf4j; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +@Mapper(componentModel = "spring", imports = {CipherUtil.class, System.class, Constants.class}) +public interface IdentityProviderMapper { + + IdentityProviderConfigResponseDto toDto(IdentityProviderConfig entity); + + @Mapping(target = "spPrivateKeyPem", expression = "java(CipherUtil.encrypt(request.getSpPrivateKeyPem(), System.getenv(Constants.ENV_ENCRYPTION_KEY)))") + IdentityProviderConfig toEntity(IdentityProviderCreateConfigDto request); + + List toDtoList(List entities); + + void updateEntityFromRequest(IdentityProviderConfigRequestDto request, @MappingTarget IdentityProviderConfig entity); + + @Mapping(target = "name", source = "name") + @Mapping(target = "spPrivateKeyPem", source = "privateKeyFile") + @Mapping(target = "spCertificatePem", source = "certificateFile") + @Mapping(target = "spEntityId", source = "spEntityId") + @Mapping(target = "spAcsUrl", source = "spAcsUrl") + @Mapping(target = "providerType", expression = "java(com.park.utmstack.domain.idp_provider.enums.ProviderType.valueOf(providerType))") + IdentityProviderCreateConfigDto toCreateConfigDto(String name, String providerType, String metadataUrl, Boolean active, + MultipartFile privateKeyFile, MultipartFile certificateFile, String spEntityId, String spAcsUrl); + + default String mapFileToString(MultipartFile file) { + if (file == null || file.isEmpty()) { + return null; + } + + try { + return new String(file.getBytes(), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new FileProcessingException("An error occurred while processing the file: " + file.getOriginalFilename()); + } + + } +} diff --git a/backend/src/main/java/com/park/utmstack/service/dto/idp_provider/dto/IdentityProviderSpecification.java b/backend/src/main/java/com/park/utmstack/service/dto/idp_provider/dto/IdentityProviderSpecification.java new file mode 100644 index 000000000..fe856cdd5 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/service/dto/idp_provider/dto/IdentityProviderSpecification.java @@ -0,0 +1,39 @@ +package com.park.utmstack.service.dto.idp_provider.dto; + +import com.park.utmstack.domain.idp_provider.IdentityProviderConfig; +import org.springframework.data.jpa.domain.Specification; + +import javax.persistence.criteria.Predicate; + +/** + * Specification builder for IdentityProviderConfig. + * Adapted for SAML providers only. + */ +public class IdentityProviderSpecification { + public static Specification build(IdentityProviderCriteria criteria) { + return (root, query, cb) -> { + Predicate predicate = cb.conjunction(); + + if (criteria.getId() != null && criteria.getId().getEquals() != null) { + predicate = cb.and(predicate, cb.equal(root.get("id"), criteria.getId().getEquals())); + } + if (criteria.getName() != null && criteria.getName().getContains() != null) { + predicate = cb.and(predicate, cb.like(root.get("name"), "%" + criteria.getName().getContains() + "%")); + } + if (criteria.getProviderType() != null && criteria.getProviderType().getEquals() != null) { + predicate = cb.and(predicate, cb.equal(root.get("providerType"), criteria.getProviderType().getEquals())); + } + if (criteria.getCreatedDate() != null && criteria.getCreatedDate().getEquals() != null) { + predicate = cb.and(predicate, cb.equal(root.get("createdDate"), criteria.getCreatedDate().getEquals())); + } + if (criteria.getLastModifiedDate() != null && criteria.getLastModifiedDate().getEquals() != null) { + predicate = cb.and(predicate, cb.equal(root.get("lastModifiedDate"), criteria.getLastModifiedDate().getEquals())); + } + if (criteria.getActive() != null && criteria.getActive().getEquals() != null) { + predicate = cb.and(predicate, cb.equal(root.get("active"), criteria.getActive().getEquals())); + } + + return predicate; + }; + } +} diff --git a/backend/src/main/java/com/park/utmstack/service/idp_provider/IdentityProviderService.java b/backend/src/main/java/com/park/utmstack/service/idp_provider/IdentityProviderService.java new file mode 100644 index 000000000..19f404b76 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/service/idp_provider/IdentityProviderService.java @@ -0,0 +1,127 @@ +package com.park.utmstack.service.idp_provider; + + +import com.park.utmstack.domain.idp_provider.IdentityProviderConfig; +import com.park.utmstack.repository.idp_provider.IdentityProviderConfigRepository; +import com.park.utmstack.service.dto.idp_provider.dto.*; +import com.park.utmstack.util.CipherUtil; +import com.park.utmstack.util.events.ProviderChangedEvent; +import com.park.utmstack.util.exceptions.IdpNotFoundException; +import com.park.utmstack.util.exceptions.SamlMetadataUrlInvalidException; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class IdentityProviderService { + + private final IdentityProviderMapper mapper; + private final IdentityProviderConfigRepository repository; + private final ApplicationEventPublisher publisher; + + public List getAllActiveProviders() { + return repository.findAllByActiveTrue(); + } + + public IdentityProviderConfigResponseDto create(IdentityProviderCreateConfigDto dto) { + + validateMetadataUrl(dto.getMetadataUrl()); + IdentityProviderConfig entity = mapper.toEntity(dto); + entity.setCreatedAt(LocalDateTime.now()); + entity.setUpdatedAt(LocalDateTime.now()); + entity.setSpEntityId(dto.getSpEntityId()); + entity.setSpAcsUrl(dto.getSpAcsUrl()); + IdentityProviderConfig saved = repository.save(entity); + publisher.publishEvent(new ProviderChangedEvent(saved)); + return mapper.toDto(saved); + } + + + public IdentityProviderConfigResponseDto update(Long id, IdentityProviderConfigRequestDto dto) { + + validateMetadataUrl(dto.getMetadataUrl()); + + IdentityProviderConfig existing = repository.findById(id) + .orElseThrow(() -> new IdpNotFoundException("IdentityProviderConfig not found: " + id)); + + + existing.setName(dto.getName()); + existing.setMetadataUrl(dto.getMetadataUrl()); + existing.setActive(dto.getActive()); + existing.setUpdatedAt(LocalDateTime.now()); + + if(dto instanceof IdentityProviderCreateConfigDto createDto){ + if (createDto.getSpPrivateKeyPem() != null) { + String encryptedKey = CipherUtil.encrypt(createDto.getSpPrivateKeyPem(), System.getenv("ENCRYPTION_KEY")); + existing.setSpPrivateKeyPem(encryptedKey); + } + if (createDto.getSpCertificatePem() != null) { + existing.setSpCertificatePem(createDto.getSpCertificatePem()); + } + } + + + IdentityProviderConfig updated = repository.save(existing); + publisher.publishEvent(new ProviderChangedEvent(updated)); + return mapper.toDto(updated); + } + + + @Transactional(readOnly = true) + public Page findAll(IdentityProviderCriteria criteria, Pageable pageable) { + Specification spec = IdentityProviderSpecification.build(criteria); + Page result = repository.findAll(spec, pageable); + return result.map(mapper::toDto); + } + + + + @Transactional(readOnly = true) + public Optional findById(Long id) { + return repository.findById(id) + .map(mapper::toDto); + } + + + public void delete(Long id) { + if (!repository.existsById(id)) { + throw new IdpNotFoundException("IdentityProviderConfig not found: " + id); + } + repository.deleteById(id); + } + + private void validateMetadataUrl(String metadataUrl) { + if (metadataUrl == null || metadataUrl.trim().isEmpty()) { + throw new SamlMetadataUrlInvalidException("Metadata URL is required"); + } + + try { + URL url = new URL(metadataUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + + int responseCode = connection.getResponseCode(); + if (responseCode != 200) { + throw new SamlMetadataUrlInvalidException("Metadata URL is not accessible"); + } + } catch (IOException e) { + throw new SamlMetadataUrlInvalidException("Failed to access metadata URL"); + } + } + +} diff --git a/backend/src/main/java/com/park/utmstack/util/events/ProviderChangedEvent.java b/backend/src/main/java/com/park/utmstack/util/events/ProviderChangedEvent.java new file mode 100644 index 000000000..e1a07aeea --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/util/events/ProviderChangedEvent.java @@ -0,0 +1,9 @@ +package com.park.utmstack.util.events; + +import org.springframework.context.ApplicationEvent; + +public class ProviderChangedEvent extends ApplicationEvent { + public ProviderChangedEvent(Object source) { + super(source); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/park/utmstack/util/exceptions/FileProcessingException.java b/backend/src/main/java/com/park/utmstack/util/exceptions/FileProcessingException.java new file mode 100644 index 000000000..3505c946e --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/util/exceptions/FileProcessingException.java @@ -0,0 +1,9 @@ +package com.park.utmstack.util.exceptions; + +import org.springframework.http.HttpStatus; + +public class FileProcessingException extends ApiException{ + public FileProcessingException(String message) { + super(message, HttpStatus.BAD_REQUEST); + } +} diff --git a/backend/src/main/java/com/park/utmstack/util/exceptions/IdpNotFoundException.java b/backend/src/main/java/com/park/utmstack/util/exceptions/IdpNotFoundException.java new file mode 100644 index 000000000..cb6a04ad5 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/util/exceptions/IdpNotFoundException.java @@ -0,0 +1,7 @@ +package com.park.utmstack.util.exceptions; + +public class IdpNotFoundException extends RuntimeException { + public IdpNotFoundException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/park/utmstack/util/exceptions/InvalidIdpConfigException.java b/backend/src/main/java/com/park/utmstack/util/exceptions/InvalidIdpConfigException.java new file mode 100644 index 000000000..d6fd26766 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/util/exceptions/InvalidIdpConfigException.java @@ -0,0 +1,7 @@ +package com.park.utmstack.util.exceptions; + +public class InvalidIdpConfigException extends RuntimeException { + public InvalidIdpConfigException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/park/utmstack/util/exceptions/SamlMetadataUrlInvalidException.java b/backend/src/main/java/com/park/utmstack/util/exceptions/SamlMetadataUrlInvalidException.java new file mode 100644 index 000000000..9f2888a6a --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/util/exceptions/SamlMetadataUrlInvalidException.java @@ -0,0 +1,9 @@ +package com.park.utmstack.util.exceptions; + +import org.springframework.http.HttpStatus; + +public class SamlMetadataUrlInvalidException extends ApiException { + public SamlMetadataUrlInvalidException(String message) { + super(message, HttpStatus.BAD_REQUEST); + } +} diff --git a/backend/src/main/java/com/park/utmstack/util/saml/PemUtils.java b/backend/src/main/java/com/park/utmstack/util/saml/PemUtils.java new file mode 100644 index 000000000..45c27ed4c --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/util/saml/PemUtils.java @@ -0,0 +1,111 @@ +package com.park.utmstack.util.saml; + +import com.park.utmstack.util.exceptions.FileProcessingException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; + +@Slf4j +public class PemUtils { + + private static final String className = PemUtils.class.getName(); + + public static PrivateKey parsePrivateKey(String pemContent) { + try { + String base64 = pemContent + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + byte[] decoded = Base64.getDecoder().decode(base64); + + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(decoded); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePrivate(keySpec); + } catch (Exception e) { + log.error("{} Error parsing PEM private key", className + ": "+ "parsePrivateKey" , e); + throw new IllegalArgumentException("Failed to parse PEM private key", e); + } + } + + /** + * Parse a PEM string into an X509Certificate. + * + * @param pemContent certificate in PEM format + * @return X509Certificate + */ + public static X509Certificate parseCertificate(String pemContent) { + try { + String base64 = pemContent + .replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .replaceAll("\\s+", ""); + byte[] decoded = Base64.getDecoder().decode(base64); + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + return (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(decoded)); + } catch (Exception e) { + log.error("{} Error parsing PEM certificate", className + ": "+ "parseCertificate", e); + throw new IllegalArgumentException("Failed to parse PEM certificate"); + } + } + + public static void validateFilesForCreate(MultipartFile privateKeyFile, MultipartFile certificateFile) { + if (privateKeyFile == null || privateKeyFile.isEmpty()) { + throw new FileProcessingException("The private key is required"); + } + if (certificateFile == null || certificateFile.isEmpty()) { + throw new IllegalArgumentException("The certificate is required"); + } + validateFileContent(privateKeyFile, certificateFile); + } + + public static void validateFilesForUpdate(MultipartFile privateKeyFile, MultipartFile certificateFile) { + boolean hasPrivateKey = privateKeyFile != null && !privateKeyFile.isEmpty(); + boolean hasCertificate = certificateFile != null && !certificateFile.isEmpty(); + + if (hasPrivateKey && !hasCertificate) { + throw new FileProcessingException("The certificate is required"); + } + if (hasCertificate && !hasPrivateKey) { + throw new FileProcessingException("The private key is required"); + } + if (hasPrivateKey) { + validateFileContent(privateKeyFile, certificateFile); + } + } + + + private static void validateFileContent(MultipartFile privateKeyFile, MultipartFile certificateFile) { + if (privateKeyFile != null && !privateKeyFile.isEmpty()) { + try { + String content = new String(privateKeyFile.getBytes(), StandardCharsets.UTF_8); + PemUtils.parsePrivateKey(content); + } catch (IOException e) { + throw new FileProcessingException("An error occurred while reading the private key file"); + } catch (Exception e) { + throw new FileProcessingException("The PEM private key is invalid"); + } + } + + if (certificateFile != null && !certificateFile.isEmpty()) { + try { + String content = new String(certificateFile.getBytes(), StandardCharsets.UTF_8); + PemUtils.parseCertificate(content); + } catch (IOException e) { + throw new IllegalArgumentException("An error occurred while reading the certificate file"); + } catch (Exception e) { + throw new FileProcessingException("The PEM certificate is invalid"); + } + } + } +} + + diff --git a/backend/src/main/java/com/park/utmstack/validation/saml/ValidCertificate.java b/backend/src/main/java/com/park/utmstack/validation/saml/ValidCertificate.java new file mode 100644 index 000000000..1d9123be5 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/validation/saml/ValidCertificate.java @@ -0,0 +1,18 @@ +package com.park.utmstack.validation.saml; + +import com.park.utmstack.validation.saml.impl.ValidCertificateValidator; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.*; + +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = ValidCertificateValidator.class) +@Documented +public @interface ValidCertificate { + String message() default "The file does not contain a valid PEM certificate"; + Class[] groups() default {}; + Class[] payload() default {}; +} + diff --git a/backend/src/main/java/com/park/utmstack/validation/saml/ValidPrivateKey.java b/backend/src/main/java/com/park/utmstack/validation/saml/ValidPrivateKey.java new file mode 100644 index 000000000..55b4016bb --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/validation/saml/ValidPrivateKey.java @@ -0,0 +1,17 @@ +package com.park.utmstack.validation.saml; + +import com.park.utmstack.validation.saml.impl.ValidPrivateKeyValidator; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.*; + +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = ValidPrivateKeyValidator.class) +@Documented +public @interface ValidPrivateKey { + String message() default "The file does not contain a valid PEM private key"; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/backend/src/main/java/com/park/utmstack/validation/saml/impl/ValidCertificateValidator.java b/backend/src/main/java/com/park/utmstack/validation/saml/impl/ValidCertificateValidator.java new file mode 100644 index 000000000..8e7406da8 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/validation/saml/impl/ValidCertificateValidator.java @@ -0,0 +1,30 @@ +package com.park.utmstack.validation.saml.impl; + +import com.park.utmstack.util.saml.PemUtils; +import com.park.utmstack.validation.saml.ValidCertificate; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +public class ValidCertificateValidator implements ConstraintValidator { + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null || value.isBlank()) { + return false; + } + try { + PemUtils.parseCertificate(value); + return true; + } catch (Exception e) { + addConstraintViolation(context); + return false; + } + } + + private void addConstraintViolation(ConstraintValidatorContext context) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate("The file does not contain a valid PEM certificate").addConstraintViolation(); + } +} + diff --git a/backend/src/main/java/com/park/utmstack/validation/saml/impl/ValidPrivateKeyValidator.java b/backend/src/main/java/com/park/utmstack/validation/saml/impl/ValidPrivateKeyValidator.java new file mode 100644 index 000000000..08610ab48 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/validation/saml/impl/ValidPrivateKeyValidator.java @@ -0,0 +1,29 @@ +package com.park.utmstack.validation.saml.impl; + +import com.park.utmstack.util.saml.PemUtils; +import com.park.utmstack.validation.saml.ValidPrivateKey; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +public class ValidPrivateKeyValidator implements ConstraintValidator { + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null || value.isBlank()) { + return false; + } + try { + PemUtils.parsePrivateKey(value); + return true; + } catch (Exception e) { + addConstraintViolation(context); + return false; + } + } + + private void addConstraintViolation(ConstraintValidatorContext context) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate("The file does not contain a valid PEM private key").addConstraintViolation(); + } +} diff --git a/backend/src/main/java/com/park/utmstack/web/rest/idp_provider/IdentityProviderConfigResource.java b/backend/src/main/java/com/park/utmstack/web/rest/idp_provider/IdentityProviderConfigResource.java new file mode 100644 index 000000000..c8ca58691 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/web/rest/idp_provider/IdentityProviderConfigResource.java @@ -0,0 +1,96 @@ +package com.park.utmstack.web.rest.idp_provider; + + +import com.park.utmstack.domain.idp_provider.enums.ProviderType; +import com.park.utmstack.service.dto.idp_provider.dto.*; +import com.park.utmstack.service.idp_provider.IdentityProviderService; +import com.park.utmstack.util.saml.PemUtils; +import com.park.utmstack.web.rest.util.PaginationUtil; +import io.swagger.v3.oas.annotations.Hidden; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.validation.Valid; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; + +@RestController +@RequestMapping("/api/identity-providers") +@RequiredArgsConstructor +@Hidden +public class IdentityProviderConfigResource { + + private final IdentityProviderService service; + private final IdentityProviderMapper mapper; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity create(@RequestParam String name, + @RequestParam String providerType, + @RequestParam String metadataUrl, + @RequestParam String spEntityId, + @RequestParam String spAcsUrl, + @RequestParam Boolean active, + @RequestPart("spPrivateKeyFile") MultipartFile privateKeyFile, + @RequestPart("spCertificateFile") MultipartFile certificateFile) { + + + PemUtils.validateFilesForCreate(privateKeyFile, certificateFile); + IdentityProviderCreateConfigDto dto = mapper.toCreateConfigDto(name, providerType, metadataUrl, active, privateKeyFile, certificateFile, spEntityId , spAcsUrl); + + IdentityProviderConfigResponseDto result = service.create(dto); + return ResponseEntity + .created(URI.create("/api/identity-providers/" + result.getId())) + .body(result); + } + + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable Long id, + @RequestParam String name, + @RequestParam String providerType, + @RequestParam String metadataUrl, + @RequestParam String spEntityId, + @RequestParam String spAcsUrl, + @RequestParam Boolean active, + @RequestPart(value = "spPrivateKeyFile", required = false) MultipartFile privateKeyFile, + @RequestPart(value = "spCertificateFile", required = false) MultipartFile certificateFile) { + + PemUtils.validateFilesForUpdate(privateKeyFile, certificateFile); + IdentityProviderCreateConfigDto dto = mapper.toCreateConfigDto(name, providerType, metadataUrl, active, privateKeyFile, certificateFile, spEntityId , spAcsUrl); + + IdentityProviderConfigResponseDto result = service.update(id, dto); + return ResponseEntity.ok(result); + } + + + @GetMapping + public ResponseEntity> getAll(IdentityProviderCriteria criteria, Pageable pageable) { + + Page page = service.findAll(criteria, pageable); + + HttpHeaders headers = PaginationUtil.generatePaginationHttpHeaders(page, "/api/utm-providers"); + return ResponseEntity.ok().headers(headers).body(page.getContent()); + } + + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable Long id) { + Optional dtoOpt = service.findById(id); + return dtoOpt.map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + service.delete(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/com/park/utmstack/web/rest/idp_provider/IdentityProviderResource.java b/backend/src/main/java/com/park/utmstack/web/rest/idp_provider/IdentityProviderResource.java new file mode 100644 index 000000000..aaf8aaee7 --- /dev/null +++ b/backend/src/main/java/com/park/utmstack/web/rest/idp_provider/IdentityProviderResource.java @@ -0,0 +1,38 @@ +package com.park.utmstack.web.rest.idp_provider; + + +import com.park.utmstack.domain.UtmDataInputStatus; +import com.park.utmstack.service.dto.idp_provider.dto.IdentityProviderConfigResponseDto; +import com.park.utmstack.service.dto.idp_provider.dto.IdentityProviderCriteria; +import com.park.utmstack.service.idp_provider.IdentityProviderService; +import com.park.utmstack.web.rest.util.PaginationUtil; +import io.swagger.v3.oas.annotations.Hidden; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/utm-providers") +@RequiredArgsConstructor +@Hidden +public class IdentityProviderResource { + + private final IdentityProviderService service; + + + @GetMapping + public ResponseEntity> getAll(IdentityProviderCriteria criteria, Pageable pageable) { + + Page page = service.findAll(criteria, pageable); + + HttpHeaders headers = PaginationUtil.generatePaginationHttpHeaders(page, "/api/utm-providers"); + return ResponseEntity.ok().headers(headers).body(page.getContent()); + } + + +} diff --git a/backend/src/main/resources/config/liquibase/changelog/20251203001-add-identity-provider-config.xml b/backend/src/main/resources/config/liquibase/changelog/20251203001-add-identity-provider-config.xml new file mode 100644 index 000000000..d45811bec --- /dev/null +++ b/backend/src/main/resources/config/liquibase/changelog/20251203001-add-identity-provider-config.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/config/liquibase/master.xml b/backend/src/main/resources/config/liquibase/master.xml index b118acd98..09d5f2d00 100644 --- a/backend/src/main/resources/config/liquibase/master.xml +++ b/backend/src/main/resources/config/liquibase/master.xml @@ -277,5 +277,7 @@ + + diff --git a/frontend/src/app/app-management/app-config/app-config.component.ts b/frontend/src/app/app-management/app-config/app-config.component.ts index 0ae970abe..d7cb17879 100644 --- a/frontend/src/app/app-management/app-config/app-config.component.ts +++ b/frontend/src/app/app-management/app-config/app-config.component.ts @@ -5,7 +5,7 @@ import {takeUntil} from 'rxjs/operators'; import {UtmToastService} from '../../shared/alert/utm-toast.service'; import {UtmConfigSectionService} from '../../shared/services/config/utm-config-section.service'; import {NetworkService} from '../../shared/services/network.service'; -import {VersionType, VersionTypeService} from '../../shared/services/util/version-type.service'; +import {VersionType, VersionInfoService} from '../../shared/services/version/version-info.service'; import {SectionConfigParamType} from '../../shared/types/configuration/section-config-param.type'; import {ApplicationConfigSectionEnum, SectionConfigType} from '../../shared/types/configuration/section-config.type'; @@ -29,7 +29,7 @@ export class AppConfigComponent implements OnInit, OnDestroy { private route: ActivatedRoute, private toastService: UtmToastService, private networkService: NetworkService, - private versionTypeService: VersionTypeService) { + private versionTypeService: VersionInfoService) { } ngOnInit() { diff --git a/frontend/src/app/app-management/app-management-routing.module.ts b/frontend/src/app/app-management/app-management-routing.module.ts index d498dae62..96c7c0800 100644 --- a/frontend/src/app/app-management/app-management-routing.module.ts +++ b/frontend/src/app/app-management/app-management-routing.module.ts @@ -17,6 +17,7 @@ import {RolloverConfigComponent} from './rollover-config/rollover-config.compone import {UtmApiDocComponent} from './utm-api-doc/utm-api-doc.component'; import {UtmNotificationViewComponent} from './utm-notification/components/notifications-view/utm-notification-view.component'; import {ApiKeysComponent} from "./api-keys/api-keys.component"; +import {IdentityProviderComponent} from "./identity-provider/identity-provider.component"; const routes: Routes = [ {path: '', redirectTo: 'settings', pathMatch: 'full'}, @@ -132,8 +133,16 @@ const routes: Routes = [ data: { authorities: [ADMIN_ROLE] }, + }, + { + path: 'providers', + component: IdentityProviderComponent, + canActivate: [UserRouteAccessService], + data: { + authorities: [ADMIN_ROLE] + }, } - ], + ], }, ]; diff --git a/frontend/src/app/app-management/app-management.module.ts b/frontend/src/app/app-management/app-management.module.ts index 5841a0f91..d56cca75b 100644 --- a/frontend/src/app/app-management/app-management.module.ts +++ b/frontend/src/app/app-management/app-management.module.ts @@ -11,6 +11,8 @@ import {ComplianceManagementModule} from '../compliance/compliance-management/co import {NavBehavior} from '../shared/behaviors/nav.behavior'; import {VersionUpdateBehavior} from '../shared/behaviors/version-update.behavior'; import {UtmSharedModule} from '../shared/utm-shared.module'; +import { ApiKeysComponent } from './api-keys/api-keys.component'; +import { ApiKeyModalComponent } from './api-keys/shared/components/api-key-modal/api-key-modal.component'; import {AppConfigComponent} from './app-config/app-config.component'; import {AppLogsComponent} from './app-logs/app-logs.component'; import {AppManagementRoutingModule} from './app-management-routing.module'; @@ -30,6 +32,9 @@ import {TokenActivateComponent} from './connection-key/token-activate/token-acti import {HealthChecksComponent} from './health-checks/health-checks.component'; import {HealthClusterComponent} from './health-checks/health-cluster/health-cluster.component'; import {HealthDetailComponent} from './health-checks/health-detail/health-detail.component'; +import { IdentityProviderComponent } from './identity-provider/identity-provider.component'; +import { ProviderFormComponent } from './identity-provider/shared/components/provider-form/provider-form.component'; +import { ProviderComponent } from './identity-provider/shared/components/provider/provider.component'; import {IndexDeleteComponent} from './index-management/index-delete/index-delete.component'; import {IndexManagementComponent} from './index-management/index-management.component'; import {IndexPatternDeleteComponent} from './index-pattern/index-pattern-delete/index-pattern-delete.component'; @@ -44,9 +49,8 @@ import {AppManagementSharedModule} from './shared/app-management-shared.module'; import {UtmApiDocComponent} from './utm-api-doc/utm-api-doc.component'; import { UtmNotificationViewComponent -} from "./utm-notification/components/notifications-view/utm-notification-view.component"; -import { ApiKeysComponent } from './api-keys/api-keys.component'; -import { ApiKeyModalComponent } from './api-keys/shared/components/api-key-modal/api-key-modal.component'; +} from './utm-notification/components/notifications-view/utm-notification-view.component'; +import { IdentityProviderModalComponent } from './identity-provider/shared/components/identity-provider-modal/identity-provider-modal.component'; @NgModule({ declarations: [ @@ -81,7 +85,12 @@ import { ApiKeyModalComponent } from './api-keys/shared/components/api-key-modal TokenActivateComponent, UtmHttpRequestsPreviewComponent, UtmServicesOverviewComponent, - UtmNotificationViewComponent], + UtmNotificationViewComponent, + IdentityProviderComponent, + ProviderComponent, + ProviderFormComponent, + IdentityProviderModalComponent + ], entryComponents: [ IndexPatternHelpComponent, IndexPatternDeleteComponent, @@ -89,7 +98,9 @@ import { ApiKeyModalComponent } from './api-keys/shared/components/api-key-modal MenuDeleteDialogComponent, TokenActivateComponent, ApiKeyModalComponent, - IndexDeleteComponent], + IndexDeleteComponent, + IdentityProviderModalComponent + ], imports: [ CommonModule, AppManagementRoutingModule, diff --git a/frontend/src/app/app-management/identity-provider/identity-provider.component.html b/frontend/src/app/app-management/identity-provider/identity-provider.component.html new file mode 100644 index 000000000..061893785 --- /dev/null +++ b/frontend/src/app/app-management/identity-provider/identity-provider.component.html @@ -0,0 +1,44 @@ +
+
+
+
+ Identity Providers +
+ + Configure OAuth 2.0 / OpenID Connect providers. + +
+ +
+ +
+ +
+ +
No providers configured
+

Add your first OAuth provider to enable SSO login

+ +
+ + +
+
+ + +
+
+ + + +
+
diff --git a/frontend/src/app/app-management/identity-provider/identity-provider.component.scss b/frontend/src/app/app-management/identity-provider/identity-provider.component.scss new file mode 100644 index 000000000..db7f73b3d --- /dev/null +++ b/frontend/src/app/app-management/identity-provider/identity-provider.component.scss @@ -0,0 +1,212 @@ +// Host container +:host { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + flex: 1 1 auto; +} + +// Modal styles +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1050; + animation: fadeIn 0.15s ease; +} + +.modal-container { + background: #ffffff; + border-radius: 0.25rem; + width: 90%; + max-width: 800px; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + animation: slideUp 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.modal-body { + overflow-y: auto; + padding: 1.25rem; + flex: 1; +} + +.form-section { + margin-bottom: 1.5rem; + + &:last-child { + margin-bottom: 0; + } + + .section-title { + font-size: 0.8125rem; + font-weight: 600; + color: #333; + text-transform: uppercase; + letter-spacing: 0.02em; + margin: 0 0 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid #e9ecef; + } +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } +} + +.form-group { + margin-bottom: 1rem; + + label { + display: block; + font-size: 0.8125rem; + font-weight: 600; + color: #333; + margin-bottom: 0.375rem; + } + + input, + select { + width: 100%; + height: 2.375rem; + padding: 0.375rem 0.75rem; + border: 1px solid #ddd; + border-radius: 0.1875rem; + font-size: 0.8125rem; + color: #333; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + + &:focus { + outline: none; + border-color: #2196f3; + box-shadow: 0 0 0 0.1rem rgba(33, 150, 243, 0.25); + } + } + + small { + display: block; + margin-top: 0.25rem; + font-size: 0.75rem; + color: #999; + } +} + +.checkbox-group { + label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + + input[type="checkbox"] { + width: 1.125rem; + height: 1.125rem; + cursor: pointer; + } + + span { + font-weight: 500; + font-size: 0.8125rem; + } + } +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding: 1rem 1.25rem; + border-top: 1px solid #e9ecef; + + button { + height: 2.375rem; + padding: 0 1rem; + border-radius: 0.1875rem; + font-weight: 600; + font-size: 0.8125rem; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + gap: 0.375rem; + border: 1px solid transparent; + + &:disabled { + opacity: 0.65; + cursor: not-allowed; + } + } + + .btn-secondary { + background: #fff; + border-color: #ddd; + color: #333; + + &:hover:not(:disabled) { + background: #f8f9fa; + border-color: #bbb; + } + } + + .btn-test { + background: #fff; + border-color: #ddd; + color: #333; + + &:hover:not(:disabled) { + background: #f8f9fa; + } + } + + .btn-primary { + background: #2196f3; + border-color: #2196f3; + color: #fff; + + &:hover:not(:disabled) { + background: #0c7cd5; + border-color: #0c7cd5; + } + } +} + +.spinner { + animation: spin 0.75s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/frontend/src/app/app-management/identity-provider/identity-provider.component.ts b/frontend/src/app/app-management/identity-provider/identity-provider.component.ts new file mode 100644 index 000000000..9079e1930 --- /dev/null +++ b/frontend/src/app/app-management/identity-provider/identity-provider.component.ts @@ -0,0 +1,107 @@ +import { Component, OnInit } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {UtmToastService} from '../../shared/alert/utm-toast.service'; +import { + ModalConfirmationComponent +} from '../../shared/components/utm/util/modal-confirmation/modal-confirmation.component'; +import { + IdentityProviderModalComponent +} from './shared/components/identity-provider-modal/identity-provider-modal.component'; +import {UtmIdentityProvider} from './shared/models/utm-identity-provider.model'; +import {UtmIdentityProviderService} from './shared/services/utm-identity-provider.service'; + +@Component({ + selector: 'app-identity-provider-config', + templateUrl: './identity-provider.component.html', + styleUrls: ['./identity-provider.component.scss'] +}) +export class IdentityProviderComponent implements OnInit { + providers: UtmIdentityProvider[] = []; + providerForm: FormGroup; + showModal = false; + editMode = false; + loading = false; + selectedProvider: UtmIdentityProvider | null = null; + + constructor( + private modalService: NgbModal, + private providerService: UtmIdentityProviderService, + private toast: UtmToastService) { + } + + ngOnInit(): void { + this.loadProviders(); + } + + loadProviders(): void { + this.loading = true; + this.providerService.query({page: 0, size: 10}).subscribe({ + next: (res) => { + this.providers = res.body || []; + this.loading = false; + }, + error: () => { + this.toast.showError('Error', 'An error occurred while loading providers'); + this.loading = false; + } + }); + } + + openModal(provider?: UtmIdentityProvider): void { + const modalRef = this.modalService.open(IdentityProviderModalComponent, { + size: 'lg', + centered: true, + }); + + modalRef.componentInstance.provider = provider; + modalRef.componentInstance.editMode = !!provider; + modalRef.componentInstance.providers = this.providers.length ? this.providers : []; + + modalRef.result.then( + (result) => { + if (result) { + this.loadProviders(); + } + }, + () => { + // Modal dismissed + } + ); + } + + deleteProvider(provider: UtmIdentityProvider): void { + const deleteModalRef = this.modalService.open(ModalConfirmationComponent, {centered: true}); + + deleteModalRef.componentInstance.header = 'Confirm delete operation'; + deleteModalRef.componentInstance.message = 'Are you sure that you want to delete the provider: ' + provider.providerType; + deleteModalRef.componentInstance.confirmBtnText = 'Delete'; + deleteModalRef.componentInstance.confirmBtnIcon = 'icon-database-remove'; + deleteModalRef.componentInstance.confirmBtnType = 'delete'; + deleteModalRef.result.then(() => { + + this.providerService.delete(provider.id).subscribe({ + next: () => { + this.toast.showSuccessProcess('Success', 'Provider deleted successfully'); + this.loadProviders(); + }, + error: () => { + this.toast.showError( 'Error', 'An error occurred while deleting the provider'); + } + }); + + }); + } + + toggleActive(provider: UtmIdentityProvider): void { + this.providerService.update(provider.id, provider, null, null).subscribe({ + next: () => { + this.toast.showSuccess(`Provider ${provider.active ? 'activated' : 'deactivated'}`); + this.loadProviders(); + }, + error: () => { + this.toast.showError( 'Error', 'An error occurred while updating provider status'); + } + }); + } +} diff --git a/frontend/src/app/app-management/identity-provider/shared/components/identity-provider-modal/identity-provider-modal.component.html b/frontend/src/app/app-management/identity-provider/shared/components/identity-provider-modal/identity-provider-modal.component.html new file mode 100644 index 000000000..5f23050c5 --- /dev/null +++ b/frontend/src/app/app-management/identity-provider/shared/components/identity-provider-modal/identity-provider-modal.component.html @@ -0,0 +1,45 @@ + + + + + + + + + diff --git a/frontend/src/app/app-management/identity-provider/shared/components/identity-provider-modal/identity-provider-modal.component.scss b/frontend/src/app/app-management/identity-provider/shared/components/identity-provider-modal/identity-provider-modal.component.scss new file mode 100644 index 000000000..904453688 --- /dev/null +++ b/frontend/src/app/app-management/identity-provider/shared/components/identity-provider-modal/identity-provider-modal.component.scss @@ -0,0 +1,73 @@ +:host { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + flex: 1 1 auto; +} + +::ng-deep .modal-content { + display: flex; + flex-direction: column; + max-height: 90vh; + overflow: hidden; +} + +::ng-deep app-utm-modal-header { + flex-shrink: 0; + position: sticky; + top: 0; + z-index: 10; + background: white; +} + + +.modal-body { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 1.5rem; + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: #cbd5e0; + border-radius: 4px; + + &:hover { + background: #a0aec0; + } + } +} + +// Footer fijo +.modal-footer { + flex-shrink: 0; + position: sticky; + bottom: 0; + z-index: 10; + background: white; + border-top: 1px solid #e2e8f0; + padding: 1rem 1.5rem; + display: flex; + justify-content: flex-end; + gap: 0.5rem; + + // Sombra sutil para separación visual + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05); +} + +.spinner { + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} diff --git a/frontend/src/app/app-management/identity-provider/shared/components/identity-provider-modal/identity-provider-modal.component.ts b/frontend/src/app/app-management/identity-provider/shared/components/identity-provider-modal/identity-provider-modal.component.ts new file mode 100644 index 000000000..f6e401526 --- /dev/null +++ b/frontend/src/app/app-management/identity-provider/shared/components/identity-provider-modal/identity-provider-modal.component.ts @@ -0,0 +1,108 @@ +import {HttpErrorResponse} from "@angular/common/http"; +import {Component, Input, OnInit, ViewChild} from '@angular/core'; +import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import {UtmToastService} from '../../../../../shared/alert/utm-toast.service'; +import {UtmIdentityProvider} from '../../models/utm-identity-provider.model'; +import {UtmIdentityProviderService} from '../../services/utm-identity-provider.service'; +import {ProviderFormComponent} from '../provider-form/provider-form.component'; + +@Component({ + selector: 'app-identity-provider-modal', + templateUrl: './identity-provider-modal.component.html', + styleUrls: ['./identity-provider-modal.component.scss'] +}) +export class IdentityProviderModalComponent implements OnInit { + @Input() provider?: UtmIdentityProvider; + @Input() editMode = false; + @Input() providers: UtmIdentityProvider[] = []; + + @ViewChild('providerForm') providerFormComponent!: ProviderFormComponent; + + selectedProvider?: UtmIdentityProvider; + loading = false; + testingConnection = false; + + constructor(public activeModal: NgbActiveModal, + private providerService: UtmIdentityProviderService, + private toastService: UtmToastService + ) {} + + ngOnInit(): void { + this.selectedProvider = this.provider; + } + + saveProvider(): void { + + if (!this.providerFormComponent.providerForm.valid) { + this.markFormGroupTouched(this.providerFormComponent.providerForm); + return; + } + + this.loading = true; + + const formValue = this.providerFormComponent.providerForm.value; + + const request = this.editMode && this.provider.id + ? this.providerService.update(this.provider.id, formValue, + this.providerFormComponent.privateKeyFile, this.providerFormComponent.certificateFile) + : this.providerService.create(formValue, this.providerFormComponent.privateKeyFile, this.providerFormComponent.certificateFile); + + request.subscribe({ + next: () => { + this.loading = false; + this.activeModal.close(true); + this.toastService.showSuccessProcess('Success', `Provider ${this.editMode ? 'updated' : 'created'} successfully.`); + }, + error: (error: HttpErrorResponse) => { + this.loading = false; + + if (error.status === 400) { + this.toastService.showError('Validation Error', 'Please check the form for errors and try again.'); + } else { + this.toastService.showError('Error', + `An error occurred while ${this.editMode ? 'updating' : 'creating'} the provider`); + } + } + }); + } + + testConnection(): void { + if (this.providerFormComponent.providerForm && !this.providerFormComponent.providerForm.valid) { + this.markFormGroupTouched(this.providerFormComponent.providerForm); + return; + } + + this.testingConnection = true; + + this.providerService.testConnection(this.providerFormComponent.providerForm.value).subscribe({ + next: () => { + this.testingConnection = false; + this.toastService.showSuccessProcess('Success', 'Connection test succeeded.'); + }, + error: (error) => { + this.testingConnection = false; + this.toastService.showError('Error', `Connection test failed`); + } + }); + } + + closeModal(): void { + this.activeModal.dismiss(); + } + + private markFormGroupTouched(formGroup: any): void { + Object.keys(formGroup.controls).forEach(key => { + const control = formGroup.get(key); + control.markAsTouched(); + + if (control.controls) { + this.markFormGroupTouched(control); + } + }); + } + + onChangePrivateCertificateFile($event: { file: File; name: string }): void { + this.providerFormComponent.certificateFile = $event.file; + this.providerFormComponent.certificateFileName = $event.name; + } +} diff --git a/frontend/src/app/app-management/identity-provider/shared/components/provider-form/provider-form.component.html b/frontend/src/app/app-management/identity-provider/shared/components/provider-form/provider-form.component.html new file mode 100644 index 000000000..0978f3b0d --- /dev/null +++ b/frontend/src/app/app-management/identity-provider/shared/components/provider-form/provider-form.component.html @@ -0,0 +1,360 @@ +
+ +
+
+ + Basic Information +
+ +
+
+
+ + +
+ Provider name is required (minimum 3 characters) +
+
+
+ +
+
+ +
+
+
+ + + +
+ +
+ + Provider type cannot be changed after creation + + +
+ + +
+
+ +
+
+ + +
+
+ + SAML 2.0 Metadata +
+ +
+ + + IdP Metadata Configuration + +

+ The metadata URL should point to your Identity Provider's SAML metadata endpoint. + This will be automatically parsed to extract signing certificates and endpoints. +

+
+ +
+
+
+ + + + Examples: + + + • Okta: https://your-org.okta.com/app/exk123456/sso/saml/metadata + + + • Keycloak: https://your-keycloak.com/realms/REALM/protocol/saml/descriptor + +
+ Valid HTTPS URL is required +
+
+
+
+
+ + +
+
+ + Service Provider Certificates +
+ +
+ + + Generate RSA Key Pair + +

+ If you don't have certificates, generate them using OpenSSL: +

+ + openssl genrsa -out private.pem 2048
+ openssl req -new -x509 -key private.pem -out certificate.pem -days 365 +
+
+ +
+
+
+ + +
+ + Private key is already configured. Upload a new one only if you want to replace it. +
+ +
+ + +
+ Private key file is required +
+
+ + + + {{ privateKeyFileName }} + + + + + Select your RSA private key file in PEM format (.pem, .key). Keep this secure and never share it. + +
+
+ +
+
+ + +
+ + +
+ Certificate file is required +
+
+ + + + {{ certificateFileName }} selected + + + + + + Select your public certificate file in PEM format (.pem, .crt, .cer). This will be shared with your IdP. + +
+
+
+
+ + +
+
+ + Service Provider Configuration (Setup in Your IdP) +
+ +
+ + + Required: Configure These Values in Your IdP + +

+ Copy and paste these values into your SAML Identity Provider configuration +

+
+ + +
+
+
+ + 1. Entity ID (SP Entity ID / Audience URI) +
+

+ This is the unique identifier of our Service Provider. Configure it as the Entity ID / Audience URI in your IdP. +

+
+ +
+ +
+
+
+
+ + +
+
+
+ + 2. Assertion Consumer Service (ACS) URL +
+

+ This is the endpoint where we receive the SAML response from your IdP. Configure it as the Assertion Consumer Service URL. +

+
+ +
+ +
+
+
+
+
+ + +
+
+ + Required Roles for Authentication +
+ +
+ + + User Access Requirements + +

+ Users must have at least one of these roles in your Identity Provider to access this panel: +

+
+ +
+
+
+
+ + ROLE_USER + Basic access to the panel +
+
+ + ROLE_ADMIN + Full administrative access +
+
+ + + Ensure your Identity Provider includes these roles in the SAML assertion for users to authenticate successfully. + +
+
+
+ + +
+
+ + Status +
+ +
+
+
+
+ + +
+ + Users will only be able to authenticate with this provider if it's enabled + +
+
+
+
+
diff --git a/frontend/src/app/app-management/identity-provider/shared/components/provider-form/provider-form.component.scss b/frontend/src/app/app-management/identity-provider/shared/components/provider-form/provider-form.component.scss new file mode 100644 index 000000000..c02e9ce56 --- /dev/null +++ b/frontend/src/app/app-management/identity-provider/shared/components/provider-form/provider-form.component.scss @@ -0,0 +1,46 @@ +.provider-form { + .form-section { + margin-bottom: 2rem; + padding-bottom: 2rem; + border-bottom: 1px solid #e0e0e0; + + &:last-of-type { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; + } + + .section-title { + font-size: 0.9375rem; + font-weight: 600; + color: #333; + margin-bottom: 1.25rem; + display: flex; + align-items: center; + + i { + margin-right: 0.5rem; + color: #2196F3; + } + } + } + + .form-label.required::after { + content: " *"; + color: #f44336; + } + + .form-actions { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid #e0e0e0; + + .spinner { + animation: spin 1s linear infinite; + } + } +} + +@keyframes spin { + to { transform: rotate(360deg); } +} diff --git a/frontend/src/app/app-management/identity-provider/shared/components/provider-form/provider-form.component.ts b/frontend/src/app/app-management/identity-provider/shared/components/provider-form/provider-form.component.ts new file mode 100644 index 000000000..be481e73a --- /dev/null +++ b/frontend/src/app/app-management/identity-provider/shared/components/provider-form/provider-form.component.ts @@ -0,0 +1,157 @@ +import {HttpClient} from "@angular/common/http"; +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { ProviderType, UtmIdentityProvider } from '../../models/utm-identity-provider.model'; +import {validateMetadataUrl} from '../../validators/validator'; + +@Component({ + selector: 'app-provider-form', + templateUrl: './provider-form.component.html', + styleUrls: ['./provider-form.component.scss'] +}) +export class ProviderFormComponent implements OnInit { + @Input() provider?: UtmIdentityProvider; + @Input() loading = false; + @Input() testingConnection = false; + @Input() providers: UtmIdentityProvider[] = []; + + @Output() save = new EventEmitter(); + @Output() test = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + editMode = false; + providerForm!: FormGroup; + privateKeyFile?: File; + certificateFile?: File; + privateKeyFileName = ''; + certificateFileName = ''; + + providerTypes: { label: string; value: ProviderType }[] = []; + + spEntityId = ''; + spAcsUrl = ''; + + constructor(private fb: FormBuilder, + private http: HttpClient) {} + + ngOnInit(): void { + this.editMode = !!this.provider; + this.providerTypes = Object.values(ProviderType) + .filter(type => !this.providers.some(p => p.providerType === type)) + .map((value) => ({ + label: value.charAt(0) + value.slice(1).toLowerCase(), + value + })); + this.initForm(); + + if (this.editMode && this.provider) { + const { spPrivateKeyPem, ...providerData } = this.provider; + this.providerForm.patchValue(providerData); + this.makePrivateKeyOptional(); + } + this.generateSpIdentifiers(); + + this.providerForm.get('providerType').valueChanges.subscribe(() => { + this.generateSpIdentifiers(); + }); + } + + initForm(): void { + this.providerForm = this.fb.group({ + name: ['', [Validators.required, Validators.minLength(3)]], + providerType: [ProviderType.GOOGLE, Validators.required], + metadataUrl: ['', [Validators.required, Validators.pattern(/^https?:\/\/.+/)], [validateMetadataUrl(this.http)]], + active: [true], + spEntityId: [''], + spAcsUrl: [''] + }); + } + + private makePrivateKeyOptional(): void { + this.providerForm.setControl('spPrivateKeyPem', this.fb.control('')); + } + + private generateSpIdentifiers(): void { + const origin = window.location.origin; + const provider = this.providerForm.get('providerType') ? this.providerForm.get('providerType').value : ProviderType.GOOGLE; + this.spEntityId = `${origin}/saml/sp`; + this.spAcsUrl = `${origin}/login/saml2/sso/${provider.toLowerCase()}`; + + this.providerForm.patchValue({ + spEntityId: this.spEntityId, + spAcsUrl: this.spAcsUrl + }); + } + + onPrivateKeySelected(event: Event): void { + const input = event.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + this.privateKeyFile = input.files[0]; + this.privateKeyFileName = this.privateKeyFile.name; + } + } + + onCertificateSelected(event: Event): void { + const input = event.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + this.certificateFile = input.files[0]; + this.certificateFileName = this.certificateFile.name; + } + } + + clearPrivateKeyFile(): void { + this.privateKeyFile = undefined; + this.privateKeyFileName = ''; + } + + clearCertificateFile(): void { + this.certificateFile = undefined; + this.certificateFileName = ''; + } + + /*saveProvider(): void { + if (!this.providerForm.valid) { + return; + } + + // En creación, ambos archivos son requeridos + if (!this.editMode && (!this.privateKeyFile || !this.certificateFile)) { + console.error('Both private key and certificate files are required'); + return; + } + + // En edición, al menos uno debe estar presente si se van a actualizar + if (this.editMode && !this.privateKeyFile && !this.certificateFile) { + // Si no hay archivos nuevos, solo enviar datos del formulario + const formValue: UtmIdentityProvider = this.providerForm.value; + this.save.emit(this.convertToFormData(formValue, null, null)); + return; + } + + + this.save.emit(formData); + }*/ + + testConnection(): void { + this.test.emit(); + } + + cancelForm(): void { + this.cancel.emit(); + } + + copyToClipboard(label: string, value: string): void { + if (!value) { + return; + } + + (navigator as any).clipboard.writeText(value).then( + () => { + console.log(`${label} copied to clipboard`); + }, + (err) => { + console.error('Error copying to clipboard:', err); + } + ); + } +} diff --git a/frontend/src/app/app-management/identity-provider/shared/components/provider/provider.component.html b/frontend/src/app/app-management/identity-provider/shared/components/provider/provider.component.html new file mode 100644 index 000000000..dba4d80f4 --- /dev/null +++ b/frontend/src/app/app-management/identity-provider/shared/components/provider/provider.component.html @@ -0,0 +1,76 @@ +
+ +
+
+
+ +
+
+
{{ provider.name }}
+ {{ provider.providerType }} +
+
+ +
+ + +
+
+ + +
+
+
+ +
+
+ Metadata URL +
+ {{ provider.metadataUrl }} + +
+
+
+ +
+
+ +
+
+ Client Secret +
+ {{ provider.clientSecret | slice:0:40 }}... +
+
+
+
+ + + +
diff --git a/frontend/src/app/app-management/identity-provider/shared/components/provider/provider.component.scss b/frontend/src/app/app-management/identity-provider/shared/components/provider/provider.component.scss new file mode 100644 index 000000000..07a730fa0 --- /dev/null +++ b/frontend/src/app/app-management/identity-provider/shared/components/provider/provider.component.scss @@ -0,0 +1,348 @@ +.provider-card { + background: #ffffff; + border-radius: 0.5rem; + overflow: hidden; + transition: all 0.3s ease; + border: 1px solid #e9ecef; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + position: relative; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 4px; + height: 100%; + background: #dee2e6; + transition: background 0.3s ease; + } + + &.provider-active::before { + background: linear-gradient(180deg, #26c281 0%, #1abc9c 100%); + } + + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + transform: translateY(-2px); + } +} + +// Header +.provider-header { + padding: 1.25rem 1.5rem; + background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%); + border-bottom: 1px solid #e9ecef; + display: flex; + justify-content: space-between; + align-items: center; +} + +.provider-brand { + display: flex; + align-items: center; + gap: 1rem; +} + +.provider-icon-wrapper { + width: 3rem; + height: 3rem; + border-radius: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + color: #fff; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + box-shadow: 0 4px 8px rgba(102, 126, 234, 0.25); + + &.icon-wrapper-google { + background: linear-gradient(135deg, #4285f4 0%, #34a853 100%); + } + + &.icon-wrapper-microsoft { + background: linear-gradient(135deg, #0078d4 0%, #00bcf2 100%); + } + + &.icon-wrapper-github { + background: linear-gradient(135deg, #24292e 0%, #6e5494 100%); + } + + &.icon-wrapper-gitlab { + background: linear-gradient(135deg, #fc6d26 0%, #fca326 100%); + } +} + +.provider-meta { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.provider-name { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: #2c3e50; + line-height: 1.2; +} + +.provider-type-badge { + display: inline-block; + padding: 0.125rem 0.5rem; + font-size: 0.6875rem; + font-weight: 600; + color: #667eea; + background: rgba(102, 126, 234, 0.1); + border-radius: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.provider-actions { + display: flex; + align-items: center; + gap: 1rem; +} + +// Toggle Switch +.status-toggle { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.switch { + position: relative; + display: inline-block; + width: 2.5rem; + height: 1.25rem; + + input { + opacity: 0; + width: 0; + height: 0; + + &:checked + .slider { + background: linear-gradient(135deg, #26c281 0%, #1abc9c 100%); + + &::before { + transform: translateX(1.25rem); + } + } + + &:focus + .slider { + box-shadow: 0 0 0 0.2rem rgba(38, 194, 129, 0.25); + } + } +} + +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: #cbd5e0; + transition: 0.3s; + border-radius: 1.25rem; + + &::before { + position: absolute; + content: ""; + height: 1rem; + width: 1rem; + left: 0.125rem; + bottom: 0.125rem; + background-color: white; + transition: 0.3s; + border-radius: 50%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + } +} + +.status-label { + font-size: 0.75rem; + font-weight: 500; + color: #718096; +} + +.btn-icon { + width: 2rem; + height: 2rem; + padding: 0; + border: 1px solid #e2e8f0; + background: #fff; + border-radius: 0.375rem; + color: #718096; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: #f7fafc; + color: #2d3748; + border-color: #cbd5e0; + } +} + +// Body +.provider-body { + padding: 1.25rem 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.provider-detail { + display: flex; + align-items: flex-start; + gap: 0.75rem; +} + +.detail-icon { + width: 2rem; + height: 2rem; + border-radius: 0.375rem; + background: #f7fafc; + display: flex; + align-items: center; + justify-content: center; + color: #718096; + font-size: 1rem; + flex-shrink: 0; +} + +.detail-content { + flex: 1; + min-width: 0; +} + +.detail-label { + display: block; + font-size: 0.6875rem; + font-weight: 600; + color: #a0aec0; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.25rem; +} + +.detail-value { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + color: #2d3748; + + code { + background: #f7fafc; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + color: #667eea; + border: 1px solid #e2e8f0; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .text-truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.btn-copy { + padding: 0.25rem 0.5rem; + border: 1px solid #e2e8f0; + background: #fff; + border-radius: 0.25rem; + color: #718096; + cursor: pointer; + transition: all 0.15s ease; + flex-shrink: 0; + + &:hover { + background: #f7fafc; + color: #667eea; + border-color: #cbd5e0; + } +} + +.badge-outline { + padding: 0.25rem 0.625rem; + font-size: 0.75rem; + font-weight: 500; + border: 1px solid #e2e8f0; + background: #fff; + color: #718096; + border-radius: 0.375rem; +} + +// Footer +.provider-footer { + padding: 0.75rem 1.5rem; + background: #f7fafc; + border-top: 1px solid #e9ecef; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.75rem; +} + +.status-indicator { + display: flex; + align-items: center; + gap: 0.375rem; + color: #a0aec0; + font-weight: 500; + + i { + font-size: 0.5rem; + } + + &.status-active { + color: #26c281; + + i { + color: #26c281; + } + } +} + +.last-updated { + color: #a0aec0; +} + +.btn-delete { + width: 2rem; + height: 2rem; + padding: 0; + border: 1px solid #fee; + background: #fff; + border-radius: 0.375rem; + color: #e53e3e; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.875rem; + + &:hover { + background: #fff5f5; + border-color: #fc8181; + color: #c53030; + } + + &:active { + transform: scale(0.95); + } +} diff --git a/frontend/src/app/app-management/identity-provider/shared/components/provider/provider.component.ts b/frontend/src/app/app-management/identity-provider/shared/components/provider/provider.component.ts new file mode 100644 index 000000000..038c374a3 --- /dev/null +++ b/frontend/src/app/app-management/identity-provider/shared/components/provider/provider.component.ts @@ -0,0 +1,48 @@ +import {Component, EventEmitter, Input, Output} from '@angular/core'; +import {PROVIDER_ICONS, ProviderType, UtmIdentityProvider} from '../../models/utm-identity-provider.model'; + +@Component({ + selector: 'app-provider-card', + templateUrl: './provider.component.html', + styleUrls: ['./provider.component.scss'] +}) +export class ProviderComponent { + @Input() provider: UtmIdentityProvider; + @Output() edit = new EventEmitter(); + @Output() delete = new EventEmitter(); + @Output() toggleStatus = new EventEmitter(); + + getProviderIcon(type: ProviderType): string { + return PROVIDER_ICONS[type] || 'icon-key'; + } + + openModal(provider: UtmIdentityProvider): void { + this.edit.emit(provider); + } + + deleteProvider(provider: UtmIdentityProvider): void { + this.delete.emit(provider); + } + + toggleActive(provider: UtmIdentityProvider): void { + this.toggleStatus.emit({ + ...provider, + active: !provider.active + }); + } + + copyToClipboard(label: string, value: string): void { + if (!value) { + return; + } + + (navigator as any).clipboard.writeText(value).then( + () => { + console.log(`${label} copied to clipboard`); + }, + (err) => { + console.error('Error copying to clipboard:', err); + } + ); + } +} diff --git a/frontend/src/app/app-management/identity-provider/shared/models/utm-identity-provider.model.ts b/frontend/src/app/app-management/identity-provider/shared/models/utm-identity-provider.model.ts new file mode 100644 index 000000000..a368b781d --- /dev/null +++ b/frontend/src/app/app-management/identity-provider/shared/models/utm-identity-provider.model.ts @@ -0,0 +1,30 @@ +export enum ProviderType { + GOOGLE = 'GOOGLE', + MICROSOFT = 'MICROSOFT', + OKTA = 'OKTA', + KEYCLOAK = 'KEYCLOAK', + GENERIC = 'GENERIC' +} + +export const PROVIDER_ICONS: Record = { + [ProviderType.GOOGLE]: 'fa fa-brands fa-google', + [ProviderType.MICROSOFT]: 'fa fa-brands fa-microsoft', + [ProviderType.OKTA]: 'fa fa-solid fa-shield', + [ProviderType.KEYCLOAK]: 'fa fa-solid fa-key', + [ProviderType.GENERIC]: 'fa fa-solid fa-lock' +}; + +export interface UtmIdentityProvider { + id?: number; + name: string; + spIdentityId: string; + spAcsUrl: string; + providerType: ProviderType; + metadataUrl: string; + spPrivateKeyPem: string; + spCertificatePem: string; + active: boolean; + createdAt?: Date; + updatedAt?: Date; +} + diff --git a/frontend/src/app/app-management/identity-provider/shared/services/utm-identity-provider.service.ts b/frontend/src/app/app-management/identity-provider/shared/services/utm-identity-provider.service.ts new file mode 100644 index 000000000..8f59a691a --- /dev/null +++ b/frontend/src/app/app-management/identity-provider/shared/services/utm-identity-provider.service.ts @@ -0,0 +1,66 @@ +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import {SERVER_API_URL} from '../../../../app.constants'; +import {createRequestOption} from '../../../../shared/util/request-util'; +import { UtmIdentityProvider } from '../models/utm-identity-provider.model'; + +@Injectable({ + providedIn: 'root' +}) +export class UtmIdentityProviderService { + private resourceUrl = SERVER_API_URL + 'api/identity-providers'; + + constructor(private http: HttpClient) {} + + create(provider: UtmIdentityProvider, privateKey?: File | null, certificate?: File | null) + : Observable> { + + const formData = this.convertToFormData(provider, privateKey, certificate); + return this.http.post(this.resourceUrl, formData, { observe: 'response' }); + } + + update(id: number, provider: UtmIdentityProvider, privateKey?: File | null, certificate?: File | null): + Observable> { + + const formData = this.convertToFormData(provider, privateKey, certificate); + return this.http.put(`${this.resourceUrl}/${id}`, formData, { observe: 'response' }); + } + + find(id: number): Observable> { + return this.http.get(`${this.resourceUrl}/${id}`, { observe: 'response' }); + } + + query(request: any): Observable> { + const params = createRequestOption(request); + return this.http.get(this.resourceUrl, { params, observe: 'response' }); + } + + delete(id: number): Observable> { + return this.http.delete(`${this.resourceUrl}/${id}`, { observe: 'response' }); + } + + testConnection(provider: UtmIdentityProvider): Observable> { + return this.http.post<{ success: boolean; message: string }>(`${this.resourceUrl}/test`, provider, { observe: 'response' }); + } + + private convertToFormData(data: any, privateKey?: File | null, certificate?: File | null): FormData { + const formData = new FormData(); + + formData.append('name', data.name); + formData.append('spEntityId', data.spEntityId); + formData.append('spAcsUrl', data.spAcsUrl); + formData.append('providerType', data.providerType); + formData.append('metadataUrl', data.metadataUrl); + formData.append('active', data.active.toString()); + + if (privateKey) { + formData.append('spPrivateKeyFile', privateKey); + } + if (certificate) { + formData.append('spCertificateFile', certificate); + } + + return formData; + } +} diff --git a/frontend/src/app/app-management/identity-provider/shared/validators/validator.ts b/frontend/src/app/app-management/identity-provider/shared/validators/validator.ts new file mode 100644 index 000000000..d6a81acc2 --- /dev/null +++ b/frontend/src/app/app-management/identity-provider/shared/validators/validator.ts @@ -0,0 +1,31 @@ +import { HttpClient } from '@angular/common/http'; +import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms'; +import { Observable, of } from 'rxjs'; +import { catchError, debounceTime, first, map } from 'rxjs/operators'; + +export function validateMetadataUrl(http: HttpClient): AsyncValidatorFn { + return (control: AbstractControl): Observable => { + if (!control.value) { + return of(null); + } + + return validateFileUrl(control.value, http).pipe( + debounceTime(500), + map(() => null), + catchError(() => of({ invalidMetadataUrl: true })), + first() + ); + }; +} + +export function validateFileUrl(metadataUrl: string, http: HttpClient): Observable { + return http.get(metadataUrl, { responseType: 'text' }).pipe( + map((response: string) => { + if (!response.includes('EntityDescriptor') && !response.includes('SPSSODescriptor')) { + throw new Error('Invalid SAML metadata'); + } + return response; + }) + ); +} + diff --git a/frontend/src/app/app-management/layout/app-management-sidebar/app-management-sidebar.component.html b/frontend/src/app/app-management/layout/app-management-sidebar/app-management-sidebar.component.html index a67af0739..63b1219de 100644 --- a/frontend/src/app/app-management/layout/app-management-sidebar/app-management-sidebar.component.html +++ b/frontend/src/app/app-management/layout/app-management-sidebar/app-management-sidebar.component.html @@ -135,6 +135,33 @@ + + + + +   + Identity Providers + + + + + + +   + Identity Providers + + + + + + + + = new Subject(); + + version: VersionType; constructor(public router: Router, - private licenceChangeBehavior: LicenceChangeBehavior, + public modalVersionInfoService: ModalVersionInfoService, private checkLicenseService: CheckLicenseService, private spinner: NgxSpinnerService) { } ngOnInit() { this.inSass = isSubdomainOfUtmstack(); - /*if (!this.inSass) { - this.updateView(); - this.licenceChangeBehavior.$licenceChange.subscribe(licenceChange => { - if (licenceChange) { - this.updateView(); - } - }); - }*/ } private updateView(): void { @@ -59,4 +54,9 @@ export class AppManagementSidebarComponent implements OnInit { this.spinner.hide('licenseSpinner'); }); } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } } diff --git a/frontend/src/app/app-management/menu/menu.component.ts b/frontend/src/app/app-management/menu/menu.component.ts index 3a8b3471b..fab2dc722 100644 --- a/frontend/src/app/app-management/menu/menu.component.ts +++ b/frontend/src/app/app-management/menu/menu.component.ts @@ -5,7 +5,7 @@ import {UtmToastService} from '../../shared/alert/utm-toast.service'; import {NavBehavior} from '../../shared/behaviors/nav.behavior'; import {MenuCreateComponent} from '../../shared/components/utm/util/menu-create/menu-create.component'; import {MenuService} from '../../shared/services/menu/menu.service'; -import {CheckForUpdatesService} from '../../shared/services/updates/check-for-updates.service'; +import {AppVersionService} from '../../shared/services/version/app-version.service'; import {IMenu, Menu} from '../../shared/types/menu/menu.model'; import {QueryType} from '../../shared/types/query-type'; import {MenuDeleteDialogComponent} from './menu-delete/menu-delete-dialog.component'; @@ -38,7 +38,7 @@ export class MenuComponent implements OnInit { constructor(private menuService: MenuService, private modalService: NgbModal, - private checkForUpdatesService: CheckForUpdatesService, + private checkForUpdatesService: AppVersionService, private utmToastService: UtmToastService, private navBehavior: NavBehavior) { } diff --git a/frontend/src/app/app-module/module-integration/module-integration.component.ts b/frontend/src/app/app-module/module-integration/module-integration.component.ts index 300293dcb..65687606a 100644 --- a/frontend/src/app/app-module/module-integration/module-integration.component.ts +++ b/frontend/src/app/app-module/module-integration/module-integration.component.ts @@ -1,5 +1,5 @@ import {Component, Input, OnInit} from '@angular/core'; -import {CheckForUpdatesService} from '../../shared/services/updates/check-for-updates.service'; +import {AppVersionService} from '../../shared/services/version/app-version.service'; import {UtmModulesEnum} from '../shared/enum/utm-module.enum'; import {UtmModuleType} from '../shared/type/utm-module.type'; @@ -14,7 +14,7 @@ export class ModuleIntegrationComponent implements OnInit { moduleEnum = UtmModulesEnum; currentVersion: string; - constructor(private checkForUpdatesService: CheckForUpdatesService) { + constructor(private checkForUpdatesService: AppVersionService) { } ngOnInit() { diff --git a/frontend/src/app/app-module/shared/components/app-module-card/app-module-card.component.ts b/frontend/src/app/app-module/shared/components/app-module-card/app-module-card.component.ts index e6de8014a..b42e81947 100644 --- a/frontend/src/app/app-module/shared/components/app-module-card/app-module-card.component.ts +++ b/frontend/src/app/app-module/shared/components/app-module-card/app-module-card.component.ts @@ -5,7 +5,7 @@ import {takeUntil} from 'rxjs/operators'; import { ModalConfirmationComponent } from '../../../../shared/components/utm/util/modal-confirmation/modal-confirmation.component'; -import {VersionType, VersionTypeService} from '../../../../shared/services/util/version-type.service'; +import {VersionType, VersionInfoService} from '../../../../shared/services/version/version-info.service'; import {ModulesEnterprise} from '../../../services/module.service'; import {UtmModulesEnum} from '../../enum/utm-module.enum'; import {UtmModuleType} from '../../type/utm-module.type'; @@ -18,7 +18,7 @@ import {UtmModuleType} from '../../type/utm-module.type'; }) export class AppModuleCardComponent implements OnInit, OnDestroy { - constructor(private versionTypeService: VersionTypeService, + constructor(private versionTypeService: VersionInfoService, private modalService: NgbModal) { } @Input() module: UtmModuleType; diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 43055d930..fe002398c 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -111,7 +111,7 @@ -
+
diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index e40c65a93..e97a0e2db 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -1,7 +1,7 @@ -import {Component, ElementRef, HostListener, OnInit, Renderer2} from '@angular/core'; +import {Component, HostListener, OnInit, Renderer2} from '@angular/core'; import {Router} from '@angular/router'; import {TranslateService} from '@ngx-translate/core'; -import {delay, distinctUntilChanged, filter, tap} from 'rxjs/operators'; +import {catchError, delay, distinctUntilChanged, filter, map, takeUntil, tap} from 'rxjs/operators'; import {AccountService} from './core/auth/account.service'; import {ApiServiceCheckerService} from './core/auth/api-checker-service'; import {MenuBehavior} from './shared/behaviors/menu.behavior'; @@ -9,6 +9,10 @@ import {ThemeChangeBehavior} from './shared/behaviors/theme-change.behavior'; import {ADMIN_ROLE, USER_ROLE} from './shared/constants/global.constant'; import {AppThemeLocationEnum} from './shared/enums/app-theme-location.enum'; import {UtmAppThemeService} from './shared/services/theme/utm-app-theme.service'; +import {AppVersionInfo} from "./shared/types/updates/updates.type"; +import {VersionType, VersionInfoService} from "./shared/services/version/version-info.service"; +import {EMPTY} from "rxjs"; +import {AppVersionService} from './shared/services/version/app-version.service'; @Component({ selector: 'app-root', @@ -24,6 +28,7 @@ export class AppComponent implements OnInit { online = false; iframeView = false; favIcon: HTMLLinkElement; + appLoading: HTMLElement; hideStatus = false; isAuth = false; viewportHeight: number; @@ -35,7 +40,9 @@ export class AppComponent implements OnInit { private utmAppThemeService: UtmAppThemeService, private router: Router, private renderer: Renderer2, private apiServiceCheckerService: ApiServiceCheckerService, - private accountService: AccountService) { + private accountService: AccountService, + private checkForUpdatesService: AppVersionService, + private versionTypeService: VersionInfoService) { this.translate.setDefaultLang('en'); @@ -65,6 +72,7 @@ export class AppComponent implements OnInit { ngOnInit(): void { this.favIcon = document.querySelector('#appFavicon'); + this.appLoading = document.getElementById('app-loading'); this.viewportHeight = window.innerHeight; this.init(); @@ -74,26 +82,36 @@ export class AppComponent implements OnInit { } }); - this.apiServiceCheckerService.isOnlineApi$ + this.accountService.getAuthenticationState() + .pipe(distinctUntilChanged((prev, next) => prev && next && prev.id === next.id)) + .subscribe(identity => { + this.isAuth = !!identity; + if (this.isAuth) { + const appBg = document.getElementById('app-background'); + appBg.style.display = 'none'; + } + }); + + this.checkForUpdatesService.getVersion() .pipe( - filter(isOnline => isOnline), - tap(() => { - if (this.offline) { - this.init(); + map(response => response.body || null), + tap((versionInfo: AppVersionInfo) => { + const version = versionInfo && versionInfo.version || ''; + const versionType = versionInfo.edition.includes('community') || version === '' + ? VersionType.COMMUNITY + : VersionType.ENTERPRISE; + + if (versionType !== this.versionTypeService.versionType()) { + this.versionTypeService.changeVersionType(versionType); } - this.online = true; - console.log('status', this.online); }), - delay(1000) + catchError(() => { + return EMPTY; + }) ) - .subscribe(() => { - this.offline = false; - this.online = false; - }); + .subscribe(); - this.accountService.getAuthenticationState() - .pipe(distinctUntilChanged((prev, next) => prev && next && prev.id === next.id)) - .subscribe(identity => this.isAuth = !!identity); + this.appLoading.style.display = 'none'; } @HostListener('window', ['$event']) diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 1257398b3..089880fed 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -36,12 +36,14 @@ import {AlertIncidentStatusChangeBehavior} from './shared/behaviors/alert-incide import {GettingStartedBehavior} from './shared/behaviors/getting-started.behavior'; import {NavBehavior} from './shared/behaviors/nav.behavior'; import {NewAlertBehavior} from './shared/behaviors/new-alert.behavior'; +import {AppVersionService} from './shared/services/version/app-version.service'; import {TimezoneFormatService} from './shared/services/utm-timezone.service'; import {UtmSharedModule} from './shared/utm-shared.module'; export function initTimezoneFormat( timezoneService: TimezoneFormatService, - apiChecker: ApiServiceCheckerService + apiChecker: ApiServiceCheckerService, + appVersionService: AppVersionService ) { return () => new Promise((resolve, reject) => { @@ -49,7 +51,10 @@ export function initTimezoneFormat( .pipe(first(val => val === true)) .subscribe({ next: () => { - timezoneService.loadTimezoneAndFormat().then(resolve).catch(reject); + timezoneService.loadTimezoneAndFormat() + .then(() => appVersionService.loadVersionInfo()) + .then(resolve) + .catch(reject); }, error: reject }); @@ -129,7 +134,11 @@ export function initTimezoneFormat( { provide: APP_INITIALIZER, useFactory: initTimezoneFormat, - deps: [TimezoneFormatService, ApiServiceCheckerService], + deps: [ + TimezoneFormatService, + ApiServiceCheckerService, + AppVersionService + ], multi: true }, NewAlertBehavior, diff --git a/frontend/src/app/core/auth/api-checker-service.ts b/frontend/src/app/core/auth/api-checker-service.ts index 6e543c54e..d85f1cd4d 100644 --- a/frontend/src/app/core/auth/api-checker-service.ts +++ b/frontend/src/app/core/auth/api-checker-service.ts @@ -17,7 +17,6 @@ export class ApiServiceCheckerService { constructor(private http: HttpClient) { this.checkApiAvailability(); - console.log('ping'); } checkApiAvailability() { diff --git a/frontend/src/app/shared/components/auth/login-providers/login-providers.component.html b/frontend/src/app/shared/components/auth/login-providers/login-providers.component.html new file mode 100644 index 000000000..edeca2813 --- /dev/null +++ b/frontend/src/app/shared/components/auth/login-providers/login-providers.component.html @@ -0,0 +1,19 @@ + +
+
+ OR +
+
+ +
+ +
+
diff --git a/frontend/src/app/shared/components/auth/login-providers/login-providers.component.scss b/frontend/src/app/shared/components/auth/login-providers/login-providers.component.scss new file mode 100644 index 000000000..b07261fc7 --- /dev/null +++ b/frontend/src/app/shared/components/auth/login-providers/login-providers.component.scss @@ -0,0 +1,282 @@ +@import "../../../../../assets/styles/theme"; +@import "../../../../../assets/styles/var"; + +// Divider +.providers-divider { + display: flex; + align-items: center; + margin: 1rem; +} + +.divider-line { + flex: 1; + height: 1px; + background: linear-gradient(to right, transparent, #e0e0e0, transparent); +} + +.divider-text { + padding: 0 1rem; + color: #999; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +// Providers List +.providers-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0 1rem; + margin-bottom: 1rem; +} + +// Provider Button Base +.provider-button { + height: 48px; + border-radius: 8px; + border: 1px solid #e0e0e0; + background-color: #ffffff; + color: #333; + font-weight: 500; + font-size: 14px; + padding: 0 1rem; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(0, 0, 0, 0.05); + transform: translate(-50%, -50%); + transition: width 0.6s, height 0.6s; + } + + &:hover:not(:disabled)::before { + width: 300px; + height: 300px; + } + + &:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); + } + + &:active:not(:disabled) { + transform: translateY(0); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + } + + &:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.1); + } +} + +.provider-icon { + font-size: 18px; + margin-right: 0.75rem; + position: relative; + z-index: 1; + transition: color 0.3s ease; +} + +.provider-text { + position: relative; + z-index: 1; +} + +// Provider Specific Styles +.provider-google { + border-color: #db4437; + + .provider-icon { + color: #db4437; + } + + &:hover:not(:disabled) { + background-color: #db4437; + border-color: #db4437; + color: white; + + .provider-icon { + color: white; + } + } +} + +.provider-azure { + border-color: #00a4ef; + + .provider-icon { + color: #00a4ef; + } + + &:hover:not(:disabled) { + background-color: #00a4ef; + border-color: #00a4ef; + color: white; + + .provider-icon { + color: white; + } + } +} + +.provider-github { + border-color: #333; + + .provider-icon { + color: #333; + } + + &:hover:not(:disabled) { + background-color: #333; + border-color: #333; + color: white; + + .provider-icon { + color: white; + } + } +} + +.provider-facebook { + border-color: #1877f2; + + .provider-icon { + color: #1877f2; + } + + &:hover:not(:disabled) { + background-color: #1877f2; + border-color: #1877f2; + color: white; + + .provider-icon { + color: white; + } + } +} + +.provider-linkedin { + border-color: #0a66c2; + + .provider-icon { + color: #0a66c2; + } + + &:hover:not(:disabled) { + background-color: #0a66c2; + border-color: #0a66c2; + color: white; + + .provider-icon { + color: white; + } + } +} + +.provider-okta { + border-color: #007dc1; + + .provider-icon { + color: #007dc1; + } + + &:hover:not(:disabled) { + background-color: #007dc1; + border-color: #007dc1; + color: white; + + .provider-icon { + color: white; + } + } +} + +.provider-auth0 { + border-color: #eb5424; + + .provider-icon { + color: #eb5424; + } + + &:hover:not(:disabled) { + background-color: #eb5424; + border-color: #eb5424; + color: white; + + .provider-icon { + color: white; + } + } +} + +// Animación de entrada +@keyframes slideInProvider { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.provider-button { + animation: slideInProvider 0.3s ease forwards; + opacity: 0; + + @for $i from 1 through 7 { + &:nth-child(#{$i}) { + animation-delay: #{$i * 0.05}s; + } + } +} + +// Responsive +@media (max-width: 576px) { + .provider-button { + height: 44px; + font-size: 13px; + } + + .provider-icon { + font-size: 16px; + margin-right: 0.5rem; + } + + .providers-list { + padding: 0 0.5rem; + gap: 0.5rem; + } + + .providers-divider { + margin: 1rem 0.5rem; + } +} + +// Smooth rendering +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/frontend/src/app/shared/components/auth/login-providers/login-providers.component.ts b/frontend/src/app/shared/components/auth/login-providers/login-providers.component.ts new file mode 100644 index 000000000..f0550b2cd --- /dev/null +++ b/frontend/src/app/shared/components/auth/login-providers/login-providers.component.ts @@ -0,0 +1,56 @@ +import { Component, OnInit } from '@angular/core'; +import { + PROVIDER_ICONS, + ProviderType, + UtmIdentityProvider +} from '../../../../app-management/identity-provider/shared/models/utm-identity-provider.model'; +import { + LoginProviderService, +} from '../../../services/login-provider.service'; + +@Component({ + selector: 'app-login-providers', + templateUrl: './login-providers.component.html', + styleUrls: ['./login-providers.component.scss'] +}) +export class LoginProvidersComponent implements OnInit { + + request = { + 'active.equals': true, + page: 0, + size: 10 + }; + + providers: UtmIdentityProvider[] = [] as UtmIdentityProvider[]; + + constructor(private loginProviderService: LoginProviderService) { } + + ngOnInit() { + this.loadAllProviders(); + } + + loadAllProviders() { + this.loginProviderService.getProviders(this.request).subscribe( + response => { + this.providers = response.body || []; + }, + error => { + console.error('Error fetching login providers:', error); + this.providers = []; + } + ); + } + + getProviderIcon(providerType: ProviderType) { + return PROVIDER_ICONS[providerType] || 'bi-shield-lock'; + } + + loginWithProvider(provider: UtmIdentityProvider) { + if (!provider.active) { + console.warn(`Provider ${provider.name} is inactive.`); + return; + } + + this.loginProviderService.loginWithProvider(provider.providerType.toLowerCase()); + } +} diff --git a/frontend/src/app/shared/components/auth/login/login.component.html b/frontend/src/app/shared/components/auth/login/login.component.html index 2c9cdf0e7..7af4e58be 100644 --- a/frontend/src/app/shared/components/auth/login/login.component.html +++ b/frontend/src/app/shared/components/auth/login/login.component.html @@ -60,6 +60,7 @@ + diff --git a/frontend/src/app/shared/components/auth/login/login.component.scss b/frontend/src/app/shared/components/auth/login/login.component.scss index ac0bb667e..34bdbbe12 100644 --- a/frontend/src/app/shared/components/auth/login/login.component.scss +++ b/frontend/src/app/shared/components/auth/login/login.component.scss @@ -21,7 +21,6 @@ flex-wrap: wrap; justify-content: center; align-items: center; - //padding: 100px 39%; } .wrap-login { @@ -29,7 +28,6 @@ background: #fff; border-radius: 10px; overflow: hidden; - display: -webkit-box; display: -webkit-flex; display: -moz-box; @@ -39,7 +37,6 @@ justify-content: space-around; align-items: center; padding: 20px 0; - } /*------------------------------------------------------------------ @@ -52,6 +49,11 @@ .login-pic img { max-width: 100%; max-height: 100px; + transition: transform 0.3s ease; +} + +.login-pic img:hover { + transform: scale(1.05); } /*------------------------------------------------------------------ @@ -66,7 +68,6 @@ color: #333333; line-height: 1.2; text-align: center; - width: 100%; display: block; padding-bottom: 54px; @@ -103,7 +104,6 @@ justify-content: center; align-items: center; padding: 0 25px; - -webkit-transition: all 0.4s; -o-transition: all 0.4s; -moz-transition: all 0.4s; @@ -118,10 +118,6 @@ [ Responsive ]*/ @media (max-width: 992px) { - .wrap-login { - // padding: 177px 90px 33px 85px; - } - .login-pic { width: 35%; } @@ -132,10 +128,6 @@ } @media (max-width: 768px) { - .wrap-login { - // padding: 100px 80px 33px 80px; - } - .login-pic { display: none; } @@ -146,8 +138,9 @@ } @media (max-width: 576px) { - .wrap-login { - //padding: 100px 15px 33px 15px; + .w-350px { + width: 90% !important; + max-width: 350px; } } @@ -174,16 +167,13 @@ transform: translateY(-50%); right: 8px; pointer-events: none; - font-family: Poppins-Medium; color: #c80000; font-size: 13px; line-height: 1.4; text-align: left; - visibility: hidden; opacity: 0; - -webkit-transition: opacity 0.4s; -o-transition: opacity 0.4s; -moz-transition: opacity 0.4s; @@ -224,74 +214,217 @@ left: 0; bottom: 0; right: 0; + z-index: 1; + padding: 20px; } .w-350px { width: 350px !important; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1); + transition: box-shadow 0.3s ease; + + &:hover { + box-shadow: 0 15px 50px rgba(0, 0, 0, 0.15); + } } .card { box-shadow: unset; } -.utm-login-cover { - position: fixed; - top: 0; bottom: 0; left: 0; right: 0; - z-index: 1; - padding: 20px; -} - -.login-pic img { - max-height: 100px; - max-width: 100%; -} - +// Form Controls mejorados .form-control { - border-radius: 6px; - border-color: #ccc; + border-radius: 8px; + border: 1px solid #ddd; box-shadow: none; + padding: 12px 40px 12px 40px; + height: 48px !important; + font-size: 14px; + transition: all 0.3s ease; + + &::placeholder { + color: #999; + opacity: 1; + } + + &:focus { + border-color: $primary-color; + box-shadow: 0 0 0 3px rgba($primary-color, 0.1); + outline: none; + } + + &:hover:not(:focus) { + border-color: #bbb; + } } -.form-control:focus { - border-color: #007bff; - box-shadow: 0 0 0 2px rgba(0,123,255,0.1); +// Form group feedback position +.form-group-feedback-right .form-control-feedback { + display: flex; + align-items: center; + justify-content: center; + height: 48px; + + i { + font-size: 16px; + } } +// Botones mejorados .utm-button { - border-radius: 6px; - padding: 10px 16px; + border-radius: 8px; + padding: 12px 24px; font-weight: 600; font-size: 15px; + transition: all 0.3s ease; + position: relative; + overflow: hidden; + height: 48px; + display: inline-flex; + align-items: center; + justify-content: center; + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + transform: translate(-50%, -50%); + transition: width 0.6s, height 0.6s; + } + + &:hover::before { + width: 300px; + height: 300px; + } } .utm-button-primary { -/* background-color: #007bff; - border-color: #007bff;*/ color: white; - transition: background-color 0.3s ease; + + &:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba($primary-color, 0.4); + } + + &:active:not(:disabled) { + transform: translateY(0); + box-shadow: 0 4px 10px rgba($primary-color, 0.3); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + } } -.utm-button-primary:hover:not(:disabled) { -/* background-color: #0056b3; - border-color: #004085;*/ +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } } +// Links y textos .cursor-pointer { cursor: pointer; + padding: 8px; + border-radius: 6px; + transition: all 0.2s ease; + display: inline-block; + + &:hover { + background-color: rgba(0, 0, 0, 0.03); + } } -.txt1, .txt2 { +.txt1 { font-size: 13px; - /* color: #007bff;*/ + color: #666; + margin-right: 4px; } -.txt2:hover { - text-decoration: underline; +.txt2 { + font-size: 13px; + color: $primary-color; + transition: all 0.2s ease; + font-weight: 500; + + &:hover { + color: darken($primary-color, 10%); + text-decoration: underline; + } } .border-grey { - border: 1px solid #ccc!important; + border: 1px solid #e0e0e0 !important; } +// Mensaje de demo +.text-danger.font-weight-semibold { + background: linear-gradient(135deg, #fff5f5 0%, #ffe8e8 100%); + border-top: 1px solid #ffe0e0; + border-radius: 0 0 1rem 1rem; + animation: fadeIn 0.5s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +// Mejora de espaciado +.my-2 { + margin-top: 1rem; + margin-bottom: 1rem; +} + +.mb-4 { + margin-bottom: 1.5rem !important; +} +.mt-5 { + margin-top: 2rem !important; +} + +// Card body +.card-body { + padding: 1.5rem; +} + +// Smooth rendering +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +// Loading states +.spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 0!important; +} + +// Focus visible para accesibilidad +button:focus, +.form-control:focus, +a:focus { + outline: none; +} + +button:focus-visible, +.form-control:focus-visible, +a:focus-visible { + outline: 2px solid $primary-color; + outline-offset: 2px; +} diff --git a/frontend/src/app/shared/components/auth/login/login.component.ts b/frontend/src/app/shared/components/auth/login/login.component.ts index 89335f80f..879319dcb 100644 --- a/frontend/src/app/shared/components/auth/login/login.component.ts +++ b/frontend/src/app/shared/components/auth/login/login.component.ts @@ -1,10 +1,11 @@ -import {Component, OnInit} from '@angular/core'; +import {Component, OnDestroy, OnInit} from '@angular/core'; import {FormBuilder, FormGroup, Validators} from '@angular/forms'; import {DomSanitizer} from '@angular/platform-browser'; import {ActivatedRoute, Router} from '@angular/router'; import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; import {NgxSpinnerService} from 'ngx-spinner'; -import {Observable} from 'rxjs'; +import {Observable, Subject} from 'rxjs'; +import {filter, switchMap, takeUntil} from 'rxjs/operators'; import {AccountService} from '../../../../core/auth/account.service'; import {ApiServiceCheckerService} from '../../../../core/auth/api-checker-service'; import {StateStorageService} from '../../../../core/auth/state-storage.service'; @@ -15,14 +16,13 @@ import {ThemeChangeBehavior} from '../../../behaviors/theme-change.behavior'; import {ADMIN_DEFAULT_EMAIL, ADMIN_ROLE, DEMO_URL, USER_ROLE} from '../../../constants/global.constant'; import {extractQueryParamsForNavigation, stringParamToQueryParams} from '../../../util/query-params-to-filter.util'; import {PasswordResetInitComponent} from '../password-reset/init/password-reset-init.component'; -import {AuthServerProvider} from "../../../../core/auth/auth-jwt.service"; @Component({ selector: 'app-login', templateUrl: './login.component.html', styleUrls: ['./login.component.scss'] }) -export class LoginComponent implements OnInit { +export class LoginComponent implements OnInit, OnDestroy { authenticationError: boolean; password: string; rememberMe: boolean; @@ -37,6 +37,7 @@ export class LoginComponent implements OnInit { loginImage$: Observable; loadingLogin = false; isInternalNavigation = false; + destroy$ = new Subject(); constructor( private loginService: LoginService, @@ -60,27 +61,41 @@ export class LoginComponent implements OnInit { ngOnInit() { this.initForm(); - this.apiServiceCheckerService.isOnlineApi$.subscribe(result => { - if (result) { - this.activatedRoute.queryParams.subscribe(params => { - if (params.token) { - this.loadingLogin = false; - this.loginService.loginWithToken(params.token, true).then(() => { + this.apiServiceCheckerService.isOnlineApi$ + .pipe( + filter(result => !!result ), + switchMap(() => this.activatedRoute.queryParams), + takeUntil(this.destroy$) + ) + .subscribe(params => { + if (params) { + this.activatedRoute.queryParams.subscribe(params => { + if (params.token) { + this.loginService.loginWithToken(params.token, true).then(() => { + this.loadingLogin = false; + this.isInternalNavigation = true; + this.startInternalNavigation(params); + }); + } else if (params.key) { this.loadingLogin = false; this.isInternalNavigation = true; - this.startInternalNavigation(params); - }); - } else if (params.key) { - this.loadingLogin = false; - this.isInternalNavigation = true; - this.loginService.loginWithKey(params.key, true).then(() => { - this.startInternalNavigation(params); - }); - } else { - this.loadingAuth = false; - } - }); - } + this.loginService.loginWithKey(params.key, true).then(() => { + this.startInternalNavigation(params); + }); + } else if (params.error) { + if (params.error === 'saml2') { + this.utmToast.showError('Login fail', 'The provided credentials do not match any active' + + ' user account or the account lacks required roles.'); + } else { + this.utmToast.showError('Login fail', 'Authentication error, ' + + 'check your data and try again.'); + } + this.loadingAuth = false; + } else { + this.loadingAuth = false; + } + }); + } }); } @@ -88,7 +103,7 @@ export class LoginComponent implements OnInit { this.accountService.identity(true).then(value => { setTimeout(() => { if (value) { - //this.spinner.show('loadingSpinner'); + // this.spinner.show('loadingSpinner'); if (url) { const urlRoute = url.split('<-PARAMS->'); const route = urlRoute[0]; @@ -98,7 +113,7 @@ export class LoginComponent implements OnInit { this.router.navigate([route], {queryParams}).then(() => { this.menuBehavior.$menu.next(false); - //this.spinner.hide('loadingSpinner'); + // this.spinner.hide('loadingSpinner'); }); }); } else { @@ -107,7 +122,7 @@ export class LoginComponent implements OnInit { } } } else { - //this.spinner.hide('loadingSpinner'); + // this.spinner.hide('loadingSpinner'); this.loadingAuth = false; } }, 1000); @@ -199,4 +214,8 @@ export class LoginComponent implements OnInit { } } + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } } diff --git a/frontend/src/app/shared/components/layout/header/header.component.ts b/frontend/src/app/shared/components/layout/header/header.component.ts index 201e73321..b6ae1c83d 100644 --- a/frontend/src/app/shared/components/layout/header/header.component.ts +++ b/frontend/src/app/shared/components/layout/header/header.component.ts @@ -1,15 +1,14 @@ import {Component, OnDestroy, OnInit} from '@angular/core'; import {DomSanitizer} from '@angular/platform-browser'; -import {EMPTY, Subject} from 'rxjs'; -import {catchError, filter, map, takeUntil, tap} from 'rxjs/operators'; +import {Subject} from 'rxjs'; +import {filter, takeUntil} from 'rxjs/operators'; import {AccountService} from '../../../../core/auth/account.service'; import {User} from '../../../../core/user/user.model'; import {ThemeChangeBehavior} from '../../../behaviors/theme-change.behavior'; import {ADMIN_ROLE} from '../../../constants/global.constant'; import {AppThemeLocationEnum} from '../../../enums/app-theme-location.enum'; -import {VersionType, VersionTypeService} from "../../../services/util/version-type.service"; -import {AppVersionInfo} from "../../../types/updates/updates.type"; -import {CheckForUpdatesService} from "../../../services/updates/check-for-updates.service"; +import {VersionInfoService} from '../../../services/version/version-info.service'; +import {AppVersionInfo} from '../../../types/updates/updates.type'; @Component({ selector: 'app-header', @@ -29,8 +28,7 @@ export class HeaderComponent implements OnInit, OnDestroy { constructor(private accountService: AccountService, public sanitizer: DomSanitizer, private themeChangeBehavior: ThemeChangeBehavior, - private versionTypeService: VersionTypeService, - private checkForUpdatesService: CheckForUpdatesService) { + private versionTypeService: VersionInfoService) { } ngOnInit() { @@ -44,26 +42,9 @@ export class HeaderComponent implements OnInit, OnDestroy { this.user = account; }); - this.checkForUpdatesService.getVersion() - .pipe( - map(response => response.body || null), - tap((versionInfo: AppVersionInfo) => { - const version = versionInfo && versionInfo.version || ''; - const versionType = versionInfo.edition.includes('community') || version === '' - ? VersionType.COMMUNITY - : VersionType.ENTERPRISE; - - if (versionType !== this.versionTypeService.versionType()) { - this.versionTypeService.changeVersionType(versionType); - } - }), - catchError(() => { - return EMPTY; - }) - ) - .subscribe(versionInfo => { - this.versionInfo = versionInfo; - }); + this.versionTypeService.appVersionInfo$ + .pipe(takeUntil(this.destroy$)) + .subscribe((versionInfo: AppVersionInfo) => this.versionInfo = versionInfo); } ngOnDestroy() { diff --git a/frontend/src/app/shared/components/layout/header/shared/components/utm-version-info/utm-version-info.component.ts b/frontend/src/app/shared/components/layout/header/shared/components/utm-version-info/utm-version-info.component.ts index cadbe5b7b..0ed583249 100644 --- a/frontend/src/app/shared/components/layout/header/shared/components/utm-version-info/utm-version-info.component.ts +++ b/frontend/src/app/shared/components/layout/header/shared/components/utm-version-info/utm-version-info.component.ts @@ -1,6 +1,6 @@ import {Component, Input, OnInit} from '@angular/core'; import {UtmToastService} from '../../../../../../alert/utm-toast.service'; -import {CheckForUpdatesService} from '../../../../../../services/updates/check-for-updates.service'; +import {AppVersionService} from '../../../../../../services/version/app-version.service'; import {AppVersionInfo} from '../../../../../../types/updates/updates.type'; @Component({ diff --git a/frontend/src/app/shared/components/utm/config/app-config-sections/app-config-sections.component.ts b/frontend/src/app/shared/components/utm/config/app-config-sections/app-config-sections.component.ts index a160480df..2f5434e21 100644 --- a/frontend/src/app/shared/components/utm/config/app-config-sections/app-config-sections.component.ts +++ b/frontend/src/app/shared/components/utm/config/app-config-sections/app-config-sections.component.ts @@ -13,7 +13,7 @@ import { import {UtmConfigParamsService} from '../../../../services/config/utm-config-params.service'; import {LocationService, SelectOption} from '../../../../services/location.service'; import {NetworkService} from '../../../../services/network.service'; -import {VersionType, VersionTypeService} from '../../../../services/util/version-type.service'; +import {VersionType, VersionInfoService} from '../../../../services/version/version-info.service'; import {TimezoneFormatService} from '../../../../services/utm-timezone.service'; import {ConfigDataTypeEnum, SectionConfigParamType} from '../../../../types/configuration/section-config-param.type'; import {ApplicationConfigSectionEnum, SectionConfigType} from '../../../../types/configuration/section-config.type'; diff --git a/frontend/src/app/shared/components/utm/util/utm-modal-header/utm-modal-header.component.html b/frontend/src/app/shared/components/utm/util/utm-modal-header/utm-modal-header.component.html index 77cbb6f12..3ea1b63a2 100644 --- a/frontend/src/app/shared/components/utm/util/utm-modal-header/utm-modal-header.component.html +++ b/frontend/src/app/shared/components/utm/util/utm-modal-header/utm-modal-header.component.html @@ -1,3 +1,4 @@ + + + + + diff --git a/frontend/src/app/shared/components/utm/util/utm-modal-header/utm-modal-header.component.scss b/frontend/src/app/shared/components/utm/util/utm-modal-header/utm-modal-header.component.scss index ad5248ccd..1de86af68 100644 --- a/frontend/src/app/shared/components/utm/util/utm-modal-header/utm-modal-header.component.scss +++ b/frontend/src/app/shared/components/utm/util/utm-modal-header/utm-modal-header.component.scss @@ -2,15 +2,36 @@ @import "../../../../../../assets/styles/var"; @import "../../../../../../assets/styles/custom-elements"; -.utm-modal-header { - h6 { - font-size: 18px; +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.25rem; + border-bottom: 1px solid #e9ecef; + + h3 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: #333; } - border-radius: 0; - background-color: $utm-header-color; -} + .btn-close { + width: 2rem; + height: 2rem; + border-radius: 0.25rem; + border: none; + background: transparent; + color: #999; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; -.utm-modal-delete { - background-color: $danger-color; + &:hover { + background: #f8f9fa; + color: #333; + } + } } diff --git a/frontend/src/app/shared/components/utm/util/utm-modal-header/utm-modal-header.component.ts b/frontend/src/app/shared/components/utm/util/utm-modal-header/utm-modal-header.component.ts index 6cae18ac1..0b7cdf550 100644 --- a/frontend/src/app/shared/components/utm/util/utm-modal-header/utm-modal-header.component.ts +++ b/frontend/src/app/shared/components/utm/util/utm-modal-header/utm-modal-header.component.ts @@ -8,9 +8,11 @@ import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; }) export class UtmModalHeaderComponent implements OnInit, AfterViewChecked, AfterViewInit { @Input() name: string; - @Input() type: 'delete' | 'normal'; - @Output() closeModal = new EventEmitter(); + @Input() type: 'default' | 'delete' | 'warning' | 'success' = 'default'; + @Input() icon?: string; @Input() showCloseButton = true; + @Output() closeModal = new EventEmitter(); + constructor(public activeModal: NgbActiveModal, private cdr: ChangeDetectorRef) { diff --git a/frontend/src/app/shared/directives/enterprise/enterprise.directive.ts b/frontend/src/app/shared/directives/enterprise/enterprise.directive.ts new file mode 100644 index 000000000..e14433485 --- /dev/null +++ b/frontend/src/app/shared/directives/enterprise/enterprise.directive.ts @@ -0,0 +1,39 @@ +import {Directive, Input, OnDestroy, OnInit, TemplateRef, ViewContainerRef} from '@angular/core'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import {EnterpriseFeatures, VersionInfoService, VersionType} from 'src/app/shared/services/version/version-info.service'; + +@Directive({ + selector: '[appIsEnterpriseModule]' +}) +export class IsEnterpriseModuleDirective implements OnInit, OnDestroy { + @Input('appIsEnterpriseModule') module: string; + @Input('appIsEnterpriseModuleElse') elseTpl: TemplateRef | null = null; + + private destroy$ = new Subject(); + + constructor( + private tpl: TemplateRef, + private vcr: ViewContainerRef, + private versionTypeService: VersionInfoService + ) {} + + ngOnInit() { + this.versionTypeService.versionType$ + .pipe(takeUntil(this.destroy$)) + .subscribe(versionType => { + if (versionType === VersionType.ENTERPRISE && EnterpriseFeatures.includes(this.module)) { + this.vcr.createEmbeddedView(this.tpl); + } else if (this.elseTpl) { + this.vcr.createEmbeddedView(this.elseTpl); + } else { + this.vcr.clear(); + } + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/frontend/src/app/shared/services/login-provider.service.ts b/frontend/src/app/shared/services/login-provider.service.ts new file mode 100644 index 000000000..a347aa9ab --- /dev/null +++ b/frontend/src/app/shared/services/login-provider.service.ts @@ -0,0 +1,21 @@ +import {HttpClient} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {UtmIdentityProvider} from '../../app-management/identity-provider/shared/models/utm-identity-provider.model'; +import {SERVER_API_URL} from '../../app.constants'; +import {createRequestOption} from '../util/request-util'; + +@Injectable({providedIn: 'root'}) +export class LoginProviderService { + serverApiUrl = SERVER_API_URL + 'api/utm-providers'; + + constructor(private http: HttpClient) {} + + getProviders(request: any) { + const params = createRequestOption(request); + return this.http.get(this.serverApiUrl, {params, observe: 'response'}); + } + + loginWithProvider(provider: string): void { + window.location.href = `${SERVER_API_URL}saml2/authenticate/${provider}`; + } +} diff --git a/frontend/src/app/shared/services/updates/check-for-updates.service.ts b/frontend/src/app/shared/services/updates/check-for-updates.service.ts deleted file mode 100644 index fcafa79a8..000000000 --- a/frontend/src/app/shared/services/updates/check-for-updates.service.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {HttpClient, HttpResponse} from '@angular/common/http'; -import {Injectable} from '@angular/core'; -import {Observable} from 'rxjs'; -import {SERVER_API_URL} from '../../../app.constants'; -import {AppVersionInfo, VersionInfo} from '../../types/updates/updates.type'; - - -@Injectable({ - providedIn: 'root' -}) -export class CheckForUpdatesService { - public resourceUrl = SERVER_API_URL + 'api/'; - public resourceInfo = SERVER_API_URL + 'management/'; - - constructor(private http: HttpClient) { - } - - getVersion(): Observable> { - return this.http.get(this.resourceUrl + 'info/version', {observe: 'response'}); - } - - getMode(): Observable> { - return this.http.get(this.resourceUrl + 'isInDevelop', {observe: 'response'}); - } - - -} diff --git a/frontend/src/app/shared/services/util/version-type.service.ts b/frontend/src/app/shared/services/util/version-type.service.ts deleted file mode 100644 index aece37823..000000000 --- a/frontend/src/app/shared/services/util/version-type.service.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {Injectable} from '@angular/core'; -import {BehaviorSubject} from 'rxjs'; - -export enum VersionType { - COMMUNITY = 'COMMUNITY', - ENTERPRISE = 'ENTERPRISE', -} - -@Injectable({ - providedIn: 'root' -}) -export class VersionTypeService { - private versionTypeBehavior = new BehaviorSubject(VersionType.COMMUNITY); - versionType$ = this.versionTypeBehavior.asObservable(); - - changeVersionType(versionType: VersionType) { - this.versionTypeBehavior.next(versionType); - } - - versionType(): VersionType { - return this.versionTypeBehavior.getValue(); - } -} diff --git a/frontend/src/app/shared/services/version/app-version.service.ts b/frontend/src/app/shared/services/version/app-version.service.ts new file mode 100644 index 000000000..7f20d6702 --- /dev/null +++ b/frontend/src/app/shared/services/version/app-version.service.ts @@ -0,0 +1,56 @@ +import {HttpClient, HttpResponse} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {Observable} from 'rxjs'; +import {SERVER_API_URL} from '../../../app.constants'; +import {DATE_SETTING_FORMAT_SHORT, DATE_SETTING_TIMEZONE_SHORT} from '../../constants/date-timezone-date.const'; +import {AppVersionInfo, VersionInfo} from '../../types/updates/updates.type'; +import {VersionType, VersionInfoService} from './version-info.service'; + + +@Injectable({ + providedIn: 'root' +}) +export class AppVersionService { + public resourceUrl = SERVER_API_URL + 'api/'; + public resourceInfo = SERVER_API_URL + 'management/'; + + constructor(private http: HttpClient, + private versionTypeService: VersionInfoService) { + } + + getVersion(): Observable> { + return this.http.get(this.resourceUrl + 'info/version', {observe: 'response'}); + } + + getMode(): Observable> { + return this.http.get(this.resourceUrl + 'isInDevelop', {observe: 'response'}); + } + + loadVersionInfo(): Promise { + return new Promise((resolve, reject) => { + this.getVersion() + .subscribe( + response => { + const versionInfo = response.body || {} as AppVersionInfo; + + this.versionTypeService.changeAppVersionInfo(versionInfo); + const version = versionInfo && versionInfo.version || ''; + const versionType = versionInfo.edition.includes('community') || version === '' + ? VersionType.COMMUNITY + : VersionType.ENTERPRISE; + + if (versionType !== this.versionTypeService.versionType()) { + this.versionTypeService.changeVersionType(versionType); + } + resolve(); + }, + error => { + console.error('Unable to load version info', error); + reject(error); + } + ); + }); + } + + +} diff --git a/frontend/src/app/shared/services/version/modal-version-info.service.ts b/frontend/src/app/shared/services/version/modal-version-info.service.ts new file mode 100644 index 000000000..b4b148940 --- /dev/null +++ b/frontend/src/app/shared/services/version/modal-version-info.service.ts @@ -0,0 +1,29 @@ +import {Injectable} from '@angular/core'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {ModalConfirmationComponent} from '../../components/utm/util/modal-confirmation/modal-confirmation.component'; + +@Injectable({ + providedIn: 'root' +}) +export class ModalVersionInfoService { + + constructor(private modalService: NgbModal) {} + + showVersionInfo() { + const modalSource = this.modalService.open(ModalConfirmationComponent, { centered: true }); + + modalSource.componentInstance.header = 'Enterprise Feature'; + modalSource.componentInstance.message = + 'This feature is available only in the Enterprise edition of the platform. ' + + 'For more information about upgrading or accessing this functionality, please contact our support team at ' + + '
support@services.utmstack.com.'; + modalSource.componentInstance.confirmBtnText = 'OK'; + modalSource.componentInstance.confirmBtnIcon = 'icon-info'; + modalSource.componentInstance.confirmBtnType = 'default'; + modalSource.componentInstance.hideBtnCancel = true; + + modalSource.result.then(() => { + // optional callback logic + }); + } +} diff --git a/frontend/src/app/shared/services/version/version-info.service.ts b/frontend/src/app/shared/services/version/version-info.service.ts new file mode 100644 index 000000000..d0a060b4e --- /dev/null +++ b/frontend/src/app/shared/services/version/version-info.service.ts @@ -0,0 +1,64 @@ +import {Injectable} from '@angular/core'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {BehaviorSubject} from 'rxjs'; +import {ModalConfirmationComponent} from '../../components/utm/util/modal-confirmation/modal-confirmation.component'; +import {AppVersionInfo} from '../../types/updates/updates.type'; + +export const EnterpriseFeatures = [ + 'AUTH_WITH_PROVIDERS_MODULE', +]; + + +export enum VersionType { + COMMUNITY = 'COMMUNITY', + ENTERPRISE = 'ENTERPRISE', +} + +@Injectable({ + providedIn: 'root' +}) +export class VersionInfoService { + + private versionTypeBehavior = new BehaviorSubject(VersionType.COMMUNITY); + private versionInfoBehavior = new BehaviorSubject({} as AppVersionInfo); + versionType$ = this.versionTypeBehavior.asObservable(); + appVersionInfo$ = this.versionInfoBehavior.asObservable(); + + constructor() {} + + changeVersionType(versionType: VersionType) { + this.versionTypeBehavior.next(versionType); + } + + changeAppVersionInfo(versionInfo: AppVersionInfo) { + this.versionInfoBehavior.next(versionInfo); + } + + appVersionInfo(): AppVersionInfo { + return this.versionInfoBehavior.getValue(); + } + + versionType(): VersionType { + return this.versionTypeBehavior.getValue(); + } + + /*showVersionInfo() { + const modalSource = this.modalService.open(ModalConfirmationComponent, { centered: true }); + + modalSource.componentInstance.header = 'Enterprise Feature'; + modalSource.componentInstance.message = + 'This feature is available only in the Enterprise edition of the platform. ' + + 'For more information about upgrading or accessing this functionality, please contact our support team at ' + + 'support@services.utmstack.com.'; + modalSource.componentInstance.confirmBtnText = 'OK'; + modalSource.componentInstance.confirmBtnIcon = 'icon-info'; + modalSource.componentInstance.confirmBtnType = 'default'; + modalSource.componentInstance.hideBtnCancel = true; + + modalSource.result.then(() => { + // optional callback logic + }); + }*/ + + +} diff --git a/frontend/src/app/shared/utm-shared.module.ts b/frontend/src/app/shared/utm-shared.module.ts index f7a7e85be..949f1798b 100644 --- a/frontend/src/app/shared/utm-shared.module.ts +++ b/frontend/src/app/shared/utm-shared.module.ts @@ -25,6 +25,7 @@ import {MenuBehavior} from './behaviors/menu.behavior'; import {VersionUpdateBehavior} from './behaviors/version-update.behavior'; import {AppFilterComponent} from './components/app-filter/app-filter.component'; import {ConfirmIdentityComponent} from './components/auth/confirm-identity/confirm-identity.component'; +import { LoginProvidersComponent } from './components/auth/login-providers/login-providers.component'; import {LoginComponent} from './components/auth/login/login.component'; import {PasswordResetFinishComponent} from './components/auth/password-reset/finish/password-reset-finish.component'; import {PasswordResetInitComponent} from './components/auth/password-reset/init/password-reset-init.component'; @@ -244,6 +245,7 @@ import {HighlightPipe} from './pipes/text/highlight.pipe'; import {TimePeriodPipe} from './pipes/time-period.pipe'; import {TimezoneOffsetPipe} from './pipes/timezone-offset.pipe'; import {UtmNotifier} from './websocket/utm-notifier'; +import {IsEnterpriseModuleDirective} from "./directives/enterprise/enterprise.directive"; @NgModule({ @@ -408,7 +410,9 @@ import {UtmNotifier} from './websocket/utm-notifier'; UtmTfaVerificationComponent, TfaSetupComponent, ResizableFilterContainerComponent, - CodeEditorComponent + CodeEditorComponent, + LoginProvidersComponent, + IsEnterpriseModuleDirective ], exports: [ IndexPatternCreateComponent, @@ -517,7 +521,8 @@ import {UtmNotifier} from './websocket/utm-notifier'; UtmCpCronEditorComponent, RelativeTimePipe, ResizableFilterContainerComponent, - CodeEditorComponent + CodeEditorComponent, + IsEnterpriseModuleDirective ], entryComponents: [ LoginComponent, diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts index c14489291..586cb5a3b 100644 --- a/frontend/src/environments/environment.ts +++ b/frontend/src/environments/environment.ts @@ -4,8 +4,8 @@ export const environment = { production: false, - SERVER_API_URL: 'https://192.168.1.18/', - //SERVER_API_URL: 'http://localhost:8080/', + // SERVER_API_URL: 'https://192.168.1.18/', + SERVER_API_URL: 'http://localhost:8080/', SERVER_API_CONTEXT: '', SESSION_AUTH_TOKEN: window.location.host.split(':')[0].toLocaleUpperCase(), WEBSOCKET_URL: '//localhost:8080', diff --git a/frontend/src/index.html b/frontend/src/index.html index 32b842c14..13457dadc 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -17,12 +17,59 @@ + + + + UTMStack + + + + + + - - +
+ +
+
+
+

Welcome to UTMStack...

+

Preparing your workspace

+
+
+ diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 05ed6f6d7..2faa93b1b 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -1428,6 +1428,12 @@ app-utm-items-per-page { } } +#app-loading { + .icon-spinner2 { + font-size: 2rem; + } +} +