1import {
2 Alert,
3 Button,
4 Tooltip,
5 IconButton,
6 Spinner,
7 AlertIcon,
8 Box,
9 Collapse,
10 Flex,
11 Grid,
12 GridItem,
13 ModalBody,
14 ModalCloseButton,
15 ModalContent,
16 Modal,
17 VStack,
18 useColorMode,
19 useToast,
20 ModalOverlay,
21 HStack,
22} from '@chakra-ui/react';
23import { zodResolver } from '@hookform/resolvers/zod';
24import dayjs from 'dayjs';
25import { FC, useEffect, useState } from 'react';
26import ReactApexChart from 'react-apexcharts';
27import { FormProvider, useForm, useWatch } from 'react-hook-form';
28import { useTranslation } from 'react-i18next';
29import {
30 User,
31 FilterUsageType,
32 UserCreate,
33 Service
34} from 'types';
35import { UsageFilter, createUsageConfig } from 'components/usage-filter';
36import {
37 AddIcon,
38 EditIcon,
39 DialogModalHeader,
40 DialogModalFooter,
41 DeleteIcon
42} from 'components/dialog';
43import { UserUsageIcon } from './icons';
44import { getDefaultValues } from './default-values';
45import { schema, FormType } from './form-schema';
46import { ServicesField } from './services-field';
47import { UsernameField } from './username-field';
48import { DataLimitField } from './data-limit-field';
49import { PeriodicUsageReset } from './periodic-usage-reset';
50import { ExpireDateField } from './expire-date-field';
51import { NoteField } from './note-field';
52import { useUsers, useServices } from 'stores';
53
54const formatUser = (user: User): FormType => {
55 const services: number[] = user.services.map((service: number | Service): number => {
56 return (typeof service !== 'number') ? service.id : service;
57 });
58 return {
59 ...user,
60 services,
61 data_limit: user.data_limit
62 ? Number((user.data_limit / 1073741824).toFixed(5))
63 : user.data_limit,
64 };
65};
66
67export type UserDialogProps = {};
68
69export const UserDialog: FC<UserDialogProps> = () => {
70 const {
71 editingUser,
72 isCreatingNewUser,
73 onCreateUser,
74 editUser,
75 fetchUserUsage,
76 onEditingUser,
77 createUser,
78 onDeletingUser,
79 } = useUsers();
80 const { refetchServices, services } = useServices();
81 const isEditing = !!editingUser;
82 const isOpen = isCreatingNewUser || isEditing;
83 const [loading, setLoading] = useState(false);
84 const [error, setError] = useState<string | null>('');
85 const toast = useToast();
86 const { t, i18n } = useTranslation();
87
88 const { colorMode } = useColorMode();
89
90 const [usageVisible, setUsageVisible] = useState(false);
91 const handleUsageToggle = () => {
92 setUsageVisible((current) => !current);
93 };
94
95 const form = useForm<FormType>({
96 defaultValues: getDefaultValues(),
97 resolver: zodResolver(schema),
98 });
99
100 const [dataLimit] = useWatch({
101 control: form.control,
102 name: ['data_limit'],
103 });
104
105 const usageTitle = t('userDialog.total');
106 const [usage, setUsage] = useState(createUsageConfig(colorMode, usageTitle));
107 const [usageFilter, setUsageFilter] = useState('1m');
108 const fetchUsageWithFilter = (query: FilterUsageType) => {
109 fetchUserUsage(editingUser!, query).then((data: any) => {
110 const labels = [];
111 const series = [];
112 for (const key in data.usages) {
113 series.push(data.usages[key].used_traffic);
114 labels.push(data.usages[key].node_name);
115 }
116 setUsage(createUsageConfig(colorMode, usageTitle, series, labels));
117 });
118 };
119
120 useEffect(() => {
121 if (isOpen) {
122 refetchServices();
123 }
124 }, [isOpen]);
125
126
127 useEffect(() => {
128 if (editingUser) {
129 form.reset(formatUser(editingUser));
130
131 fetchUsageWithFilter({
132 start: dayjs().utc().subtract(30, 'day').format('YYYY-MM-DDTHH:00:00'),
133 });
134 }
135 }, [editingUser]);
136
137 const submit = async (values: FormType) => {
138 setLoading(true);
139 const methods = { edited: editUser, created: createUser };
140 const method = isEditing ? 'edited' : 'created';
141 setError(null);
142 const { services, username, ...rest } = values;
143
144 let body: UserCreate = {
145 ...rest,
146 username,
147 services,
148 data_limit: values.data_limit,
149 data_limit_reset_strategy:
150 values.data_limit && values.data_limit > 0
151 ? values.data_limit_reset_strategy
152 : 'no_reset',
153 status:
154 values.status === 'active' || values.status === 'disabled'
155 ? values.status
156 : 'active',
157 };
158
159 await methods[method](body)
160 .then(() => {
161 toast({
162 title: t(
163 isEditing ? 'userDialog.userEdited' : 'userDialog.userCreated',
164 { username: values.username }
165 ),
166 status: 'success',
167 isClosable: true,
168 position: 'top',
169 duration: 3000,
170 });
171 onClose();
172 })
173 .catch((err) => {
174 if (err?.response?.status === 409 || err?.response?.status === 400)
175 setError(err?.response?._data?.detail);
176 if (err?.response?.status === 422) {
177 Object.keys(err.response._data.detail).forEach((key) => {
178 setError(err?.response._data.detail[key] as string);
179 form.setError(
180 key as 'services' | 'username' | 'data_limit' | 'expire',
181 {
182 type: 'custom',
183 message: err.response._data.detail[key],
184 }
185 );
186 });
187 }
188 })
189 .finally(() => {
190 setLoading(false);
191 });
192 };
193
194 const onClose = () => {
195 form.reset(getDefaultValues());
196 onCreateUser(false);
197 onEditingUser(null);
198 setError(null);
199 setUsageVisible(false);
200 setUsageFilter('1m');
201 };
202
203 const handleResetUsage = () => {
204 useUsers.setState({ resetUsageUser: editingUser });
205 };
206
207 const handleRevokeSubscription = () => {
208 useUsers.setState({ revokeSubscriptionUser: editingUser });
209 };
210
211 const disabled = loading;
212
213 return (
214 <Modal isOpen={isOpen} onClose={onClose} size="2xl">
215 <ModalOverlay bg="blackAlpha.300" backdropFilter="blur(10px)" />
216 <FormProvider {...form} formState={form.formState}>
217 <ModalContent mx="3">
218 <form onSubmit={form.handleSubmit(submit)}>
219 <DialogModalHeader HeaderIcon={isEditing ? EditIcon : AddIcon} title={isEditing ? t('userDialog.editUserTitle') : t('createNewUser')} />
220 <ModalCloseButton mt={3} disabled={disabled} />
221 <ModalBody>
222 <Grid
223 templateColumns={{
224 base: 'repeat(1, 1fr)',
225 md: 'repeat(2, 1fr)',
226 }}
227 gap={3}
228 >
229 <GridItem>
230 <VStack justifyContent="space-between">
231 <Flex
232 flexDirection="column"
233 gridAutoRows="min-content"
234 w="full"
235 >
236 <UsernameField form={form} disabled={disabled} isEditing={isEditing} t={t} />
237 <DataLimitField form={form} disabled={disabled} t={t} />
238
239 <Collapse
240 in={!!(dataLimit && dataLimit > 0)}
241 animateOpacity
242 style={{ width: '100%' }}
243 >
244 <PeriodicUsageReset form={form} t={t} />
245 </Collapse>
246 <ExpireDateField form={form} t={t} i18n={i18n} disabled={disabled} />
247 <NoteField form={form} t={t} />
248 </Flex>
249 {error && (
250 <Alert
251 status="error"
252 display={{ base: 'none', md: 'flex' }}
253 >
254 <AlertIcon />
255 {error}
256 </Alert>
257 )}
258 </VStack>
259 </GridItem>
260
261 <GridItem>
262 <ServicesField t={t} services={services} form={form} />
263 </GridItem>
264
265 {isEditing && usageVisible && (
266 <GridItem pt={6} colSpan={{ base: 1, md: 2 }}>
267 <VStack gap={4}>
268 <UsageFilter
269 defaultValue={usageFilter}
270 onChange={(filter, query) => {
271 setUsageFilter(filter);
272 fetchUsageWithFilter(query);
273 }}
274 />
275 <Box
276 width={{ base: '100%', md: '70%' }}
277 justifySelf="center"
278 >
279 <ReactApexChart
280 options={usage.options}
281 series={usage.series}
282 type="donut"
283 />
284 </Box>
285 </VStack>
286 </GridItem>
287 )}
288 </Grid>
289
290 {error && (
291 <Alert
292 mt="3"
293 status="error"
294 display={{ base: 'flex', md: 'none' }}
295 >
296 <AlertIcon />
297 {error}
298 </Alert>
299 )}
300 </ModalBody>
301 <DialogModalFooter>
302 <HStack
303 justifyContent="flex-start"
304 w={{
305 base: 'full',
306 sm: 'unset',
307 }}
308 >
309 {isEditing && editingUser !== null && (
310 <>
311 <Tooltip label={t('delete')} placement="top">
312 <IconButton
313 aria-label="Delete"
314 size="sm"
315 onClick={() => {
316 onDeletingUser(editingUser);
317 onClose();
318 }}
319 >
320 <DeleteIcon />
321 </IconButton>
322 </Tooltip>
323 <Tooltip label={t('userDialog.usage')} placement="top">
324 <IconButton
325 aria-label="usage"
326 size="sm"
327 onClick={handleUsageToggle}
328 >
329 <UserUsageIcon />
330 </IconButton>
331 </Tooltip>
332 <Button onClick={handleResetUsage} size="sm">
333 {t('userDialog.resetUsage')}
334 </Button>
335 <Button onClick={handleRevokeSubscription} size="sm">
336 {t('userDialog.revokeSubscription')}
337 </Button>
338 </>
339 )}
340 </HStack>
341 <HStack
342 w="full"
343 maxW={{ md: '50%', base: 'full' }}
344 justify="end"
345 >
346 <Button
347 type="submit"
348 size="sm"
349 px="8"
350 colorScheme="primary"
351 leftIcon={loading ? <Spinner size="xs" /> : undefined}
352 disabled={false}
353 >
354 {isEditing ? t('userDialog.editUser') : t('createUser')}
355 </Button>
356 </HStack>
357 </DialogModalFooter>
358 </form>
359 </ModalContent>
360 </FormProvider>
361 </Modal >
362 );
363};