3x-ui/frontend/src/pages/settings/GeneralTab.tsx
MHSanaei d50ec74b24
refactor(frontend): port settings to react+ts
Step 5 of the planned vue->react migration. Settings is the first
entry whose state model didn't translate to the Vue-style "parent
passes a reactive object, children mutate it in place" pattern, so
the React port flips it to lifted state + a typed updateSetting
patch function.

* models/setting.ts — typed AllSetting class with the same field
  defaults and equals() behavior the vue version had. The .js
  twin is deleted; nothing else imported it.
* hooks/useAllSetting.ts — owns allSetting + oldAllSetting state,
  exposes updateSetting(patch), saveDisabled is derived via useMemo
  off equals() (no more 1Hz dirty-check timer).
* components/SettingListItem.tsx — children-based wrapper instead
  of named slots. The vue twin stays alive because xray (BasicsTab,
  DnsTab) still imports it; deleted when xray migrates.

The five tab components and the TwoFactorModal each accept
{ allSetting, updateSetting } and render with AntD v5's Collapse
items[] API. Every v-model:value="x" became
value={...} onChange={(e) => updateSetting({ key: e.target.value })}
or onChange={(v) => updateSetting({ key: v })} for non-input
controls.

SubscriptionFormatsTab is the trickiest — fragment / noises[] /
mux / direct routing rules are stored as JSON-encoded strings on
the wire. Parsing them once via useMemo per field, mutating the
parsed object on edit, and stringifying back into the patch keeps
the round-trip identical to the vue version.

SettingsPage hosts the tab navigation (with hash sync), the
save / restart action bar, the security-warnings alert banner,
and the restart flow that rebuilds the panel URL after the new
host/port/cert settings take effect.
2026-05-21 21:48:15 +02:00

352 lines
16 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Collapse,
Input,
InputNumber,
Select,
Space,
Switch,
} from 'antd';
import type { AllSetting } from '@/models/setting';
import { HttpUtil, LanguageManager } from '@/utils';
import SettingListItem from '@/components/SettingListItem';
interface ApiMsg<T = unknown> {
success?: boolean;
obj?: T;
}
interface GeneralTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
const REMARK_MODELS: Record<string, string> = { i: 'Inbound', e: 'Email', o: 'Other' };
const REMARK_SEPARATORS = [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'];
const DATEPICKER_LIST: { name: string; value: 'gregorian' | 'jalalian' }[] = [
{ name: 'Gregorian (Standard)', value: 'gregorian' },
{ name: 'Jalalian (شمسی)', value: 'jalalian' },
];
export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProps) {
const { t } = useTranslation();
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
const msg = await HttpUtil.get('/panel/api/inbounds/list') as ApiMsg<{
tag: string; protocol: string; port: number;
}[]>;
if (cancelled) return;
if (msg?.success && Array.isArray(msg.obj)) {
setInboundOptions(msg.obj.map((ib) => ({
label: `${ib.tag} (${ib.protocol}@${ib.port})`,
value: ib.tag,
})));
} else {
setInboundOptions([]);
}
})();
return () => { cancelled = true; };
}, []);
const remarkModel = useMemo(() => {
const rm = allSetting.remarkModel || '';
return rm.length > 1 ? rm.substring(1).split('') : [];
}, [allSetting.remarkModel]);
const remarkSeparator = useMemo(() => {
const rm = allSetting.remarkModel || '-';
return rm.length > 1 ? rm.charAt(0) : '-';
}, [allSetting.remarkModel]);
const remarkSample = useMemo(() => {
const parts = remarkModel.map((k) => REMARK_MODELS[k]);
return parts.length === 0 ? '' : parts.join(remarkSeparator);
}, [remarkModel, remarkSeparator]);
function setRemarkModel(parts: string[]) {
updateSetting({ remarkModel: remarkSeparator + parts.join('') });
}
function setRemarkSeparator(sep: string) {
const tail = (allSetting.remarkModel || '-').substring(1);
updateSetting({ remarkModel: sep + tail });
}
const ldapInboundTagList = useMemo(() => {
const csv = allSetting.ldapInboundTags || '';
return csv.length ? csv.split(',').map((s) => s.trim()).filter(Boolean) : [];
}, [allSetting.ldapInboundTags]);
function setLdapInboundTagList(list: string[]) {
updateSetting({ ldapInboundTags: Array.isArray(list) ? list.join(',') : '' });
}
function onLangChange(value: string) {
setLang(value);
LanguageManager.setLanguage(value);
}
const langOptions = useMemo(
() => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({
value: l.value,
label: (
<>
<span role="img" aria-label={l.name}>{l.icon}</span>
&nbsp;&nbsp;<span>{l.name}</span>
</>
),
})),
[],
);
return (
<Collapse defaultActiveKey="1" items={[
{
key: '1',
label: t('pages.settings.panelSettings'),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.remarkModel')}
description={<>{t('pages.settings.sampleRemark')}: <i>#{remarkSample}</i></>}
>
<Space.Compact style={{ width: '100%' }}>
<Select
mode="multiple"
value={remarkModel}
onChange={setRemarkModel}
style={{ paddingRight: '.5rem', minWidth: '80%', width: 'auto' }}
options={Object.entries(REMARK_MODELS).map(([k, l]) => ({ value: k, label: l }))}
/>
<Select
value={remarkSeparator}
onChange={setRemarkSeparator}
style={{ width: '20%' }}
options={REMARK_SEPARATORS.map((s) => ({ value: s, label: s }))}
/>
</Space.Compact>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.panelListeningIP')} description={t('pages.settings.panelListeningIPDesc')}>
<Input value={allSetting.webListen} onChange={(e) => updateSetting({ webListen: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.panelListeningDomain')} description={t('pages.settings.panelListeningDomainDesc')}>
<Input value={allSetting.webDomain} onChange={(e) => updateSetting({ webDomain: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.panelPort')} description={t('pages.settings.panelPortDesc')}>
<InputNumber value={allSetting.webPort} min={1} max={65535} style={{ width: '100%' }}
onChange={(v) => updateSetting({ webPort: Number(v) || 0 })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.panelUrlPath')} description={t('pages.settings.panelUrlPathDesc')}>
<Input value={allSetting.webBasePath} onChange={(e) => updateSetting({ webBasePath: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} description={t('pages.settings.sessionMaxAgeDesc')}>
<InputNumber value={allSetting.sessionMaxAge} min={60} style={{ width: '100%' }}
onChange={(v) => updateSetting({ sessionMaxAge: Number(v) || 0 })} />
</SettingListItem>
<SettingListItem
paddings="small"
title="Trusted proxy CIDRs"
description="Comma-separated IPs/CIDRs allowed to set forwarded host, proto, and client IP headers."
>
<Input
value={allSetting.trustedProxyCIDRs}
placeholder="127.0.0.1/32,::1/128"
onChange={(e) => updateSetting({ trustedProxyCIDRs: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.pageSize')} description={t('pages.settings.pageSizeDesc')}>
<InputNumber value={allSetting.pageSize} min={0} step={5} style={{ width: '100%' }}
onChange={(v) => updateSetting({ pageSize: Number(v) || 0 })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.language')}>
<Select
value={lang}
onChange={onLangChange}
style={{ width: '100%' }}
options={langOptions}
/>
</SettingListItem>
</>
),
},
{
key: '2',
label: t('pages.settings.notifications'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} description={t('pages.settings.expireTimeDiffDesc')}>
<InputNumber value={allSetting.expireDiff} min={0} style={{ width: '100%' }}
onChange={(v) => updateSetting({ expireDiff: Number(v) || 0 })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} description={t('pages.settings.trafficDiffDesc')}>
<InputNumber value={allSetting.trafficDiff} min={0} style={{ width: '100%' }}
onChange={(v) => updateSetting({ trafficDiff: Number(v) || 0 })} />
</SettingListItem>
</>
),
},
{
key: '3',
label: t('pages.settings.certs'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.publicKeyPath')} description={t('pages.settings.publicKeyPathDesc')}>
<Input value={allSetting.webCertFile} onChange={(e) => updateSetting({ webCertFile: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.privateKeyPath')} description={t('pages.settings.privateKeyPathDesc')}>
<Input value={allSetting.webKeyFile} onChange={(e) => updateSetting({ webKeyFile: e.target.value })} />
</SettingListItem>
</>
),
},
{
key: '4',
label: t('pages.settings.externalTraffic'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.externalTrafficInformEnable')} description={t('pages.settings.externalTrafficInformEnableDesc')}>
<Switch checked={allSetting.externalTrafficInformEnable}
onChange={(v) => updateSetting({ externalTrafficInformEnable: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.externalTrafficInformURI')} description={t('pages.settings.externalTrafficInformURIDesc')}>
<Input
value={allSetting.externalTrafficInformURI}
placeholder="(http|https)://domain[:port]/path/"
onChange={(e) => updateSetting({ externalTrafficInformURI: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.restartXrayOnClientDisable')} description={t('pages.settings.restartXrayOnClientDisableDesc')}>
<Switch checked={allSetting.restartXrayOnClientDisable}
onChange={(v) => updateSetting({ restartXrayOnClientDisable: v })} />
</SettingListItem>
</>
),
},
{
key: '5',
label: t('pages.settings.dateAndTime'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.timeZone')} description={t('pages.settings.timeZoneDesc')}>
<Input value={allSetting.timeLocation} onChange={(e) => updateSetting({ timeLocation: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.datepicker')} description={t('pages.settings.datepickerDescription')}>
<Select
value={allSetting.datepicker || 'gregorian'}
onChange={(v) => updateSetting({ datepicker: v as 'gregorian' | 'jalalian' })}
style={{ width: '100%' }}
options={DATEPICKER_LIST.map((d) => ({ value: d.value, label: d.name }))}
/>
</SettingListItem>
</>
),
},
{
key: '6',
label: 'LDAP',
children: (
<>
<SettingListItem paddings="small" title="Enable LDAP sync">
<Switch checked={allSetting.ldapEnable} onChange={(v) => updateSetting({ ldapEnable: v })} />
</SettingListItem>
<SettingListItem paddings="small" title="LDAP host">
<Input value={allSetting.ldapHost} onChange={(e) => updateSetting({ ldapHost: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title="LDAP port">
<InputNumber value={allSetting.ldapPort} min={1} max={65535} style={{ width: '100%' }}
onChange={(v) => updateSetting({ ldapPort: Number(v) || 0 })} />
</SettingListItem>
<SettingListItem paddings="small" title="Use TLS (LDAPS)">
<Switch checked={allSetting.ldapUseTLS} onChange={(v) => updateSetting({ ldapUseTLS: v })} />
</SettingListItem>
<SettingListItem paddings="small" title="Bind DN">
<Input value={allSetting.ldapBindDN} onChange={(e) => updateSetting({ ldapBindDN: e.target.value })} />
</SettingListItem>
<SettingListItem
paddings="small"
title={t('password')}
description={allSetting.hasLdapPassword ? 'Configured; leave blank to keep current password.' : 'Not configured.'}
>
<Input.Password
value={allSetting.ldapPassword}
placeholder={allSetting.hasLdapPassword ? 'Configured - enter a new value to replace' : ''}
onChange={(e) => updateSetting({ ldapPassword: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title="Base DN">
<Input value={allSetting.ldapBaseDN} onChange={(e) => updateSetting({ ldapBaseDN: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title="User filter">
<Input value={allSetting.ldapUserFilter} onChange={(e) => updateSetting({ ldapUserFilter: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title="User attribute (username/email)">
<Input value={allSetting.ldapUserAttr} onChange={(e) => updateSetting({ ldapUserAttr: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title="VLESS flag attribute">
<Input value={allSetting.ldapVlessField} onChange={(e) => updateSetting({ ldapVlessField: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title="Generic flag attribute (optional)" description="If set, overrides VLESS flag — e.g. shadowInactive.">
<Input value={allSetting.ldapFlagField} onChange={(e) => updateSetting({ ldapFlagField: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title="Truthy values" description="Comma-separated; default: true,1,yes,on">
<Input value={allSetting.ldapTruthyValues} onChange={(e) => updateSetting({ ldapTruthyValues: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title="Invert flag" description="Enable when the attribute means disabled (e.g. shadowInactive).">
<Switch checked={allSetting.ldapInvertFlag} onChange={(v) => updateSetting({ ldapInvertFlag: v })} />
</SettingListItem>
<SettingListItem paddings="small" title="Sync schedule" description="Cron-like string, e.g. @every 1m">
<Input value={allSetting.ldapSyncCron} onChange={(e) => updateSetting({ ldapSyncCron: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title="Inbound tags" description="Inbounds that LDAP sync may auto-create or auto-delete clients on.">
<>
<Select
mode="multiple"
value={ldapInboundTagList}
onChange={setLdapInboundTagList}
style={{ width: '100%' }}
options={inboundOptions}
/>
{inboundOptions.length === 0 && (
<div className="ldap-no-inbounds">No inbounds found. Create one in Inbounds first.</div>
)}
</>
</SettingListItem>
<SettingListItem paddings="small" title="Auto create clients">
<Switch checked={allSetting.ldapAutoCreate} onChange={(v) => updateSetting({ ldapAutoCreate: v })} />
</SettingListItem>
<SettingListItem paddings="small" title="Auto delete clients">
<Switch checked={allSetting.ldapAutoDelete} onChange={(v) => updateSetting({ ldapAutoDelete: v })} />
</SettingListItem>
<SettingListItem paddings="small" title="Default total (GB)">
<InputNumber value={allSetting.ldapDefaultTotalGB} min={0} style={{ width: '100%' }}
onChange={(v) => updateSetting({ ldapDefaultTotalGB: Number(v) || 0 })} />
</SettingListItem>
<SettingListItem paddings="small" title="Default expiry (days)">
<InputNumber value={allSetting.ldapDefaultExpiryDays} min={0} style={{ width: '100%' }}
onChange={(v) => updateSetting({ ldapDefaultExpiryDays: Number(v) || 0 })} />
</SettingListItem>
<SettingListItem paddings="small" title="Default IP limit">
<InputNumber value={allSetting.ldapDefaultLimitIP} min={0} style={{ width: '100%' }}
onChange={(v) => updateSetting({ ldapDefaultLimitIP: Number(v) || 0 })} />
</SettingListItem>
</>
),
},
]} />
);
}