Преглед на файлове

feat(frontend): security tab TLS certificates list (Pattern A)

Closes out the security tab: a Form.List of certificates that toggles
between TlsCertFileSchema (certificateFile + keyFile string paths) and
TlsCertInlineSchema (certificate + key as string arrays per the wire
shape) via a per-row useFile boolean.

useFile is a transient form-only field — not part of TlsCertSchema.
Zod's default-strip behavior drops it during InboundFormSchema parse
on submit, leaving only the matching wire branch's keys populated.
Whichever side the user wasn't on stays empty, so Zod's union picks
the populated branch.

For inline certs the TextAreas use normalize + getValueProps to convert
between the wire-side string[] and the multi-line text the user types.
Each line becomes one array element, matching the legacy class's
`cert.split('\n')` toJson convention.

Per-row buildChain is conditionally rendered when usage === 'issue' —
a shouldUpdate-closure watches the specific path so the toggle
re-renders inline without listening to unrelated form changes.

Security tab is now functionally complete. Advanced JSON tab,
Fallbacks card, and the atomic swap in InboundsPage are next.
MHSanaei преди 11 часа
родител
ревизия
40d17b5e59
променени са 1 файла, в които са добавени 155 реда и са изтрити 0 реда
  1. 155 0
      frontend/src/pages/inbounds/InboundFormModal.new.tsx

+ 155 - 0
frontend/src/pages/inbounds/InboundFormModal.new.tsx

@@ -8,6 +8,7 @@ import {
   Input,
   InputNumber,
   Modal,
+  Radio,
   Select,
   Space,
   Switch,
@@ -46,6 +47,7 @@ import {
   TCP_CONGESTION_OPTION,
   TLS_CIPHER_OPTION,
   TLS_VERSION_OPTION,
+  USAGE_OPTION,
   UTLS_FINGERPRINT,
 } from '@/schemas/primitives';
 import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
@@ -53,6 +55,8 @@ import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
 import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality';
 import DateTimePicker from '@/components/DateTimePicker';
 import InputAddon from '@/components/InputAddon';
+
+const { TextArea } = Input;
 import type { DBInbound } from '@/models/dbinbound';
 import type { NodeRecord } from '@/api/queries/useNodesQuery';
 
@@ -1556,6 +1560,157 @@ export default function InboundFormModalNew({
             <Switch />
           </Form.Item>
 
+          <Form.List name={['streamSettings', 'tlsSettings', 'certificates']}>
+            {(certFields, { add, remove }) => (
+              <>
+                <Form.Item label={t('certificate')}>
+                  <Button
+                    type="primary"
+                    size="small"
+                    onClick={() => add({
+                      useFile: true,
+                      certificateFile: '',
+                      keyFile: '',
+                      certificate: [],
+                      key: [],
+                      oneTimeLoading: false,
+                      usage: 'encipherment',
+                      buildChain: false,
+                    })}
+                  >
+                    <PlusOutlined />
+                  </Button>
+                </Form.Item>
+                {certFields.map((certField, idx) => (
+                  <div key={certField.key}>
+                    <Form.Item
+                      name={[certField.name, 'useFile']}
+                      label={`${t('certificate')} ${idx + 1}`}
+                    >
+                      <Radio.Group buttonStyle="solid">
+                        <Radio.Button value={true}>
+                          {t('pages.inbounds.certificatePath')}
+                        </Radio.Button>
+                        <Radio.Button value={false}>
+                          {t('pages.inbounds.certificateContent')}
+                        </Radio.Button>
+                      </Radio.Group>
+                    </Form.Item>
+                    {certFields.length > 1 && (
+                      <Form.Item label=" ">
+                        <Button
+                          size="small"
+                          danger
+                          onClick={() => remove(certField.name)}
+                        >
+                          <MinusOutlined /> Remove
+                        </Button>
+                      </Form.Item>
+                    )}
+                    <Form.Item
+                      noStyle
+                      shouldUpdate={(prev, curr) =>
+                        prev.streamSettings?.tlsSettings?.certificates?.[certField.name]?.useFile
+                        !== curr.streamSettings?.tlsSettings?.certificates?.[certField.name]?.useFile
+                      }
+                    >
+                      {({ getFieldValue }) => {
+                        const useFile = getFieldValue([
+                          'streamSettings', 'tlsSettings', 'certificates',
+                          certField.name, 'useFile',
+                        ]);
+                        return useFile ? (
+                          <>
+                            <Form.Item
+                              name={[certField.name, 'certificateFile']}
+                              label={t('pages.inbounds.publicKey')}
+                            >
+                              <Input />
+                            </Form.Item>
+                            <Form.Item
+                              name={[certField.name, 'keyFile']}
+                              label={t('pages.inbounds.privatekey')}
+                            >
+                              <Input />
+                            </Form.Item>
+                          </>
+                        ) : (
+                          <>
+                            <Form.Item
+                              name={[certField.name, 'certificate']}
+                              label={t('pages.inbounds.publicKey')}
+                              normalize={(v) => typeof v === 'string'
+                                ? v.split('\n')
+                                : v}
+                              getValueProps={(v) => ({
+                                value: Array.isArray(v) ? v.join('\n') : v,
+                              })}
+                            >
+                              <TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
+                            </Form.Item>
+                            <Form.Item
+                              name={[certField.name, 'key']}
+                              label={t('pages.inbounds.privatekey')}
+                              normalize={(v) => typeof v === 'string'
+                                ? v.split('\n')
+                                : v}
+                              getValueProps={(v) => ({
+                                value: Array.isArray(v) ? v.join('\n') : v,
+                              })}
+                            >
+                              <TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
+                            </Form.Item>
+                          </>
+                        );
+                      }}
+                    </Form.Item>
+                    <Form.Item
+                      name={[certField.name, 'oneTimeLoading']}
+                      label="One Time Loading"
+                      valuePropName="checked"
+                    >
+                      <Switch />
+                    </Form.Item>
+                    <Form.Item
+                      name={[certField.name, 'usage']}
+                      label="Usage Option"
+                    >
+                      <Select style={{ width: '50%' }}>
+                        {Object.values(USAGE_OPTION).map((u) => (
+                          <Select.Option key={u} value={u}>{u}</Select.Option>
+                        ))}
+                      </Select>
+                    </Form.Item>
+                    <Form.Item
+                      noStyle
+                      shouldUpdate={(prev, curr) =>
+                        prev.streamSettings?.tlsSettings?.certificates?.[certField.name]?.usage
+                        !== curr.streamSettings?.tlsSettings?.certificates?.[certField.name]?.usage
+                      }
+                    >
+                      {({ getFieldValue }) => {
+                        const usage = getFieldValue([
+                          'streamSettings', 'tlsSettings', 'certificates',
+                          certField.name, 'usage',
+                        ]);
+                        if (usage !== 'issue') return null;
+                        return (
+                          <Form.Item
+                            name={[certField.name, 'buildChain']}
+                            label="Build Chain"
+                            valuePropName="checked"
+                          >
+                            <Switch />
+                          </Form.Item>
+                        );
+                      }}
+                    </Form.Item>
+                  </div>
+                ))}
+              </>
+            )}
+          </Form.List>
+
           <Form.Item name={['streamSettings', 'tlsSettings', 'echServerKeys']} label="ECH key">
             <Input />
           </Form.Item>