ClientBulkAddModal.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. import { useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import {
  4. AutoComplete,
  5. Button,
  6. Form,
  7. Input,
  8. InputNumber,
  9. Modal,
  10. Select,
  11. Space,
  12. Switch,
  13. Tooltip,
  14. message,
  15. } from 'antd';
  16. import { ReloadOutlined } from '@ant-design/icons';
  17. import dayjs from 'dayjs';
  18. import type { Dayjs } from 'dayjs';
  19. import { FormProvider, useForm, useWatch } from 'react-hook-form';
  20. import { RandomUtil, SizeFormatter } from '@/utils';
  21. import { formatInboundLabel } from '@/lib/inbounds/label';
  22. import { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives';
  23. import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
  24. import { FormField } from '@/components/form/rhf';
  25. import { useClients, type InboundOption } from '@/hooks/useClients';
  26. import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
  27. import ClientRenewalFields from './ClientRenewalFields';
  28. import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client';
  29. const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
  30. const MULTI_CLIENT_PROTOCOLS = new Set([
  31. 'shadowsocks',
  32. 'vless',
  33. 'vmess',
  34. 'trojan',
  35. 'hysteria',
  36. 'wireguard',
  37. 'amneziawg',
  38. 'tuic',
  39. ]);
  40. const EMPTY: ClientBulkAddFormValues = {
  41. emailMethod: 0,
  42. firstNum: 1,
  43. lastNum: 1,
  44. emailPrefix: '',
  45. emailPostfix: '',
  46. quantity: 1,
  47. subId: '',
  48. group: '',
  49. comment: '',
  50. flow: '',
  51. limitIp: 0,
  52. limitHwid: 0,
  53. totalGB: 0,
  54. expiryTime: 0,
  55. reset: 0,
  56. resetDay: 0,
  57. resetWeekday: 0,
  58. resetMax: 0,
  59. trafficReset: 'never' as const,
  60. trafficResetDay: 1,
  61. inboundIds: [],
  62. };
  63. interface ClientBulkAddModalProps {
  64. open: boolean;
  65. inbounds: InboundOption[];
  66. groups?: string[];
  67. onOpenChange: (open: boolean) => void;
  68. onSaved?: () => void;
  69. }
  70. export default function ClientBulkAddModal({
  71. open,
  72. inbounds,
  73. groups = [],
  74. onOpenChange,
  75. onSaved,
  76. }: ClientBulkAddModalProps) {
  77. const { t } = useTranslation();
  78. const [messageApi, messageContextHolder] = message.useMessage();
  79. const { bulkCreate } = useClients({ list: false });
  80. const methods = useForm<ClientBulkAddFormValues>({ defaultValues: EMPTY });
  81. const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
  82. const emailMethod = useWatch({ control: methods.control, name: 'emailMethod' });
  83. const firstNum = useWatch({ control: methods.control, name: 'firstNum' });
  84. const flow = useWatch({ control: methods.control, name: 'flow' });
  85. const expiryTime = useWatch({ control: methods.control, name: 'expiryTime' });
  86. const subId = useWatch({ control: methods.control, name: 'subId' });
  87. const limitIp = useWatch({ control: methods.control, name: 'limitIp' });
  88. const trafficReset = useWatch({ control: methods.control, name: 'trafficReset' });
  89. const [delayedStart, setDelayedStart] = useState(false);
  90. const [saving, setSaving] = useState(false);
  91. const fail2ban = useFail2banStatusQuery();
  92. const limitIpDisabled = !fail2ban.usable;
  93. const limitIpNotice = getLimitIpNotice(fail2ban, t);
  94. const [wasOpen, setWasOpen] = useState(false);
  95. if (open !== wasOpen) {
  96. setWasOpen(open);
  97. if (open) {
  98. methods.reset(EMPTY);
  99. setDelayedStart(false);
  100. }
  101. }
  102. const flowCapableIds = useMemo(() => {
  103. const ids = new Set<number>();
  104. for (const row of inbounds || []) {
  105. if (row?.tlsFlowCapable) ids.add(row.id);
  106. }
  107. return ids;
  108. }, [inbounds]);
  109. const showFlow = useMemo(
  110. () => (inboundIds || []).some((id) => flowCapableIds.has(id)),
  111. [inboundIds, flowCapableIds],
  112. );
  113. const ss2022Method = useMemo(() => {
  114. for (const id of inboundIds || []) {
  115. const ib = (inbounds || []).find((row) => row.id === id);
  116. const method = ib?.ssMethod;
  117. if (method && method.substring(0, 4) === '2022') return method;
  118. }
  119. return '';
  120. }, [inboundIds, inbounds]);
  121. const tuicIds = useMemo(() => {
  122. const ids = new Set<number>();
  123. for (const row of inbounds || []) {
  124. if (row && row.protocol === 'tuic') ids.add(row.id);
  125. }
  126. return ids;
  127. }, [inbounds]);
  128. const hasTuic = useMemo(
  129. () => (inboundIds || []).some((id) => tuicIds.has(id)),
  130. [inboundIds, tuicIds],
  131. );
  132. useEffect(() => {
  133. if (!showFlow && flow) {
  134. methods.setValue('flow', '');
  135. }
  136. }, [showFlow, flow, methods]);
  137. const inboundOptions = useMemo(
  138. () =>
  139. (inbounds || [])
  140. .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
  141. .map((ib) => ({
  142. label: formatInboundLabel(ib.tag, ib.remark),
  143. value: ib.id,
  144. })),
  145. [inbounds],
  146. );
  147. const expiryDate = useMemo<Dayjs | null>(
  148. () => (expiryTime > 0 ? dayjs(expiryTime) : null),
  149. [expiryTime],
  150. );
  151. const delayedExpireDays = expiryTime < 0 ? expiryTime / -86400000 : 0;
  152. function buildEmails(values: ClientBulkAddFormValues): string[] {
  153. const method = values.emailMethod;
  154. const out: string[] = [];
  155. let start: number;
  156. let end: number;
  157. if (method > 1) {
  158. start = values.firstNum;
  159. end = values.lastNum + 1;
  160. } else {
  161. start = 0;
  162. end = values.quantity;
  163. }
  164. const prefix = method > 0 && values.emailPrefix.length > 0 ? values.emailPrefix : '';
  165. const useNum = method > 1;
  166. const postfix = method > 2 && values.emailPostfix.length > 0 ? values.emailPostfix : '';
  167. for (let i = start; i < end; i++) {
  168. let email = '';
  169. if (method !== 4) email = RandomUtil.randomLowerAndNum(10);
  170. email += useNum ? prefix + String(i) + postfix : prefix + postfix;
  171. out.push(email);
  172. }
  173. return out;
  174. }
  175. async function submit() {
  176. const current = methods.getValues();
  177. const validated = ClientBulkAddFormSchema.safeParse(current);
  178. if (!validated.success) {
  179. messageApi.error(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
  180. return;
  181. }
  182. const emails = buildEmails(current);
  183. if (emails.length === 0) return;
  184. setSaving(true);
  185. try {
  186. const payloads = emails.map((email) => ({
  187. client: {
  188. email,
  189. subId: current.subId || RandomUtil.randomLowerAndNum(16),
  190. id: RandomUtil.randomUUID(),
  191. password: ss2022Method
  192. ? RandomUtil.randomShadowsocksPassword(ss2022Method)
  193. : RandomUtil.randomLowerAndNum(16),
  194. auth: RandomUtil.randomLowerAndNum(16),
  195. flow: showFlow ? current.flow || '' : '',
  196. totalGB: Math.round((current.totalGB || 0) * SizeFormatter.ONE_GB),
  197. expiryTime: current.expiryTime,
  198. reset: Number(current.reset) || 0,
  199. resetDay: Number(current.resetDay) || 0,
  200. resetWeekday: Number(current.resetWeekday) || 0,
  201. resetMax: Number(current.resetMax) || 0,
  202. trafficReset: current.trafficReset || 'never',
  203. trafficResetDay: Number(current.trafficResetDay) || 1,
  204. limitIp: Number(current.limitIp) || 0,
  205. limitHwid: Number(current.limitHwid) || 0,
  206. group: current.group,
  207. comment: current.comment,
  208. enable: true,
  209. },
  210. inboundIds: current.inboundIds,
  211. }));
  212. const msg = await bulkCreate(payloads);
  213. const ok = msg?.obj?.created ?? 0;
  214. const skipped = msg?.obj?.skipped ?? [];
  215. const failed = skipped.length;
  216. const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
  217. if (failed === 0 && msg?.success) {
  218. messageApi.success(t('pages.clients.toasts.bulkCreated', { count: ok }));
  219. } else {
  220. messageApi.warning(
  221. firstError
  222. ? `${t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })} — ${firstError}`
  223. : t('pages.clients.toasts.bulkCreatedMixed', { ok, failed }),
  224. );
  225. }
  226. onSaved?.();
  227. onOpenChange(false);
  228. } finally {
  229. setSaving(false);
  230. }
  231. }
  232. return (
  233. <>
  234. {messageContextHolder}
  235. <Modal
  236. open={open}
  237. title={t('pages.clients.bulk')}
  238. okText={t('create')}
  239. cancelText={t('close')}
  240. confirmLoading={saving}
  241. mask={{ closable: false }}
  242. width={640}
  243. onOk={submit}
  244. onCancel={() => onOpenChange(false)}
  245. >
  246. <FormProvider {...methods}>
  247. <Form colon={false} labelCol={{ sm: { span: 8 } }} wrapperCol={{ sm: { span: 14 } }}>
  248. <Form.Item label={t('pages.clients.attachedInbounds')} required>
  249. <SelectAllClearButtons
  250. options={inboundOptions}
  251. value={inboundIds}
  252. onChange={(v) => methods.setValue('inboundIds', v)}
  253. />
  254. <Select
  255. mode="multiple"
  256. value={inboundIds}
  257. onChange={(v) => methods.setValue('inboundIds', v)}
  258. options={inboundOptions}
  259. placeholder={t('pages.clients.selectInbound')}
  260. showSearch={{
  261. filterOption: (input, option) =>
  262. ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
  263. }}
  264. />
  265. </Form.Item>
  266. <FormField name="emailMethod" label={t('pages.clients.method')}>
  267. <Select
  268. options={[
  269. { value: 0, label: 'Random' },
  270. { value: 1, label: 'Random + Prefix' },
  271. { value: 2, label: 'Random + Prefix + Num' },
  272. { value: 3, label: 'Random + Prefix + Num + Postfix' },
  273. { value: 4, label: 'Prefix + Num + Postfix' },
  274. ]}
  275. />
  276. </FormField>
  277. {emailMethod > 1 && (
  278. <>
  279. <FormField
  280. name="firstNum"
  281. label={t('pages.clients.first')}
  282. transform={{ output: (v) => Number(v) || 1 }}
  283. >
  284. <InputNumber min={1} />
  285. </FormField>
  286. <FormField
  287. name="lastNum"
  288. label={t('pages.clients.last')}
  289. transform={{ output: (v) => Number(v) || 1 }}
  290. >
  291. <InputNumber min={firstNum} />
  292. </FormField>
  293. </>
  294. )}
  295. {emailMethod > 0 && (
  296. <FormField name="emailPrefix" label={t('pages.clients.prefix')}>
  297. <Input />
  298. </FormField>
  299. )}
  300. {emailMethod > 2 && (
  301. <FormField name="emailPostfix" label={t('pages.clients.postfix')}>
  302. <Input />
  303. </FormField>
  304. )}
  305. {emailMethod < 2 && (
  306. <FormField
  307. name="quantity"
  308. label={t('pages.clients.clientCount')}
  309. transform={{ output: (v) => Number(v) || 1 }}
  310. >
  311. <InputNumber min={1} max={1000} />
  312. </FormField>
  313. )}
  314. <Form.Item label={t('pages.clients.subId')}>
  315. <Space.Compact style={{ display: 'flex' }}>
  316. <Input
  317. value={subId}
  318. onChange={(e) => methods.setValue('subId', e.target.value)}
  319. style={{ flex: 1 }}
  320. />
  321. <Button
  322. aria-label={t('regenerate')}
  323. icon={<ReloadOutlined />}
  324. onClick={() => methods.setValue('subId', RandomUtil.randomLowerAndNum(16))}
  325. />
  326. </Space.Compact>
  327. </Form.Item>
  328. <FormField
  329. name="group"
  330. label={t('pages.clients.group')}
  331. tooltip={t('pages.clients.groupDesc')}
  332. transform={{ output: (v) => v ?? '' }}
  333. >
  334. <AutoComplete
  335. placeholder={t('pages.clients.groupPlaceholder')}
  336. options={groups.map((g) => ({ value: g }))}
  337. allowClear
  338. />
  339. </FormField>
  340. <FormField
  341. name="limitHwid"
  342. label={t('pages.clients.limitHwid')}
  343. tooltip={t('pages.clients.limitHwidDesc')}
  344. transform={{ output: (v) => Number(v) || 0 }}
  345. >
  346. <InputNumber min={0} />
  347. </FormField>
  348. <FormField name="comment" label={t('comment')}>
  349. <Input />
  350. </FormField>
  351. {showFlow && (
  352. <FormField name="flow" label={t('pages.clients.flow')}>
  353. <Select
  354. style={{ width: 220 }}
  355. options={[
  356. { value: '', label: t('none') },
  357. ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
  358. ]}
  359. />
  360. </FormField>
  361. )}
  362. <Form.Item label={t('pages.clients.limitIp')}>
  363. <Tooltip title={limitIpNotice || undefined}>
  364. <span style={{ display: 'inline-flex' }}>
  365. <InputNumber
  366. value={limitIp}
  367. min={0}
  368. disabled={limitIpDisabled}
  369. style={limitIpDisabled ? { pointerEvents: 'none' } : undefined}
  370. onChange={(v) => methods.setValue('limitIp', Number(v) || 0)}
  371. />
  372. </span>
  373. </Tooltip>
  374. </Form.Item>
  375. <FormField
  376. name="totalGB"
  377. label={t('pages.clients.totalGB')}
  378. tooltip={
  379. hasTuic ? t('pages.clients.tuicTotalGBDesc') : t('pages.clients.totalGBDesc')
  380. }
  381. transform={{ output: (v) => Number(v) || 0 }}
  382. >
  383. <InputNumber min={0} step={1} />
  384. </FormField>
  385. <Form.Item label={t('pages.clients.delayedStart')}>
  386. <Switch
  387. checked={delayedStart}
  388. onClick={() => {
  389. setDelayedStart(!delayedStart);
  390. methods.setValue('expiryTime', 0);
  391. }}
  392. />
  393. </Form.Item>
  394. {delayedStart ? (
  395. <Form.Item label={t('pages.clients.expireDays')}>
  396. <InputNumber
  397. value={delayedExpireDays}
  398. min={0}
  399. onChange={(v) => methods.setValue('expiryTime', -86400000 * (Number(v) || 0))}
  400. />
  401. </Form.Item>
  402. ) : (
  403. <Form.Item label={t('pages.inbounds.expireDate')}>
  404. <DateTimePicker
  405. value={expiryDate}
  406. onChange={(next) => methods.setValue('expiryTime', next ? next.valueOf() : 0)}
  407. />
  408. </Form.Item>
  409. )}
  410. <ClientRenewalFields
  411. active={open}
  412. delayedStart={delayedStart}
  413. expiryTime={expiryTime}
  414. bulk
  415. setExpiry={(expiry) => methods.setValue('expiryTime', expiry)}
  416. />
  417. <FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
  418. <Select
  419. options={TRAFFIC_RESETS.map((r) => ({
  420. value: r,
  421. label: t(`pages.inbounds.periodicTrafficReset.${r}`),
  422. }))}
  423. />
  424. </FormField>
  425. {trafficReset === 'monthly' && (
  426. <FormField
  427. name="trafficResetDay"
  428. label={t('pages.inbounds.periodicTrafficResetDay')}
  429. transform={{ output: (v) => Number(v) || 1 }}
  430. >
  431. <InputNumber min={1} max={31} />
  432. </FormField>
  433. )}
  434. </Form>
  435. </FormProvider>
  436. </Modal>
  437. </>
  438. );
  439. }