| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551 |
- import { useEffect, useMemo, useState } from 'react';
- import { useTranslation } from 'react-i18next';
- import {
- AutoComplete,
- Button,
- Col,
- Form,
- Input,
- InputNumber,
- Modal,
- Popconfirm,
- Row,
- Select,
- Space,
- Switch,
- Tabs,
- Tag,
- Tooltip,
- Typography,
- message,
- } from 'antd';
- import {
- DeleteOutlined,
- EyeOutlined,
- PlusOutlined,
- ReloadOutlined,
- RetweetOutlined,
- } from '@ant-design/icons';
- import dayjs from 'dayjs';
- import type { Dayjs } from 'dayjs';
- import { Controller, FormProvider, useForm, useWatch, useFieldArray } from 'react-hook-form';
- import { HttpUtil, IntlUtil, RandomUtil, Wireguard } from '@/utils';
- import { formatInboundLabel } from '@/lib/inbounds/label';
- import { generateMtprotoSecret } from '@/lib/xray/inbound-defaults';
- import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
- import { useDatepicker } from '@/hooks/useDatepicker';
- import { useClientHwids } from '@/hooks/useClientHwids';
- import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
- import { FormField } from '@/components/form/rhf';
- import ClientHwidListModal from '@/components/clients/ClientHwidList';
- import { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives';
- import type {
- ClientRecord,
- InboundOption,
- ExternalLink,
- ExternalLinkInput,
- } from '@/hooks/useClients';
- import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
- import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
- const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
- const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const;
- const MULTI_CLIENT_PROTOCOLS = new Set([
- 'shadowsocks',
- 'vless',
- 'vmess',
- 'trojan',
- 'hysteria',
- 'wireguard',
- 'mtproto',
- 'amneziawg',
- ]);
- const CLIENT_FORM_MODAL_Z_INDEX = 1000;
- const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1;
- interface ExternalLinkRow {
- kind: 'link' | 'subscription';
- value: string;
- remark: string;
- enable: boolean;
- expiryTime: number;
- namePrefix: string;
- lastFetchAt: number;
- lastFetchError: string;
- }
- interface ApiMsg<T = unknown> {
- success?: boolean;
- msg?: string;
- obj?: T;
- }
- type Mode = 'add' | 'edit';
- interface SaveMetaEdit {
- isEdit: true;
- email: string;
- attach: number[];
- detach: number[];
- externalLinks: ExternalLinkInput[];
- }
- interface SaveMetaCreate {
- isEdit: false;
- email: string;
- externalLinks: ExternalLinkInput[];
- }
- interface SaveCreatePayload {
- client: Record<string, unknown>;
- inboundIds: number[];
- }
- interface ClientFormModalProps {
- open: boolean;
- mode: Mode;
- client: ClientRecord | null;
- inbounds: InboundOption[];
- attachedExternalLinks?: ExternalLink[];
- attachedIds?: number[];
- tunnelAllowedIPs?: Record<number, string>;
- tgBotEnable?: boolean;
- groups?: string[];
- save: (
- payload: Record<string, unknown> | SaveCreatePayload,
- meta: SaveMetaEdit | SaveMetaCreate,
- ) => Promise<ApiMsg | null>;
- resetTraffic?: (client: ClientRecord) => Promise<ApiMsg | null>;
- onOpenChange: (open: boolean) => void;
- }
- type Values = ClientFormValues & {
- expiryDate: number;
- limitHwid: number;
- externalLinks: ExternalLinkRow[];
- wgPrivateKey: string;
- wgPublicKey: string;
- wgPreSharedKey: string;
- wgAllowedIPs: string;
- awgAllowedIPs: string;
- awgForwardedPorts: string;
- secret: string;
- adTag: string;
- };
- const EMPTY: Values = {
- email: '',
- subId: '',
- uuid: '',
- password: '',
- auth: '',
- flow: '',
- security: 'auto',
- reverseTag: '',
- totalGB: 0,
- expiryDate: 0,
- delayedStart: false,
- delayedDays: 0,
- reset: 0,
- resetDay: 0,
- resetMax: 0,
- trafficReset: 'never' as const,
- trafficResetDay: 1,
- limitIp: 0,
- limitHwid: 0,
- tgId: 0,
- group: '',
- comment: '',
- enable: true,
- inboundIds: [],
- externalLinks: [],
- wgPrivateKey: '',
- wgPublicKey: '',
- wgPreSharedKey: '',
- wgAllowedIPs: '',
- awgAllowedIPs: '',
- awgForwardedPorts: '',
- secret: '',
- adTag: '',
- };
- function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[] {
- return (links || []).map((l) => ({
- kind: l.kind === 'subscription' ? 'subscription' : 'link',
- value: l.value || '',
- remark: l.remark || '',
- enable: l.enable !== false,
- expiryTime: Number(l.expiryTime) || 0,
- namePrefix: l.namePrefix || '',
- lastFetchAt: Number(l.lastFetchAt) || 0,
- lastFetchError: l.lastFetchError || '',
- }));
- }
- function bytesToGB(bytes: number): number {
- if (!bytes || bytes <= 0) return 0;
- return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
- }
- export function gbToBytes(gb: number): number {
- if (!gb || gb <= 0) return 0;
- return Math.round(gb * 1024 * 1024 * 1024);
- }
- export function parseAllowedIPsList(raw: string): string[] {
- return raw
- .split(',')
- .map((s) => s.trim())
- .filter((s) => s !== '');
- }
- // Maps each of the two AllowedIPs fields to the specific wg/awg inbound the
- // client is currently attached to, so a save with both protocols attached at
- // once can send each its own value instead of one shared field ambiguously
- // covering both (see model.Client.AllowedIPsByInbound on the Go side).
- // Absent from the result when the client isn't actually attached to that
- // protocol's inbound (e.g. mid-edit, before the attach takes effect).
- export function resolveTunnelAllowedIPsByInbound(
- attachedInboundIds: number[],
- wireguardInboundIds: Set<number>,
- amneziawgInboundIds: Set<number>,
- wgAllowedIPs: string[],
- awgAllowedIPs: string[],
- ): Record<number, string[]> {
- const wgId = attachedInboundIds.find((id) => wireguardInboundIds.has(id));
- const awgId = attachedInboundIds.find((id) => amneziawgInboundIds.has(id));
- const result: Record<number, string[]> = {};
- if (wgId != null) result[wgId] = wgAllowedIPs;
- if (awgId != null) result[awgId] = awgAllowedIPs;
- return result;
- }
- export function resolveTotalBytes(
- originalBytes: number | null | undefined,
- displayedGB: number,
- ): number {
- if (originalBytes != null && displayedGB === bytesToGB(originalBytes)) {
- return originalBytes;
- }
- return gbToBytes(displayedGB);
- }
- export default function ClientFormModal({
- open,
- mode,
- client,
- inbounds,
- attachedExternalLinks = [],
- attachedIds = [],
- tunnelAllowedIPs = {},
- tgBotEnable = false,
- groups = [],
- save,
- resetTraffic,
- onOpenChange,
- }: ClientFormModalProps) {
- const { t } = useTranslation();
- const [messageApi, messageContextHolder] = message.useMessage();
- const isEdit = mode === 'edit';
- const methods = useForm<Values>({ defaultValues: EMPTY });
- const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
- const delayedStart = useWatch({ control: methods.control, name: 'delayedStart' });
- const expiryDate = useWatch({ control: methods.control, name: 'expiryDate' });
- const enable = useWatch({ control: methods.control, name: 'enable' });
- const flow = useWatch({ control: methods.control, name: 'flow' });
- const reverseTag = useWatch({ control: methods.control, name: 'reverseTag' });
- const secret = useWatch({ control: methods.control, name: 'secret' });
- const email = useWatch({ control: methods.control, name: 'email' });
- const uuid = useWatch({ control: methods.control, name: 'uuid' });
- const trafficReset = useWatch({ control: methods.control, name: 'trafficReset' });
- const password = useWatch({ control: methods.control, name: 'password' });
- const subId = useWatch({ control: methods.control, name: 'subId' });
- const limitHwid = useWatch({ control: methods.control, name: 'limitHwid' });
- const auth = useWatch({ control: methods.control, name: 'auth' });
- const wgPrivateKey = useWatch({ control: methods.control, name: 'wgPrivateKey' });
- const limitIp = useWatch({ control: methods.control, name: 'limitIp' });
- const {
- fields: externalLinkFields,
- append: appendExternalLink,
- remove: removeExternalLink,
- } = useFieldArray({ control: methods.control, name: 'externalLinks' });
- const [submitting, setSubmitting] = useState(false);
- const [resetting, setResetting] = useState(false);
- const [clientIps, setClientIps] = useState<ClientIpInfo[]>([]);
- const [ipsLoading, setIpsLoading] = useState(false);
- const [ipsClearing, setIpsClearing] = useState(false);
- const [ipsModalOpen, setIpsModalOpen] = useState(false);
- const {
- clientHwids,
- hwidsLoading,
- hwidsClearing,
- deletingHwidId,
- loadHwids,
- clearHwids,
- deleteHwid,
- } = useClientHwids(client?.email);
- const [hwidsModalOpen, setHwidsModalOpen] = useState(false);
- const { datepicker } = useDatepicker();
- const hwidDateLabel = (ts: number) =>
- !ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker);
- const fail2ban = useFail2banStatusQuery();
- const limitIpDisabled = !fail2ban.usable;
- const limitIpNotice = getLimitIpNotice(fail2ban, t);
- // Declared ahead of the seeding effect below (which needs them to resolve
- // which specific wg/awg inbound this client is attached to, for seeding
- // wgAllowedIPs/awgAllowedIPs from tunnelAllowedIPs) -- both are pure
- // derivations of the stable `inbounds` prop, so moving them earlier is
- // just a declaration-order change, not a behavior change.
- const wireguardIds = useMemo(() => {
- const ids = new Set<number>();
- for (const row of inbounds || []) {
- if (row && row.protocol === 'wireguard') ids.add(row.id);
- }
- return ids;
- }, [inbounds]);
- const amneziawgIds = useMemo(() => {
- const ids = new Set<number>();
- for (const row of inbounds || []) {
- if (row && row.protocol === 'amneziawg') ids.add(row.id);
- }
- return ids;
- }, [inbounds]);
- function addExternalLinkRow(kind: 'link' | 'subscription') {
- appendExternalLink({
- kind,
- value: '',
- remark: '',
- enable: true,
- expiryTime: 0,
- namePrefix: '',
- lastFetchAt: 0,
- lastFetchError: '',
- });
- }
- useEffect(() => {
- if (!open) return;
- setIpsModalOpen(false);
- setHwidsModalOpen(false);
- if (isEdit && client) {
- const et = Number(client.expiryTime) || 0;
- const seedIds = Array.isArray(attachedIds) ? attachedIds : [];
- const attachedWireguardId = seedIds.find((id) => wireguardIds.has(id));
- const attachedAmneziawgId = seedIds.find((id) => amneziawgIds.has(id));
- const wgTunnelIPs =
- attachedWireguardId != null ? tunnelAllowedIPs[attachedWireguardId] : undefined;
- const awgTunnelIPs =
- attachedAmneziawgId != null ? tunnelAllowedIPs[attachedAmneziawgId] : undefined;
- const seed: Values = {
- ...EMPTY,
- email: client.email || '',
- subId: client.subId || '',
- uuid: client.uuid || '',
- password: client.password || '',
- auth: client.auth || '',
- flow: client.flow || '',
- security:
- !client.security || client.security === 'none' || client.security === 'zero'
- ? 'auto'
- : client.security,
- reverseTag: client.reverse?.tag || '',
- totalGB: bytesToGB(client.totalGB || 0),
- reset: Number(client.reset) || 0,
- resetDay: Number(client.resetDay) || 0,
- resetMax: Number(client.resetMax) || 0,
- trafficReset: (client.trafficReset as ClientFormValues['trafficReset']) || 'never',
- trafficResetDay: Number(client.trafficResetDay) || 1,
- limitIp: client.limitIp || 0,
- limitHwid: client.limitHwid || 0,
- tgId: Number(client.tgId) || 0,
- group: client.group || '',
- comment: client.comment || '',
- enable: !!client.enable,
- inboundIds: Array.isArray(attachedIds) ? [...attachedIds] : [],
- externalLinks: toExternalLinkRows(attachedExternalLinks),
- wgPrivateKey: client.privateKey || '',
- wgPublicKey: client.publicKey || '',
- wgPreSharedKey: client.preSharedKey || '',
- wgAllowedIPs: wgTunnelIPs ?? client.allowedIPs ?? '',
- awgAllowedIPs: awgTunnelIPs ?? client.allowedIPs ?? '',
- awgForwardedPorts: client.forwardedPorts || '',
- secret: client.secret || '',
- adTag: client.adTag || '',
- };
- if (et < 0) {
- seed.delayedStart = true;
- seed.delayedDays = Math.round(et / -86400000);
- seed.expiryDate = 0;
- } else {
- seed.delayedStart = false;
- seed.delayedDays = 0;
- seed.expiryDate = et > 0 ? et : 0;
- }
- methods.reset(seed);
- void loadIps();
- void loadHwids();
- } else {
- const wgKeypair = Wireguard.generateKeypair();
- methods.reset({
- ...EMPTY,
- email: RandomUtil.randomLowerAndNum(10),
- uuid: RandomUtil.randomUUID(),
- subId: RandomUtil.randomLowerAndNum(16),
- password: RandomUtil.randomLowerAndNum(16),
- auth: RandomUtil.randomLowerAndNum(16),
- wgPrivateKey: wgKeypair.privateKey,
- wgPublicKey: wgKeypair.publicKey,
- });
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [open, isEdit]);
- const flowCapableIds = useMemo(() => {
- const ids = new Set<number>();
- for (const row of inbounds || []) {
- if (row?.tlsFlowCapable) ids.add(row.id);
- }
- return ids;
- }, [inbounds]);
- const vlessLikeIds = useMemo(() => {
- const ids = new Set<number>();
- for (const row of inbounds || []) {
- if (row && row.protocol === 'vless') ids.add(row.id);
- }
- return ids;
- }, [inbounds]);
- const vmessIds = useMemo(() => {
- const ids = new Set<number>();
- for (const row of inbounds || []) {
- if (row && row.protocol === 'vmess') ids.add(row.id);
- }
- return ids;
- }, [inbounds]);
- const mtprotoIds = useMemo(() => {
- const ids = new Set<number>();
- for (const row of inbounds || []) {
- if (row && row.protocol === 'mtproto') ids.add(row.id);
- }
- return ids;
- }, [inbounds]);
- const mtprotoDomain = useMemo(() => {
- for (const id of inboundIds || []) {
- const ib = (inbounds || []).find((row) => row.id === id);
- if (ib?.protocol === 'mtproto' && ib.mtprotoDomain) return ib.mtprotoDomain;
- }
- return 'www.cloudflare.com';
- }, [inboundIds, inbounds]);
- const ss2022Method = useMemo(() => {
- for (const id of inboundIds || []) {
- const ib = (inbounds || []).find((row) => row.id === id);
- const method = ib?.ssMethod;
- if (method && method.substring(0, 4) === '2022') return method;
- }
- return '';
- }, [inboundIds, inbounds]);
- function regeneratePassword() {
- methods.setValue(
- 'password',
- ss2022Method
- ? RandomUtil.randomShadowsocksPassword(ss2022Method)
- : RandomUtil.randomLowerAndNum(16),
- );
- }
- const showFlow = useMemo(
- () => (inboundIds || []).some((id) => flowCapableIds.has(id)),
- [inboundIds, flowCapableIds],
- );
- const showReverseTag = useMemo(
- () => (inboundIds || []).some((id) => vlessLikeIds.has(id)),
- [inboundIds, vlessLikeIds],
- );
- const showSecurity = useMemo(
- () => (inboundIds || []).some((id) => vmessIds.has(id)),
- [inboundIds, vmessIds],
- );
- const showWireguard = useMemo(
- () => (inboundIds || []).some((id) => wireguardIds.has(id)),
- [inboundIds, wireguardIds],
- );
- const showAmneziawg = useMemo(
- () => (inboundIds || []).some((id) => amneziawgIds.has(id)),
- [inboundIds, amneziawgIds],
- );
- const showMtproto = useMemo(
- () => (inboundIds || []).some((id) => mtprotoIds.has(id)),
- [inboundIds, mtprotoIds],
- );
- function regenerateWireguardKeys() {
- const kp = Wireguard.generateKeypair();
- methods.setValue('wgPrivateKey', kp.privateKey);
- methods.setValue('wgPublicKey', kp.publicKey);
- }
- function regenerateMtprotoSecret() {
- methods.setValue('secret', generateMtprotoSecret(mtprotoDomain));
- }
- useEffect(() => {
- // Only clear the flow once we actually have inbound options to judge
- // capability from. While the options list is momentarily empty (e.g. the
- // options query is (re)loading and `inbounds` falls back to `[]`), showFlow
- // is a false negative, so clearing here would silently drop a valid
- // xtls-rprx-vision flow the user picked for a Reality/TLS inbound.
- if (inbounds.length > 0 && !showFlow && flow) {
- methods.setValue('flow', '');
- }
- }, [inbounds, showFlow, flow, methods]);
- useEffect(() => {
- if (!showReverseTag && reverseTag) {
- methods.setValue('reverseTag', '');
- }
- }, [showReverseTag, reverseTag, methods]);
- useEffect(() => {
- if (!ss2022Method) return;
- const current = methods.getValues('password');
- if (!RandomUtil.isShadowsocks2022Password(current, ss2022Method)) {
- methods.setValue('password', RandomUtil.randomShadowsocksPassword(ss2022Method));
- }
- }, [ss2022Method, methods]);
- useEffect(() => {
- if (showMtproto && !secret) {
- methods.setValue('secret', generateMtprotoSecret(mtprotoDomain));
- }
- }, [showMtproto, secret, mtprotoDomain, methods]);
- const inboundOptions = useMemo(
- () =>
- (inbounds || [])
- .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
- .filter((ib) => ib.enable || (inboundIds || []).includes(ib.id))
- .map((ib) => ({
- label: formatInboundLabel(ib.tag, ib.remark),
- value: ib.id,
- title: formatInboundLabel(ib.tag, ib.remark),
- })),
- [inbounds, inboundIds],
- );
- const expiryDayjs = useMemo<Dayjs | null>(
- () => (expiryDate > 0 ? dayjs(expiryDate) : null),
- [expiryDate],
- );
- const linkRows = externalLinkFields
- .map((field, index) => ({ field, index }))
- .filter((row) => row.field.kind === 'link');
- const subscriptionRows = externalLinkFields
- .map((field, index) => ({ field, index }))
- .filter((row) => row.field.kind === 'subscription');
- async function loadIps() {
- if (!isEdit || !client?.email) return;
- setIpsLoading(true);
- try {
- const msg = (await HttpUtil.post(
- `/panel/api/clients/ips/${encodeURIComponent(client.email)}`,
- )) as ApiMsg<unknown[]>;
- if (!msg?.success) {
- setClientIps([]);
- return;
- }
- setClientIps(normalizeClientIps(msg.obj));
- } finally {
- setIpsLoading(false);
- }
- }
- function openIpsModal() {
- setIpsModalOpen(true);
- if (clientIps.length === 0) void loadIps();
- }
- async function clearIps() {
- if (!isEdit || !client?.email) return;
- setIpsClearing(true);
- try {
- const msg = (await HttpUtil.post(
- `/panel/api/clients/clearIps/${encodeURIComponent(client.email)}`,
- )) as ApiMsg;
- if (msg?.success) setClientIps([]);
- } finally {
- setIpsClearing(false);
- }
- }
- function openHwidsModal() {
- setHwidsModalOpen(true);
- if (clientHwids.length === 0) void loadHwids();
- }
- function close() {
- onOpenChange(false);
- }
- async function onResetTraffic() {
- if (!isEdit || !client?.email || !resetTraffic) return;
- setResetting(true);
- try {
- const msg = await resetTraffic(client);
- if (msg?.success) {
- messageApi.success(t('pages.clients.toasts.trafficReset'));
- } else {
- messageApi.error(msg?.msg || t('somethingWentWrong'));
- }
- } finally {
- setResetting(false);
- }
- }
- async function onSubmit() {
- const values = methods.getValues();
- const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema;
- const validated = schema.safeParse({
- email: values.email,
- subId: values.subId,
- uuid: values.uuid,
- password: values.password,
- auth: values.auth,
- flow: values.flow,
- security: values.security,
- reverseTag: values.reverseTag,
- totalGB: values.totalGB,
- delayedStart: values.delayedStart,
- delayedDays: values.delayedDays,
- reset: values.reset,
- resetDay: values.resetDay,
- resetMax: values.resetMax,
- trafficReset: values.trafficReset,
- trafficResetDay: values.trafficResetDay,
- limitIp: values.limitIp,
- limitHwid: values.limitHwid,
- tgId: values.tgId,
- group: values.group,
- comment: values.comment,
- enable: values.enable,
- inboundIds: values.inboundIds,
- });
- if (!validated.success) {
- const issue = validated.error.issues[0];
- messageApi.error(t(issue?.message ?? 'somethingWentWrong'));
- return;
- }
- const expiryTime = values.delayedStart
- ? -86400000 * (Number(values.delayedDays) || 0)
- : values.expiryDate || 0;
- const totalBytes = resolveTotalBytes(client ? (client.totalGB ?? 0) : null, values.totalGB);
- const clientPayload: Record<string, unknown> = {
- email: values.email.trim(),
- subId: values.subId,
- id: values.uuid,
- password: values.password,
- auth: values.auth,
- flow: showFlow ? values.flow || '' : '',
- security: showSecurity ? values.security || 'auto' : 'auto',
- totalGB: totalBytes,
- expiryTime,
- reset: Number(values.reset) || 0,
- resetDay: Number(values.resetDay) || 0,
- resetMax: Number(values.resetMax) || 0,
- trafficReset: values.trafficReset || 'never',
- trafficResetDay: Number(values.trafficResetDay) || 1,
- limitIp: Number(values.limitIp) || 0,
- limitHwid: Number(values.limitHwid) || 0,
- tgId: Number(values.tgId) || 0,
- group: values.group,
- comment: values.comment,
- enable: !!values.enable,
- };
- const reverseTagValue = showReverseTag ? (values.reverseTag || '').trim() : '';
- if (reverseTagValue) {
- clientPayload.reverse = { tag: reverseTagValue };
- }
- if (showWireguard || showAmneziawg) {
- // AmneziaWG peers are wire-identical to WireGuard peers (same
- // privateKey/publicKey/preSharedKey/allowedIPs fields on model.Client),
- // so both protocols share this one field set — see wgPrivateKey etc.
- // below and the AmneziaWG-labeled variants of the same inputs.
- clientPayload.privateKey = values.wgPrivateKey;
- clientPayload.publicKey = values.wgPublicKey;
- if (values.wgPreSharedKey) {
- clientPayload.preSharedKey = values.wgPreSharedKey;
- }
- const wgAllowedIPs = parseAllowedIPsList(values.wgAllowedIPs);
- if (showWireguard && showAmneziawg) {
- // Both protocols are attached at once: the two fields hold genuinely
- // different addresses, so each must land on its own inbound instead
- // of one broadcast value overwriting the other's (allowedIPsByInbound
- // is what Update/Create key their per-inbound override off of).
- const awgAllowedIPs = parseAllowedIPsList(values.awgAllowedIPs);
- clientPayload.allowedIPsByInbound = resolveTunnelAllowedIPsByInbound(
- values.inboundIds || [],
- wireguardIds,
- amneziawgIds,
- wgAllowedIPs,
- awgAllowedIPs,
- );
- if (wgAllowedIPs.length > 0) {
- clientPayload.allowedIPs = wgAllowedIPs;
- }
- } else if (wgAllowedIPs.length > 0) {
- clientPayload.allowedIPs = wgAllowedIPs;
- }
- // Port-forwarding has no WireGuard equivalent — Xray-native WireGuard
- // has no host-level iptables layer to hang per-client DNAT off of.
- if (showAmneziawg) {
- clientPayload.forwardedPorts = values.awgForwardedPorts.trim();
- }
- }
- if (showMtproto) {
- const adTag = values.adTag.trim();
- if (adTag !== '' && !/^[0-9a-fA-F]{32}$/.test(adTag)) {
- messageApi.error(t('pages.inbounds.form.mtgAdTagInvalid'));
- return;
- }
- clientPayload.secret = values.secret;
- clientPayload.adTag = adTag;
- }
- const externalLinks: ExternalLinkInput[] = values.externalLinks
- .map((r) => ({
- kind: r.kind,
- value: r.value.trim(),
- remark: (r.remark || '').trim(),
- enable: r.enable !== false,
- expiryTime: Number(r.expiryTime) || 0,
- namePrefix: (r.namePrefix || '').trim(),
- }))
- .filter((r) => r.value !== '');
- setSubmitting(true);
- try {
- let msg;
- if (isEdit && client) {
- const original = new Set(attachedIds || []);
- const next = new Set(values.inboundIds || []);
- const toAttach = [...next].filter((id) => !original.has(id));
- const toDetach = [...original].filter((id) => !next.has(id));
- msg = await save(clientPayload, {
- isEdit: true,
- email: client.email,
- attach: toAttach,
- detach: toDetach,
- externalLinks,
- });
- } else {
- msg = await save(
- { client: clientPayload, inboundIds: values.inboundIds },
- { isEdit: false, email: clientPayload.email as string, externalLinks },
- );
- }
- if (msg?.success) close();
- } finally {
- setSubmitting(false);
- }
- }
- return (
- <>
- {messageContextHolder}
- <Modal
- open={open}
- title={isEdit ? t('pages.clients.editClient') : t('pages.clients.addClient')}
- destroyOnHidden
- width={720}
- zIndex={CLIENT_FORM_MODAL_Z_INDEX}
- style={{ top: 20 }}
- styles={{
- body: { maxHeight: 'calc(100vh - 160px)', overflowY: 'auto', overflowX: 'hidden' },
- }}
- onCancel={close}
- footer={
- <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
- {isEdit && resetTraffic && (
- <Popconfirm
- title={t('pages.inbounds.resetTraffic')}
- description={t('pages.inbounds.resetTrafficContent')}
- okText={t('reset')}
- cancelText={t('cancel')}
- zIndex={CLIENT_IP_LOG_MODAL_Z_INDEX}
- onConfirm={onResetTraffic}
- >
- <Button
- color="danger"
- variant="filled"
- icon={<RetweetOutlined />}
- loading={resetting}
- >
- {t('pages.inbounds.resetTraffic')}
- </Button>
- </Popconfirm>
- )}
- <div style={{ marginInlineStart: 'auto', display: 'flex', gap: 8 }}>
- <Button onClick={close}>{t('cancel')}</Button>
- <Button type="primary" loading={submitting} onClick={onSubmit}>
- {isEdit ? t('save') : t('create')}
- </Button>
- </div>
- </div>
- }
- >
- <FormProvider {...methods}>
- <Form layout="vertical">
- <Tabs
- defaultActiveKey="basic"
- items={[
- {
- key: 'basic',
- label: t('pages.clients.tabBasics'),
- children: (
- <>
- <Row gutter={16}>
- <Col xs={24} md={12}>
- <Form.Item label={t('pages.clients.email')} required>
- <Space.Compact style={{ display: 'flex' }}>
- <Input
- value={email}
- placeholder={t('pages.clients.email')}
- style={{ flex: 1 }}
- onChange={(e) => methods.setValue('email', e.target.value)}
- />
- {!isEdit && (
- <Button
- aria-label={t('regenerate')}
- icon={<ReloadOutlined />}
- onClick={() =>
- methods.setValue('email', RandomUtil.randomLowerAndNum(12))
- }
- />
- )}
- </Space.Compact>
- </Form.Item>
- </Col>
- <Col xs={24} md={6}>
- <FormField
- name="totalGB"
- label={t('pages.clients.totalGB')}
- tooltip={t('pages.clients.totalGBDesc')}
- transform={{ output: (v) => Number(v) || 0 }}
- >
- <InputNumber min={0} step={1} style={{ width: '100%' }} />
- </FormField>
- </Col>
- <Col xs={24} md={6}>
- <Form.Item
- label={t('pages.clients.limitIp')}
- tooltip={t('pages.clients.limitIpDesc')}
- >
- <Tooltip title={limitIpNotice || undefined}>
- <span style={{ display: 'flex', width: '100%' }}>
- <Space.Compact style={{ display: 'flex', flex: 1 }}>
- <InputNumber
- value={limitIp}
- min={0}
- disabled={limitIpDisabled}
- style={{
- flex: 1,
- ...(limitIpDisabled ? { pointerEvents: 'none' } : null),
- }}
- onChange={(v) => methods.setValue('limitIp', Number(v) || 0)}
- />
- {isEdit && (
- <Tooltip title={t('pages.clients.ipLog')}>
- <Button
- aria-label={t('pages.clients.ipLog')}
- icon={<EyeOutlined />}
- loading={ipsLoading}
- onClick={openIpsModal}
- >
- {clientIps.length > 0 ? clientIps.length : ''}
- </Button>
- </Tooltip>
- )}
- </Space.Compact>
- </span>
- </Tooltip>
- </Form.Item>
- </Col>
- <Col xs={24} md={6}>
- <Form.Item
- label={t('pages.clients.limitHwid')}
- tooltip={t('pages.clients.limitHwidDesc')}
- >
- <Space.Compact style={{ display: 'flex' }}>
- <InputNumber
- value={limitHwid}
- min={0}
- style={{ flex: 1 }}
- onChange={(v) => methods.setValue('limitHwid', Number(v) || 0)}
- />
- {isEdit && (
- <Tooltip title={t('pages.clients.hwidLog')}>
- <Button
- aria-label={t('pages.clients.hwidLog')}
- icon={<EyeOutlined />}
- loading={hwidsLoading}
- onClick={openHwidsModal}
- >
- {clientHwids.length > 0 ? clientHwids.length : ''}
- </Button>
- </Tooltip>
- )}
- </Space.Compact>
- </Form.Item>
- </Col>
- </Row>
- <Row gutter={16}>
- <Col xs={24} md={12}>
- {delayedStart ? (
- <FormField
- name="delayedDays"
- label={t('pages.clients.expireDays')}
- transform={{ output: (v) => Number(v) || 0 }}
- >
- <InputNumber min={0} style={{ width: '100%' }} />
- </FormField>
- ) : (
- <Form.Item label={t('pages.clients.expiryTime')}>
- <DateTimePicker
- value={expiryDayjs}
- onChange={(d) =>
- methods.setValue('expiryDate', d ? d.valueOf() : 0)
- }
- />
- </Form.Item>
- )}
- </Col>
- <Col xs={12} md={6}>
- <Form.Item label={t('pages.clients.delayedStart')}>
- <Switch
- checked={delayedStart}
- onChange={(v) => {
- methods.setValue('delayedStart', v);
- if (v) methods.setValue('expiryDate', 0);
- else methods.setValue('delayedDays', 0);
- }}
- />
- </Form.Item>
- </Col>
- <Col xs={12} md={6}>
- <FormField
- name="reset"
- label={t('pages.clients.renewDays')}
- tooltip={t('pages.clients.renewDesc')}
- transform={{ output: (v) => Number(v) || 0 }}
- >
- <InputNumber min={0} style={{ width: '100%' }} />
- </FormField>
- </Col>
- <Col xs={12} md={6}>
- <FormField
- name="resetDay"
- label={t('pages.clients.renewOnDay')}
- tooltip={t('pages.clients.renewOnDayDesc')}
- transform={{ output: (v) => Number(v) || 0 }}
- >
- <InputNumber min={0} max={31} style={{ width: '100%' }} />
- </FormField>
- </Col>
- <Col xs={12} md={6}>
- <FormField
- name="resetMax"
- label={t('pages.clients.renewMax')}
- tooltip={t('pages.clients.renewMaxDesc')}
- transform={{ output: (v) => Number(v) || 0 }}
- >
- <InputNumber min={0} style={{ width: '100%' }} />
- </FormField>
- </Col>
- <Col xs={12} md={6}>
- <FormField
- name="trafficReset"
- label={t('pages.inbounds.periodicTrafficResetTitle')}
- >
- <Select
- options={TRAFFIC_RESETS.map((r) => ({
- value: r,
- label: t(`pages.inbounds.periodicTrafficReset.${r}`),
- }))}
- />
- </FormField>
- </Col>
- {trafficReset === 'monthly' && (
- <Col xs={12} md={6}>
- <FormField
- name="trafficResetDay"
- label={t('pages.inbounds.periodicTrafficResetDay')}
- transform={{ output: (v) => Number(v) || 1 }}
- >
- <InputNumber min={1} max={31} style={{ width: '100%' }} />
- </FormField>
- </Col>
- )}
- </Row>
- <Row gutter={16}>
- <Col xs={24} md={12}>
- <FormField name="comment" label={t('pages.clients.comment')}>
- <Input />
- </FormField>
- </Col>
- <Col xs={24} md={12}>
- <FormField
- name="group"
- label={t('pages.clients.group')}
- tooltip={t('pages.clients.groupDesc')}
- transform={{ output: (v) => v ?? '' }}
- >
- <AutoComplete
- placeholder={t('pages.clients.groupPlaceholder')}
- options={groups.map((g) => ({ value: g }))}
- allowClear
- />
- </FormField>
- </Col>
- </Row>
- {(tgBotEnable || showReverseTag) && (
- <Row gutter={16}>
- {tgBotEnable && (
- <Col xs={24} md={12}>
- <FormField
- name="tgId"
- label={t('pages.clients.telegramId')}
- transform={{ output: (v) => Number(v) || 0 }}
- >
- <InputNumber
- min={0}
- controls={false}
- placeholder={t('pages.clients.telegramIdPlaceholder')}
- style={{ width: '100%' }}
- />
- </FormField>
- </Col>
- )}
- {showReverseTag && (
- <Col xs={24} md={12}>
- <FormField name="reverseTag" label={t('pages.clients.reverseTag')}>
- <Input placeholder={t('pages.clients.reverseTagPlaceholder')} />
- </FormField>
- </Col>
- )}
- </Row>
- )}
- <Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
- <SelectAllClearButtons
- options={inboundOptions}
- value={inboundIds}
- onChange={(v) => methods.setValue('inboundIds', v)}
- />
- <Select
- mode="multiple"
- value={inboundIds}
- onChange={(v) => methods.setValue('inboundIds', v)}
- options={inboundOptions}
- placeholder={t('pages.clients.selectInbound')}
- maxTagCount="responsive"
- placement="topLeft"
- listHeight={220}
- showSearch={{
- filterOption: (input, option) =>
- ((option?.label as string) || '')
- .toLowerCase()
- .includes(input.toLowerCase()),
- }}
- />
- </Form.Item>
- <Form.Item>
- <Switch
- aria-label={t('enable')}
- checked={enable}
- onChange={(v) => methods.setValue('enable', v)}
- />
- <span style={{ marginLeft: 8 }}>{t('enable')}</span>
- </Form.Item>
- </>
- ),
- },
- {
- key: 'config',
- label: t('pages.clients.tabCredentials'),
- children: (
- <>
- <Form.Item label={t('pages.clients.uuid')}>
- <Space.Compact style={{ display: 'flex' }}>
- <Input
- value={uuid}
- style={{ flex: 1 }}
- onChange={(e) => methods.setValue('uuid', e.target.value)}
- />
- <Button
- aria-label={t('regenerate')}
- icon={<ReloadOutlined />}
- onClick={() => methods.setValue('uuid', RandomUtil.randomUUID())}
- />
- </Space.Compact>
- </Form.Item>
- <Form.Item
- label={t('pages.clients.password')}
- tooltip={t('pages.clients.passwordDesc')}
- >
- <Space.Compact style={{ display: 'flex' }}>
- <Input
- value={password}
- style={{ flex: 1 }}
- onChange={(e) => methods.setValue('password', e.target.value)}
- />
- <Button
- aria-label={t('regenerate')}
- icon={<ReloadOutlined />}
- onClick={regeneratePassword}
- />
- </Space.Compact>
- </Form.Item>
- <Form.Item label={t('pages.clients.subId')}>
- <Space.Compact style={{ display: 'flex' }}>
- <Input
- value={subId}
- style={{ flex: 1 }}
- onChange={(e) => methods.setValue('subId', e.target.value)}
- />
- <Button
- aria-label={t('regenerate')}
- icon={<ReloadOutlined />}
- onClick={() =>
- methods.setValue('subId', RandomUtil.randomLowerAndNum(16))
- }
- />
- </Space.Compact>
- </Form.Item>
- <Form.Item
- label={t('pages.clients.hysteriaAuth')}
- tooltip={t('pages.clients.hysteriaAuthDesc')}
- >
- <Space.Compact style={{ display: 'flex' }}>
- <Input
- value={auth}
- style={{ flex: 1 }}
- onChange={(e) => methods.setValue('auth', e.target.value)}
- />
- <Button
- aria-label={t('regenerate')}
- icon={<ReloadOutlined />}
- onClick={() =>
- methods.setValue('auth', RandomUtil.randomLowerAndNum(16))
- }
- />
- </Space.Compact>
- </Form.Item>
- {showFlow && (
- <FormField name="flow" label={t('pages.clients.flow')}>
- <Select
- options={[
- { value: '', label: t('none') },
- ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
- ]}
- />
- </FormField>
- )}
- {showSecurity && (
- <FormField name="security" label={t('pages.clients.vmessSecurity')}>
- <Select
- options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
- />
- </FormField>
- )}
- {(showWireguard || showAmneziawg) && (
- <>
- <Form.Item
- label={t(
- showAmneziawg
- ? 'pages.clients.amneziaWgPrivateKey'
- : 'pages.clients.wireguardPrivateKey',
- )}
- >
- <Space.Compact style={{ display: 'flex' }}>
- <Input
- value={wgPrivateKey}
- style={{ flex: 1 }}
- onChange={(e) => {
- const priv = e.target.value;
- methods.setValue('wgPrivateKey', priv);
- methods.setValue(
- 'wgPublicKey',
- priv ? Wireguard.generateKeypair(priv).publicKey : '',
- );
- }}
- />
- <Button
- aria-label={t('regenerate')}
- icon={<ReloadOutlined />}
- onClick={regenerateWireguardKeys}
- />
- </Space.Compact>
- </Form.Item>
- <FormField
- name="wgPublicKey"
- label={t(
- showAmneziawg
- ? 'pages.clients.amneziaWgPublicKey'
- : 'pages.clients.wireguardPublicKey',
- )}
- >
- <Input disabled />
- </FormField>
- <FormField
- name="wgPreSharedKey"
- label={t(
- showAmneziawg
- ? 'pages.clients.amneziaWgPreSharedKey'
- : 'pages.clients.wireguardPreSharedKey',
- )}
- >
- <Input />
- </FormField>
- {showWireguard && showAmneziawg ? (
- <>
- <FormField
- name="wgAllowedIPs"
- label={t('pages.clients.wireguardAllowedIPs')}
- extra={t('pages.clients.wireguardAllowedIPsHint')}
- >
- <Input placeholder="10.0.0.2/32" />
- </FormField>
- <FormField
- name="awgAllowedIPs"
- label={t('pages.clients.amneziaWgAllowedIPs')}
- extra={t('pages.clients.amneziaWgAllowedIPsHint')}
- >
- <Input placeholder="10.8.1.2/32" />
- </FormField>
- </>
- ) : (
- <FormField
- name="wgAllowedIPs"
- label={t(
- showAmneziawg
- ? 'pages.clients.amneziaWgAllowedIPs'
- : 'pages.clients.wireguardAllowedIPs',
- )}
- extra={t(
- showAmneziawg
- ? 'pages.clients.amneziaWgAllowedIPsHint'
- : 'pages.clients.wireguardAllowedIPsHint',
- )}
- >
- <Input placeholder="10.8.1.2/32" />
- </FormField>
- )}
- {showAmneziawg && (
- <FormField
- name="awgForwardedPorts"
- label={t('pages.clients.amneziaWgForwardedPorts')}
- extra={t('pages.clients.amneziaWgForwardedPortsHint')}
- >
- <Input placeholder="80, 443, 8000-8100" />
- </FormField>
- )}
- </>
- )}
- {showMtproto && (
- <>
- <Form.Item
- label={t('pages.clients.mtprotoSecret')}
- extra={t('pages.clients.mtprotoSecretHint')}
- >
- <Space.Compact style={{ display: 'flex' }}>
- <Input
- value={secret}
- style={{ flex: 1 }}
- onChange={(e) => methods.setValue('secret', e.target.value)}
- />
- <Button
- aria-label={t('regenerate')}
- icon={<ReloadOutlined />}
- onClick={regenerateMtprotoSecret}
- />
- </Space.Compact>
- </Form.Item>
- <FormField
- name="adTag"
- label={t('pages.clients.mtprotoAdTag')}
- extra={t('pages.clients.mtprotoAdTagHint')}
- >
- <Input allowClear placeholder="0123456789abcdef0123456789abcdef" />
- </FormField>
- </>
- )}
- </>
- ),
- },
- {
- key: 'links',
- label: t('pages.clients.tabLinks'),
- children: (
- <>
- <Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
- {t('pages.clients.linksHint')}
- </Typography.Paragraph>
- <Button
- type="primary"
- icon={<PlusOutlined />}
- onClick={() => addExternalLinkRow('link')}
- >
- {t('pages.clients.addExternalLink')}
- </Button>
- <div style={{ marginTop: 12, marginBottom: 24 }}>
- {linkRows.length === 0 ? (
- <Typography.Text type="secondary">
- {t('pages.clients.noExternalLinks')}
- </Typography.Text>
- ) : (
- linkRows.map(({ field, index }) => (
- <div key={field.id} className="external-link-card">
- <div className="external-link-row">
- <div className="external-link-enable">
- <FormField
- name={`externalLinks.${index}.enable`}
- valueProp="checked"
- noStyle
- >
- <Switch size="small" />
- </FormField>
- <span>{t('enable')}</span>
- </div>
- <FormField name={`externalLinks.${index}.value`} noStyle>
- <Input
- aria-label="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
- placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
- />
- </FormField>
- <Tooltip title={t('delete')}>
- <Button
- aria-label={t('delete')}
- danger
- icon={<DeleteOutlined />}
- onClick={() => removeExternalLink(index)}
- />
- </Tooltip>
- </div>
- <div className="external-link-details two-cols">
- <FormField name={`externalLinks.${index}.remark`} noStyle>
- <Input aria-label={t('remark')} placeholder={t('remark')} />
- </FormField>
- <Controller
- control={methods.control}
- name={`externalLinks.${index}.expiryTime`}
- render={({ field: expiryField }) => (
- <DateTimePicker
- value={
- Number(expiryField.value) > 0
- ? dayjs(Number(expiryField.value))
- : null
- }
- onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
- placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
- />
- )}
- />
- </div>
- </div>
- ))
- )}
- </div>
- <Button
- type="primary"
- icon={<PlusOutlined />}
- onClick={() => addExternalLinkRow('subscription')}
- >
- {t('pages.clients.addExternalSubscription')}
- </Button>
- <div style={{ marginTop: 12 }}>
- {subscriptionRows.length === 0 ? (
- <Typography.Text type="secondary">
- {t('pages.clients.noExternalSubscriptions')}
- </Typography.Text>
- ) : (
- subscriptionRows.map(({ field, index }) => (
- <div key={field.id} className="external-link-card">
- <div className="external-link-row">
- <div className="external-link-enable">
- <FormField
- name={`externalLinks.${index}.enable`}
- valueProp="checked"
- noStyle
- >
- <Switch size="small" />
- </FormField>
- <span>{t('enable')}</span>
- </div>
- <FormField name={`externalLinks.${index}.value`} noStyle>
- <Input
- aria-label="https://provider.example/sub/…"
- placeholder="https://provider.example/sub/…"
- />
- </FormField>
- <Tooltip title={t('delete')}>
- <Button
- aria-label={t('delete')}
- danger
- icon={<DeleteOutlined />}
- onClick={() => removeExternalLink(index)}
- />
- </Tooltip>
- </div>
- <div className="external-link-details three-cols">
- <FormField name={`externalLinks.${index}.remark`} noStyle>
- <Input aria-label={t('remark')} placeholder={t('remark')} />
- </FormField>
- <FormField name={`externalLinks.${index}.namePrefix`} noStyle>
- <Input
- aria-label={t('pages.clients.namePrefix')}
- placeholder={t('pages.clients.namePrefix')}
- />
- </FormField>
- <Controller
- control={methods.control}
- name={`externalLinks.${index}.expiryTime`}
- render={({ field: expiryField }) => (
- <DateTimePicker
- value={
- Number(expiryField.value) > 0
- ? dayjs(Number(expiryField.value))
- : null
- }
- onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
- placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
- />
- )}
- />
- </div>
- <Typography.Text
- type={field.lastFetchError ? 'danger' : 'secondary'}
- className="external-link-fetch-status"
- >
- {field.lastFetchError
- ? `${t('pages.clients.lastFetchError')}: ${field.lastFetchError}`
- : field.lastFetchAt > 0
- ? `${t('pages.clients.lastFetchAt')}: ${dayjs(field.lastFetchAt).format('YYYY-MM-DD HH:mm:ss')}`
- : t('pages.clients.neverFetched')}
- </Typography.Text>
- </div>
- ))
- )}
- </div>
- </>
- ),
- },
- ]}
- />
- </Form>
- </FormProvider>
- </Modal>
- <Modal
- open={ipsModalOpen}
- title={`${t('pages.clients.ipLog')}${client?.email ? ` — ${client.email}` : ''}`}
- width={440}
- zIndex={CLIENT_IP_LOG_MODAL_Z_INDEX}
- onCancel={() => setIpsModalOpen(false)}
- footer={[
- <Button key="refresh" icon={<ReloadOutlined />} loading={ipsLoading} onClick={loadIps}>
- {t('refresh')}
- </Button>,
- <Button
- key="clear"
- danger
- loading={ipsClearing}
- disabled={clientIps.length === 0}
- onClick={clearIps}
- >
- {t('pages.clients.clearAll')}
- </Button>,
- <Button key="close" type="primary" onClick={() => setIpsModalOpen(false)}>
- {t('close')}
- </Button>,
- ]}
- >
- {clientIps.length > 0 ? (
- <div style={{ maxHeight: 360, overflowY: 'auto' }}>
- {clientIps.map((entry, idx) => (
- <Tag
- key={idx}
- color="blue"
- style={{
- display: 'block',
- width: 'fit-content',
- maxWidth: '100%',
- marginBottom: 6,
- padding: '2px 8px',
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
- }}
- >
- {entry.ip}
- {entry.time ? ` (${entry.time})` : ''}
- {entry.node ? (
- <span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>
- @ {entry.node}
- </span>
- ) : null}
- </Tag>
- ))}
- </div>
- ) : (
- <Tag>{t('tgbot.noIpRecord')}</Tag>
- )}
- </Modal>
- <ClientHwidListModal
- open={hwidsModalOpen}
- email={client?.email}
- zIndex={CLIENT_IP_LOG_MODAL_Z_INDEX}
- hwids={clientHwids}
- loading={hwidsLoading}
- clearing={hwidsClearing}
- deletingId={deletingHwidId}
- formatDate={hwidDateLabel}
- onRefresh={loadHwids}
- onClearAll={clearHwids}
- onDelete={deleteHwid}
- onClose={() => setHwidsModalOpen(false)}
- />
- </>
- );
- }
|