diff --git a/.java-version b/.java-version new file mode 100644 index 000000000..aabe6ec39 --- /dev/null +++ b/.java-version @@ -0,0 +1 @@ +21 diff --git a/api/src/main/java/io/kafbat/ui/config/auth/AbstractAuthSecurityConfig.java b/api/src/main/java/io/kafbat/ui/config/auth/AbstractAuthSecurityConfig.java index f23a0dd2a..265bac03f 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/AbstractAuthSecurityConfig.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/AbstractAuthSecurityConfig.java @@ -1,24 +1,53 @@ package io.kafbat.ui.config.auth; +import io.kafbat.ui.util.EmptyRedirectStrategy; +import java.net.URI; +import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler; +import org.springframework.security.web.server.authentication.logout.RedirectServerLogoutSuccessHandler; + abstract class AbstractAuthSecurityConfig { protected AbstractAuthSecurityConfig() { } + protected static final String LOGIN_URL = "/login"; + protected static final String LOGOUT_URL = "/auth?logout"; + protected static final String[] AUTH_WHITELIST = { - "/css/**", - "/js/**", - "/media/**", + /* STATIC */ + "/index.html", + "/assets/**", + "/manifest.json", + "/favicon.svg", + "/favicon/**", + + "/static/**", "/resources/**", + + /* ACTUATOR */ "/actuator/health/**", "/actuator/info", "/actuator/prometheus", - "/auth", + + /* AUTH */ "/login", "/logout", "/oauth2/**", - "/static/**" + "/api/config/authentication", + "/api/authorization" }; + protected RedirectServerAuthenticationSuccessHandler emptyRedirectSuccessHandler() { + final var authHandler = new RedirectServerAuthenticationSuccessHandler(); + authHandler.setRedirectStrategy(new EmptyRedirectStrategy()); + return authHandler; + } + + protected RedirectServerLogoutSuccessHandler redirectLogoutSuccessHandler() { + final var logoutSuccessHandler = new RedirectServerLogoutSuccessHandler(); + logoutSuccessHandler.setLogoutSuccessUrl(URI.create(LOGOUT_URL)); + return logoutSuccessHandler; + } + } diff --git a/api/src/main/java/io/kafbat/ui/config/auth/BasicAuthSecurityConfig.java b/api/src/main/java/io/kafbat/ui/config/auth/BasicAuthSecurityConfig.java index 7a25fb3a7..db8ef8153 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/BasicAuthSecurityConfig.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/BasicAuthSecurityConfig.java @@ -1,6 +1,7 @@ package io.kafbat.ui.config.auth; import io.kafbat.ui.util.EmptyRedirectStrategy; +import io.kafbat.ui.util.StaticFileWebFilter; import java.net.URI; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -8,6 +9,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler; @@ -20,32 +22,28 @@ @Slf4j public class BasicAuthSecurityConfig extends AbstractAuthSecurityConfig { - public static final String LOGIN_URL = "/auth"; - public static final String LOGOUT_URL = "/auth?logout"; - @Bean public SecurityWebFilterChain configure(ServerHttpSecurity http) { log.info("Configuring LOGIN_FORM authentication."); - final var authHandler = new RedirectServerAuthenticationSuccessHandler(); - authHandler.setRedirectStrategy(new EmptyRedirectStrategy()); - - final var logoutSuccessHandler = new RedirectServerLogoutSuccessHandler(); - logoutSuccessHandler.setLogoutSuccessUrl(URI.create(LOGOUT_URL)); - - - return http.authorizeExchange(spec -> spec + var builder = http.authorizeExchange(spec -> spec .pathMatchers(AUTH_WHITELIST) .permitAll() .anyExchange() .authenticated() ) - .formLogin(spec -> spec.loginPage(LOGIN_URL).authenticationSuccessHandler(authHandler)) + .formLogin(form -> form + .loginPage(LOGIN_URL) + .authenticationSuccessHandler(emptyRedirectSuccessHandler()) + ) .logout(spec -> spec - .logoutSuccessHandler(logoutSuccessHandler) + .logoutSuccessHandler(redirectLogoutSuccessHandler()) .requiresLogout(ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, "/logout"))) - .csrf(ServerHttpSecurity.CsrfSpec::disable) - .build(); + .csrf(ServerHttpSecurity.CsrfSpec::disable); + + builder.addFilterAt(new StaticFileWebFilter(), SecurityWebFiltersOrder.LOGIN_PAGE_GENERATING); + + return builder.build(); } } diff --git a/api/src/main/java/io/kafbat/ui/config/auth/LdapSecurityConfig.java b/api/src/main/java/io/kafbat/ui/config/auth/LdapSecurityConfig.java index 1b5a8ca87..4d89a9568 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/LdapSecurityConfig.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/LdapSecurityConfig.java @@ -1,9 +1,8 @@ package io.kafbat.ui.config.auth; -import static io.kafbat.ui.config.auth.AbstractAuthSecurityConfig.AUTH_WHITELIST; - import io.kafbat.ui.service.rbac.AccessControlService; import io.kafbat.ui.service.rbac.extractor.RbacLdapAuthoritiesExtractor; +import io.kafbat.ui.util.StaticFileWebFilter; import java.util.Collection; import java.util.List; import java.util.Optional; @@ -14,6 +13,7 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.ldap.core.DirContextOperations; import org.springframework.ldap.core.support.BaseLdapPathContextSource; import org.springframework.ldap.core.support.LdapContextSource; @@ -21,8 +21,8 @@ import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.authentication.ReactiveAuthenticationManagerAdapter; -import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; @@ -36,6 +36,7 @@ import org.springframework.security.ldap.userdetails.LdapAuthoritiesPopulator; import org.springframework.security.ldap.userdetails.LdapUserDetailsMapper; import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; @Configuration @EnableWebFluxSecurity @@ -43,7 +44,7 @@ @EnableConfigurationProperties(LdapProperties.class) @RequiredArgsConstructor @Slf4j -public class LdapSecurityConfig { +public class LdapSecurityConfig extends AbstractAuthSecurityConfig { private final LdapProperties props; @@ -121,16 +122,24 @@ public SecurityWebFilterChain configureLdap(ServerHttpSecurity http) { log.info("Active Directory support for LDAP has been enabled."); } - return http.authorizeExchange(spec -> spec + var builder = http.authorizeExchange(spec -> spec .pathMatchers(AUTH_WHITELIST) .permitAll() .anyExchange() .authenticated() ) - .formLogin(Customizer.withDefaults()) - .logout(Customizer.withDefaults()) - .csrf(ServerHttpSecurity.CsrfSpec::disable) - .build(); + .formLogin(form -> form + .loginPage(LOGIN_URL) + .authenticationSuccessHandler(emptyRedirectSuccessHandler()) + ) + .logout(spec -> spec + .logoutSuccessHandler(redirectLogoutSuccessHandler()) + .requiresLogout(ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, "/logout"))) + .csrf(ServerHttpSecurity.CsrfSpec::disable); + + builder.addFilterAt(new StaticFileWebFilter(), SecurityWebFiltersOrder.LOGIN_PAGE_GENERATING); + + return builder.build(); } private static class UserDetailsMapper extends LdapUserDetailsMapper { diff --git a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java index 09c7df794..4794b83ca 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java @@ -3,6 +3,7 @@ import io.kafbat.ui.config.auth.logout.OAuthLogoutSuccessHandler; import io.kafbat.ui.service.rbac.AccessControlService; import io.kafbat.ui.service.rbac.extractor.ProviderAuthorityExtractor; +import io.kafbat.ui.util.StaticFileWebFilter; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -19,6 +20,7 @@ import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.oauth2.client.oidc.userinfo.OidcReactiveOAuth2UserService; import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; @@ -50,7 +52,7 @@ public class OAuthSecurityConfig extends AbstractAuthSecurityConfig { public SecurityWebFilterChain configure(ServerHttpSecurity http, OAuthLogoutSuccessHandler logoutHandler) { log.info("Configuring OAUTH2 authentication."); - return http.authorizeExchange(spec -> spec + var builder = http.authorizeExchange(spec -> spec .pathMatchers(AUTH_WHITELIST) .permitAll() .anyExchange() @@ -58,8 +60,12 @@ public SecurityWebFilterChain configure(ServerHttpSecurity http, OAuthLogoutSucc ) .oauth2Login(Customizer.withDefaults()) .logout(spec -> spec.logoutSuccessHandler(logoutHandler)) - .csrf(ServerHttpSecurity.CsrfSpec::disable) - .build(); + .csrf(ServerHttpSecurity.CsrfSpec::disable); + + + builder.addFilterAt(new StaticFileWebFilter(), SecurityWebFiltersOrder.LOGIN_PAGE_GENERATING); + + return builder.build(); } @Bean diff --git a/api/src/main/java/io/kafbat/ui/controller/ApplicationConfigController.java b/api/src/main/java/io/kafbat/ui/controller/ApplicationConfigController.java index 5d5d4ed98..e8d763545 100644 --- a/api/src/main/java/io/kafbat/ui/controller/ApplicationConfigController.java +++ b/api/src/main/java/io/kafbat/ui/controller/ApplicationConfigController.java @@ -6,6 +6,7 @@ import io.kafbat.ui.api.ApplicationConfigApi; import io.kafbat.ui.config.ClustersProperties; import io.kafbat.ui.model.ActionDTO; +import io.kafbat.ui.model.AppAuthenticationSettingsDTO; import io.kafbat.ui.model.ApplicationConfigDTO; import io.kafbat.ui.model.ApplicationConfigPropertiesDTO; import io.kafbat.ui.model.ApplicationConfigValidationDTO; @@ -66,6 +67,13 @@ public Mono> getApplicationInfo(ServerWebExch return Mono.just(applicationInfoService.getApplicationInfo()).map(ResponseEntity::ok); } + @Override + public Mono> getAuthenticationSettings( + ServerWebExchange exchange) { + return Mono.just(applicationInfoService.getAuthenticationProperties()) + .map(ResponseEntity::ok); + } + @Override public Mono> getCurrentConfig(ServerWebExchange exchange) { var context = AccessContext.builder() @@ -109,7 +117,7 @@ public Mono> uploadConfigRelatedFile(Flux dynamicConfigOperations.uploadConfigRelatedFile((FilePart) file) - .map(path -> new UploadedFileInfoDTO().location(path.toString())) + .map(path -> new UploadedFileInfoDTO(path.toString())) .map(ResponseEntity::ok)) .doOnEach(sig -> audit(context, sig)); } diff --git a/api/src/main/java/io/kafbat/ui/controller/AuthController.java b/api/src/main/java/io/kafbat/ui/controller/AuthController.java deleted file mode 100644 index e4532dda3..000000000 --- a/api/src/main/java/io/kafbat/ui/controller/AuthController.java +++ /dev/null @@ -1,99 +0,0 @@ -package io.kafbat.ui.controller; - -import java.nio.charset.Charset; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.web.server.csrf.CsrfToken; -import org.springframework.util.MultiValueMap; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ServerWebExchange; -import reactor.core.publisher.Mono; - -@RestController -@RequiredArgsConstructor -@Slf4j -public class AuthController { - - @GetMapping(value = "/auth", produces = {"text/html"}) - public Mono getAuth(ServerWebExchange exchange) { - Mono token = exchange.getAttributeOrDefault(CsrfToken.class.getName(), Mono.empty()); - return token - .map(AuthController::csrfToken) - .defaultIfEmpty("") - .map(csrfTokenHtmlInput -> createPage(exchange, csrfTokenHtmlInput)); - } - - private byte[] createPage(ServerWebExchange exchange, String csrfTokenHtmlInput) { - MultiValueMap queryParams = exchange.getRequest() - .getQueryParams(); - String contextPath = exchange.getRequest().getPath().contextPath().value(); - String page = - "\n" + "\n" + " \n" - + " \n" - + " \n" - + " \n" - + " \n" - + " Please sign in\n" - + " \n" - + " \n" - + " \n" - + " \n" - + "
\n" - + formLogin(queryParams, contextPath, csrfTokenHtmlInput) - + "
\n" - + " \n" - + ""; - - return page.getBytes(Charset.defaultCharset()); - } - - private String formLogin( - MultiValueMap queryParams, - String contextPath, String csrfTokenHtmlInput) { - - boolean isError = queryParams.containsKey("error"); - boolean isLogoutSuccess = queryParams.containsKey("logout"); - return - "
\n" - + " \n" - + createError(isError) - + createLogoutSuccess(isLogoutSuccess) - + "

\n" - + " \n" - + " \n" - + "

\n" + "

\n" - + " \n" - + " \n" - + "

\n" + csrfTokenHtmlInput - + " \n" - + "
\n"; - } - - private static String csrfToken(CsrfToken token) { - return " \n"; - } - - private static String createError(boolean isError) { - return isError - ? "
Invalid credentials
" - : ""; - } - - private static String createLogoutSuccess(boolean isLogoutSuccess) { - return isLogoutSuccess - ? "
You have been signed out
" - : ""; - } -} diff --git a/api/src/main/java/io/kafbat/ui/controller/AuthenticationController.java b/api/src/main/java/io/kafbat/ui/controller/AuthenticationController.java new file mode 100644 index 000000000..c94c344c9 --- /dev/null +++ b/api/src/main/java/io/kafbat/ui/controller/AuthenticationController.java @@ -0,0 +1,22 @@ +package io.kafbat.ui.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +@RestController +@RequiredArgsConstructor +@Slf4j +public class AuthenticationController { + + private static final String INDEX_HTML = "/static/index.html"; + + @GetMapping(value = "/login", produces = {"text/html"}) + public Mono getLoginPage() { + return Mono.just(new ClassPathResource(INDEX_HTML)); + } + +} diff --git a/api/src/main/java/io/kafbat/ui/controller/AccessController.java b/api/src/main/java/io/kafbat/ui/controller/AuthorizationController.java similarity index 97% rename from api/src/main/java/io/kafbat/ui/controller/AccessController.java rename to api/src/main/java/io/kafbat/ui/controller/AuthorizationController.java index e5b1ea438..1ac0aeb85 100644 --- a/api/src/main/java/io/kafbat/ui/controller/AccessController.java +++ b/api/src/main/java/io/kafbat/ui/controller/AuthorizationController.java @@ -26,7 +26,7 @@ @RestController @RequiredArgsConstructor @Slf4j -public class AccessController implements AuthorizationApi { +public class AuthorizationController implements AuthorizationApi { private final AccessControlService accessControlService; diff --git a/api/src/main/java/io/kafbat/ui/service/ApplicationInfoService.java b/api/src/main/java/io/kafbat/ui/service/ApplicationInfoService.java index 7d380036c..7ee28b62d 100644 --- a/api/src/main/java/io/kafbat/ui/service/ApplicationInfoService.java +++ b/api/src/main/java/io/kafbat/ui/service/ApplicationInfoService.java @@ -1,16 +1,23 @@ package io.kafbat.ui.service; +import static io.kafbat.ui.api.model.AuthType.DISABLED; +import static io.kafbat.ui.api.model.AuthType.OAUTH2; import static io.kafbat.ui.model.ApplicationInfoDTO.EnabledFeaturesEnum; import static io.kafbat.ui.util.GithubReleaseInfo.GITHUB_RELEASE_INFO_TIMEOUT; import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.Streams; +import io.kafbat.ui.model.AppAuthenticationSettingsDTO; import io.kafbat.ui.model.ApplicationInfoBuildDTO; import io.kafbat.ui.model.ApplicationInfoDTO; import io.kafbat.ui.model.ApplicationInfoLatestReleaseDTO; +import io.kafbat.ui.model.AuthTypeDTO; +import io.kafbat.ui.model.OAuthProviderDTO; import io.kafbat.ui.util.DynamicConfigOperations; import io.kafbat.ui.util.GithubReleaseInfo; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.Properties; @@ -18,20 +25,27 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.info.BuildProperties; import org.springframework.boot.info.GitProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.core.ResolvableType; import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.stereotype.Service; @Service public class ApplicationInfoService { private final GithubReleaseInfo githubReleaseInfo; + private final ApplicationContext applicationContext; private final DynamicConfigOperations dynamicConfigOperations; private final BuildProperties buildProperties; private final GitProperties gitProperties; public ApplicationInfoService(DynamicConfigOperations dynamicConfigOperations, + ApplicationContext applicationContext, @Autowired(required = false) BuildProperties buildProperties, @Autowired(required = false) GitProperties gitProperties, @Value("${" + GITHUB_RELEASE_INFO_TIMEOUT + ":10}") int githubApiMaxWaitTime) { + this.applicationContext = applicationContext; this.dynamicConfigOperations = dynamicConfigOperations; this.buildProperties = Optional.ofNullable(buildProperties).orElse(new BuildProperties(new Properties())); this.gitProperties = Optional.ofNullable(gitProperties).orElse(new GitProperties(new Properties())); @@ -70,6 +84,38 @@ private List getEnabledFeatures() { return enabledFeatures; } + public AppAuthenticationSettingsDTO getAuthenticationProperties() { + return new AppAuthenticationSettingsDTO() + .authType(AuthTypeDTO.fromValue(getAuthType())) + .oAuthProviders(getOAuthProviders()); + } + + private String getAuthType() { + return Optional.ofNullable(applicationContext.getEnvironment().getProperty("auth.type")) + .orElse(DISABLED.getValue()); + } + + @SuppressWarnings("unchecked") + private List getOAuthProviders() { + if (!getAuthType().equalsIgnoreCase(OAUTH2.getValue())) { + return Collections.emptyList(); + } + var type = ResolvableType.forClassWithGenerics(Iterable.class, ClientRegistration.class); + String[] names = this.applicationContext.getBeanNamesForType(type); + var bean = (Iterable) (names.length == 1 ? this.applicationContext.getBean(names[0]) : null); + + if (bean == null) { + return Collections.emptyList(); + } + + return Streams.stream(bean.iterator()) + .filter(r -> AuthorizationGrantType.AUTHORIZATION_CODE.equals(r.getAuthorizationGrantType())) + .map(r -> new OAuthProviderDTO() + .clientName(r.getClientName()) + .authorizationUri("/oauth2/authorization/" + r.getRegistrationId())) + .toList(); + } + // updating on startup and every hour @Scheduled(fixedRateString = "${github-release-info-update-rate:3600000}") public void updateGithubReleaseInfo() { diff --git a/api/src/main/java/io/kafbat/ui/util/StaticFileWebFilter.java b/api/src/main/java/io/kafbat/ui/util/StaticFileWebFilter.java new file mode 100644 index 000000000..1b74bd374 --- /dev/null +++ b/api/src/main/java/io/kafbat/ui/util/StaticFileWebFilter.java @@ -0,0 +1,61 @@ +package io.kafbat.ui.util; + +import java.io.IOException; +import org.jetbrains.annotations.NotNull; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +public class StaticFileWebFilter implements WebFilter { + + private static final String INDEX_HTML = "/static/index.html"; + + private final ServerWebExchangeMatcher matcher; + private final String contents; + + public StaticFileWebFilter() { + this("/login", new ClassPathResource(INDEX_HTML)); + } + + public StaticFileWebFilter(String path, ClassPathResource resource) { + this.matcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, path); + + try { + this.contents = ResourceUtil.readAsString(resource); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public @NotNull Mono filter(@NotNull ServerWebExchange exchange, WebFilterChain chain) { + return this.matcher.matches(exchange) + .filter(ServerWebExchangeMatcher.MatchResult::isMatch) + .switchIfEmpty(chain.filter(exchange).then(Mono.empty())) + .flatMap((matchResult) -> this.render(exchange)); + } + + private Mono render(ServerWebExchange exchange) { + String contextPath = exchange.getRequest().getPath().contextPath().value(); + + String contentBody = contents + .replace("\"assets/", "\"" + contextPath + "/assets/") + .replace("PUBLIC-PATH-VARIABLE", contextPath); + + ServerHttpResponse result = exchange.getResponse(); + result.setStatusCode(HttpStatus.OK); + result.getHeaders().setContentType(MediaType.TEXT_HTML); + DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory(); + return result.writeWith(Mono.just(bufferFactory.wrap(contentBody.getBytes()))); + } + +} diff --git a/contract/src/main/resources/swagger/kafbat-ui-api.yaml b/contract/src/main/resources/swagger/kafbat-ui-api.yaml index 04cc17514..97d0e5b11 100644 --- a/contract/src/main/resources/swagger/kafbat-ui-api.yaml +++ b/contract/src/main/resources/swagger/kafbat-ui-api.yaml @@ -31,7 +31,6 @@ paths: items: $ref: '#/components/schemas/Cluster' - /api/clusters/{clusterName}/cache: post: tags: @@ -54,7 +53,6 @@ paths: 404: description: Not found - /api/clusters/{clusterName}/brokers: get: tags: @@ -432,7 +430,6 @@ paths: 404: description: Not found - /api/clusters/{clusterName}/topics/{topicName}: get: tags: @@ -2150,7 +2147,7 @@ paths: get: tags: - Authorization - summary: Get user authentication related info + summary: Get user authorization related info operationId: getUserAuthInfo responses: 200: @@ -2220,7 +2217,6 @@ paths: schema: $ref: '#/components/schemas/ApplicationConfigValidation' - /api/config/relatedfiles: post: tags: @@ -2244,6 +2240,43 @@ paths: schema: $ref: '#/components/schemas/UploadedFileInfo' + /api/config/authentication: + get: + tags: + - ApplicationConfig + summary: Get authentication methods enabled for the app and other related settings + operationId: getAuthenticationSettings + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/AppAuthenticationSettings' + + /login: + post: + tags: + - Unmapped + summary: Authenticate + operationId: authenticate + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + username: + type: string + password: + type: string + responses: + '200': + description: OK + '401': + description: Unauthorized + components: schemas: TopicSerdeSuggestion: @@ -2354,6 +2387,32 @@ components: htmlUrl: type: string + AppAuthenticationSettings: + type: object + properties: + authType: + $ref: '#/components/schemas/AuthType' + oAuthProviders: + type: array + items: + $ref: '#/components/schemas/OAuthProvider' + + OAuthProvider: + type: object + properties: + clientName: + type: string + authorizationUri: + type: string + + AuthType: + type: string + enum: + - DISABLED + - OAUTH2 + - LOGIN_FORM + - LDAP + Cluster: type: object properties: diff --git a/frontend/public/serviceImage.png b/frontend/public/serviceImage.png new file mode 100644 index 000000000..8006b13f5 Binary files /dev/null and b/frontend/public/serviceImage.png differ diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 16dd1305d..d15cb95a0 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -1,5 +1,5 @@ import React, { Suspense, useContext } from 'react'; -import { Routes, Route, Navigate } from 'react-router-dom'; +import { Routes, Route, Navigate, useMatch } from 'react-router-dom'; import { accessErrorPage, clusterPath, @@ -24,6 +24,7 @@ import { GlobalSettingsProvider } from './contexts/GlobalSettingsContext'; import { UserInfoRolesAccessProvider } from './contexts/UserInfoRolesAccessContext'; import PageContainer from './PageContainer/PageContainer'; +const AuthPage = React.lazy(() => import('components/AuthPage/AuthPage')); const Dashboard = React.lazy(() => import('components/Dashboard/Dashboard')); const ClusterPage = React.lazy( () => import('components/ClusterPage/ClusterPage') @@ -49,54 +50,59 @@ const queryClient = new QueryClient({ }); const App: React.FC = () => { const { isDarkMode } = useContext(ThemeModeContext); + const isAuthRoute = useMatch('/login'); return ( - - - }> - - - - - - - {['/', '/ui', '/ui/clusters'].map((path) => ( + + {isAuthRoute ? ( + + ) : ( + + }> + + + + + + + {['/', '/ui', '/ui/clusters'].map((path) => ( + } + /> + ))} } + path={getNonExactPath(clusterNewConfigPath)} + element={} /> - ))} - } - /> - } - /> - - } - /> - } /> - } - /> - - - - - - - - - - + } + /> + + } + /> + } /> + } + /> + + + + + + + + + + )} + ); diff --git a/frontend/src/components/AuthPage/AuthPage.styled.tsx b/frontend/src/components/AuthPage/AuthPage.styled.tsx new file mode 100644 index 000000000..16f86f714 --- /dev/null +++ b/frontend/src/components/AuthPage/AuthPage.styled.tsx @@ -0,0 +1,14 @@ +import styled, { css } from 'styled-components'; + +export const AuthPageStyled = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + min-height: 100vh; + background-color: ${theme.auth_page.backgroundColor}; + font-family: ${theme.auth_page.fontFamily}; + overflow-x: hidden; + ` +); diff --git a/frontend/src/components/AuthPage/AuthPage.tsx b/frontend/src/components/AuthPage/AuthPage.tsx new file mode 100644 index 000000000..ceae3069a --- /dev/null +++ b/frontend/src/components/AuthPage/AuthPage.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { useAuthSettings } from 'lib/hooks/api/appConfig'; + +import Header from './Header/Header'; +import SignIn from './SignIn/SignIn'; +import * as S from './AuthPage.styled'; + +function AuthPage() { + const { data } = useAuthSettings(); + + return ( + +
+ {data && ( + + )} + + ); +} + +export default AuthPage; diff --git a/frontend/src/components/AuthPage/Header/Header.styled.tsx b/frontend/src/components/AuthPage/Header/Header.styled.tsx new file mode 100644 index 000000000..4ba86f2bc --- /dev/null +++ b/frontend/src/components/AuthPage/Header/Header.styled.tsx @@ -0,0 +1,33 @@ +import styled, { css } from 'styled-components'; + +export const HeaderStyled = styled.div` + display: grid; + grid-template-columns: repeat(47, 41.11px); + grid-template-rows: repeat(4, 41.11px); + justify-content: center; + margin-bottom: 13.5px; +`; + +export const HeaderCell = styled.div<{ $sections?: number }>( + ({ theme, $sections }) => css` + border: 1.23px solid ${theme.auth_page.header.cellBorderColor}; + border-radius: 75.98px; + ${$sections && `grid-column: span ${$sections};`} + ` +); + +export const StyledSVG = styled.svg` + grid-column: span 3; +`; + +export const StyledRect = styled.rect( + ({ theme }) => css` + fill: ${theme.auth_page.header.LogoBgColor}; + ` +); + +export const StyledPath = styled.path( + ({ theme }) => css` + fill: ${theme.auth_page.header.LogoTextColor}; + ` +); diff --git a/frontend/src/components/AuthPage/Header/Header.tsx b/frontend/src/components/AuthPage/Header/Header.tsx new file mode 100644 index 000000000..16980af29 --- /dev/null +++ b/frontend/src/components/AuthPage/Header/Header.tsx @@ -0,0 +1,81 @@ +import React from 'react'; + +import * as S from './Header.styled'; +import HeaderLogo from './HeaderLogo'; + +function Header() { + return ( + + + {Array(2).fill()} + + {Array(2).fill()} + + {Array(2).fill()} + {Array(4).fill()} + {Array(2).fill()} + + {Array(2).fill()} + + {Array(3).fill()} + + {Array(2).fill()} + {Array(2).fill()} + {Array(2).fill()} + + + {Array(3).fill()} + {Array(8).fill()} + + {Array(2).fill()} + + {Array(3).fill()} + + {Array(6).fill()} + {Array(3).fill()} + + + {Array(2).fill()} + + + + + + + + {Array(2).fill()} + + + + {Array(3).fill()} + + + {Array(3).fill()} + + {Array(3).fill()} + {Array(3).fill()} + + + + + + {Array(2).fill()} + + {Array(2).fill()} + {Array(5).fill()} + {Array(2).fill()} + + + + {Array(5).fill()} + {Array(2).fill()} + + {Array(2).fill()} + + + + + ); +} + +export default Header; diff --git a/frontend/src/components/AuthPage/Header/HeaderLogo.tsx b/frontend/src/components/AuthPage/Header/HeaderLogo.tsx new file mode 100644 index 000000000..e5d9ca12d --- /dev/null +++ b/frontend/src/components/AuthPage/Header/HeaderLogo.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import * as S from './Header.styled'; + +const HeaderLogo = () => ( + + + + + + + + + + +); + +export default HeaderLogo; diff --git a/frontend/src/components/AuthPage/SignIn/BasicSignIn/BasicSignIn.styled.tsx b/frontend/src/components/AuthPage/SignIn/BasicSignIn/BasicSignIn.styled.tsx new file mode 100644 index 000000000..da1388b0a --- /dev/null +++ b/frontend/src/components/AuthPage/SignIn/BasicSignIn/BasicSignIn.styled.tsx @@ -0,0 +1,56 @@ +import styled from 'styled-components'; + +export const Fieldset = styled.fieldset` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + border: none; + width: 100%; +`; + +export const Form = styled.form` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 40px; + width: 100%; + + ${Fieldset} div { + width: 100%; + } +`; + +export const Field = styled.div` + ${({ theme }) => theme.auth_page.signIn.label}; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + gap: 4px; +`; + +export const Label = styled.label` + font-size: 12px; + font-weight: 500; + line-height: 16px; +`; + +export const ErrorMessage = styled.div` + display: flex; + column-gap: 2px; + align-items: center; + justify-content: center; + font-weight: 400; + font-size: 14px; + line-height: 20px; +`; + +export const ErrorMessageText = styled.span` + ${({ theme }) => theme.auth_page.signIn.errorMessage}; + font-weight: 400; + font-size: 14px; + line-height: 20px; +`; diff --git a/frontend/src/components/AuthPage/SignIn/BasicSignIn/BasicSignIn.tsx b/frontend/src/components/AuthPage/SignIn/BasicSignIn/BasicSignIn.tsx new file mode 100644 index 000000000..044f4781b --- /dev/null +++ b/frontend/src/components/AuthPage/SignIn/BasicSignIn/BasicSignIn.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { Button } from 'components/common/Button/Button'; +import Input from 'components/common/Input/Input'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; +import { useAuthenticate } from 'lib/hooks/api/appConfig'; +import AlertIcon from 'components/common/Icons/AlertIcon'; +import { useNavigate } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; + +import * as S from './BasicSignIn.styled'; + +interface FormValues { + username: string; + password: string; +} + +function BasicSignIn() { + const methods = useForm({ + defaultValues: { username: '', password: '' }, + }); + const navigate = useNavigate(); + const { mutateAsync, isLoading } = useAuthenticate(); + const client = useQueryClient(); + + const onSubmit = async (data: FormValues) => { + await mutateAsync(data, { + onSuccess: async (response) => { + if (response.raw.url.includes('error')) { + methods.setError('root', { message: 'error' }); + } else { + await client.invalidateQueries({ queryKey: ['app', 'info'] }); + navigate('/'); + } + }, + }); + }; + + return ( + + + + {methods.formState.errors.root && ( + + + + Username or password entered incorrectly + + + )} + ( + + Username + + + )} + /> + ( + + Password + + + )} + /> + + + + + ); +} + +export default BasicSignIn; diff --git a/frontend/src/components/AuthPage/SignIn/OAuthSignIn/AuthCard/AuthCard.styled.tsx b/frontend/src/components/AuthPage/SignIn/OAuthSignIn/AuthCard/AuthCard.styled.tsx new file mode 100644 index 000000000..d1eae050f --- /dev/null +++ b/frontend/src/components/AuthPage/SignIn/OAuthSignIn/AuthCard/AuthCard.styled.tsx @@ -0,0 +1,66 @@ +import styled, { css } from 'styled-components'; +import GitHubIcon from 'components/common/Icons/GitHubIcon'; +import { Button } from 'components/common/Button/Button'; + +export const AuthCardStyled = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + width: 400px; + border: 1px solid black; + border: 1px solid ${theme.auth_page.signIn.authCard.borderColor}; + border-radius: ${theme.auth_page.signIn.authCard.borderRadius}; + background-color: ${theme.auth_page.signIn.authCard.backgroundColor}; + ` +); + +export const ServiceData = styled.div( + ({ theme }) => css` + display: flex; + gap: 8px; + align-items: center; + + svg, + img { + margin: 8px; + width: 48px; + height: 48px; + } + + ${GitHubIcon} { + fill: ${theme.auth_page.icons.githubColor}; + } + ` +); + +export const ServiceDataTextContainer = styled.div` + display: flex; + flex-direction: column; +`; + +export const ServiceNameStyled = styled.span( + ({ theme }) => css` + color: ${theme.auth_page.signIn.authCard.serviceNamecolor}; + font-size: 16px; + font-weight: 500; + line-height: 24px; + ` +); + +export const ServiceTextStyled = styled.span( + ({ theme }) => css` + color: ${theme.auth_page.signIn.authCard.serviceTextColor}; + font-size: 12px; + font-weight: 500; + line-height: 16px; + ` +); + +export const ServiceButton = styled(Button)` + width: 100%; + border-radius: 8px; + font-size: 14px; + text-decoration: none; +`; diff --git a/frontend/src/components/AuthPage/SignIn/OAuthSignIn/AuthCard/AuthCard.tsx b/frontend/src/components/AuthPage/SignIn/OAuthSignIn/AuthCard/AuthCard.tsx new file mode 100644 index 000000000..b9a09812b --- /dev/null +++ b/frontend/src/components/AuthPage/SignIn/OAuthSignIn/AuthCard/AuthCard.tsx @@ -0,0 +1,41 @@ +import React, { ElementType, useState } from 'react'; +import ServiceImage from 'components/common/Icons/ServiceImage'; + +import * as S from './AuthCard.styled'; + +interface Props { + serviceName: string; + authPath: string | undefined; + Icon?: ElementType; +} + +function AuthCard({ serviceName, authPath, Icon = ServiceImage }: Props) { + const [isLoading, setIsLoading] = useState(false); + + return ( + + + + + {serviceName} + + Use an account issued by the organization + + + + { + setIsLoading(true); + window.location.replace(`${window.basePath}${authPath}`); + }} + inProgress={isLoading} + > + {!isLoading && `Log in with ${serviceName}`} + + + ); +} + +export default AuthCard; diff --git a/frontend/src/components/AuthPage/SignIn/OAuthSignIn/OAuthSignIn.styled.tsx b/frontend/src/components/AuthPage/SignIn/OAuthSignIn/OAuthSignIn.styled.tsx new file mode 100644 index 000000000..bf238e9b2 --- /dev/null +++ b/frontend/src/components/AuthPage/SignIn/OAuthSignIn/OAuthSignIn.styled.tsx @@ -0,0 +1,25 @@ +import styled from 'styled-components'; + +export const OAuthSignInStyled = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +export const ErrorMessage = styled.div` + display: flex; + column-gap: 2px; + align-items: center; + justify-content: center; + font-weight: 400; + font-size: 14px; + line-height: 20px; + margin-bottom: 8px; +`; + +export const ErrorMessageText = styled.span` + ${({ theme }) => theme.auth_page.signIn.errorMessage}; + font-weight: 400; + font-size: 14px; + line-height: 20px; +`; diff --git a/frontend/src/components/AuthPage/SignIn/OAuthSignIn/OAuthSignIn.tsx b/frontend/src/components/AuthPage/SignIn/OAuthSignIn/OAuthSignIn.tsx new file mode 100644 index 000000000..fca5b4925 --- /dev/null +++ b/frontend/src/components/AuthPage/SignIn/OAuthSignIn/OAuthSignIn.tsx @@ -0,0 +1,55 @@ +import React, { ElementType } from 'react'; +import GitHubIcon from 'components/common/Icons/GitHubIcon'; +import GoogleIcon from 'components/common/Icons/GoogleIcon'; +import CognitoIcon from 'components/common/Icons/CognitoIcon'; +import OktaIcon from 'components/common/Icons/OktaIcon'; +import KeycloakIcon from 'components/common/Icons/KeycloakIcon'; +import ServiceImage from 'components/common/Icons/ServiceImage'; +import { OAuthProvider } from 'generated-sources'; +import { useLocation } from 'react-router-dom'; +import AlertIcon from 'components/common/Icons/AlertIcon'; + +import * as S from './OAuthSignIn.styled'; +import AuthCard from './AuthCard/AuthCard'; + +interface Props { + oAuthProviders: OAuthProvider[] | undefined; +} + +const ServiceIconMap: Record = { + github: GitHubIcon, + google: GoogleIcon, + cognito: CognitoIcon, + keycloak: KeycloakIcon, + okta: OktaIcon, + unknownService: ServiceImage, +}; + +function OAuthSignIn({ oAuthProviders }: Props) { + const { search } = useLocation(); + + return ( + + {search.includes('error') && ( + + + Invalid credentials + + )} + {oAuthProviders?.map((provider) => ( + + ))} + + ); +} + +export default OAuthSignIn; diff --git a/frontend/src/components/AuthPage/SignIn/SignIn.styled.tsx b/frontend/src/components/AuthPage/SignIn/SignIn.styled.tsx new file mode 100644 index 000000000..0f24b45fd --- /dev/null +++ b/frontend/src/components/AuthPage/SignIn/SignIn.styled.tsx @@ -0,0 +1,19 @@ +import styled, { css } from 'styled-components'; + +export const SignInStyled = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 320px; + gap: 56px; + flex-grow: 1; +`; + +export const SignInTitle = styled.span( + ({ theme }) => css` + color: ${theme.auth_page.signIn.titleColor}; + font-size: 24px; + font-weight: 600; + ` +); diff --git a/frontend/src/components/AuthPage/SignIn/SignIn.tsx b/frontend/src/components/AuthPage/SignIn/SignIn.tsx new file mode 100644 index 000000000..987ee5ebf --- /dev/null +++ b/frontend/src/components/AuthPage/SignIn/SignIn.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { AuthType, OAuthProvider } from 'generated-sources'; + +import BasicSignIn from './BasicSignIn/BasicSignIn'; +import * as S from './SignIn.styled'; +import OAuthSignIn from './OAuthSignIn/OAuthSignIn'; + +interface Props { + authType?: AuthType; + oAuthProviders?: OAuthProvider[]; +} + +function SignInForm({ authType, oAuthProviders }: Props) { + return ( + + Sign in + {(authType === AuthType.LDAP || authType === AuthType.LOGIN_FORM) && ( + + )} + {authType === AuthType.OAUTH2 && ( + + )} + + ); +} + +export default SignInForm; diff --git a/frontend/src/components/NavBar/UserInfo/UserInfo.tsx b/frontend/src/components/NavBar/UserInfo/UserInfo.tsx index dae43364c..b52cc7631 100644 --- a/frontend/src/components/NavBar/UserInfo/UserInfo.tsx +++ b/frontend/src/components/NavBar/UserInfo/UserInfo.tsx @@ -19,7 +19,7 @@ const UserInfo = () => { } > - + Log out diff --git a/frontend/src/components/common/Button/Button.tsx b/frontend/src/components/common/Button/Button.tsx index 828b5d301..8964b6e17 100644 --- a/frontend/src/components/common/Button/Button.tsx +++ b/frontend/src/components/common/Button/Button.tsx @@ -9,6 +9,7 @@ export interface Props ButtonProps { to?: string | object; inProgress?: boolean; + className?: string; } export const Button: FC = ({ @@ -20,7 +21,7 @@ export const Button: FC = ({ }) => { if (to) { return ( - + {children} diff --git a/frontend/src/components/common/Icons/AlertIcon.tsx b/frontend/src/components/common/Icons/AlertIcon.tsx new file mode 100644 index 000000000..3c79f78e6 --- /dev/null +++ b/frontend/src/components/common/Icons/AlertIcon.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const AlertIcon: React.FC = () => { + return ( + + + + ); +}; + +export default AlertIcon; diff --git a/frontend/src/components/common/Icons/CognitoIcon.tsx b/frontend/src/components/common/Icons/CognitoIcon.tsx new file mode 100644 index 000000000..2d0b0d38a --- /dev/null +++ b/frontend/src/components/common/Icons/CognitoIcon.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import styled from 'styled-components'; + +function CognitoIcon() { + return ( + + + + + + + + + + + + + + + ); +} + +export default styled(CognitoIcon)``; diff --git a/frontend/src/components/common/Icons/GoogleIcon.tsx b/frontend/src/components/common/Icons/GoogleIcon.tsx new file mode 100644 index 000000000..2e569dbfe --- /dev/null +++ b/frontend/src/components/common/Icons/GoogleIcon.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import styled from 'styled-components'; + +function GoogleIcon() { + return ( + + + + + + + ); +} + +export default styled(GoogleIcon)``; diff --git a/frontend/src/components/common/Icons/KeycloakIcon.tsx b/frontend/src/components/common/Icons/KeycloakIcon.tsx new file mode 100644 index 000000000..e6b45ef69 --- /dev/null +++ b/frontend/src/components/common/Icons/KeycloakIcon.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import styled from 'styled-components'; + +function KeycloakIcon() { + return ( + + + + + ); +} + +export default styled(KeycloakIcon)``; diff --git a/frontend/src/components/common/Icons/OktaIcon.tsx b/frontend/src/components/common/Icons/OktaIcon.tsx new file mode 100644 index 000000000..a9d6871b0 --- /dev/null +++ b/frontend/src/components/common/Icons/OktaIcon.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import styled from 'styled-components'; + +function OktaIcon() { + return ( + + + + ); +} + +export default styled(OktaIcon)``; diff --git a/frontend/src/components/common/Icons/ServiceImage.tsx b/frontend/src/components/common/Icons/ServiceImage.tsx new file mode 100644 index 000000000..9311334f1 --- /dev/null +++ b/frontend/src/components/common/Icons/ServiceImage.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +interface Props { + serviceName: string; +} + +function ServiceImage({ serviceName }: Props) { + return {serviceName}; +} + +export default ServiceImage; diff --git a/frontend/src/components/contexts/GlobalSettingsContext.tsx b/frontend/src/components/contexts/GlobalSettingsContext.tsx index 4de05307b..5e906c292 100644 --- a/frontend/src/components/contexts/GlobalSettingsContext.tsx +++ b/frontend/src/components/contexts/GlobalSettingsContext.tsx @@ -1,6 +1,7 @@ import { useAppInfo } from 'lib/hooks/api/appConfig'; import React from 'react'; import { ApplicationInfoEnabledFeaturesEnum } from 'generated-sources'; +import { useNavigate } from 'react-router-dom'; interface GlobalSettingsContextProps { hasDynamicConfig: boolean; @@ -15,13 +16,26 @@ export const GlobalSettingsProvider: React.FC< React.PropsWithChildren > = ({ children }) => { const info = useAppInfo(); - const value = React.useMemo(() => { - const features = info.data?.enabledFeatures || []; - return { - hasDynamicConfig: features.includes( - ApplicationInfoEnabledFeaturesEnum.DYNAMIC_CONFIG - ), - }; + const navigate = useNavigate(); + const [value, setValue] = React.useState({ + hasDynamicConfig: false, + }); + + React.useEffect(() => { + if (info.data?.redirect && !info.isFetching) { + navigate('login'); + return; + } + + const features = info?.data?.response?.enabledFeatures; + + if (features) { + setValue({ + hasDynamicConfig: features.includes( + ApplicationInfoEnabledFeaturesEnum.DYNAMIC_CONFIG + ), + }); + } }, [info.data]); return ( diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 19423d2ac..d6f409ea2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -11,6 +11,7 @@ import { AuthorizationApi, ApplicationConfigApi, AclsApi, + UnmappedApi, } from 'generated-sources'; import { BASE_PARAMS } from 'lib/constants'; @@ -27,3 +28,4 @@ export const consumerGroupsApiClient = new ConsumerGroupsApi(apiClientConf); export const authApiClient = new AuthorizationApi(apiClientConf); export const appConfigApiClient = new ApplicationConfigApi(apiClientConf); export const aclApiClient = new AclsApi(apiClientConf); +export const internalApiClient = new UnmappedApi(apiClientConf); diff --git a/frontend/src/lib/hooks/api/appConfig.ts b/frontend/src/lib/hooks/api/appConfig.ts index e3ee0fdcb..a91c6eb4b 100644 --- a/frontend/src/lib/hooks/api/appConfig.ts +++ b/frontend/src/lib/hooks/api/appConfig.ts @@ -1,21 +1,52 @@ -import { appConfigApiClient as api } from 'lib/api'; +import { + appConfigApiClient as appConfig, + internalApiClient as internalApi, +} from 'lib/api'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ApplicationConfig, ApplicationConfigPropertiesKafkaClusters, + ApplicationInfo, } from 'generated-sources'; import { QUERY_REFETCH_OFF_OPTIONS } from 'lib/constants'; -export function useAppInfo() { +export function useAuthSettings() { return useQuery( - ['app', 'info'], - () => api.getApplicationInfo(), + ['app', 'authSettings'], + () => appConfig.getAuthenticationSettings(), QUERY_REFETCH_OFF_OPTIONS ); } +export function useAuthenticate() { + return useMutation({ + mutationFn: (params: { username: string; password: string }) => + internalApi.authenticateRaw(params, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }), + }); +} + +export function useAppInfo() { + return useQuery(['app', 'info'], async () => { + const data = await appConfig.getApplicationInfoRaw(); + + let response: ApplicationInfo = {}; + try { + response = await data.value(); + } catch { + response = {}; + } + + return { + redirect: data.raw.url.includes('auth'), + response, + }; + }); +} + export function useAppConfig() { - return useQuery(['app', 'config'], () => api.getCurrentConfig()); + return useQuery(['app', 'config'], () => appConfig.getCurrentConfig()); } function aggregateClusters( @@ -47,7 +78,7 @@ export function useUpdateAppConfig({ const client = useQueryClient(); return useMutation( async (cluster: ApplicationConfigPropertiesKafkaClusters) => { - const existingConfig = await api.getCurrentConfig(); + const existingConfig = await appConfig.getCurrentConfig(); const clusters = aggregateClusters( cluster, @@ -63,7 +94,7 @@ export function useUpdateAppConfig({ kafka: { clusters }, }, }; - return api.restartWithConfig({ restartRequest: { config } }); + return appConfig.restartWithConfig({ restartRequest: { config } }); }, { onSuccess: () => client.invalidateQueries(['app', 'config']), @@ -82,7 +113,7 @@ export function useAppConfigFilesUpload() { export function useValidateAppConfig() { return useMutation((config: ApplicationConfigPropertiesKafkaClusters) => - api.validateConfig({ + appConfig.validateConfig({ applicationConfig: { properties: { kafka: { clusters: [config] } } }, }) ); diff --git a/frontend/src/theme/theme.ts b/frontend/src/theme/theme.ts index f6cd2bacc..bdfe93271 100644 --- a/frontend/src/theme/theme.ts +++ b/frontend/src/theme/theme.ts @@ -57,6 +57,7 @@ const Colors = { '10': '#FAD1D1', '20': '#F5A3A3', '50': '#E51A1A', + '52': '#E63B19', '55': '#CF1717', '60': '#B81414', }, @@ -79,6 +80,45 @@ const Colors = { const baseTheme = { defaultIconColor: Colors.neutral[50], + auth_page: { + backgroundColor: Colors.brand[0], + fontFamily: 'Inter, sans-serif', + header: { + cellBorderColor: Colors.brand[10], + LogoBgColor: Colors.brand[90], + LogoTextColor: Colors.brand[0], + }, + signIn: { + titleColor: Colors.brand[90], + errorMessage: { + color: Colors.red[52], + }, + label: { + color: Colors.brand[70], + }, + authCard: { + borderRadius: '16px', + borderColor: Colors.brand[10], + backgroundColor: Colors.brand[0], + serviceNamecolor: Colors.brand[90], + serviceTextColor: Colors.brand[50], + }, + }, + footer: { + fontSize: '12px', + span: { + color: Colors.brand[70], + fontWeight: 500, + }, + p: { + color: Colors.brand[50], + fontWeight: 400, + }, + }, + icons: { + githubColor: Colors.brand[90], + }, + }, heading: { h1: { color: Colors.neutral[90], @@ -821,6 +861,38 @@ export type ThemeType = typeof theme; export const darkTheme: ThemeType = { ...baseTheme, + auth_page: { + backgroundColor: Colors.neutral[90], + fontFamily: baseTheme.auth_page.fontFamily, + header: { + cellBorderColor: Colors.brand[80], + LogoBgColor: Colors.brand[0], + LogoTextColor: Colors.brand[90], + }, + signIn: { + ...baseTheme.auth_page.signIn, + titleColor: Colors.brand[0], + label: { + color: Colors.brand[30], + }, + authCard: { + ...baseTheme.auth_page.signIn.authCard, + borderColor: Colors.brand[80], + backgroundColor: Colors.brand[85], + serviceNamecolor: Colors.brand[0], + }, + }, + footer: { + ...baseTheme.auth_page.footer, + span: { + color: Colors.brand[10], + fontWeight: 500, + }, + }, + icons: { + githubColor: Colors.brand[0], + }, + }, logo: { color: '#FDFDFD', }, diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3a4e861e9..455ef39ae 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react-swc'; import tsconfigPaths from 'vite-tsconfig-paths'; import { ViteEjsPlugin } from 'vite-plugin-ejs'; import checker from 'vite-plugin-checker'; +import { IncomingMessage } from 'http'; export default defineConfig(({ mode }) => { process.env = { ...process.env, ...loadEnv(mode, process.cwd()) }; @@ -87,6 +88,21 @@ export default defineConfig(({ mode }) => { ...defaultConfig.server, open: true, proxy: { + '/login': { + target: isProxy, + changeOrigin: true, + secure: false, + bypass: (req: IncomingMessage) => { + if (req.method === 'GET') { + return req.url; + } + }, + }, + '/logout': { + target: isProxy, + changeOrigin: true, + secure: false, + }, '/api': { target: isProxy, changeOrigin: true,