This is an automated email from the ASF dual-hosted git repository.

guoqqqi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git


The following commit(s) were added to refs/heads/master by this push:
     new 0fc388aef fix: array<string> fields rendered with wrong widgets; SSL 
edit wiped the mTLS client block (#3435)
0fc388aef is described below

commit 0fc388aef1731cbd1b997e7b77e8dc11bf9b290e
Author: Yuhan <[email protected]>
AuthorDate: Thu Jul 23 17:13:29 2026 +0800

    fix: array<string> fields rendered with wrong widgets; SSL edit wiped the 
mTLS client block (#3435)
---
 .../ssls.noop-edit-preserves-client.spec.ts        |  87 ++++++++++++++++++
 .../ssls.skip-mtls-uri-regex-field.spec.ts         |  98 ++++++++++++++++++++
 .../upstreams.http-request-headers-field.spec.ts   | 100 +++++++++++++++++++++
 src/components/form-slice/FormPartSSL/index.tsx    |  18 ++--
 .../FormPartUpstream/FormSectionChecks.tsx         |   9 +-
 src/routes/ssls/detail.$id.tsx                     |   8 ++
 6 files changed, 311 insertions(+), 9 deletions(-)

diff --git a/e2e/tests/regression/ssls.noop-edit-preserves-client.spec.ts 
b/e2e/tests/regression/ssls.noop-edit-preserves-client.spec.ts
new file mode 100644
index 000000000..dac859565
--- /dev/null
+++ b/e2e/tests/regression/ssls.noop-edit-preserves-client.spec.ts
@@ -0,0 +1,87 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Regression for a silent data-loss bug found while fixing the
+// skip_mtls_uri_regex widget (#3417 follow-up): the SSL detail page
+// created its form WITHOUT `defaultValues` and populated it only through
+// a later `form.reset(...)`. Under `shouldUnregister: true`, a
+// controlled field that mounts AFTER that reset (the whole client
+// section, gated on `__clientEnabled`) has its entry in `_defaultValues`
+// overwritten from `_options.defaultValues` — i.e. with undefined — by
+// react-hook-form's useController mount effect. The unmount/remount
+// around toggling Edit then drops the value with nothing to re-seed
+// from, wiping the whole `client.*` subtree: an edit-save silently
+// DELETED the mTLS client block (PUT succeeded, verification gone).
+// Passing the producer output as `defaultValues` at creation (the same
+// pattern routes/services/stream_routes detail pages already use) keeps
+// the re-seed source populated.
+
+import { sslsPom } from '@e2e/pom/ssls';
+import { genTLS, randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllSSLs } from '@/apis/ssls';
+import type { APISIXType } from '@/types/schema/apisix';
+
+test.beforeAll(async () => {
+  await deleteAllSSLs(e2eReq);
+});
+
+test.afterAll(async () => {
+  await deleteAllSSLs(e2eReq);
+});
+
+test('no-op edit-save preserves the mTLS client block', async ({ page }) => {
+  const { cert, key } = await genTLS();
+  const sni = `${randomId('reg-client-keep')}.example.com`;
+  const res = await e2eReq.put<{ value: APISIXType['SSL'] }>(
+    `/ssls/${randomId('reg-client-keep')}`,
+    {
+      snis: [sni],
+      cert,
+      key,
+      client: {
+        ca: cert,
+        depth: 2,
+        skip_mtls_uri_regex: ['/health.*', '/metrics'],
+      },
+    }
+  );
+  const id = res.data.value.id;
+
+  await uiGoto(page, '/ssls/detail/$id', { id });
+  await sslsPom.isDetailPage(page);
+
+  await page.getByRole('button', { name: 'Edit' }).click();
+  // the API never returns the private key, so a save always needs it
+  // re-entered — everything else must survive untouched
+  await page.getByRole('textbox', { name: 'Key 1' }).fill(key);
+  await page.getByRole('button', { name: 'Save' }).click();
+  await expect(
+    page.getByRole('alert').filter({ hasText: /success/i })
+  ).toBeVisible();
+
+  const after = await e2eReq.get<{ value: APISIXType['SSL'] }>(`/ssls/${id}`);
+  const client = after.data.value.client;
+  expect(client).toBeTruthy();
+  expect(client?.ca).toBe(cert);
+  expect(client?.depth).toBe(2);
+  expect(client?.skip_mtls_uri_regex).toEqual(['/health.*', '/metrics']);
+});
diff --git a/e2e/tests/regression/ssls.skip-mtls-uri-regex-field.spec.ts 
b/e2e/tests/regression/ssls.skip-mtls-uri-regex-field.spec.ts
new file mode 100644
index 000000000..d2c587220
--- /dev/null
+++ b/e2e/tests/regression/ssls.skip-mtls-uri-regex-field.spec.ts
@@ -0,0 +1,98 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Regression for a data-integrity item of apache/apisix-dashboard#3417:
+// `ssls.client.skip_mtls_uri_regex` is typed `array<string>` (URI regex
+// list, matching the Admin API) but was rendered as a boolean Switch.
+// Both directions were broken: a stored regex list displayed as a
+// meaningless "on" toggle with the actual values invisible, and toggling
+// wrote a boolean that failed the zod array schema on submit.
+
+import { sslsPom } from '@e2e/pom/ssls';
+import { genTLS, randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllSSLs } from '@/apis/ssls';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const regexes = ['/health.*', '/metrics'];
+
+test.beforeAll(async () => {
+  await deleteAllSSLs(e2eReq);
+});
+
+test.afterAll(async () => {
+  await deleteAllSSLs(e2eReq);
+});
+
+test('client skip_mtls_uri_regex displays and round-trips', async ({
+  page,
+}) => {
+  const { cert, key } = await genTLS();
+  const sni = `${randomId('reg-mtls')}.example.com`;
+  const res = await e2eReq.put<{ value: APISIXType['SSL'] }>(
+    `/ssls/${randomId('reg-mtls')}`,
+    {
+      snis: [sni],
+      cert,
+      key,
+      client: { ca: cert, skip_mtls_uri_regex: regexes },
+    }
+  );
+  const id = res.data.value.id;
+
+  await uiGoto(page, '/ssls/detail/$id', { id });
+  await sslsPom.isDetailPage(page);
+
+  // stored regexes must be visible (unfixed: a bare "on" Switch, values
+  // nowhere on the page)
+  await expect(page.getByText(regexes[0], { exact: true })).toBeVisible();
+  await expect(page.getByText(regexes[1], { exact: true })).toBeVisible();
+
+  await page.getByRole('button', { name: 'Edit' }).click();
+
+  const field = page.getByRole('textbox', {
+    name: 'Skip mTLS URI Regex',
+    exact: true,
+  });
+  // a regex legitimately containing a comma (quantifier {1,3}) must stay
+  // ONE tag — the field must not comma-split it (#3435 review)
+  const commaRegex = '^/v[0-9]{1,3}$';
+  // type character by character so the comma keystroke would trigger
+  // Mantine's default comma-split (the actual user path)
+  await field.pressSequentially(commaRegex);
+  await field.press('Enter');
+  await expect(page.getByText(commaRegex, { exact: true })).toBeVisible();
+
+  // the API does not return the private key to the form; re-fill it so
+  // the PUT passes validation (same reason the crud spec cancels edits)
+  await page.getByRole('textbox', { name: 'Key 1' }).fill(key);
+
+  await page.getByRole('button', { name: 'Save' }).click();
+  await expect(
+    page.getByRole('alert').filter({ hasText: /success/i })
+  ).toBeVisible();
+
+  const after = await e2eReq.get<{ value: APISIXType['SSL'] }>(`/ssls/${id}`);
+  expect(after.data.value.client?.skip_mtls_uri_regex).toEqual([
+    ...regexes,
+    commaRegex,
+  ]);
+});
diff --git a/e2e/tests/regression/upstreams.http-request-headers-field.spec.ts 
b/e2e/tests/regression/upstreams.http-request-headers-field.spec.ts
new file mode 100644
index 000000000..98242e055
--- /dev/null
+++ b/e2e/tests/regression/upstreams.http-request-headers-field.spec.ts
@@ -0,0 +1,100 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Regression for a data-integrity item of apache/apisix-dashboard#3417:
+// `checks.active.http_request_headers` is typed `array<string>` (matching
+// the Admin API) but was rendered with the Labels widget, which expects
+// an OBJECT value. Both directions were broken: stored header lines
+// displayed as an empty field (the widget's array guard returns []), and
+// anything typed in produced an object that failed the zod array schema
+// on submit — the field was unusable.
+
+import { upstreamsPom } from '@e2e/pom/upstreams';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllUpstreams } from '@/apis/upstreams';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const headers = ['User-Agent: apisix-probe', 'X-Check: 1'];
+
+test.beforeAll(async () => {
+  await deleteAllUpstreams(e2eReq);
+});
+
+test.afterAll(async () => {
+  await deleteAllUpstreams(e2eReq);
+});
+
+test('active health-check request headers display and round-trip', async ({
+  page,
+}) => {
+  const name = randomId('reg-hdr-field');
+  const res = await e2eReq.put<{ value: APISIXType['Upstream'] }>(
+    `/upstreams/${name}`,
+    {
+      name,
+      type: 'roundrobin',
+      nodes: { 'hdr-field.local:80': 1 },
+      checks: {
+        active: {
+          http_path: '/health',
+          http_request_headers: headers,
+          unhealthy: { interval: 2, http_failures: 3 },
+        },
+      },
+    }
+  );
+  const id = res.data.value.id;
+
+  await uiGoto(page, '/upstreams/detail/$id', { id });
+  await upstreamsPom.isDetailPage(page);
+
+  // stored header lines must be visible (unfixed: the field renders empty)
+  await expect(page.getByText(headers[0], { exact: true })).toBeVisible();
+  await expect(page.getByText(headers[1], { exact: true })).toBeVisible();
+
+  await page.getByRole('button', { name: 'Edit' }).click();
+
+  // typed input must survive submit as an array member
+  const field = page.getByRole('textbox', {
+    name: 'HTTP Request Headers',
+    exact: true,
+  });
+  // a header value legitimately containing a comma must stay ONE entry —
+  // the field must not comma-split it (#3435 review)
+  const commaHeader = 'Accept: text/html,application/json';
+  await field.pressSequentially(commaHeader);
+  await field.press('Enter');
+  await expect(page.getByText(commaHeader, { exact: true })).toBeVisible();
+
+  await page.getByRole('button', { name: 'Save' }).click();
+  await expect(
+    page.getByRole('alert').filter({ hasText: /success/i })
+  ).toBeVisible();
+
+  const after = await e2eReq.get<{ value: APISIXType['Upstream'] }>(
+    `/upstreams/${id}`
+  );
+  expect(after.data.value.checks?.active?.http_request_headers).toEqual([
+    ...headers,
+    commaHeader,
+  ]);
+});
diff --git a/src/components/form-slice/FormPartSSL/index.tsx 
b/src/components/form-slice/FormPartSSL/index.tsx
index 7d8abbb1a..599d8a51f 100644
--- a/src/components/form-slice/FormPartSSL/index.tsx
+++ b/src/components/form-slice/FormPartSSL/index.tsx
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { InputWrapper, Text } from '@mantine/core';
+import { Text } from '@mantine/core';
 import { useFormContext, useWatch } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 
@@ -54,12 +54,16 @@ const FormSectionClient = () => {
             defaultValue={1}
             min={0}
           />
-          <InputWrapper label={t('form.ssls.client.skipMtlsUriRegex')}>
-            <FormItemSwitch
-              control={control}
-              name="client.skip_mtls_uri_regex"
-            />
-          </InputWrapper>
+          {/* array<string> of URI regexes — a TagsInput, not a Switch:
+              the boolean broke this field both ways (#3417). splitChars={[]}
+              because a regex can contain a comma (e.g. the quantifier
+              `{1,3}`) and must stay one entry (#3435 review). */}
+          <FormItemTagsInput
+            control={control}
+            name="client.skip_mtls_uri_regex"
+            label={t('form.ssls.client.skipMtlsUriRegex')}
+            splitChars={[]}
+          />
         </>
       ) : (
         <Text c="gray.6" size="sm">
diff --git a/src/components/form-slice/FormPartUpstream/FormSectionChecks.tsx 
b/src/components/form-slice/FormPartUpstream/FormSectionChecks.tsx
index 4becf788a..ff5f905c3 100644
--- a/src/components/form-slice/FormPartUpstream/FormSectionChecks.tsx
+++ b/src/components/form-slice/FormPartUpstream/FormSectionChecks.tsx
@@ -18,7 +18,6 @@ import { Text } from '@mantine/core';
 import { useFormContext, useWatch } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 
-import { FormItemLabels } from '@/components/form/Labels';
 import { FormItemNumberInput } from '@/components/form/NumberInput';
 import { FormItemSelect } from '@/components/form/Select';
 import { FormItemSwitch } from '@/components/form/Switch';
@@ -76,10 +75,16 @@ const FormSectionChecksActive = () => {
         name={np('checks.active.http_path')}
         label={t('form.upstreams.checks.active.http_path')}
       />
-      <FormItemLabels
+      {/* array<string> of raw header lines — a TagsInput, not the Labels
+          widget (which produces an object and broke this field both ways,
+          #3417). splitChars={[]} because a header value can contain a
+          comma (e.g. `Accept: text/html,application/json`) and must stay
+          one entry (#3435 review). */}
+      <FormItemTagsInput
         control={control}
         name={np('checks.active.http_request_headers')}
         label={t('form.upstreams.checks.active.http_request_headers')}
+        splitChars={[]}
       />
       <FormSection legend={t('form.upstreams.checks.active.healthy.title')}>
         <FormItemNumberInput
diff --git a/src/routes/ssls/detail.$id.tsx b/src/routes/ssls/detail.$id.tsx
index 7b028a522..2f4d996aa 100644
--- a/src/routes/ssls/detail.$id.tsx
+++ b/src/routes/ssls/detail.$id.tsx
@@ -65,6 +65,14 @@ const SSLDetailForm = (props: Props & { id: string }) => {
     shouldUnregister: true,
     mode: 'all',
     disabled: readOnly,
+    // Without creation-time defaultValues, any controlled field that
+    // mounts AFTER the reset below (the whole client section, gated on
+    // __clientEnabled) gets its _defaultValues entry overwritten with
+    // undefined by RHF's useController mount effect under
+    // shouldUnregister — the unmount/remount around toggling Edit then
+    // wipes the client.* subtree and an edit-save silently deletes the
+    // mTLS client block (same pattern as routes/services detail, #3414).
+    defaultValues: produceToSSLForm(sslData),
   });
 
   const putSSL = useMutation({

Reply via email to