Kaynağa Gözat

Merge branch 'master' into master

Roman Zabaluev 2 yıl önce
ebeveyn
işleme
81f9270999

+ 12 - 5
kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/KafkaConfigSanitizer.java

@@ -5,11 +5,13 @@ import static java.util.regex.Pattern.CASE_INSENSITIVE;
 import com.google.common.collect.ImmutableList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
+import javax.annotation.Nullable;
 import org.apache.kafka.common.config.ConfigDef;
 import org.apache.kafka.common.config.SaslConfigs;
 import org.apache.kafka.common.config.SslConfigs;
@@ -17,7 +19,7 @@ import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Component;
 
 @Component
-class KafkaConfigSanitizer  {
+class KafkaConfigSanitizer {
 
   private static final String SANITIZED_VALUE = "******";
 
@@ -65,10 +67,8 @@ class KafkaConfigSanitizer  {
         .collect(Collectors.toSet());
   }
 
-  public Object sanitize(String key, Object value) {
-    if (value == null) {
-      return null;
-    }
+  @Nullable
+  public Object sanitize(String key, @Nullable Object value) {
     for (Pattern pattern : sanitizeKeysPatterns) {
       if (pattern.matcher(key).matches()) {
         return SANITIZED_VALUE;
@@ -77,5 +77,12 @@ class KafkaConfigSanitizer  {
     return value;
   }
 
+  public Map<String, Object> sanitizeConnectorConfig(@Nullable Map<String, Object> original) {
+    var result = new HashMap<String, Object>(); //null-values supporting map!
+    if (original != null) {
+      original.forEach((k, v) -> result.put(k, sanitize(k, v)));
+    }
+    return result;
+  }
 
 }

+ 4 - 15
kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/KafkaConnectService.java

@@ -24,7 +24,6 @@ import com.provectus.kafka.ui.model.NewConnectorDTO;
 import com.provectus.kafka.ui.model.TaskDTO;
 import com.provectus.kafka.ui.model.connect.InternalConnectInfo;
 import com.provectus.kafka.ui.util.ReactiveFailover;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -176,19 +175,14 @@ public class KafkaConnectService {
                         e -> emptyStatus(connectorName))
                     .map(connectorStatus -> {
                       var status = connectorStatus.getConnector();
-                      final Map<String, Object> obfuscatedConfig = connector.getConfig().entrySet()
-                          .stream()
-                          .collect(Collectors.toMap(
-                              Map.Entry::getKey,
-                              e -> kafkaConfigSanitizer.sanitize(e.getKey(), e.getValue())
-                          ));
-                      ConnectorDTO result = (ConnectorDTO) new ConnectorDTO()
+                      var sanitizedConfig = kafkaConfigSanitizer.sanitizeConnectorConfig(connector.getConfig());
+                      ConnectorDTO result = new ConnectorDTO()
                           .connect(connectName)
                           .status(kafkaConnectMapper.fromClient(status))
                           .type(connector.getType())
                           .tasks(connector.getTasks())
                           .name(connector.getName())
-                          .config(obfuscatedConfig);
+                          .config(sanitizedConfig);
 
                       if (connectorStatus.getTasks() != null) {
                         boolean isAnyTaskFailed = connectorStatus.getTasks().stream()
@@ -217,12 +211,7 @@ public class KafkaConnectService {
                                                       String connectorName) {
     return api(cluster, connectName)
         .mono(c -> c.getConnectorConfig(connectorName))
-        .map(connectorConfig -> {
-          final Map<String, Object> obfuscatedMap = new HashMap<>();
-          connectorConfig.forEach((key, value) ->
-              obfuscatedMap.put(key, kafkaConfigSanitizer.sanitize(key, value)));
-          return obfuscatedMap;
-        });
+        .map(kafkaConfigSanitizer::sanitizeConnectorConfig);
   }
 
   public Mono<ConnectorDTO> setConnectorConfig(KafkaCluster cluster, String connectName,

+ 24 - 4
kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/KafkaConfigSanitizerTest.java

@@ -3,14 +3,16 @@ package com.provectus.kafka.ui.service;
 import static org.assertj.core.api.Assertions.assertThat;
 
 import java.util.Arrays;
-import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 import org.junit.jupiter.api.Test;
 
 class KafkaConfigSanitizerTest {
 
   @Test
   void doNothingIfEnabledPropertySetToFalse() {
-    final var sanitizer = new KafkaConfigSanitizer(false, Collections.emptyList());
+    final var sanitizer = new KafkaConfigSanitizer(false, List.of());
     assertThat(sanitizer.sanitize("password", "secret")).isEqualTo("secret");
     assertThat(sanitizer.sanitize("sasl.jaas.config", "secret")).isEqualTo("secret");
     assertThat(sanitizer.sanitize("database.password", "secret")).isEqualTo("secret");
@@ -18,7 +20,7 @@ class KafkaConfigSanitizerTest {
 
   @Test
   void obfuscateCredentials() {
-    final var sanitizer = new KafkaConfigSanitizer(true, Collections.emptyList());
+    final var sanitizer = new KafkaConfigSanitizer(true, List.of());
     assertThat(sanitizer.sanitize("sasl.jaas.config", "secret")).isEqualTo("******");
     assertThat(sanitizer.sanitize("consumer.sasl.jaas.config", "secret")).isEqualTo("******");
     assertThat(sanitizer.sanitize("producer.sasl.jaas.config", "secret")).isEqualTo("******");
@@ -36,7 +38,7 @@ class KafkaConfigSanitizerTest {
 
   @Test
   void notObfuscateNormalConfigs() {
-    final var sanitizer = new KafkaConfigSanitizer(true, Collections.emptyList());
+    final var sanitizer = new KafkaConfigSanitizer(true, List.of());
     assertThat(sanitizer.sanitize("security.protocol", "SASL_SSL")).isEqualTo("SASL_SSL");
     final String[] bootstrapServer = new String[] {"test1:9092", "test2:9092"};
     assertThat(sanitizer.sanitize("bootstrap.servers", bootstrapServer)).isEqualTo(bootstrapServer);
@@ -52,4 +54,22 @@ class KafkaConfigSanitizerTest {
     assertThat(sanitizer.sanitize("database.password", "no longer credential"))
             .isEqualTo("no longer credential");
   }
+
+  @Test
+  void sanitizeConnectorConfigDoNotFailOnNullableValues() {
+    Map<String, Object> originalConfig = new HashMap<>();
+    originalConfig.put("password", "secret");
+    originalConfig.put("asIs", "normal");
+    originalConfig.put("nullVal", null);
+
+    var sanitizedConfig = new KafkaConfigSanitizer(true, List.of())
+        .sanitizeConnectorConfig(originalConfig);
+
+    assertThat(sanitizedConfig)
+        .hasSize(3)
+        .containsEntry("password", "******")
+        .containsEntry("asIs", "normal")
+        .containsEntry("nullVal", null);
+  }
+
 }

+ 5 - 0
kafka-ui-e2e-checks/src/main/java/com/provectus/kafka/ui/pages/BasePage.java

@@ -28,6 +28,7 @@ public abstract class BasePage extends WebUtils {
   protected SelenideElement confirmBtn = $x("//button[contains(text(),'Confirm')]");
   protected SelenideElement cancelBtn = $x("//button[contains(text(),'Cancel')]");
   protected SelenideElement backBtn = $x("//button[contains(text(),'Back')]");
+  protected SelenideElement previousBtn = $x("//button[contains(text(),'Previous')]");
   protected SelenideElement nextBtn = $x("//button[contains(text(),'Next')]");
   protected ElementsCollection ddlOptions = $$x("//li[@value]");
   protected ElementsCollection gridItems = $$x("//tr[@class]");
@@ -75,6 +76,10 @@ public abstract class BasePage extends WebUtils {
     clickByJavaScript(backBtn);
   }
 
+  protected void clickPreviousBtn() {
+    clickByJavaScript(previousBtn);
+  }
+
   protected void setJsonInputValue(SelenideElement jsonInput, String jsonConfig) {
     sendKeysByActions(jsonInput, jsonConfig.replace("  ", ""));
     new Actions(WebDriverRunner.getWebDriver())

+ 7 - 0
kafka-ui-e2e-checks/src/main/java/com/provectus/kafka/ui/pages/brokers/BrokersConfigTab.java

@@ -66,6 +66,13 @@ public class BrokersConfigTab extends BasePage {
     return this;
   }
 
+  @Step
+  public BrokersConfigTab clickPreviousButton() {
+    clickPreviousBtn();
+    waitUntilSpinnerDisappear(1);
+    return this;
+  }
+
   private List<BrokersConfigTab.BrokersConfigItem> initGridItems() {
     List<BrokersConfigTab.BrokersConfigItem> gridItemList = new ArrayList<>();
     gridItems.shouldHave(CollectionCondition.sizeGreaterThan(0))

+ 2 - 9
kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/manualsuite/backlog/SmokeBacklog.java

@@ -64,24 +64,17 @@ public class SmokeBacklog extends BaseManualTest {
   public void testCaseG() {
   }
 
-  @Automation(state = TO_BE_AUTOMATED)
-  @Suite(id = BROKERS_SUITE_ID)
-  @QaseId(350)
-  @Test
-  public void testCaseH() {
-  }
-
   @Automation(state = NOT_AUTOMATED)
   @Suite(id = TOPICS_SUITE_ID)
   @QaseId(50)
   @Test
-  public void testCaseI() {
+  public void testCaseH() {
   }
 
   @Automation(state = NOT_AUTOMATED)
   @Suite(id = SCHEMAS_SUITE_ID)
   @QaseId(351)
   @Test
-  public void testCaseJ() {
+  public void testCaseI() {
   }
 }

+ 35 - 7
kafka-ui-e2e-checks/src/test/java/com/provectus/kafka/ui/smokesuite/brokers/BrokersTest.java

@@ -50,11 +50,11 @@ public class BrokersTest extends BaseTest {
   @Issue("https://github.com/provectus/kafka-ui/issues/3347")
   @QaseId(330)
   @Test
-  public void brokersConfigSearchCheck() {
+  public void brokersConfigFirstPageSearchCheck() {
     navigateToBrokersAndOpenDetails(DEFAULT_BROKER_ID);
     brokersDetails
         .openDetailsTab(CONFIGS);
-    String anyConfigKey = brokersConfigTab
+    String anyConfigKeyFirstPage = brokersConfigTab
         .getAllConfigs().stream()
         .findAny().orElseThrow()
         .getKey();
@@ -62,14 +62,42 @@ public class BrokersTest extends BaseTest {
         .clickNextButton();
     Assert.assertFalse(brokersConfigTab.getAllConfigs().stream()
             .map(BrokersConfigTab.BrokersConfigItem::getKey)
-            .toList().contains(anyConfigKey),
-        String.format("getAllConfigs().contains(%s)", anyConfigKey));
+            .toList().contains(anyConfigKeyFirstPage),
+        String.format("getAllConfigs().contains(%s)", anyConfigKeyFirstPage));
     brokersConfigTab
-        .searchConfig(anyConfigKey);
+        .searchConfig(anyConfigKeyFirstPage);
     Assert.assertTrue(brokersConfigTab.getAllConfigs().stream()
             .map(BrokersConfigTab.BrokersConfigItem::getKey)
-            .toList().contains(anyConfigKey),
-        String.format("getAllConfigs().contains(%s)", anyConfigKey));
+            .toList().contains(anyConfigKeyFirstPage),
+        String.format("getAllConfigs().contains(%s)", anyConfigKeyFirstPage));
+  }
+
+  @Ignore
+  @Issue("https://github.com/provectus/kafka-ui/issues/3347")
+  @QaseId(350)
+  @Test
+  public void brokersConfigSecondPageSearchCheck() {
+    navigateToBrokersAndOpenDetails(DEFAULT_BROKER_ID);
+    brokersDetails
+        .openDetailsTab(CONFIGS);
+    brokersConfigTab
+        .clickNextButton();
+    String anyConfigKeySecondPage = brokersConfigTab
+        .getAllConfigs().stream()
+        .findAny().orElseThrow()
+        .getKey();
+    brokersConfigTab
+        .clickPreviousButton();
+    Assert.assertFalse(brokersConfigTab.getAllConfigs().stream()
+            .map(BrokersConfigTab.BrokersConfigItem::getKey)
+            .toList().contains(anyConfigKeySecondPage),
+        String.format("getAllConfigs().contains(%s)", anyConfigKeySecondPage));
+    brokersConfigTab
+        .searchConfig(anyConfigKeySecondPage);
+    Assert.assertTrue(brokersConfigTab.getAllConfigs().stream()
+            .map(BrokersConfigTab.BrokersConfigItem::getKey)
+            .toList().contains(anyConfigKeySecondPage),
+        String.format("getAllConfigs().contains(%s)", anyConfigKeySecondPage));
   }
 
   @QaseId(331)

+ 4 - 3
kafka-ui-react-app/src/components/App.tsx

@@ -1,4 +1,4 @@
-import React, { Suspense } from 'react';
+import React, { Suspense, useContext } from 'react';
 import { Routes, Route, Navigate } from 'react-router-dom';
 import {
   accessErrorPage,
@@ -18,6 +18,7 @@ import { Toaster } from 'react-hot-toast';
 import GlobalCSS from 'components/globalCss';
 import * as S from 'components/App.styled';
 import ClusterConfigForm from 'widgets/ClusterConfigForm';
+import { ThemeModeContext } from 'components/contexts/ThemeModeContext';
 
 import ConfirmationModal from './common/ConfirmationModal/ConfirmationModal';
 import { ConfirmContextProvider } from './contexts/ConfirmContext';
@@ -42,7 +43,7 @@ const queryClient = new QueryClient({
   },
 });
 const App: React.FC = () => {
-  const [isDarkMode, setDarkMode] = React.useState<boolean>(false);
+  const { isDarkMode } = useContext(ThemeModeContext);
 
   return (
     <QueryClientProvider client={queryClient}>
@@ -53,7 +54,7 @@ const App: React.FC = () => {
               <ConfirmContextProvider>
                 <GlobalCSS />
                 <S.Layout>
-                  <PageContainer setDarkMode={setDarkMode}>
+                  <PageContainer>
                     <Routes>
                       {['/', '/ui', '/ui/clusters'].map((path) => (
                         <Route

+ 6 - 42
kafka-ui-react-app/src/components/NavBar/NavBar.tsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useContext } from 'react';
 import Select from 'components/common/Select/Select';
 import Logo from 'components/common/Logo/Logo';
 import Version from 'components/Version/Version';
@@ -7,16 +7,16 @@ import DiscordIcon from 'components/common/Icons/DiscordIcon';
 import AutoIcon from 'components/common/Icons/AutoIcon';
 import SunIcon from 'components/common/Icons/SunIcon';
 import MoonIcon from 'components/common/Icons/MoonIcon';
+import { ThemeModeContext } from 'components/contexts/ThemeModeContext';
 
 import UserInfo from './UserInfo/UserInfo';
 import * as S from './NavBar.styled';
 
 interface Props {
   onBurgerClick: () => void;
-  setDarkMode: (value: boolean) => void;
 }
 
-type ThemeDropDownValue = 'auto_theme' | 'light_theme' | 'dark_theme';
+export type ThemeDropDownValue = 'auto_theme' | 'light_theme' | 'dark_theme';
 
 const options = [
   {
@@ -48,44 +48,8 @@ const options = [
   },
 ];
 
-const NavBar: React.FC<Props> = ({ onBurgerClick, setDarkMode }) => {
-  const matchDark = window.matchMedia('(prefers-color-scheme: dark)');
-  const [themeMode, setThemeMode] = React.useState<ThemeDropDownValue>();
-
-  React.useLayoutEffect(() => {
-    const mode = localStorage.getItem('mode');
-    if (mode) {
-      setThemeMode(mode as ThemeDropDownValue);
-      if (mode === 'auto_theme') {
-        setDarkMode(matchDark.matches);
-      } else if (mode === 'light_theme') {
-        setDarkMode(false);
-      } else if (mode === 'dark_theme') {
-        setDarkMode(true);
-      }
-    } else {
-      setThemeMode('auto_theme');
-    }
-  }, []);
-
-  React.useEffect(() => {
-    if (themeMode === 'auto_theme') {
-      setDarkMode(matchDark.matches);
-      matchDark.addListener((e) => {
-        setDarkMode(e.matches);
-      });
-    }
-  }, [matchDark, themeMode]);
-
-  const onChangeThemeMode = (value: string | number) => {
-    setThemeMode(value as ThemeDropDownValue);
-    localStorage.setItem('mode', value as string);
-    if (value === 'light_theme') {
-      setDarkMode(false);
-    } else if (value === 'dark_theme') {
-      setDarkMode(true);
-    }
-  };
+const NavBar: React.FC<Props> = ({ onBurgerClick }) => {
+  const { themeMode, setThemeMode } = useContext(ThemeModeContext);
 
   return (
     <S.Navbar role="navigation" aria-label="Page Header">
@@ -117,7 +81,7 @@ const NavBar: React.FC<Props> = ({ onBurgerClick, setDarkMode }) => {
         <Select
           options={options}
           value={themeMode}
-          onChange={onChangeThemeMode}
+          onChange={setThemeMode}
           isThemeMode
         />
         <S.SocialLink

+ 27 - 6
kafka-ui-react-app/src/components/PageContainer/PageContainer.tsx

@@ -1,27 +1,48 @@
-import React, { PropsWithChildren } from 'react';
-import { useLocation } from 'react-router-dom';
+import React, { PropsWithChildren, useEffect, useMemo } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
 import NavBar from 'components/NavBar/NavBar';
 import * as S from 'components/PageContainer/PageContainer.styled';
 import Nav from 'components/Nav/Nav';
 import useBoolean from 'lib/hooks/useBoolean';
+import { clusterNewConfigPath } from 'lib/paths';
+import { GlobalSettingsContext } from 'components/contexts/GlobalSettingsContext';
+import { useClusters } from 'lib/hooks/api/clusters';
+import { ResourceType } from 'generated-sources';
+import { useGetUserInfo } from 'lib/hooks/api/roles';
 
-const PageContainer: React.FC<
-  PropsWithChildren<{ setDarkMode: (value: boolean) => void }>
-> = ({ children, setDarkMode }) => {
+const PageContainer: React.FC<PropsWithChildren<unknown>> = ({ children }) => {
   const {
     value: isSidebarVisible,
     toggle,
     setFalse: closeSidebar,
   } = useBoolean(false);
+  const clusters = useClusters();
+  const appInfo = React.useContext(GlobalSettingsContext);
   const location = useLocation();
+  const navigate = useNavigate();
+  const { data: authInfo } = useGetUserInfo();
 
   React.useEffect(() => {
     closeSidebar();
   }, [location, closeSidebar]);
 
+  const hasApplicationPermissions = useMemo(() => {
+    if (!authInfo?.rbacEnabled) return true;
+    return !!authInfo?.userInfo?.permissions.some(
+      (permission) => permission.resource === ResourceType.APPLICATIONCONFIG
+    );
+  }, [authInfo]);
+
+  useEffect(() => {
+    if (!appInfo.hasDynamicConfig) return;
+    if (clusters?.data?.length !== 0) return;
+    if (!hasApplicationPermissions) return;
+    navigate(clusterNewConfigPath);
+  }, [clusters?.data, appInfo.hasDynamicConfig]);
+
   return (
     <>
-      <NavBar onBurgerClick={toggle} setDarkMode={setDarkMode} />
+      <NavBar onBurgerClick={toggle} />
       <S.Container>
         <S.Sidebar aria-label="Sidebar" $visible={isSidebarVisible}>
           <Nav />

+ 44 - 13
kafka-ui-react-app/src/components/PageContainer/__tests__/PageContainer.spec.tsx

@@ -4,21 +4,24 @@ import userEvent from '@testing-library/user-event';
 import { render } from 'lib/testHelpers';
 import PageContainer from 'components/PageContainer/PageContainer';
 import { useClusters } from 'lib/hooks/api/clusters';
+import { Cluster, ServerStatus } from 'generated-sources';
 
 const burgerButtonOptions = { name: 'burger' };
 
-jest.mock('lib/hooks/api/clusters', () => ({
-  ...jest.requireActual('lib/hooks/api/roles'),
-  useClusters: jest.fn(),
-}));
-
 jest.mock('components/Version/Version', () => () => <div>Version</div>);
-
+interface DataType {
+  data: Cluster[] | undefined;
+}
+jest.mock('lib/hooks/api/clusters');
+const mockedNavigate = jest.fn();
+jest.mock('react-router-dom', () => ({
+  ...jest.requireActual('react-router-dom'),
+  useNavigate: () => mockedNavigate,
+}));
 describe('Page Container', () => {
-  beforeEach(() => {
-    (useClusters as jest.Mock).mockImplementation(() => ({
-      isSuccess: false,
-    }));
+  const renderComponent = (hasDynamicConfig: boolean, data: DataType) => {
+    const useClustersMock = useClusters as jest.Mock;
+    useClustersMock.mockReturnValue(data);
     Object.defineProperty(window, 'matchMedia', {
       writable: true,
       value: jest.fn().mockImplementation(() => ({
@@ -26,15 +29,18 @@ describe('Page Container', () => {
         addListener: jest.fn(),
       })),
     });
-
     render(
       <PageContainer setDarkMode={jest.fn()}>
         <div>child</div>
-      </PageContainer>
+      </PageContainer>,
+      {
+        globalSettings: { hasDynamicConfig },
+      }
     );
-  });
+  };
 
   it('handle burger click correctly', async () => {
+    renderComponent(false, { data: undefined });
     const burger = within(screen.getByLabelText('Page Header')).getByRole(
       'button',
       burgerButtonOptions
@@ -49,6 +55,31 @@ describe('Page Container', () => {
   });
 
   it('render the inner container', async () => {
+    renderComponent(false, { data: undefined });
     expect(screen.getByText('child')).toBeInTheDocument();
   });
+
+  describe('Redirect to the Wizard page', () => {
+    it('redirects to new cluster configuration page if there are no clusters and dynamic config is enabled', async () => {
+      await renderComponent(true, { data: [] });
+
+      expect(mockedNavigate).toHaveBeenCalled();
+    });
+
+    it('should not navigate to new cluster config page when there are clusters', async () => {
+      await renderComponent(true, {
+        data: [{ name: 'Cluster 1', status: ServerStatus.ONLINE }],
+      });
+
+      expect(mockedNavigate).not.toHaveBeenCalled();
+    });
+
+    it('should not navigate to new cluster config page when there are no clusters and hasDynamicConfig is false', async () => {
+      await renderComponent(false, {
+        data: [],
+      });
+
+      expect(mockedNavigate).not.toHaveBeenCalled();
+    });
+  });
 });

+ 6 - 2
kafka-ui-react-app/src/components/common/SQLEditor/SQLEditor.tsx

@@ -3,7 +3,9 @@ import AceEditor, { IAceEditorProps } from 'react-ace';
 import 'ace-builds/src-noconflict/ace';
 import 'ace-builds/src-noconflict/mode-sql';
 import 'ace-builds/src-noconflict/theme-textmate';
-import React from 'react';
+import 'ace-builds/src-noconflict/theme-dracula';
+import React, { useContext } from 'react';
+import { ThemeModeContext } from 'components/contexts/ThemeModeContext';
 
 interface SQLEditorProps extends IAceEditorProps {
   isFixedHeight?: boolean;
@@ -12,11 +14,13 @@ interface SQLEditorProps extends IAceEditorProps {
 const SQLEditor = React.forwardRef<AceEditor | null, SQLEditorProps>(
   (props, ref) => {
     const { isFixedHeight, ...rest } = props;
+    const { isDarkMode } = useContext(ThemeModeContext);
+
     return (
       <AceEditor
         ref={ref}
         mode="sql"
-        theme="textmate"
+        theme={isDarkMode ? 'dracula' : 'textmate'}
         tabSize={2}
         width="100%"
         height={

+ 58 - 0
kafka-ui-react-app/src/components/contexts/ThemeModeContext.tsx

@@ -0,0 +1,58 @@
+import React, { useMemo } from 'react';
+import type { FC, PropsWithChildren } from 'react';
+import type { ThemeDropDownValue } from 'components/NavBar/NavBar';
+
+interface ThemeModeContextProps {
+  isDarkMode: boolean;
+  themeMode: ThemeDropDownValue;
+  setThemeMode: (value: string | number) => void;
+}
+
+export const ThemeModeContext = React.createContext<ThemeModeContextProps>({
+  isDarkMode: false,
+  themeMode: 'auto_theme',
+  setThemeMode: () => {},
+});
+
+export const ThemeModeProvider: FC<PropsWithChildren<unknown>> = ({
+  children,
+}) => {
+  const matchDark = window.matchMedia('(prefers-color-scheme: dark)');
+  const [themeMode, setThemeModeState] =
+    React.useState<ThemeDropDownValue>('auto_theme');
+
+  React.useLayoutEffect(() => {
+    const mode = localStorage.getItem('mode');
+    setThemeModeState((mode as ThemeDropDownValue) ?? 'auto_theme');
+  }, [setThemeModeState]);
+
+  const isDarkMode = React.useMemo(() => {
+    if (themeMode === 'auto_theme') {
+      return matchDark.matches;
+    }
+    return themeMode === 'dark_theme';
+  }, [themeMode]);
+
+  const setThemeMode = React.useCallback(
+    (value: string | number) => {
+      setThemeModeState(value as ThemeDropDownValue);
+      localStorage.setItem('mode', value as string);
+    },
+    [setThemeModeState]
+  );
+
+  const contextValue = useMemo(
+    () => ({
+      isDarkMode,
+      themeMode,
+      setThemeMode,
+    }),
+    [isDarkMode, themeMode, setThemeMode]
+  );
+
+  return (
+    <ThemeModeContext.Provider value={contextValue}>
+      {children}
+    </ThemeModeContext.Provider>
+  );
+};

+ 4 - 1
kafka-ui-react-app/src/index.tsx

@@ -2,6 +2,7 @@ import React from 'react';
 import { createRoot } from 'react-dom/client';
 import { BrowserRouter } from 'react-router-dom';
 import { Provider } from 'react-redux';
+import { ThemeModeProvider } from 'components/contexts/ThemeModeContext';
 import App from 'components/App';
 import { store } from 'redux/store';
 import 'lib/constants';
@@ -14,7 +15,9 @@ const root = createRoot(container);
 root.render(
   <Provider store={store}>
     <BrowserRouter basename={window.basePath || '/'}>
-      <App />
+      <ThemeModeProvider>
+        <App />
+      </ThemeModeProvider>
     </BrowserRouter>
   </Provider>
 );

+ 7 - 1
kafka-ui-react-app/src/lib/testHelpers.tsx

@@ -39,6 +39,9 @@ interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
     roles?: RolesType;
     rbacFlag: boolean;
   };
+  globalSettings?: {
+    hasDynamicConfig: boolean;
+  };
 }
 
 interface WithRouteProps {
@@ -111,6 +114,7 @@ const customRender = (
     }),
     initialEntries,
     userInfo,
+    globalSettings,
     ...renderOptions
   }: CustomRenderOptions = {}
 ) => {
@@ -119,7 +123,9 @@ const customRender = (
     children,
   }) => (
     <TestQueryClientProvider>
-      <GlobalSettingsContext.Provider value={{ hasDynamicConfig: false }}>
+      <GlobalSettingsContext.Provider
+        value={globalSettings || { hasDynamicConfig: false }}
+      >
         <ThemeProvider theme={theme}>
           <TestUserInfoProvider data={userInfo}>
             <ConfirmContextProvider>

+ 7 - 1
kafka-ui-react-app/src/widgets/ClusterConfigForm/utils/getJaasConfig.ts

@@ -20,7 +20,13 @@ export const getJaasConfig = (
   options: Record<string, string>
 ) => {
   const optionsString = Object.entries(options)
-    .map(([key, value]) => (isUndefined(value) ? null : ` ${key}="${value}"`))
+    .map(([key, value]) => {
+      if (isUndefined(value)) return null;
+      if (value === 'true' || value === 'false') {
+        return ` ${key}=${value}`;
+      }
+      return ` ${key}="${value}"`;
+    })
     .join('');
 
   return `${JAAS_CONFIGS[method]} required${optionsString};`;

+ 1 - 1
kafka-ui-react-app/src/widgets/ClusterConfigForm/utils/transformFormDataToPayload.ts

@@ -122,7 +122,7 @@ export const transformFormDataToPayload = (data: ClusterConfigFormValues) => {
           'sasl.mechanism': 'GSSAPI',
           'sasl.kerberos.service.name': props.saslKerberosServiceName,
           'sasl.jaas.config': getJaasConfig('SASL/GSSAPI', {
-            useKeytab: props.keyTabFile ? 'true' : 'false',
+            useKeyTab: props.keyTabFile ? 'true' : 'false',
             keyTab: props.keyTabFile,
             storeKey: String(!!props.storeKey),
             principal: props.principal,