1import {
2 Button,
3 Select as ChakraSelect,
4 FormControl,
5 FormLabel,
6 HStack,
7 IconButton,
8 Modal,
9 ModalBody,
10 ModalCloseButton,
11 ModalContent,
12 ModalOverlay,
13 Tooltip,
14 VStack,
15 chakra,
16 useToast,
17 Spacer,
18 Popover,
19 PopoverTrigger,
20 Portal,
21 PopoverContent,
22 PopoverArrow,
23 PopoverCloseButton,
24 Text,
25 Badge,
26 Alert,
27 AlertIcon,
28 InputRightElement,
29 InputGroup,
30} from '@chakra-ui/react';
31import { zodResolver } from '@hookform/resolvers/zod';
32import { FC, useEffect, useState } from 'react';
33import { useInbounds, HostType, HostSchema, hostSchema } from 'stores';
34import { Trans, useTranslation } from 'react-i18next';
35import { getDefaultValues } from './default-values';
36import { useForm } from 'react-hook-form';
37import 'slick-carousel/slick/slick-theme.css';
38import 'slick-carousel/slick/slick.css';
39import { DialogModalHeader, AddIcon, DeleteIcon } from 'components/dialog';
40import { Input as CustomInput } from 'components/input';
41import { EditIcon } from 'components/table';
42import { InfoIcon } from './icons';
43import {
44 proxyALPN,
45 proxyFingerprint,
46 proxyHostSecurity,
47} from 'constants/Proxies';
48import { RemarkFieldPopover } from './remark-field-popover';
49
50export const Select = chakra(ChakraSelect, {
51 baseStyle: {
52 bg: 'white',
53 _dark: {
54 bg: 'gray.700',
55 },
56 },
57});
58
59export const Input = chakra(CustomInput, {
60 baseStyle: {
61 bg: 'white',
62 _dark: {
63 bg: 'gray.700',
64 },
65 },
66});
67
68/* TODO: Impelement specific errors and input verifications
69 * */
70export const HostsDialog: FC = () => {
71 const {
72 selectedHost, isEditingHost, isCreatingHost, selectedInbound,
73 onEditingHost, editHost, createHost, onCreatingHost, onDeletingHost
74 } = useInbounds();
75 const [loading, setLoading] = useState(false);
76 const [error, setError] = useState<string | null>('');
77 const { t } = useTranslation();
78 const toast = useToast();
79 const defaultValueHost: HostSchema = (isEditingHost === true && selectedHost !== null) ? selectedHost : getDefaultValues();
80 const form = useForm<HostSchema>({
81 resolver: zodResolver(hostSchema),
82 defaultValues: defaultValueHost,
83 });
84 const isOpen = isEditingHost || isCreatingHost;
85 const onClose = () => {
86 onCreatingHost(false);
87 onEditingHost(false, null);
88 };
89 useEffect(() => {
90 if (isOpen) {
91 form.reset(defaultValueHost);
92 }
93 }, [isOpen]);
94
95 const submit = async (values: HostSchema | HostType) => {
96 setLoading(true);
97 const methods = { edited: editHost, created: createHost };
98 const method = isEditingHost ? 'edited' : 'created';
99 const refId = (isEditingHost ? selectedHost?.id : null) ?? selectedInbound?.id;
100 setError(null);
101 await methods[method](refId, values)
102 .then(() => {
103 toast({
104 title: t(
105 isEditingHost ? 'hostsDialog.editHost' : 'hostsDialog.createNewHost',
106 { hoshtName: values.host }
107 ),
108 status: 'success',
109 isClosable: true,
110 position: 'top',
111 duration: 3000,
112 });
113 // refetchNodes();
114 onClose();
115 })
116 .catch((err) => {
117 if (err?.response?.status === 409 || err?.response?.status === 400)
118 setError(err?.response?._data?.detail);
119 if (err?.response?.status === 422) {
120 Object.keys(err.response._data.detail).forEach((key) => {
121 setError(err?.response._data.detail[key] as string);
122 form.setError(
123 key as 'address' | 'port' | 'host' | 'path' | 'remark' | 'sni' | 'security' | 'alpn' | 'fingerprint',
124 {
125 type: 'custom',
126 message: err.response._data.detail[key],
127 }
128 );
129 });
130 }
131 })
132 .finally(() => {
133 setLoading(false);
134 });
135 };
136
137 return (
138 <Modal isOpen={isOpen} onClose={onClose}>
139 <ModalOverlay bg="blackAlpha.300" backdropFilter="blur(10px)" />
140 <ModalContent mx="3" w="lg" >
141 <DialogModalHeader HeaderIcon={isEditingHost ? EditIcon : AddIcon} title={isEditingHost ? t('hostsDialog.editHost') : t('hostsDialog.createHost')} />
142 <ModalCloseButton mt={3} />
143 <ModalBody pb={4} pt={3}>
144 {loading && 'loading...'}
145 <form onSubmit={form.handleSubmit((v) => submit(v))}>
146 <VStack>
147 <HStack w="100%" alignItems="flex-start">
148 <FormControl
149 position="relative"
150 zIndex={10}
151 >
152 <InputGroup>
153 <Input
154 {...form.register('remark')}
155 size="sm"
156 borderRadius="4px"
157 placeholder="Remark"
158 />
159 <InputRightElement>
160 <RemarkFieldPopover />
161 </InputRightElement>
162 </InputGroup>
163 </FormControl>
164 </HStack>
165 <FormControl>
166 <InputGroup>
167 <Input
168 size="sm"
169 borderRadius="4px"
170 placeholder="Address (e.g. example.com)"
171 {...form.register('address')}
172 />
173 <InputRightElement>
174 <RemarkFieldPopover />
175 </InputRightElement>
176 </InputGroup>
177 </FormControl>
178 <FormControl>
179 <FormLabel
180 display="flex"
181 pb={1}
182 alignItems="center"
183 justifyContent="space-between"
184 gap={1}
185 m="0"
186 >
187 <span>{t('hostsDialog.port')}</span>
188 <Popover isLazy placement="right">
189 <PopoverTrigger>
190 <InfoIcon />
191 </PopoverTrigger>
192 <Portal>
193 <PopoverContent p={2}>
194 <PopoverArrow />
195 <PopoverCloseButton />
196 <Text fontSize="xs" pr={5}>
197 {t('hostsDialog.port.info')}
198 </Text>
199 </PopoverContent>
200 </Portal>
201 </Popover>
202 </FormLabel>
203 <Input
204 size="sm"
205 borderRadius="4px"
206 placeholder={String(selectedInbound?.port || '8080')}
207 type="number"
208 {...form.register('port')}
209 />
210 </FormControl>
211 <FormControl>
212 <FormLabel
213 display="flex"
214 pb={1}
215 alignItems="center"
216 gap={1}
217 justifyContent="space-between"
218 m="0"
219 >
220 <span>{t('hostsDialog.sni')}</span>
221 <Popover isLazy placement="right">
222 <PopoverTrigger>
223 <InfoIcon />
224 </PopoverTrigger>
225 <Portal>
226 <PopoverContent p={2}>
227 <PopoverArrow />
228 <PopoverCloseButton />
229 <Text fontSize="xs" pr={5}>
230 {t('hostsDialog.sni.info')}
231 </Text>
232 <Text fontSize="xs" mt="2">
233 <Trans
234 i18nKey="hostsDialog.host.wildcard"
235 components={{
236 badge: <Badge />,
237 }}
238 />
239 </Text>
240 <Text fontSize="xs">
241 <Trans
242 i18nKey="hostsDialog.host.multiHost"
243 components={{
244 badge: <Badge />,
245 }}
246 />
247 </Text>
248 </PopoverContent>
249 </Portal>
250 </Popover>
251 </FormLabel>
252 <Input
253 size="sm"
254 borderRadius="4px"
255 placeholder="SNI (e.g. example.com)"
256 {...form.register('sni')}
257 />
258 </FormControl>
259 <FormControl>
260 <FormLabel
261 display="flex"
262 pb={1}
263 alignItems="center"
264 gap={1}
265 justifyContent="space-between"
266 m="0"
267 >
268 <span>{t('hostsDialog.host')}</span>
269
270 <Popover isLazy placement="right">
271 <PopoverTrigger>
272 <InfoIcon />
273 </PopoverTrigger>
274 <Portal>
275 <PopoverContent p={2}>
276 <PopoverArrow />
277 <PopoverCloseButton />
278 <Text fontSize="xs" pr={5}>
279 {t('hostsDialog.host.info')}
280 </Text>
281 <Text fontSize="xs" mt="2">
282 <Trans
283 i18nKey="hostsDialog.host.wildcard"
284 components={{
285 badge: <Badge />,
286 }}
287 />
288 </Text>
289 <Text fontSize="xs">
290 <Trans
291 i18nKey="hostsDialog.host.multiHost"
292 components={{
293 badge: <Badge />,
294 }}
295 />
296 </Text>
297 </PopoverContent>
298 </Portal>
299 </Popover>
300 </FormLabel>
301 <Input
302 size="sm"
303 borderRadius="4px"
304 placeholder="Host (e.g. example.com)"
305 {...form.register('host')}
306 />
307 </FormControl>
308 <FormControl>
309 <FormLabel
310 display="flex"
311 pb={1}
312 alignItems="center"
313 gap={1}
314 justifyContent="space-between"
315 m="0"
316 >
317 <span>{t('hostsDialog.path')}</span>
318 <Popover isLazy placement="right">
319 <PopoverTrigger>
320 <InfoIcon />
321 </PopoverTrigger>
322 <Portal>
323 <PopoverContent p={2}>
324 <PopoverArrow />
325 <PopoverCloseButton />
326 <Text fontSize="xs" pr={5}>
327 {t('hostsDialog.path.info')}
328 </Text>
329 </PopoverContent>
330 </Portal>
331 </Popover>
332 </FormLabel>
333 <Input
334 size="sm"
335 borderRadius="4px"
336 placeholder="path (e.g. /vless)"
337 {...form.register('path')}
338 />
339 </FormControl>
340 <FormControl height="66px">
341 <FormLabel
342 display="flex"
343 pb={1}
344 alignItems="center"
345 gap={1}
346 justifyContent="space-between"
347 m="0"
348 >
349 <span>{t('hostsDialog.security')}</span>
350 <Popover isLazy placement="right">
351 <PopoverTrigger>
352 <InfoIcon />
353 </PopoverTrigger>
354 <Portal>
355 <PopoverContent p={2}>
356 <PopoverArrow />
357 <PopoverCloseButton />
358 <Text fontSize="xs" pr={5}>
359 {t('hostsDialog.security.info')}
360 </Text>
361 </PopoverContent>
362 </Portal>
363 </Popover>
364 </FormLabel>
365 <Select size="sm" {...form.register('security')} >
366 {proxyHostSecurity.map((s) => {
367 return (
368 <option key={s.value} value={s.value}>
369 {s.title}
370 </option>
371 );
372 })}
373 </Select>
374 </FormControl>
375 {/* TODO: Add ALPN and fingerprint fields smooth entrance and
376 * pushing down animation when security field is TLS
377 * - Insertion and pushing down the below element
378 * - Slowly increasing the opacity
379 * Related issue: #95
380 */}
381 <FormControl height="66px">
382 <FormLabel
383 display="flex"
384 pb={1}
385 alignItems="center"
386 gap={1}
387 justifyContent="space-between"
388 m="0"
389 >
390 <span>{t('hostsDialog.alpn')}</span>
391 </FormLabel>
392 <Select
393 size="sm"
394 {...form.register('alpn')}
395 >
396 {proxyALPN.map((s) => {
397 return (
398 <option key={s.value} value={s.value}>
399 {s.title}
400 </option>
401 );
402 })}
403 </Select>
404 </FormControl>
405 <FormControl height="66px">
406 <FormLabel
407 display="flex"
408 pb={1}
409 alignItems="center"
410 gap={1}
411 justifyContent="space-between"
412 m="0"
413 >
414 <span>{t('hostsDialog.fingerprint')}</span>
415 </FormLabel>
416 <Select
417 size="sm"
418 {...form.register('fingerprint')}
419 >
420 {proxyFingerprint.map((s) => {
421 return (
422 <option key={s.value} value={s.value}>
423 {s.title}
424 </option>
425 );
426 })}
427 </Select>
428 </FormControl>
429 <Spacer m={2}></Spacer>
430 <HStack w="full">
431 <Tooltip label={t('delete')} placement="top">
432 <IconButton
433 colorScheme="red"
434 variant="ghost"
435 size="sm"
436 aria-label="delete host"
437 onClick={() => {
438 if (selectedHost !== null) {
439 onDeletingHost(true, selectedHost);
440 onClose();
441 }
442 }}
443 >
444 <DeleteIcon />
445 </IconButton>
446 </Tooltip>
447 <Button
448 flexGrow={1}
449 type="submit"
450 colorScheme="primary"
451 size="sm"
452 px={5}
453 w="full"
454 isLoading={loading}
455 >
456 {isEditingHost ? t('hostsDialog.editSubmit') : t('hostsDialog.createSubmit')}
457 </Button>
458 </HStack>
459 {error && (
460 <Alert
461 status="error"
462 display={{ base: 'none', md: 'flex' }}
463 >
464 <AlertIcon />
465 {error}
466 </Alert>
467 )}
468 </VStack>
469 </form>
470 </ModalBody>
471 </ModalContent>
472 </Modal>
473 );
474};