diff --git a/app/dashboard/.prettierignore b/app/dashboard/.prettierignore deleted file mode 100644 index 8032e36..0000000 --- a/app/dashboard/.prettierignore +++ /dev/null @@ -1,5 +0,0 @@ -.cache -package.json -package-lock.json -public -dist diff --git a/app/dashboard/.prettierrc.json b/app/dashboard/.prettierrc.json deleted file mode 100644 index 1c5e966..0000000 --- a/app/dashboard/.prettierrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "printWidth": 80 -} diff --git a/app/dashboard/public/locales/en.json b/app/dashboard/public/locales/en.json index a92adb7..19dd6b0 100644 --- a/app/dashboard/public/locales/en.json +++ b/app/dashboard/public/locales/en.json @@ -1,130 +1,172 @@ { "password": "Password", - "login": "Login", "cancel": "Cancel", "apply": "Apply", "delete": "Delete", "reset": "Reset", "createUser": "Create User", + "createService": "Create Service", "username": "Username", + "name": "Name", "expires": "Expires in {{time}}", "expired": "Expired {{time}} ago", "dateFormat": "MMMM d, yyy", + "services": "Services", "inbound": "Inbound", - "login.loginYourAccount": "Login to your account", - "login.welcomeBack": "Welcome back, please enter your details", - "login.fieldRequired": "This field is required", - "header.hostSettings": "Host Settings", - "header.nodeSettings": "Node Settings", - "header.nodesUsage": "Nodes Usage", - "header.donation": "Donation", - "header.logout": "Log out", - "pageHeaderTitle.Home": "Home", - "pageHeaderTitle.Users": "Users", - "pageHeaderTitle.Services": "Services", - "pageHeaderTitle.Host": "Host", - "pageHeaderTitle.Nodes": "Nodes", - "deleteUser.title": "Delete User", - "deleteUser.prompt": "Are you sure you want to delete {{username}}?", - "deleteUser.deleteSuccess": "{{username}} deleted successfully.", - "usersTable.status": "status", - "usersTable.dataUsage": "data usage", - "usersTable.noUserMatched": "It seems there is no user matched with what you are looking for", - "usersTable.noUser": "There is no user added to the system", - "usersTable.copyLink": "Copy Subscription Link", - "usersTable.copied": "Copied", - "usersTable.copyConfigs": "Copy Configs", - "usersTable.total": "Total", - "userDialog.dataLimit": "Data Limit", - "userDialog.periodicUsageReset": "Periodic Usage Reset", - "userDialog.warningNoProtocol": "Please select at least one protocol", - "userDialog.expiryDate": "Expiry Date", - "userDialog.note": "Note", - "userDialog.resetUsage": "Reset Usage", - "userDialog.usage": "Usage", - "userDialog.protocols": "Protocols", - "userDialog.services": "Services", - "userDialog.editUserTitle": "Edit user", - "userDialog.editUser": "Edit user", - "userDialog.userEdited": "User {{username}} edited.", - "userDialog.userCreated": "User {{username}} created.", - "userDialog.userAlreadyExists": "User already exists", - "userDialog.vmessDesc": "Fast and secure", - "userDialog.vlessDesc": "Lightweight, fast and secure", - "userDialog.trojanDesc": "Lightweight, secure and lightening fast", - "userDialog.shadowsocksDesc": "Fast and secure, but not efficient as others", - "userDialog.resetStrategyNo": "No", - "userDialog.resetStrategyDaily": "Daily", - "userDialog.resetStrategyWeekly": "Weekly", - "userDialog.resetStrategyMonthly": "Monthly", - "userDialog.resetStrategyAnnually": "Annually", - "userDialog.selectOneProtocol": "Please select at least one protocol", - "userDialog.optional": "optional", - "userDialog.method": "Method", - "userDialog.generatedByDefault": "Automatically generated by default", - "userDialog.hours": "Hours", - "userDialog.days": "Days", - "userDialog.weeks": "Weeks", - "userDialog.months": "Months", - "userDialog.relative": "Relative", - "userDialog.absolute": "Absolute", - "userDialog.custom": "Custom", - "userDialog.startDate": "Start date", - "userDialog.endDate": "End date", - "userDialog.revokeSubscription": "Revoke Subscription", + "inbounds": "Inbounds", + "login": { + "login": "Login", + "loginYourAccount": "Login to your account", + "welcomeBack": "Welcome back, please enter your details", + "fieldRequired": "This field is required" + }, + "header": { + "hostSettings": "Host Settings", + "nodeSettings": "Node Settings", + "nodesUsage": "Nodes Usage", + "donation": "Donation", + "logout": "Log out" + }, + "pageHeaderTitle": { + "Home": "Home", + "Users": "Users", + "Services": "Services", + "Host": "Host", + "Nodes": "Nodes" + }, + "deleteUser": { + "title": "Delete User", + "prompt": "Are you sure you want to delete {{username}}?", + "deleteSuccess": "{{username}} deleted successfully." + }, + "usersTable": { + "status": "status", + "dataUsage": "data usage", + "noUserMatched": "It seems there is no user matched with what you are looking for", + "noUser": "There is no user added to the system", + "copyLink": "Copy Subscription Link", + "copied": "Copied", + "copyConfigs": "Copy Configs", + "total": "Total" + }, + "servicesTable": { + "noServiceMatched": "It seems there is no user matched with what you are looking for", + "noService": "There is no user added to the system", + "usersNumber": "Users", + "inboundsNumber": "Inbounds", + "total": "Total" + }, + "userDialog": { + "dataLimit": "Data Limit", + "periodicUsageReset": "Periodic Usage Reset", + "warningNoProtocol": "Please select at least one protocol", + "expiryDate": "Expiry Date", + "note": "Note", + "resetUsage": "Reset Usage", + "usage": "Usage", + "protocols": "Protocols", + "services": "Services", + "editUserTitle": "Edit user", + "editUser": "Edit user", + "userEdited": "User {{username}} edited.", + "userCreated": "User {{username}} created.", + "userAlreadyExists": "User already exists", + "vmessDesc": "Fast and secure", + "vlessDesc": "Lightweight, fast and secure", + "trojanDesc": "Lightweight, secure and lightening fast", + "shadowsocksDesc": "Fast and secure, but not efficient as others", + "resetStrategyNo": "No", + "resetStrategyDaily": "Daily", + "resetStrategyWeekly": "Weekly", + "resetStrategyMonthly": "Monthly", + "resetStrategyAnnually": "Annually", + "selectOneProtocol": "Please select at least one protocol", + "optional": "optional", + "method": "Method", + "generatedByDefault": "Automatically generated by default", + "hours": "Hours", + "days": "Days", + "weeks": "Weeks", + "months": "Months", + "relative": "Relative", + "absolute": "Absolute", + "custom": "Custom", + "startDate": "Start date", + "endDate": "End date", + "revokeSubscription": "Revoke Subscription", + "total": "Total: " + }, + "serviceDialog": { + "editServiceTitle": "Edit service", + "editService": "Edit service", + "createNewService": "Create service", + "serviceEdited": "Service {{name}} edited.", + "serviceCreated": "Service {{name}} created.", + "serviceAlreadyExists": "Service already exists", + "resetStrategyNo": "No", + "resetStrategyDaily": "Daily", + "resetStrategyWeekly": "Weekly", + "resetStrategyMonthly": "Monthly", + "resetStrategyAnnually": "Annually", + "optional": "optional", + "total": "Total: " + }, + "hostsDialog": { + "title": "Using this setting, you are able to assign specific address for each inbound.", + "desc": "Use these variables to make it dynamic", + "username": "The username of the user", + "dataUsage": "The current usage of the user", + "remainingData": "Remaining data of the user", + "dataLimit": "The usage limit of the user", + "remainingDays": "Remaining days of the user", + "expireDate": "Expiry date of the user", + "jalaliExpireDate": "Expiry date of the user in solar calendar", + "remainingTime": "Remaining time of the user", + "statusEmoji": "User status as an emoji (✅,âŒ›ī¸,đŸĒĢ,❌,🔌)", + "proxyProtocol": "Proxy protocol (e.g. VMess)", + "proxyMethod": "Proxy transport method (e.g. ws)", + "currentServer": "Current server ip address", + "security": "Security Layer", + "host": "Request Host", + "port": "Port", + "path": "Path", + "sni": "SNI", + "advancedOptions": "Advanced options", + "addHost": "Add host", + "savedSuccess": "Hosts saved successfully", + "loading": "loading...", + "apply": "Apply", + "port.info": "By default, a host uses the default port of the inbound. You can set a custom port in case this host is a server that forwards traffic from a port that differs from your server's port. For example, the server may forward traffic from port 8443 to the default port of your inbound server.", + "path.info": "Set a path for host users, useful behind a reverse proxy.", + "sni.info": "By default, a host uses the default SNI of the inbound. You can set a custom SNI in case this host is a server that has a different SNI. For example, the server may receive traffic with a different SSL certificate, perform SSL termination, and forward it to your inbound server.", + "host.info": "By default, if a request host is set in the Xray config, this host is used. However, you can set a custom request host here if needed.", + "security.info": "If the middleware server of this host uses a different security layer than the inbound's default, you can set a custom security layer here.", + "alpn": "ALPN", + "fingerprint": "Fingerprint", + "host.multiHost": "To set multiple addresses, separate them with , Each time an address is chosen randomly.", + "host.wildcard": "Use * to generate a random string (works for wildcard domain names)" + }, "revoke": "Revoke", - "userDialog.total": "Total: ", - "hostsDialog.title": "Using this setting, you are able to assign specific address for each inbound.", - "hostsDialog.desc": "Use these variables to make it dynamic", - "hostsDialog.username": "The username of the user", - "hostsDialog.dataUsage": "The current usage of the user", - "hostsDialog.remainingData": "Remaining data of the user", - "hostsDialog.dataLimit": "The usage limit of the user", - "hostsDialog.remainingDays": "Remaining days of the user", - "hostsDialog.expireDate": "Expiry date of the user", - "hostsDialog.jalaliExpireDate": "Expiry date of the user in solar calendar", - "hostsDialog.remainingTime": "Remaining time of the user", - "hostsDialog.statusEmoji": "User status as an emoji (✅,âŒ›ī¸,đŸĒĢ,❌,🔌)", - "hostsDialog.proxyProtocol": "Proxy protocol (e.g. VMess)", - "hostsDialog.proxyMethod": "Proxy transport method (e.g. ws)", - "hostsDialog.currentServer": "Current server ip address", - "hostsDialog.security": "Security Layer", - "hostsDialog.host": "Request Host", - "hostsDialog.port": "Port", - "hostsDialog.path": "Path", - "hostsDialog.sni": "SNI", - "hostsDialog.advancedOptions": "Advanced options", - "hostsDialog.addHost": "Add host", - "hostsDialog.savedSuccess": "Hosts saved successfully", - "hostsDialog.loading": "loading...", - "hostsDialog.apply": "Apply", - "hostsDialog.port.info": "By default, a host uses the default port of the inbound. You can set a custom port in case this host is a server that forwards traffic from a port that differs from your server's port. For example, the server may forward traffic from port 8443 to the default port of your inbound server.", - "hostsDialog.path.info": "Set a path for host users, useful behind a reverse proxy.", - "hostsDialog.sni.info": "By default, a host uses the default SNI of the inbound. You can set a custom SNI in case this host is a server that has a different SNI. For example, the server may receive traffic with a different SSL certificate, perform SSL termination, and forward it to your inbound server.", - "hostsDialog.host.info": "By default, if a request host is set in the Xray config, this host is used. However, you can set a custom request host here if needed.", - "hostsDialog.security.info": "If the middleware server of this host uses a different security layer than the inbound's default, you can set a custom security layer here.", - "hostsDialog.alpn": "ALPN", - "hostsDialog.fingerprint": "Fingerprint", - "hostsDialog.host.multiHost": "To set multiple addresses, separate them with , Each time an address is chosen randomly.", - "hostsDialog.host.wildcard": "Use * to generate a random string (works for wildcard domain names)", - "nodes.title": "Using Marzban-Node, you are able to scale up your connection quality by adding different nodes on different servers.", - "nodes.addNewMarzbanNode": "Add New Marzban Node", - "nodes.certificate": "Certificate", - "nodes.connection-hint": "To setup a Marzban Node, you need to set this certificate on the node to initialize a secure connection between main server and the node", - "nodes.download-certificate": "Download certificate", - "nodes.show-certificate": "Show certificate", - "nodes.hide-certificate": "Hide certificate", - "nodes.addHostForEveryInbound": "Add this node as a new host for every inbound", - "nodes.addNode": "Add Node", - "nodes.addNodeSuccess": "Node {{name}} added successfully", - "nodes.apply": "editNode", - "nodes.nodeName": "Name", - "nodes.nodeAddress": "Address", - "nodes.nodePort": "Port", - "nodes.nodeAPIPort": "API Port", - "nodes.editNode": "Update Node", - "nodes.reconnect": "Reconnect", - "nodes.reconnecting": "Reconnecting...", + "nodes": { + "title": "Using Marzban-Node, you are able to scale up your connection quality by adding different nodes on different servers.", + "addNewMarzbanNode": "Add New Marzban Node", + "certificate": "Certificate", + "connection-hint": "To setup a Marzban Node, you need to set this certificate on the node to initialize a secure connection between main server and the node", + "download-certificate": "Download certificate", + "show-certificate": "Show certificate", + "hide-certificate": "Hide certificate", + "addHostForEveryInbound": "Add this node as a new host for every inbound", + "addNode": "Add Node", + "addNodeSuccess": "Node {{name}} added successfully", + "apply": "editNode", + "nodeName": "Name", + "nodeAddress": "Address", + "nodePort": "Port", + "nodeAPIPort": "API Port", + "editNode": "Update Node", + "reconnecting": "Reconnecting...", + "reconnect": "Reconnect" + }, "deleteNode.title": "Delete Node", "deleteNode.prompt": "Are you sure you want to delete the {{name}} node?", "deleteNode.deleteSuccess": "Node {{name}} removed successfully", diff --git a/app/dashboard/src/components/DeleteNodeModal.tsx b/app/dashboard/src/components/DeleteNodeModal.tsx index b3b2891..b88cfc6 100644 --- a/app/dashboard/src/components/DeleteNodeModal.tsx +++ b/app/dashboard/src/components/DeleteNodeModal.tsx @@ -19,7 +19,8 @@ import { generateErrorMessage, generateSuccessMessage, } from 'utils/toastHandler'; -import { DeleteIcon, DeleteUserModalProps } from './DeleteUserModal'; +import { DeleteUserModalProps } from './DeleteUserModal'; +import { DeleteIcon } from './Dialog/Icons'; import { Icon } from './Icon'; export const DeleteNodeModal: FC = ({ @@ -36,7 +37,7 @@ export const DeleteNodeModal: FC = ({ const { isLoading, mutate: onDelete } = useMutation(deleteNode, { onSuccess: () => { generateSuccessMessage( - t('deleteNode.deleteSuccess', {name: deletingNode && deletingNode.name}), + t('deleteNode.deleteSuccess', { name: deletingNode && deletingNode.name }), toast ); setDeletingNode(null); @@ -70,7 +71,7 @@ export const DeleteNodeModal: FC = ({ color="gray.600" > }}> - {t('deleteNode.prompt', {name: deletingNode.name})} + {t('deleteNode.prompt', { name: deletingNode.name })} )} diff --git a/app/dashboard/src/components/DeleteUserModal.tsx b/app/dashboard/src/components/DeleteUserModal.tsx index 122c2da..fc661c3 100644 --- a/app/dashboard/src/components/DeleteUserModal.tsx +++ b/app/dashboard/src/components/DeleteUserModal.tsx @@ -1,6 +1,6 @@ +import { DeleteIcon } from 'components/Dialog/Icons'; import { Button, - chakra, Modal, ModalBody, ModalCloseButton, @@ -12,21 +12,13 @@ import { Text, useToast, } from '@chakra-ui/react'; -import { TrashIcon } from '@heroicons/react/24/outline'; import { useDashboard } from 'contexts/DashboardContext'; import { FC, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { Icon } from './Icon'; -export const DeleteIcon = chakra(TrashIcon, { - baseStyle: { - w: 5, - h: 5, - }, -}); - export type DeleteUserModalProps = { - deleteCallback?: () => void; + deleteCallback?: () => void; }; export const DeleteUserModal: FC = () => { diff --git a/app/dashboard/src/components/Dialog/Icons.tsx b/app/dashboard/src/components/Dialog/Icons.tsx new file mode 100644 index 0000000..5cac0b8 --- /dev/null +++ b/app/dashboard/src/components/Dialog/Icons.tsx @@ -0,0 +1,29 @@ + +import { chakra } from '@chakra-ui/react'; +import { + PencilIcon, + UserPlusIcon, + TrashIcon +} from '@heroicons/react/24/outline'; + +export const AddIcon = chakra(UserPlusIcon, { + baseStyle: { + w: 5, + h: 5, + }, +}); + +export const EditIcon = chakra(PencilIcon, { + baseStyle: { + w: 5, + h: 5, + }, +}); + + +export const DeleteIcon = chakra(TrashIcon, { + baseStyle: { + w: 5, + h: 5, + }, +}); diff --git a/app/dashboard/src/components/Dialog/ModalFooter.tsx b/app/dashboard/src/components/Dialog/ModalFooter.tsx new file mode 100644 index 0000000..ffd7afa --- /dev/null +++ b/app/dashboard/src/components/Dialog/ModalFooter.tsx @@ -0,0 +1,30 @@ +import React, { ReactNode } from 'react'; +import { + HStack, +} from '@chakra-ui/react'; +import { ModalFooter } from '@chakra-ui/react'; + +interface UserDialogFooterProps { + children: ReactNode +} + +export const DialogModalFooter: React.FC = ({ + children +}) => { + + return ( + + + {children} + + + ); +}; diff --git a/app/dashboard/src/components/UserDialog/ModalHeader.tsx b/app/dashboard/src/components/Dialog/ModalHeader.tsx similarity index 79% rename from app/dashboard/src/components/UserDialog/ModalHeader.tsx rename to app/dashboard/src/components/Dialog/ModalHeader.tsx index ef6bb65..683bef6 100644 --- a/app/dashboard/src/components/UserDialog/ModalHeader.tsx +++ b/app/dashboard/src/components/Dialog/ModalHeader.tsx @@ -3,7 +3,7 @@ import { Text, HStack, ModalHeader } from '@chakra-ui/react'; import { ChakraIcon } from 'types/ChakraIcon'; import { Icon } from '../Icon'; -export const UserDialogModalHeader = ({ HeaderIcon, title }: { HeaderIcon: ChakraIcon, title: string }) => { +export const DialogModalHeader = ({ HeaderIcon, title }: { HeaderIcon: ChakraIcon, title: string }) => { return ( diff --git a/app/dashboard/src/components/Dialog/index.tsx b/app/dashboard/src/components/Dialog/index.tsx new file mode 100644 index 0000000..e69de29 diff --git a/app/dashboard/src/components/HostsDialog.tsx b/app/dashboard/src/components/HostsDialog.tsx index 798523b..f43751b 100644 --- a/app/dashboard/src/components/HostsDialog.tsx +++ b/app/dashboard/src/components/HostsDialog.tsx @@ -54,7 +54,7 @@ import 'slick-carousel/slick/slick-theme.css'; import 'slick-carousel/slick/slick.css'; import { z } from 'zod'; import { useDashboard } from '../contexts/DashboardContext'; -import { DeleteIcon } from './DeleteUserModal'; +import { DeleteIcon } from './Dialog/Icons'; import { Icon } from './Icon'; import { Input as CustomInput } from './Input'; diff --git a/app/dashboard/src/components/ListCheckBox/index.tsx b/app/dashboard/src/components/ListCheckBox/index.tsx new file mode 100644 index 0000000..aec2fe4 --- /dev/null +++ b/app/dashboard/src/components/ListCheckBox/index.tsx @@ -0,0 +1,47 @@ + + +import { + Box, +} from '@chakra-ui/react'; +import { forwardRef, ReactNode, Ref } from 'react'; + +type ListCheckboxProps = { + isChecked: boolean; + onChange: () => void; + renderContent: () => ReactNode; + value: string; +} + +export const ListCheckBox = forwardRef( + ( + { isChecked, onChange, renderContent, ...props }: ListCheckboxProps, + ref: Ref + ) => ( + + {renderContent()} + + ) +); diff --git a/app/dashboard/src/components/NodesModal.tsx b/app/dashboard/src/components/NodesModal.tsx index 4fa6e9a..c591843 100644 --- a/app/dashboard/src/components/NodesModal.tsx +++ b/app/dashboard/src/components/NodesModal.tsx @@ -63,8 +63,8 @@ import { } from 'utils/toastHandler'; import { useDashboard } from '../contexts/DashboardContext'; import { DeleteNodeModal } from './DeleteNodeModal'; -import { DeleteIcon } from './DeleteUserModal'; -import { ReloadIcon } from './Filters'; +import { DeleteIcon } from './Dialog/Icons'; +import { ReloadIcon } from './UsersFilters'; import { Icon } from './Icon'; import { StatusBadge } from './StatusBadge'; diff --git a/app/dashboard/src/components/Pagination.tsx b/app/dashboard/src/components/Pagination.tsx index 5e80858..88dfcfa 100644 --- a/app/dashboard/src/components/Pagination.tsx +++ b/app/dashboard/src/components/Pagination.tsx @@ -77,7 +77,7 @@ function generatePageItems(total: number, current: number, width: number) { export const Pagination: FC = () => { const { - filters, + usersFilters: filters, onFilterChange, users: { total }, } = useDashboard(); diff --git a/app/dashboard/src/components/ServiceDialog/DefaultValues.ts b/app/dashboard/src/components/ServiceDialog/DefaultValues.ts new file mode 100644 index 0000000..20a8a6c --- /dev/null +++ b/app/dashboard/src/components/ServiceDialog/DefaultValues.ts @@ -0,0 +1,10 @@ + + +import { FormType } from './FormSchema'; + +export const getDefaultValues = (): FormType => { + return { + name: '', + inbounds: [], + }; +}; diff --git a/app/dashboard/src/components/ServiceDialog/FormSchema.ts b/app/dashboard/src/components/ServiceDialog/FormSchema.ts new file mode 100644 index 0000000..5d41b3a --- /dev/null +++ b/app/dashboard/src/components/ServiceDialog/FormSchema.ts @@ -0,0 +1,9 @@ + +import { z } from 'zod'; +import { ServiceCreate } from 'types/Service'; + +export type FormType = Pick; +export const schema = z.object({ + name: z.string().min(1, { message: 'Name is required' }), + inbounds: z.array(z.number()).nonempty({ message: 'At least one inbounds is required' }), +}); diff --git a/app/dashboard/src/components/ServiceDialog/InboundsField.tsx b/app/dashboard/src/components/ServiceDialog/InboundsField.tsx new file mode 100644 index 0000000..e627c68 --- /dev/null +++ b/app/dashboard/src/components/ServiceDialog/InboundsField.tsx @@ -0,0 +1,50 @@ +import { FormControl, FormLabel, FormErrorMessage, Center, Spinner, Text, Spacer, Flex } from '@chakra-ui/react'; +import { Inbounds, InboundType } from 'contexts/DashboardContext'; +import { TFunction } from 'i18next'; +import { Controller } from 'react-hook-form'; +import { InboundsSelectList } from './InboundsSelectList'; + + +interface InboundsFieldProps { + form: any; + inbounds: Inbounds; + t: TFunction<'translation', undefined, 'translation'>; +} + +export const InboundsField = ({ form, t, inbounds }: InboundsFieldProps) => { + return inbounds.length === 0 ? ( +
+ ) : ( + + {t('inbounds')} + { + return ( + ( + <> + + {inbound.tag} {inbound.protocol} + + + + + + )} + /> + ); + }} + /> + + {t(form.formState.errors.services?.message as string)} + + + ); +} diff --git a/app/dashboard/src/components/ServiceDialog/InboundsSelectList.tsx b/app/dashboard/src/components/ServiceDialog/InboundsSelectList.tsx new file mode 100644 index 0000000..26dc370 --- /dev/null +++ b/app/dashboard/src/components/ServiceDialog/InboundsSelectList.tsx @@ -0,0 +1,53 @@ + +import { + Box, + Flex, + Stack, +} from '@chakra-ui/react'; +import { ListCheckBox } from 'components/ListCheckBox'; +import { Inbounds, InboundType } from 'contexts/DashboardContext'; +import { forwardRef, ReactNode } from 'react'; +import { + useFormContext, +} from 'react-hook-form'; + +interface InboundsSelectListProps { + list: Inbounds; + renderCheckboxContent: (inbound: InboundType) => ReactNode; +} + +export const InboundsSelectList = forwardRef( + function({ list, renderCheckboxContent }, ref) { + const form = useFormContext(); + + const handleInboundToggle = (inboundId: number) => { + const currentInbounds = form.getValues('inbounds'); + const isSelected = currentInbounds.includes(inboundId); + + let updatedInbounds = currentInbounds.filter((id: number) => id !== inboundId); + + if (!isSelected) { + updatedInbounds = [...currentInbounds, inboundId]; + } + + form.setValue('inbounds', updatedInbounds, { shouldValidate: true }); + }; + + return ( + + {list.map((inbound: InboundType) => ( + + + handleInboundToggle(inbound.id)} + value={String(inbound.id)} + renderContent={() => renderCheckboxContent(inbound)} + /> + + + ))} + + ); + } +); diff --git a/app/dashboard/src/components/ServiceDialog/NameField.tsx b/app/dashboard/src/components/ServiceDialog/NameField.tsx new file mode 100644 index 0000000..cbfbf26 --- /dev/null +++ b/app/dashboard/src/components/ServiceDialog/NameField.tsx @@ -0,0 +1,64 @@ + +import { HStack, Input, Tooltip, Box, Switch, FormControl, FormLabel } from '@chakra-ui/react'; +import { TFunction } from 'i18next'; +import { Controller } from 'react-hook-form'; + +interface NameFieldProps { + form: any; + isEditing: boolean; + t: TFunction<'translation', undefined, 'translation'>; + disabled: boolean; +} + +export const NameField = ({ form, isEditing, t, disabled }: NameFieldProps) => { + return ( + + {t('name')} + + + {isEditing && ( + + { + return ( + + + { + if (e.target.checked) { + field.onChange('active'); + } else { + field.onChange('disabled'); + } + }} + /> + + + ); + }} + /> + + )} + + + ) +} diff --git a/app/dashboard/src/components/ServiceDialog/index.tsx b/app/dashboard/src/components/ServiceDialog/index.tsx new file mode 100644 index 0000000..557cc5b --- /dev/null +++ b/app/dashboard/src/components/ServiceDialog/index.tsx @@ -0,0 +1,219 @@ + +import { + Alert, + AlertIcon, + Flex, + Button, + Tooltip, + HStack, + Spinner, + ModalBody, + ModalCloseButton, + ModalContent, + Modal, + VStack, + useToast, + ModalOverlay, + IconButton, +} from '@chakra-ui/react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { InboundType, useDashboard } from 'contexts/DashboardContext'; +import { FC, useEffect, useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { AddIcon, DeleteIcon, EditIcon } from 'components/Dialog/Icons'; +import { DialogModalHeader } from 'components/Dialog/ModalHeader'; +import { DialogModalFooter } from 'components/Dialog/ModalFooter'; +import { getDefaultValues } from './DefaultValues'; +import { schema, FormType } from './FormSchema'; +import { InboundsField } from './InboundsField'; +import { NameField } from './NameField'; +import { Service, ServiceCreate } from 'types/Service'; +import { DevTool } from '@hookform/devtools'; + +const formatService = (service: Service): FormType => { + const inbounds: number[] = service.inbounds.map((inbound: number | InboundType): number => { + return (typeof inbound !== 'number') ? inbound.id : inbound; + }); + return { + ...service, + inbounds, + }; +}; + +export type ServiceDialogProps = {}; + +export const ServiceDialog: FC = () => { + const { + editingService, + isCreatingNewService, + inbounds, + onCreateService, + editService, + refetchInbounds, + onEditingService, + createService, + onDeletingService, + } = useDashboard(); + const isEditing = !!editingService; + const isOpen = isCreatingNewService || isEditing; + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const toast = useToast(); + const { t } = useTranslation(); + + const form = useForm({ + defaultValues: getDefaultValues(), + resolver: zodResolver(schema), + }); + + useEffect(() => { + if (isOpen) { + refetchInbounds(); + } + }, [isOpen]); + + + useEffect(() => { + if (editingService) { + form.reset(formatService(editingService)); + } + }, [editingService]); + + const submit = async (values: FormType) => { + setLoading(true); + const methods = { edited: editService, created: createService }; + const method = isEditing ? 'edited' : 'created'; + setError(null); + const { inbounds, name } = values; + + let body: Service | ServiceCreate = (isEditing) ? + { name, inbounds, id: editingService.id } + : { name, inbounds }; + + await methods[method](body) + .then(() => { + toast({ + title: t( + isEditing ? 'serviceDialog.serviceEdited' : 'serviceDialog.serviceCreated', + { name: values.name } + ), + status: 'success', + isClosable: true, + position: 'top', + duration: 3000, + }); + onClose(); + }) + .catch((err) => { + if (err?.response?.status === 409 || err?.response?.status === 400) + setError(err?.response?._data?.detail); + if (err?.response?.status === 422) { + Object.keys(err.response._data.detail).forEach((key) => { + setError(err?.response._data.detail[key] as string); + form.setError(key as 'name' | 'inbounds', { type: 'custom', message: err.response._data.detail[key], }); + }); + } + }) + .finally(() => { + setLoading(false); + }); + }; + + const onClose = () => { + form.reset(getDefaultValues()); + onCreateService(false); + onEditingService(null); + setError(null); + }; + + const disabled = loading; + + return ( + + + + +
+ + + + + + + + + {error && ( + + + {error} + + )} + + {error && ( + + + {error} + + )} + + + + {isEditing && editingService !== null && ( + <> + + { + onDeletingService(editingService); + onClose(); + }} + > + + + + + )} + + + + + + + +
+
+
+ ); +}; diff --git a/app/dashboard/src/components/ServicesFilters.tsx b/app/dashboard/src/components/ServicesFilters.tsx new file mode 100644 index 0000000..67a6513 --- /dev/null +++ b/app/dashboard/src/components/ServicesFilters.tsx @@ -0,0 +1,137 @@ +import { + BoxProps, + Button, + chakra, + Grid, + GridItem, + HStack, + IconButton, + Input, + InputGroup, + InputLeftElement, + InputRightElement, + Spinner, +} from '@chakra-ui/react'; +import { + ArrowPathIcon, + MagnifyingGlassIcon, + XMarkIcon, +} from '@heroicons/react/24/outline'; +import classNames from 'classnames'; +import { useDashboard } from 'contexts/DashboardContext'; +import debounce from 'lodash.debounce'; +import React, { FC, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +const iconProps = { + baseStyle: { + w: 4, + h: 4, + }, +}; + +const SearchIcon = chakra(MagnifyingGlassIcon, iconProps); +const ClearIcon = chakra(XMarkIcon, iconProps); +export const ReloadIcon = chakra(ArrowPathIcon, iconProps); + +export type FilterProps = {} & BoxProps; +const setSearchField = debounce((username: string) => { + useDashboard.getState().onFilterChange({ + ...useDashboard.getState().usersFilters, + offset: 0, + username, + }); +}, 300); + +export const ServicesFilters: FC = ({ ...props }) => { + const { loading, servicesFilters: filters, onFilterChange, refetchUsers, onCreateService } = + useDashboard(); + const { t } = useTranslation(); + const [search, setSearch] = useState(''); + const onChange = (e: React.ChangeEvent) => { + setSearch(e.target.value); + setSearchField(e.target.value); + }; + const clear = () => { + setSearch(''); + onFilterChange({ + ...filters, + offset: 0, + username: '', + }); + }; + return ( + + + + } /> + + + + {loading && } + {filters.name && filters.name.length > 0 && ( + + + + )} + + + + + + + + + + + + + ); +}; diff --git a/app/dashboard/src/components/ServicesTable.tsx b/app/dashboard/src/components/ServicesTable.tsx new file mode 100644 index 0000000..9b897ab --- /dev/null +++ b/app/dashboard/src/components/ServicesTable.tsx @@ -0,0 +1,221 @@ + +import { + Box, + Button, + chakra, + HStack, + Table, + TableProps, + Tbody, + Td, + Text, + Th, + Thead, + Tr, + useBreakpointValue, +} from '@chakra-ui/react'; + + +import { ReactComponent as AddFileIcon } from 'assets/add_file.svg'; +import classNames from 'classnames'; +import { useDashboard } from 'contexts/DashboardContext'; +import { t } from 'i18next'; +import { FC, Fragment, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pagination } from './Pagination'; +import { SortType } from './UsersTable'; +import { SortIcon } from './UsersTable/Icons'; + +const EmptySectionIcon = chakra(AddFileIcon); + +export const Sort: FC = ({ sort, column }) => { + if (sort.includes(column)) + return ( + + ); + return null; +}; +type ServicesTableProps = {} & TableProps; + +export const ServicesTable: FC = (props) => { + const { + servicesFilters: filters, + services, + onEditingService, + onFilterChange, + } = useDashboard(); + + const { t } = useTranslation(); + const marginTop = useBreakpointValue({ base: 120, lg: 72 }) || 72; + const [top, setTop] = useState(`${marginTop}px`); + + useEffect(() => { + const calcTop = () => { + const el = document.querySelectorAll('#filters')[0] as HTMLElement; + setTop(`${el.offsetHeight}px`); + }; + window.addEventListener('scroll', calcTop); + () => window.removeEventListener('scroll', calcTop); + }, []); + + const handleSort = (column: string) => { + let newSort = filters.sort; + if (newSort.includes(column)) { + if (newSort.startsWith('-')) { + newSort = '-created_at'; + } else { + newSort = '-' + column; + } + } else { + newSort = column; + } + onFilterChange({ + sort: newSort, + }); + }; + + return ( + + + + + + + + + + + + {services ? services.map((service, i) => { + return ( + + { + e.stopPropagation(); + onEditingService(service); + }} + > + + + + + + ); + }) : + + + + } + +
+ + {t('services')} + + + + + {t('servicesTable.usersNumber')} + + + + + {t('servicesTable.inboundsNumber')} + + +
+ {service.name} + + {service.users.length} + + {service.inbounds.length} +
+ +
+ +
+ ); +}; + +type EmptySectionProps = { + isFiltered: boolean; +}; + +const EmptySection: FC = ({ isFiltered }) => { + const { onCreateService } = useDashboard(); + return ( + + + + {isFiltered ? t('servicesTable.noServiceMatched') : t('servicesTable.noService')} + + {!isFiltered && ( + + )} + + ); +}; diff --git a/app/dashboard/src/components/UserDialog/ModalFooter.tsx b/app/dashboard/src/components/UserDialog/ModalFooter.tsx deleted file mode 100644 index 19627d8..0000000 --- a/app/dashboard/src/components/UserDialog/ModalFooter.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React from 'react'; -import { - Button, - Tooltip, - HStack, - IconButton, - Spinner, -} from '@chakra-ui/react'; -import { DeleteIcon } from '../DeleteUserModal'; -import { UserUsageIcon } from './UserDialogIcons'; -import { useTranslation } from 'react-i18next'; -import { User } from 'types/User'; -import { ModalFooter } from '@chakra-ui/react'; - -interface UserDialogFooterProps { - isEditing: boolean; - onDeletingUser: (user: User) => void; - handleUsageToggle: () => void; - handleResetUsage: () => void; - handleRevokeSubscription: () => void; - loading: boolean; - editingUser: User | null; - onClose: () => void; -} - -export const UserDialogModalFooter: React.FC = ({ - isEditing, - onClose, editingUser, - onDeletingUser, - handleUsageToggle, - handleResetUsage, - handleRevokeSubscription, - loading, -}) => { - - const { t } = useTranslation(); - return ( - - - - - {isEditing && editingUser !== null && ( - <> - - { - onDeletingUser(editingUser); - onClose(); - }} - > - - - - - - - - - - - - )} - - - - - - - ); -}; diff --git a/app/dashboard/src/components/UserDialog/ServicesField.tsx b/app/dashboard/src/components/UserDialog/ServicesField.tsx index 31b127b..c1b5d96 100644 --- a/app/dashboard/src/components/UserDialog/ServicesField.tsx +++ b/app/dashboard/src/components/UserDialog/ServicesField.tsx @@ -1,9 +1,15 @@ -import { FormControl, FormLabel, FormErrorMessage, Center, Spinner } from '@chakra-ui/react'; +import { FormControl, FormLabel, FormErrorMessage, Center, Spinner, Text, Spacer, Flex, chakra } from '@chakra-ui/react'; +import { UsersIcon, ServerStackIcon } from '@heroicons/react/24/outline'; +import { IconBaseStyle } from 'constants/IconBaseStyle'; import { TFunction } from 'i18next'; import { Controller } from 'react-hook-form'; import { Service } from 'types/Service'; +import { ServicePropertyStatus } from './ServicePropertyStatus'; import { ServicesSelectList } from './ServicesSelectList'; +const UsersServiceIcon = chakra(UsersIcon, IconBaseStyle); +const InboundsServiceIcon = chakra(ServerStackIcon, IconBaseStyle); + interface ServicesFieldProps { form: any; services: Service[]; @@ -23,11 +29,23 @@ export const ServicesField = ({ form, t, services }: ServicesFieldProps) => { { + render={() => { return ( ( + <> + + {service.name} + + + + + / + + + + )} /> ); }} diff --git a/app/dashboard/src/components/UserDialog/ServicesSelectList.tsx b/app/dashboard/src/components/UserDialog/ServicesSelectList.tsx index 4d0be9e..336b39a 100644 --- a/app/dashboard/src/components/UserDialog/ServicesSelectList.tsx +++ b/app/dashboard/src/components/UserDialog/ServicesSelectList.tsx @@ -1,75 +1,23 @@ import { Box, - chakra, Flex, - Spacer, Stack, - Text, } from '@chakra-ui/react'; -import { ServerStackIcon, UsersIcon } from '@heroicons/react/24/outline'; -import { IconBaseStyle } from 'constants/IconBaseStyle'; -import { forwardRef, Ref } from 'react'; +import { ListCheckBox } from 'components/ListCheckBox'; +import { forwardRef, ReactNode } from 'react'; import { useFormContext, } from 'react-hook-form'; import { Service } from 'types/Service'; -import { ServicePropertyStatus } from './ServicePropertyStatus'; - - -const UsersServiceIcon = chakra(UsersIcon, IconBaseStyle); - -const InboundsServiceIcon = chakra(ServerStackIcon, IconBaseStyle); - -type CustomCheckboxProps = { - isChecked: boolean; - onChange: () => void; - service: Service; - value: string; -} - -const CustomCheckbox = forwardRef( - ({ isChecked, onChange, service, ...props }: CustomCheckboxProps, ref: Ref) => ( - - - {service.name} - - - - - / - - - - ) -); - interface ServicesSelectListProps { - list: Service[]; + list: Service[]; + renderCheckboxContent: (service: Service) => ReactNode; } export const ServicesSelectList = forwardRef( - function({ list }, ref) { + function({ list, renderCheckboxContent }, ref) { const form = useFormContext(); const handleServiceToggle = (serviceId: number) => { @@ -92,15 +40,16 @@ export const ServicesSelectList = forwardRef ( - handleServiceToggle(service.id)} - service={service} value={String(service.id)} + renderContent={() => renderCheckboxContent(service)} /> ))} ); - }); + } +); diff --git a/app/dashboard/src/components/UserDialog/UserDialogIcons.ts b/app/dashboard/src/components/UserDialog/UserDialogIcons.ts index 0bb1dec..8f02730 100644 --- a/app/dashboard/src/components/UserDialog/UserDialogIcons.ts +++ b/app/dashboard/src/components/UserDialog/UserDialogIcons.ts @@ -2,24 +2,7 @@ import { chakra } from '@chakra-ui/react'; import { ChartPieIcon, - PencilIcon, - UserPlusIcon, } from '@heroicons/react/24/outline'; - -export const AddUserIcon = chakra(UserPlusIcon, { - baseStyle: { - w: 5, - h: 5, - }, -}); - -export const EditUserIcon = chakra(PencilIcon, { - baseStyle: { - w: 5, - h: 5, - }, -}); - export const UserUsageIcon = chakra(ChartPieIcon, { baseStyle: { w: 5, diff --git a/app/dashboard/src/components/UserDialog/index.tsx b/app/dashboard/src/components/UserDialog/index.tsx index decc676..26b1438 100644 --- a/app/dashboard/src/components/UserDialog/index.tsx +++ b/app/dashboard/src/components/UserDialog/index.tsx @@ -1,5 +1,9 @@ import { Alert, + Button, + Tooltip, + IconButton, + Spinner, AlertIcon, Box, Collapse, @@ -14,6 +18,7 @@ import { useColorMode, useToast, ModalOverlay, + HStack, } from '@chakra-ui/react'; import { zodResolver } from '@hookform/resolvers/zod'; import { FilterUsageType, useDashboard } from 'contexts/DashboardContext'; @@ -27,9 +32,11 @@ import { UserCreate, } from 'types/User'; import { UsageFilter, createUsageConfig } from '../UsageFilter'; -import { AddUserIcon, EditUserIcon } from './UserDialogIcons'; -import { UserDialogModalHeader } from './ModalHeader'; -import { UserDialogModalFooter } from './ModalFooter'; +import { AddIcon, EditIcon } from 'components/Dialog/Icons'; +import { DialogModalHeader } from 'components/Dialog/ModalHeader'; +import { DialogModalFooter } from 'components/Dialog/ModalFooter'; +import { DeleteIcon } from 'components/Dialog/Icons'; +import { UserUsageIcon } from './UserDialogIcons'; import { getDefaultValues } from './DefaultValues'; import { schema, FormType } from './FormSchema'; import { ServicesField } from './ServicesField'; @@ -206,7 +213,7 @@ export const UserDialog: FC = () => {
- + = () => { )} - + + + {isEditing && editingUser !== null && ( + <> + + { + onDeletingUser(editingUser); + onClose(); + }} + > + + + + + + + + + + + + )} + + + + +
diff --git a/app/dashboard/src/components/Filters.tsx b/app/dashboard/src/components/UsersFilters.tsx similarity index 93% rename from app/dashboard/src/components/Filters.tsx rename to app/dashboard/src/components/UsersFilters.tsx index b054f24..d90827d 100644 --- a/app/dashboard/src/components/Filters.tsx +++ b/app/dashboard/src/components/UsersFilters.tsx @@ -37,14 +37,14 @@ export const ReloadIcon = chakra(ArrowPathIcon, iconProps); export type FilterProps = {} & BoxProps; const setSearchField = debounce((username: string) => { useDashboard.getState().onFilterChange({ - ...useDashboard.getState().filters, + ...useDashboard.getState().usersFilters, offset: 0, username, }); }, 300); -export const Filters: FC = ({ ...props }) => { - const { loading, filters, onFilterChange, refetchUsers, onCreateUser } = +export const UsersFilters: FC = ({ ...props }) => { + const { loading, usersFilters: filters, onFilterChange, refetchUsers, onCreateUser } = useDashboard(); const { t } = useTranslation(); const [search, setSearch] = useState(''); @@ -62,7 +62,7 @@ export const Filters: FC = ({ ...props }) => { }; return ( { - for (var i = 0; i < resetStrategy.length; i++) { - const entry = resetStrategy[i]; - if (entry.value == strategy) { - return entry.title; - } - } - return 'No'; -}; -const UsageSliderCompact: FC = (props) => { - const { used, total } = props; - const isUnlimited = total === 0 || total === null; - return ( - - - {formatBytes(used)} /{' '} - {isUnlimited ? ( - - ∞ - - ) : ( - formatBytes(total) - )} - - - ); -}; -const UsageSlider: FC = (props) => { - const { - used, - total, - dataLimitResetStrategy, - totalUsedTraffic, - ...restOfProps - } = props; - const isUnlimited = total === 0 || total === null; - const isReached = !isUnlimited && (used / total) * 100 >= 100; - return ( - <> - - - - - - - - {formatBytes(used)} /{' '} - {isUnlimited ? ( - - ∞ - - ) : ( - formatBytes(total) + - (dataLimitResetStrategy && dataLimitResetStrategy !== 'no_reset' - ? ' ' + - t( - 'userDialog.resetStrategy' + - getResetStrategy(dataLimitResetStrategy) - ) - : '') - )} - - - {t('usersTable.total')}: {formatBytes(totalUsedTraffic)} - - - - ); -}; -export type SortType = { - sort: string; - column: string; -}; -export const Sort: FC = ({ sort, column }) => { - if (sort.includes(column)) - return ( - - ); - return null; -}; -type UsersTableProps = {} & TableProps; -export const UsersTable: FC = (props) => { - const { - filters, - users: { users }, - users: totalUsers, - onEditingUser, - onFilterChange, - } = useDashboard(); - - const { t } = useTranslation(); - const [selectedRow, setSelectedRow] = useState( - undefined - ); - const marginTop = useBreakpointValue({ base: 120, lg: 72 }) || 72; - const [top, setTop] = useState(`${marginTop}px`); - const useTable = useBreakpointValue({ base: false, md: true }); - - useEffect(() => { - const calcTop = () => { - const el = document.querySelectorAll('#filters')[0] as HTMLElement; - setTop(`${el.offsetHeight}px`); - }; - window.addEventListener('scroll', calcTop); - () => window.removeEventListener('scroll', calcTop); - }, []); - - const isFiltered = users.length !== totalUsers.total; - - const handleSort = (column: string) => { - let newSort = filters.sort; - if (newSort.includes(column)) { - if (newSort.startsWith('-')) { - newSort = '-created_at'; - } else { - newSort = '-' + column; - } - } else { - newSort = column; - } - onFilterChange({ - sort: newSort, - }); - }; - const handleStatusFilter = (e: any) => { - onFilterChange({ - status: e.target.value.length > 0 ? e.target.value : undefined, - }); - }; - - const toggleAccordion = (index: number) => { - setSelectedRow(index === selectedRow ? undefined : index); - }; - - return ( - - - - - - - - - - - - - {!useTable && - users?.map((user, i) => { - return ( - - - - - - - - - - - - ); - })} - -
- - {t('users')} - - - - - - {t('usersTable.status')} - {filters.status ? ': ' + filters.status : ''} - - - - - - {t('usersTable.dataUsage')} - - -
-
- - {user.username} -
-
- - - - - -
- - - - - - - {t('usersTable.dataUsage')} - - - - - - - - - - - - - - { - e.stopPropagation(); - onEditingUser(user); - }} - > - - - - - - - - -
-
- - - - - - - - - - {useTable && - users?.map((user, i) => { - return ( - onEditingUser(user)} - > - - - - - - ); - })} - {users.length == 0 && ( - - - - )} - -
- - {t('username')} - - - - - - {t('usersTable.status')} - {filters.status ? ': ' + filters.status : ''} - - - - - - {t('usersTable.dataUsage')} - - - -
-
- - {user.username} - -
-
- - - - - -
- -
- -
- ); -}; - -type ActionButtonsProps = { - user: User; -}; - -const ActionButtons: FC = ({ user }) => { - const { setQRCode, setSubLink } = useDashboard(); - - const proxyLinks = user.links.join('\r\n'); - - const [copied, setCopied] = useState([-1, false]); - useEffect(() => { - if (copied[1]) { - setTimeout(() => { - setCopied([-1, false]); - }, 1000); - } - }, [copied]); - return ( - { - e.preventDefault(); - e.stopPropagation(); - }} - > - { - setCopied([0, true]); - }} - > -
- - - {copied[0] == 0 && copied[1] ? ( - - ) : ( - - )} - - -
-
- { - setCopied([1, true]); - }} - > -
- - - {copied[0] == 1 && copied[1] ? : } - - -
-
- - { - setQRCode(user.links); - setSubLink(user.subscription_url); - }} - > - - - -
- ); -}; - -type EmptySectionProps = { - isFiltered: boolean; -}; - -const EmptySection: FC = ({ isFiltered }) => { - const { onCreateUser } = useDashboard(); - return ( - - - - {isFiltered ? t('usersTable.noUserMatched') : t('usersTable.noUser')} - - {!isFiltered && ( - - )} - - ); -}; diff --git a/app/dashboard/src/components/UsersTable/EmptySection.tsx b/app/dashboard/src/components/UsersTable/EmptySection.tsx new file mode 100644 index 0000000..4ddd36f --- /dev/null +++ b/app/dashboard/src/components/UsersTable/EmptySection.tsx @@ -0,0 +1,63 @@ +import { Box, Text, Button, chakra } from '@chakra-ui/react'; +import { useDashboard } from 'contexts/DashboardContext'; +import { FC } from 'react'; +import { ReactComponent as AddFileIcon } from 'assets/add_file.svg'; +import { useTranslation } from 'react-i18next'; + +type EmptySectionProps = { + isFiltered: boolean; +}; + +const EmptySectionIcon = chakra(AddFileIcon); + +export const EmptySection: FC = ({ isFiltered }) => { + const { onCreateUser } = useDashboard(); + const { t } = useTranslation(); + return ( + + + + {isFiltered ? t('usersTable.noUserMatched') : t('usersTable.noUser')} + + {!isFiltered && ( + + )} + + ); +}; diff --git a/app/dashboard/src/components/UsersTable/Icons.ts b/app/dashboard/src/components/UsersTable/Icons.ts new file mode 100644 index 0000000..dc827a6 --- /dev/null +++ b/app/dashboard/src/components/UsersTable/Icons.ts @@ -0,0 +1,36 @@ +import { chakra } from '@chakra-ui/react'; +import { + CheckIcon, + ChevronDownIcon, + ClipboardIcon, + LinkIcon, + PencilIcon, + QrCodeIcon, +} from '@heroicons/react/24/outline'; + + +export const iconProps = { + baseStyle: { + w: { + base: 4, + md: 5, + }, + h: { + base: 4, + md: 5, + }, + }, +}; + +export const CopyIcon = chakra(ClipboardIcon, iconProps); +export const AccordionArrowIcon = chakra(ChevronDownIcon, iconProps); +export const CopiedIcon = chakra(CheckIcon, iconProps); +export const SubscriptionLinkIcon = chakra(LinkIcon, iconProps); +export const QRIcon = chakra(QrCodeIcon, iconProps); +export const EditIcon = chakra(PencilIcon, iconProps); +export const SortIcon = chakra(ChevronDownIcon, { + baseStyle: { + width: '15px', + height: '15px' + }, +}); diff --git a/app/dashboard/src/components/UsersTable/StatusSortSelect.tsx b/app/dashboard/src/components/UsersTable/StatusSortSelect.tsx new file mode 100644 index 0000000..95f90c4 --- /dev/null +++ b/app/dashboard/src/components/UsersTable/StatusSortSelect.tsx @@ -0,0 +1,39 @@ + +import { Select } from '@chakra-ui/react' +import { UsersFilterType } from 'contexts/DashboardContext' +import React from 'react' + +interface StatusSortSelectProps { + filters: UsersFilterType; + handleStatusFilter: (e: any) => void; +} + +export const StatusSortSelect: React.FC = ({ filters, handleStatusFilter }) => { + return ( +
+ + +
+ ) +} diff --git a/app/dashboard/src/components/UsersTable/UsageSlider.tsx b/app/dashboard/src/components/UsersTable/UsageSlider.tsx new file mode 100644 index 0000000..bdbcc8b --- /dev/null +++ b/app/dashboard/src/components/UsersTable/UsageSlider.tsx @@ -0,0 +1,106 @@ +import { HStack, Text, SliderTrack, SliderFilledTrack, Slider, SliderProps } from '@chakra-ui/react'; +import { resetStrategy } from 'constants/UserSettings'; +import { formatBytes } from 'utils/formatByte'; +import { useTranslation } from 'react-i18next'; +import { FC } from 'react'; + +export const UsageSliderCompact: FC = (props) => { + const { used, total } = props; + const isUnlimited = total === 0 || total === null; + return ( + + + {formatBytes(used)} /{' '} + {isUnlimited ? ( + + ∞ + + ) : ( + formatBytes(total) + )} + + + ); +}; + +type UsageSliderProps = { + used: number; + total: number | null; + dataLimitResetStrategy: string | null; + totalUsedTraffic: number; +} & SliderProps; + +const getResetStrategy = (strategy: string): string => { + for (var i = 0; i < resetStrategy.length; i++) { + const entry = resetStrategy[i]; + if (entry.value == strategy) { + return entry.title; + } + } + return 'No'; +}; + +export const UsageSlider: FC = (props) => { + const { + used, + total, + dataLimitResetStrategy, + totalUsedTraffic, + ...restOfProps + } = props; + const isUnlimited = total === 0 || total === null; + const isReached = !isUnlimited && (used / total) * 100 >= 100; + const { t } = useTranslation(); + return ( + <> + + + + + + + + {formatBytes(used)} /{' '} + {isUnlimited ? ( + + ∞ + + ) : ( + formatBytes(total) + + (dataLimitResetStrategy && dataLimitResetStrategy !== 'no_reset' + ? ' ' + + t( + 'userDialog.resetStrategy' + + getResetStrategy(dataLimitResetStrategy) + ) + : '') + )} + + + {t('usersTable.total')}: {formatBytes(totalUsedTraffic)} + + + + ); +}; diff --git a/app/dashboard/src/components/UsersTable/index.tsx b/app/dashboard/src/components/UsersTable/index.tsx new file mode 100644 index 0000000..60f41af --- /dev/null +++ b/app/dashboard/src/components/UsersTable/index.tsx @@ -0,0 +1,577 @@ + +import { + Accordion, + AccordionButton, + AccordionItem, + AccordionPanel, + Box, + HStack, + IconButton, + Table, + TableProps, + Tbody, + Td, + Text, + Th, + Thead, + Tooltip, + Tr, + useBreakpointValue, + VStack, +} from '@chakra-ui/react'; +import classNames from 'classnames'; + +import { statusColors } from 'constants/UserSettings'; +import { useDashboard } from 'contexts/DashboardContext'; +import { t } from 'i18next'; +import { FC, Fragment, useEffect, useState } from 'react'; +import CopyToClipboard from 'react-copy-to-clipboard'; +import { useTranslation } from 'react-i18next'; +import { User } from 'types/User'; +import { OnlineBadge } from '../OnlineBadge'; +import { OnlineStatus } from '../OnlineStatus'; +import { Pagination } from '../Pagination'; +import { StatusBadge } from '../StatusBadge'; +import { EmptySection } from './EmptySection'; +import { AccordionArrowIcon, SortIcon, EditIcon, CopiedIcon, CopyIcon, QRIcon, SubscriptionLinkIcon } from 'components/UsersTable/Icons'; +import { UsageSlider, UsageSliderCompact } from './UsageSlider'; +import { StatusSortSelect } from './StatusSortSelect'; + +type ExpandedIndex = number | number[]; + + +export type SortType = { + sort: string; + column: string; +}; + +export const Sort: FC = ({ sort, column }) => { + if (sort.includes(column)) + return ( + + ); + return null; +}; + +type UsersTableProps = {} & TableProps; + +export const UsersTable: FC = (props) => { + const { + usersFilters: filters, + users: { users }, + users: totalUsers, + onEditingUser, + onFilterChange, + } = useDashboard(); + + const { t } = useTranslation(); + const [selectedRow, setSelectedRow] = useState( + undefined + ); + const marginTop = useBreakpointValue({ base: 120, lg: 72 }) || 72; + const [top, setTop] = useState(`${marginTop}px`); + const useTable = useBreakpointValue({ base: false, md: true }); + + useEffect(() => { + const calcTop = () => { + const el = document.querySelectorAll('#filters')[0] as HTMLElement; + setTop(`${el.offsetHeight}px`); + }; + window.addEventListener('scroll', calcTop); + () => window.removeEventListener('scroll', calcTop); + }, []); + + const isFiltered = users.length !== totalUsers.total; + + const handleSort = (column: string) => { + let newSort = filters.sort; + if (newSort.includes(column)) { + if (newSort.startsWith('-')) { + newSort = '-created_at'; + } else { + newSort = '-' + column; + } + } else { + newSort = column; + } + onFilterChange({ + sort: newSort, + }); + }; + + const handleStatusFilter = (e: any) => { + onFilterChange({ + status: e.target.value.length > 0 ? e.target.value : undefined, + }); + }; + + const toggleAccordion = (index: number) => { + setSelectedRow(index === selectedRow ? undefined : index); + }; + + return ( + + + + + + + + + + + + + {!useTable && + users?.map((user, i) => { + return ( + + + + + + + + + + + + ); + })} + +
+ + {t('users')} + + + + + + {t('usersTable.status')} + {filters.status ? ': ' + filters.status : ''} + + + + + + {t('usersTable.dataUsage')} + + +
+
+ + {user.username} +
+
+ + + + + +
+ + + + + + + {t('usersTable.dataUsage')} + + + + + + + + + + + + + + { + e.stopPropagation(); + onEditingUser(user); + }} + > + + + + + + + + +
+
+ + + + + + + + + + {useTable && + users?.map((user, i) => { + return ( + onEditingUser(user)} + > + + + + + + ); + })} + {users.length == 0 && ( + + + + )} + +
+ + {t('username')} + + + + + + {t('usersTable.status')} + {filters.status ? ': ' + filters.status : ''} + + + + + + {t('usersTable.dataUsage')} + + + +
+
+ + {user.username} + +
+
+ + + + + +
+ +
+ +
+ ); +}; + +type ActionButtonsProps = { + user: User; +}; + +const ActionButtons: FC = ({ user }) => { + const { setQRCode, setSubLink } = useDashboard(); + + const proxyLinks = user.links.join('\r\n'); + + const [copied, setCopied] = useState([-1, false]); + useEffect(() => { + if (copied[1]) { + setTimeout(() => { + setCopied([-1, false]); + }, 1000); + } + }, [copied]); + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + > + { + setCopied([0, true]); + }} + > +
+ + + {copied[0] == 0 && copied[1] ? ( + + ) : ( + + )} + + +
+
+ { + setCopied([1, true]); + }} + > +
+ + + {copied[0] == 1 && copied[1] ? : } + + +
+
+ + { + setQRCode(user.links); + setSubLink(user.subscription_url); + }} + > + + + +
+ ); +}; diff --git a/app/dashboard/src/contexts/DashboardContext.tsx b/app/dashboard/src/contexts/DashboardContext.tsx index ef99ccc..7d4df4f 100644 --- a/app/dashboard/src/contexts/DashboardContext.tsx +++ b/app/dashboard/src/contexts/DashboardContext.tsx @@ -1,85 +1,107 @@ import { StatisticsQueryKey } from 'components/Statistics'; import { fetch } from 'service/http'; import { User, UserCreate } from 'types/User'; -import { Service } from 'types/Service'; +import { Service, ServiceCreate } from 'types/Service'; import { queryClient } from 'utils/react-query'; -import { getUsersPerPageLimitSize } from 'utils/userPreferenceStorage'; +import { getServicesPerPageLimitSize, getUsersPerPageLimitSize } from 'utils/userPreferenceStorage'; import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; -export type FilterType = { - username?: string; - limit?: number; - offset?: number; - sort: string; - status?: 'active' | 'disabled' | 'limited' | 'expired' | 'on_hold'; +export type UsersFilterType = { + username?: string; + limit?: number; + offset?: number; + sort: string; + status?: 'active' | 'disabled' | 'limited' | 'expired' | 'on_hold'; }; + +export type ServicesFilterType = { + name?: string; + users?: number; + inbounds?: number; + limit?: number; + offset?: number; + sort: string; +}; + export type ProtocolType = 'vmess' | 'vless' | 'trojan' | 'shadowsocks'; export type FilterUsageType = { - start?: string; - end?: string; + start?: string; + end?: string; }; export type InboundType = { - tag: string; - protocol: ProtocolType; - network: string; - tls: string; - port?: number; + id: number; + tag: string; + protocol: ProtocolType; + network: string; + tls: string; + port?: number; }; -export type Inbounds = Map; +export type Inbounds = InboundType[]; type PageId = number; type DashboardStateType = { - isCreatingNewUser: boolean; - editingUser: User | null | undefined; - deletingUser: User | null; - version: string | null; - users: { - users: User[]; - total: number; - }; - inbounds: Inbounds; - services: Service[]; - loading: boolean; - filters: FilterType; - subscribeUrl: string | null; - QRcodeLinks: string[] | null; - isEditingHosts: boolean; - isEditingNodes: boolean; - isShowingNodesUsage: boolean; - isResetingAllUsage: boolean; - resetUsageUser: User | null; - revokeSubscriptionUser: User | null; - isEditingCore: boolean; - activePage: number; - activatePage: (pageId: PageId) => void; - onCreateUser: (isOpen: boolean) => void; - onEditingUser: (user: User | null) => void; - onDeletingUser: (user: User | null) => void; - onResetAllUsage: (isResetingAllUsage: boolean) => void; - refetchUsers: () => void; - refetchServices: () => void; - resetAllUsage: () => Promise; - onFilterChange: (filters: Partial) => void; - deleteUser: (user: User) => Promise; - createUser: (user: UserCreate) => Promise; - editUser: (user: UserCreate) => Promise; - fetchUserUsage: (user: User, query: FilterUsageType) => Promise; - setQRCode: (links: string[] | null) => void; - setSubLink: (subscribeURL: string | null) => void; - onEditingHosts: (isEditingHosts: boolean) => void; - onEditingNodes: (isEditingHosts: boolean) => void; - onShowingNodesUsage: (isShowingNodesUsage: boolean) => void; - resetDataUsage: (user: User) => Promise; - revokeSubscription: (user: User) => Promise; + isCreatingNewUser: boolean; + editingUser: User | null | undefined; + deletingUser: User | null; + isCreatingNewService: boolean; + editingService: Service | null | undefined; + deletingService: Service | null; + version: string | null; + users: { + users: User[]; + total: number; + }; + inbounds: Inbounds; + services: Service[]; + loading: boolean; + usersFilters: UsersFilterType; + servicesFilters: ServicesFilterType; + subscribeUrl: string | null; + QRcodeLinks: string[] | null; + isEditingHosts: boolean; + isEditingNodes: boolean; + isShowingNodesUsage: boolean; + isResetingAllUsage: boolean; + resetUsageUser: User | null; + revokeSubscriptionUser: User | null; + isEditingCore: boolean; + activePage: number; + activatePage: (pageId: PageId) => void; + onCreateUser: (isOpen: boolean) => void; + onCreateService: (isOpen: boolean) => void; + onEditingUser: (user: User | null) => void; + onEditingService: (service: Service | null) => void; + onDeletingUser: (user: User | null) => void; + onDeletingService: (service: Service | null) => void; + onResetAllUsage: (isResetingAllUsage: boolean) => void; + refetchUsers: () => void; + refetchInbounds: () => void; + refetchServices: () => void; + resetAllUsage: () => Promise; + onFilterChange: (filters: Partial) => void; + deleteUser: (user: User) => Promise; + createUser: (user: UserCreate) => Promise; + editUser: (user: UserCreate) => Promise; + deleteService: (service: Service) => Promise; + createService: (service: ServiceCreate) => Promise; + editService: (service: Service) => Promise; + fetchUserUsage: (user: User, query: FilterUsageType) => Promise; + setQRCode: (links: string[] | null) => void; + setSubLink: (subscribeURL: string | null) => void; + onEditingHosts: (isEditingHosts: boolean) => void; + onEditingNodes: (isEditingHosts: boolean) => void; + onShowingNodesUsage: (isShowingNodesUsage: boolean) => void; + resetDataUsage: (user: User) => Promise; + revokeSubscription: (user: User) => Promise; }; -export const fetchUsers = async (query: FilterType): Promise => { +export const fetchUsers = async (query: UsersFilterType): Promise => { for (const key in query) { - if (!query[key as keyof FilterType]) delete query[key as keyof FilterType]; + if (!query[key as keyof UsersFilterType]) delete query[key as keyof UsersFilterType]; } useDashboard.setState({ loading: true }); return fetch('/users', { query }) @@ -95,7 +117,10 @@ export const fetchUsers = async (query: FilterType): Promise => { }); }; -export const fetchServices = async (): Promise => { +export const fetchServices = async (query: UsersFilterType): Promise => { + for (const key in query) { + if (!query[key as keyof UsersFilterType]) delete query[key as keyof UsersFilterType]; + } useDashboard.setState({ loading: true }); return fetch('/services') .then((services) => { @@ -110,9 +135,7 @@ export const fetchServices = async (): Promise => { export const fetchInbounds = async () => { return fetch('/inbounds') .then((inbounds: Inbounds) => { - useDashboard.setState({ - inbounds: new Map(Object.entries(inbounds)) as Inbounds, - }); + useDashboard.setState({ inbounds }); }) .finally(() => { useDashboard.setState({ loading: false }); @@ -123,8 +146,11 @@ export const useDashboard = create( subscribeWithSelector((set, get) => ({ version: null, editingUser: null, + editingService: null, deletingUser: null, + deletingService: null, isCreatingNewUser: false, + isCreatingNewService: false, QRcodeLinks: null, subscribeUrl: null, users: { @@ -139,22 +165,30 @@ export const useDashboard = create( isShowingNodesUsage: false, resetUsageUser: null, revokeSubscriptionUser: null, - filters: { + servicesFilters: { + name: '', + limit: getServicesPerPageLimitSize(), + sort: '-created_at', + }, + usersFilters: { username: '', limit: getUsersPerPageLimitSize(), sort: '-created_at', }, - inbounds: new Map(), + inbounds: [], isEditingCore: false, activePage: 0, activatePage: (pageId: number) => { set({ activePage: pageId }); }, refetchServices: () => { - fetchServices(); + fetchServices(get().servicesFilters); }, refetchUsers: () => { - fetchUsers(get().filters); + fetchUsers(get().usersFilters); + }, + refetchInbounds: () => { + fetchInbounds(); }, resetAllUsage: async () => { return fetch('/users/reset', { method: 'POST' }).then(() => { @@ -164,16 +198,23 @@ export const useDashboard = create( }, onResetAllUsage: (isResetingAllUsage) => set({ isResetingAllUsage }), onCreateUser: (isCreatingNewUser) => set({ isCreatingNewUser }), + onCreateService: (isCreatingNewService) => set({ isCreatingNewService }), + onEditingService: (editingService) => { + set({ editingService }); + }, onEditingUser: (editingUser) => { set({ editingUser }); }, onDeletingUser: (deletingUser) => { set({ deletingUser }); }, + onDeletingService: (deletingService) => { + set({ deletingService }); + }, onFilterChange: (filters) => { set({ - filters: { - ...get().filters, + usersFilters: { + ...get().usersFilters, ...filters, }, }); @@ -206,6 +247,30 @@ export const useDashboard = create( } ); }, + deleteService: async (service: Service) => { + set({ editingUser: null }); + return fetch(`/service/${service.id}`, { method: 'DELETE' }).then(() => { + set({ deletingUser: null }); + get().refetchUsers(); + queryClient.invalidateQueries(StatisticsQueryKey); + }); + }, + createService: async (body: ServiceCreate) => { + return fetch('/service', { method: 'POST', body }).then(() => { + set({ editingService: null }); + get().refetchServices(); + get().refetchServices(); + queryClient.invalidateQueries(StatisticsQueryKey); + }); + }, + editService: async (body: Service) => { + return fetch(`/service/${body.id}`, { method: 'PUT', body }).then( + () => { + get().onEditingUser(null); + get().refetchUsers(); + } + ); + }, fetchUserUsage: (body: User, query: FilterUsageType) => { for (const key in query) { if (!query[key as keyof FilterUsageType]) diff --git a/app/dashboard/src/pages/Login.tsx b/app/dashboard/src/pages/Login.tsx index a5d1cad..11aff16 100644 --- a/app/dashboard/src/pages/Login.tsx +++ b/app/dashboard/src/pages/Login.tsx @@ -131,7 +131,7 @@ export const Login: FC = () => { colorScheme="primary" > {} - {t('login')} + {t('login.login')} diff --git a/app/dashboard/src/pages/Services.tsx b/app/dashboard/src/pages/Services.tsx index 1422a06..67b7e92 100644 --- a/app/dashboard/src/pages/Services.tsx +++ b/app/dashboard/src/pages/Services.tsx @@ -1,6 +1,25 @@ -import { FC } from 'react'; -import { Text } from '@chakra-ui/react'; +import { FC, useEffect } from 'react'; +import { Box, VStack } from '@chakra-ui/react'; +import { ServicesFilters } from 'components/ServicesFilters'; +import { ServicesTable } from 'components/ServicesTable'; +import { useDashboard } from 'contexts/DashboardContext'; +import { pages } from 'constants/Pages'; +import { ServiceDialog } from 'components/ServiceDialog'; + export const ServicesPage: FC = () => { - return Serviced; + useEffect(() => { + useDashboard.getState().activatePage(pages.findIndex((page) => page.path === '/services')); + useDashboard.getState().refetchServices(); + }, []); + return ( + + + + {/* */} + + + + + ) } diff --git a/app/dashboard/src/pages/Users.tsx b/app/dashboard/src/pages/Users.tsx index 00cfab1..0d51ada 100644 --- a/app/dashboard/src/pages/Users.tsx +++ b/app/dashboard/src/pages/Users.tsx @@ -3,23 +3,25 @@ import { ResetAllUsageModal } from 'components/ResetAllUsageModal'; import { Box, VStack } from '@chakra-ui/react'; import { DeleteUserModal } from 'components/DeleteUserModal'; import { UserDialog } from 'components/UserDialog'; -import { UsersTable } from 'components/UsersTable'; +import { UsersTable } from 'components/UsersTable/'; import { useDashboard } from 'contexts/DashboardContext'; import { useEffect } from 'react'; -import { Filters } from 'components/Filters'; +import { UsersFilters } from 'components/UsersFilters'; import { QRCodeDialog } from 'components/QRCodeDialog'; import { ResetUserUsageModal } from 'components/ResetUserUsageModal'; import { RevokeSubscriptionModal } from 'components/RevokeSubscriptionModal'; +import { pages } from 'constants/Pages'; export const UsersPage = () => { useEffect(() => { + useDashboard.getState().activatePage(pages.findIndex((page) => page.path === '/users')); useDashboard.getState().refetchUsers(); }, []); return ( - + diff --git a/app/dashboard/src/types/Service.ts b/app/dashboard/src/types/Service.ts index 5ca28ae..263d355 100644 --- a/app/dashboard/src/types/Service.ts +++ b/app/dashboard/src/types/Service.ts @@ -5,3 +5,7 @@ export type Service = { users: number[]; inbounds: number[]; } + +export type ServiceCreate = Pick diff --git a/app/dashboard/src/utils/userPreferenceStorage.ts b/app/dashboard/src/utils/userPreferenceStorage.ts index 84894b2..fb0d250 100644 --- a/app/dashboard/src/utils/userPreferenceStorage.ts +++ b/app/dashboard/src/utils/userPreferenceStorage.ts @@ -1,5 +1,8 @@ const NUM_USERS_PER_PAGE_LOCAL_STORAGE_KEY = 'marzban-num-users-per-page'; +const NUM_SERVICES_PER_PAGE_LOCAL_STORAGE_KEY = 'marzban-num-users-per-page'; const NUM_USERS_PER_PAGE_DEFAULT = 10; +const NUM_SERVICES_PER_PAGE_DEFAULT = 10; + export const getUsersPerPageLimitSize = () => { const numUsersPerPage = localStorage.getItem(NUM_USERS_PER_PAGE_LOCAL_STORAGE_KEY) || @@ -10,3 +13,15 @@ export const getUsersPerPageLimitSize = () => { export const setUsersPerPageLimitSize = (value: string) => { return localStorage.setItem(NUM_USERS_PER_PAGE_LOCAL_STORAGE_KEY, value); }; + + +export const getServicesPerPageLimitSize = () => { + const numServicesPerPage = + localStorage.getItem(NUM_SERVICES_PER_PAGE_LOCAL_STORAGE_KEY) || + NUM_SERVICES_PER_PAGE_DEFAULT.toString(); // this catches `null` values + return parseInt(numServicesPerPage) || NUM_SERVICES_PER_PAGE_DEFAULT; // this catches NaN values +}; + +export const setServicesPerPageLimitSize = (value: string) => { + return localStorage.setItem(NUM_SERVICES_PER_PAGE_LOCAL_STORAGE_KEY, value); +}; diff --git a/app/models/service.py b/app/models/service.py index e899d9c..0e1b95a 100644 --- a/app/models/service.py +++ b/app/models/service.py @@ -7,12 +7,14 @@ from pydantic import ConfigDict, BaseModel, Field, validator, computed_field class ServiceBase(BaseModel): id: Optional[int] = None - name: Optional[str] = Field(None, nullable=True) + name: Optional[str] = Field(None) model_config = ConfigDict(from_attributes=True) + from app.models.proxy import InboundBase from app.models.user import UserBase + class Service(ServiceBase): users: List[UserBase] = Field([]) inbounds: List[InboundBase] = Field([]) @@ -50,12 +52,13 @@ class ServiceResponse(Service): @computed_field @property def user_ids(self) -> List[int]: - return [u.id for u in users] + return [u.id for u in self.users] @computed_field @property def inbound_ids(self) -> List[int]: - return [i.id for i in inbounds] + return [i.id for i in self.inbounds] + """ @validator("inbounds", pre=True) def validate_inbounds(cls, v): diff --git a/app/models/user.py b/app/models/user.py index 3a45b79..4a28f86 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -51,6 +51,7 @@ class UserDataLimitResetStrategy(str, Enum): class UserBase(BaseModel): # proxies: Dict[ProxyTypes, ProxySettings] = {} + id: Optional[int] = None username: str expire: Optional[int] = Field(None, nullable=True) key: str = Field(default_factory=lambda: secrets.token_hex(16))