Преглед изворни кода

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 пре 12 часа
родитељ
комит
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>