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