gitscope
← marzneshin / Diff · 912788f → 912788f
Changes 912788f..912788f
Patch commit
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 <b>{{username}}</b>?",
- "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 <b>{{username}}</b>?",
+ "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 <badge>,</badge> Each time an address is chosen randomly.",
+ "host.wildcard": "Use <badge>*</badge> 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 <badge>,</badge> Each time an address is chosen randomly.",
- "hostsDialog.host.wildcard": "Use <badge>*</badge> 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 <b>{{name}}</b> 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<DeleteUserModalProps> = ({
@@ -36,7 +37,7 @@ export const DeleteNodeModal: FC<DeleteUserModalProps> = ({
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<DeleteUserModalProps> = ({
color="gray.600"
>
<Trans components={{ b: <b /> }}>
- {t('deleteNode.prompt', {name: deletingNode.name})}
+ {t('deleteNode.prompt', { name: deletingNode.name })}
</Trans>
</Text>
)}
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<DeleteUserModalProps> = () => {
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<UserDialogFooterProps> = ({
+ children
+}) => {
+
+ return (
+ <ModalFooter mt="3">
+ <HStack
+ justifyContent="space-between"
+ w="full"
+ gap={3}
+ flexDirection={{
+ base: 'column',
+ sm: 'row',
+ }}
+ >
+ {children}
+ </HStack>
+ </ModalFooter >
+ );
+};
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 (
<ModalHeader pt={6}>
<HStack gap={2}>
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<HTMLDivElement>
+ ) => (
+ <Box
+ ref={ref}
+ border={isChecked ? '5px' : '2px'}
+ w="full"
+ borderColor={isChecked ? 'primary.500' : 'gray.600'}
+ boxShadow={
+ isChecked
+ ? '0 0 0 3px rgba(102, 126, 234, 0.6)'
+ : '0 0 0 1px rgba(102, 126, 234, 0.6)'
+ }
+ bg={isChecked ? 'gray.100' : 'white'}
+ py="4px"
+ px="8px"
+ borderRadius="md"
+ display="flex"
+ alignItems="center"
+ justifyContent="center"
+ _hover={{
+ cursor: 'pointer',
+ borderColor: 'primary.300',
+ }}
+ onClick={onChange}
+ {...props}
+ >
+ {renderContent()}
+ </Box>
+ )
+);
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<ServiceCreate, keyof ServiceCreate>;
+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 ? (
+ <Center><Spinner /></Center>
+ ) : (
+ <FormControl
+ isInvalid={
+ !!form.formState.errors.services?.message
+ }
+ >
+ <FormLabel>{t('inbounds')}</FormLabel>
+ <Controller
+ control={form.control}
+ name="inbounds"
+ render={() => {
+ return (
+ <InboundsSelectList
+ list={inbounds}
+ renderCheckboxContent={(inbound: InboundType) => (
+ <>
+ <Text fontSize="sm" color="gray.700" fontWeight="medium">
+ {inbound.tag} {inbound.protocol}
+ </Text>
+ <Spacer />
+ <Flex flexDirection="row" alignItems="center">
+ </Flex>
+ </>
+ )}
+ />
+ );
+ }}
+ />
+ <FormErrorMessage>
+ {t(form.formState.errors.services?.message as string)}
+ </FormErrorMessage>
+ </FormControl>
+ );
+}
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<HTMLDivElement, InboundsSelectListProps>(
+ 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 (
+ <Stack spacing={3} ref={ref}>
+ {list.map((inbound: InboundType) => (
+ <Box key={inbound.id}>
+ <Flex align="center">
+ <ListCheckBox
+ isChecked={form.getValues('inbounds').includes(inbound.id)}
+ onChange={() => handleInboundToggle(inbound.id)}
+ value={String(inbound.id)}
+ renderContent={() => renderCheckboxContent(inbound)}
+ />
+ </Flex>
+ </Box>
+ ))}
+ </Stack>
+ );
+ }
+);
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 (
+ <FormControl mb={'10px'}>
+ <FormLabel>{t('name')}</FormLabel>
+ <HStack>
+ <Input
+ size="sm"
+ type="text"
+ borderRadius="6px"
+ error={form.formState.errors.name?.message}
+ disabled={disabled || isEditing}
+ {...form.register('name')}
+ />
+ {isEditing && (
+ <HStack px={1}>
+ <Controller
+ name="status"
+ control={form.control}
+ render={({ field }) => {
+ return (
+ <Tooltip
+ placement="top"
+ label={'status: ' + field.value}
+ textTransform="capitalize"
+ >
+ <Box>
+ <Switch
+ colorScheme="primary"
+ disabled={
+ field.value !== 'active' &&
+ field.value !== 'disabled'
+ }
+ isChecked={field.value === 'active'}
+ onChange={(e) => {
+ if (e.target.checked) {
+ field.onChange('active');
+ } else {
+ field.onChange('disabled');
+ }
+ }}
+ />
+ </Box>
+ </Tooltip>
+ );
+ }}
+ />
+ </HStack>
+ )}
+ </HStack>
+ </FormControl>
+ )
+}
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<ServiceDialogProps> = () => {
+ 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<string | null>('');
+ const toast = useToast();
+ const { t } = useTranslation();
+
+ const form = useForm<FormType>({
+ 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 (
+ <Modal isOpen={isOpen} onClose={onClose} size="2xl">
+ <ModalOverlay bg="blackAlpha.300" backdropFilter="blur(10px)" />
+ <FormProvider {...form} formState={form.formState}>
+ <ModalContent mx="3">
+ <form onSubmit={form.handleSubmit(submit)}>
+ <DialogModalHeader HeaderIcon={isEditing ? EditIcon : AddIcon} title={isEditing ? t('serviceDialog.editServiceTitle') : t('serviceDialog.createNewService')} />
+ <ModalCloseButton mt={3} disabled={disabled} />
+ <ModalBody>
+ <VStack justifyContent="space-between">
+ <Flex
+ flexDirection="column"
+ gridAutoRows="min-content"
+ w="full"
+ >
+ <NameField form={form} disabled={disabled} isEditing={isEditing} t={t} />
+ <InboundsField t={t} inbounds={inbounds} form={form} />
+ </Flex>
+ {error && (
+ <Alert
+ status="error"
+ display={{ base: 'none', md: 'flex' }}
+ >
+ <AlertIcon />
+ {error}
+ </Alert>
+ )}
+ </VStack>
+ {error && (
+ <Alert
+ mt="3"
+ status="error"
+ display={{ base: 'flex', md: 'none' }}
+ >
+ <AlertIcon />
+ {error}
+ </Alert>
+ )}
+ </ModalBody>
+ <DialogModalFooter>
+ <HStack
+ justifyContent="flex-start"
+ w={{
+ base: 'full',
+ sm: 'unset',
+ }}
+ >
+ {isEditing && editingService !== null && (
+ <>
+ <Tooltip label={t('delete')} placement="top">
+ <IconButton
+ aria-label="Delete"
+ size="sm"
+ onClick={() => {
+ onDeletingService(editingService);
+ onClose();
+ }}
+ >
+ <DeleteIcon />
+ </IconButton>
+ </Tooltip>
+ </>
+ )}
+ </HStack>
+ <HStack
+ w="full"
+ maxW={{ md: '50%', base: 'full' }}
+ justify="end"
+ >
+ <Button
+ type="submit"
+ size="sm"
+ px="8"
+ colorScheme="primary"
+ leftIcon={loading ? <Spinner size="xs" /> : undefined}
+ disabled={false}
+ >
+ {isEditing ? t('serviceDialog.editService') : t('createService')}
+ </Button>
+ </HStack>
+ </DialogModalFooter>
+ </form>
+ <DevTool control={form.control} />
+ </ModalContent>
+ </FormProvider>
+ </Modal >
+ );
+};
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<FilterProps> = ({ ...props }) => {
+ const { loading, servicesFilters: filters, onFilterChange, refetchUsers, onCreateService } =
+ useDashboard();
+ const { t } = useTranslation();
+ const [search, setSearch] = useState('');
+ const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ setSearch(e.target.value);
+ setSearchField(e.target.value);
+ };
+ const clear = () => {
+ setSearch('');
+ onFilterChange({
+ ...filters,
+ offset: 0,
+ username: '',
+ });
+ };
+ return (
+ <Grid
+ id="users-filters"
+ templateColumns={{
+ lg: 'repeat(3, 1fr)',
+ md: 'repeat(4, 1fr)',
+ base: 'repeat(1, 1fr)',
+ }}
+ position="sticky"
+ top={0}
+ mx="-6"
+ px="6"
+ rowGap={4}
+ gap={{
+ lg: 4,
+ base: 0,
+ }}
+ bg="var(--chakra-colors-chakra-body-bg)"
+ py={4}
+ zIndex="docked"
+ {...props}
+ >
+ <GridItem colSpan={{ base: 1, md: 2, lg: 1 }} order={{ base: 2, md: 1 }}>
+ <InputGroup>
+ <InputLeftElement pointerEvents="none" children={<SearchIcon />} />
+ <Input
+ placeholder={t('search')}
+ value={search}
+ borderColor="light-border"
+ onChange={onChange}
+ />
+
+ <InputRightElement>
+ {loading && <Spinner size="xs" />}
+ {filters.name && filters.name.length > 0 && (
+ <IconButton
+ onClick={clear}
+ aria-label="clear"
+ size="xs"
+ variant="ghost"
+ >
+ <ClearIcon />
+ </IconButton>
+ )}
+ </InputRightElement>
+ </InputGroup>
+ </GridItem>
+ <GridItem colSpan={2} order={{ base: 1, md: 2 }}>
+ <HStack justifyContent="flex-end" alignItems="center" h="full">
+ <IconButton
+ aria-label="refresh users"
+ disabled={loading}
+ onClick={refetchUsers}
+ size="sm"
+ variant="outline"
+ >
+ <ReloadIcon
+ className={classNames({
+ 'animate-spin': loading,
+ })}
+ />
+ </IconButton>
+ <Button
+ colorScheme="primary"
+ size="sm"
+ onClick={() => onCreateService(true)}
+ px={5}
+ >
+ {t('createService')}
+ </Button>
+ </HStack>
+ </GridItem>
+ </Grid>
+ );
+};
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<SortType> = ({ sort, column }) => {
+ if (sort.includes(column))
+ return (
+ <SortIcon
+ transform={sort.startsWith('-') ? undefined : 'rotate(180deg)'}
+ />
+ );
+ return null;
+};
+type ServicesTableProps = {} & TableProps;
+
+export const ServicesTable: FC<ServicesTableProps> = (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 (
+ <Box id="services-table" overflowX={{ base: 'unset', md: 'unset' }} >
+ <Table orientation="vertical" zIndex="docked" {...props}>
+ <Thead zIndex="docked" position="relative">
+ <Tr>
+ <Th
+ position="sticky"
+ top={top}
+ minW="100px"
+ pl={4}
+ pr={4}
+ cursor={'pointer'}
+ onClick={handleSort.bind(null, 'name')}
+ >
+ <HStack>
+ <span>{t('services')}</span>
+ <Sort sort={filters.sort} column="name" />
+ </HStack>
+ </Th>
+ <Th
+ position="sticky"
+ top={top}
+ minW="50px"
+ cursor={'pointer'}
+ pr={0}
+ onClick={handleSort.bind(null, 'users_number')}
+ >
+ <HStack>
+ <span>{t('servicesTable.usersNumber')}</span>
+ <Sort sort={filters.sort} column="users_number" />
+ </HStack>
+ </Th>
+ <Th
+ position="sticky"
+ top={top}
+ minW="50px"
+ cursor={'pointer'}
+ pr={0}
+ onClick={handleSort.bind(null, 'inbounds_number')}
+ >
+ <HStack>
+ <span>{t('servicesTable.inboundsNumber')}</span>
+ <Sort sort={filters.sort} column="inbounds_number" />
+ </HStack>
+ </Th>
+
+ </Tr>
+ </Thead>
+ <Tbody>
+ {services ? services.map((service, i) => {
+ return (
+ <Fragment key={service.name}>
+ <Tr
+ cursor="pointer"
+ key={service.id}
+ className={classNames('interactive', {
+ 'last-row': i === (services.length - 1),
+ })}
+ onClick={(e) => {
+ e.stopPropagation();
+ onEditingService(service);
+ }}
+ >
+ <Td borderBottom={0} minW="100px" pl={4} pr={4}>
+ {service.name}
+ </Td>
+ <Td borderBottom={0} minW="50px" pl={6} pr={6}>
+ {service.users.length}
+ </Td>
+ <Td borderBottom={0} minW="50px" pr={0}>
+ {service.inbounds.length}
+ </Td>
+ </Tr>
+ </Fragment>
+ );
+ }) :
+ <Tr>
+ <Td colSpan={3}>
+ <EmptySection isFiltered={false} />
+ </Td>
+ </Tr>
+ }
+ </Tbody>
+ </Table>
+ <Pagination />
+ </Box>
+ );
+};
+
+type EmptySectionProps = {
+ isFiltered: boolean;
+};
+
+const EmptySection: FC<EmptySectionProps> = ({ isFiltered }) => {
+ const { onCreateService } = useDashboard();
+ return (
+ <Box
+ padding="5"
+ py="8"
+ display="flex"
+ alignItems="center"
+ flexDirection="column"
+ gap={4}
+ w="full"
+ >
+ <EmptySectionIcon
+ maxHeight="200px"
+ maxWidth="200px"
+ _dark={{
+ 'path[fill="#fff"]': {
+ fill: 'gray.800',
+ },
+ 'path[fill="#f2f2f2"], path[fill="#e6e6e6"], path[fill="#ccc"]': {
+ fill: 'gray.700',
+ },
+ 'circle[fill="#3182CE"]': {
+ fill: 'primary.300',
+ },
+ }}
+ _light={{
+ 'path[fill="#f2f2f2"], path[fill="#e6e6e6"], path[fill="#ccc"]': {
+ fill: 'gray.300',
+ },
+ 'circle[fill="#3182CE"]': {
+ fill: 'primary.500',
+ },
+ }}
+ />
+ <Text fontWeight="medium" color="gray.600" _dark={{ color: 'gray.400' }}>
+ {isFiltered ? t('servicesTable.noServiceMatched') : t('servicesTable.noService')}
+ </Text>
+ {!isFiltered && (
+ <Button
+ size="sm"
+ colorScheme="primary"
+ onClick={() => onCreateService(true)}
+ >
+ {t('createService')}
+ </Button>
+ )}
+ </Box>
+ );
+};
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<UserDialogFooterProps> = ({
- isEditing,
- onClose, editingUser,
- onDeletingUser,
- handleUsageToggle,
- handleResetUsage,
- handleRevokeSubscription,
- loading,
-}) => {
-
- const { t } = useTranslation();
- return (
-
- <ModalFooter mt="3">
- <HStack
- justifyContent="space-between"
- w="full"
- gap={3}
- flexDirection={{
- base: 'column',
- sm: 'row',
- }}
- >
- <HStack
- justifyContent="flex-start"
- w={{
- base: 'full',
- sm: 'unset',
- }}
- >
- {isEditing && editingUser !== null && (
- <>
- <Tooltip label={t('delete')} placement="top">
- <IconButton
- aria-label="Delete"
- size="sm"
- onClick={() => {
- onDeletingUser(editingUser);
- onClose();
- }}
- >
- <DeleteIcon />
- </IconButton>
- </Tooltip>
- <Tooltip label={t('userDialog.usage')} placement="top">
- <IconButton
- aria-label="usage"
- size="sm"
- onClick={handleUsageToggle}
- >
- <UserUsageIcon />
- </IconButton>
- </Tooltip>
- <Button onClick={handleResetUsage} size="sm">
- {t('userDialog.resetUsage')}
- </Button>
- <Button onClick={handleRevokeSubscription} size="sm">
- {t('userDialog.revokeSubscription')}
- </Button>
- </>
- )}
- </HStack>
- <HStack
- w="full"
- maxW={{ md: '50%', base: 'full' }}
- justify="end"
- >
- <Button
- type="submit"
- size="sm"
- px="8"
- colorScheme="primary"
- leftIcon={loading ? <Spinner size="xs" /> : undefined}
- disabled={false}
- >
- {isEditing ? t('userDialog.editUser') : t('createUser')}
- </Button>
- </HStack>
- </HStack>
- </ModalFooter >
- );
-};
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) => {
<Controller
control={form.control}
name="services"
- render={({ field }) => {
+ render={() => {
return (
<ServicesSelectList
list={services}
- {...field}
+ renderCheckboxContent={(service) => (
+ <>
+ <Text fontSize="sm" color="gray.700" fontWeight="medium">
+ {service.name}
+ </Text>
+ <Spacer />
+ <Flex flexDirection="row" alignItems="center">
+ <ServicePropertyStatus value={service.users.length} StatusIcon={UsersServiceIcon} />
+ <Text color="primary.600" mx="8px">/</Text>
+ <ServicePropertyStatus value={service.inbounds.length} StatusIcon={InboundsServiceIcon} />
+ </Flex>
+ </>
+ )}
/>
);
}}
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<HTMLDivElement>) => (
- <Box
- ref={ref}
- border={isChecked ? '5px' : '2px'}
- w="full"
- borderColor={isChecked ? 'primary.500' : 'gray.600'}
- boxShadow={isChecked ? '0 0 0 3px rgba(102, 126, 234, 0.6)' : '0 0 0 1px rgba(102, 126, 234, 0.6)'}
- bg={isChecked ? 'gray.100' : 'white'}
- py="4px"
- px="8px"
- borderRadius="md"
- display="flex"
- alignItems="center"
- justifyContent="center"
- _hover={{
- cursor: 'pointer',
- borderColor: 'primary.300',
- }}
- onClick={onChange}
- {...props}
- >
- <Text fontSize="sm" color="gray.700" fontWeight="medium">
- {service.name}
- </Text>
- <Spacer />
- <Flex flexDirection="row" alignItems="center">
- <ServicePropertyStatus value={service.users.length} StatusIcon={UsersServiceIcon} />
- <Text color="primary.600" mx="8px">/</Text>
- <ServicePropertyStatus value={service.inbounds.length} StatusIcon={InboundsServiceIcon} />
- </Flex>
- </Box>
- )
-);
-
interface ServicesSelectListProps {
- list: Service[];
+ list: Service[];
+ renderCheckboxContent: (service: Service) => ReactNode;
}
export const ServicesSelectList = forwardRef<HTMLDivElement, ServicesSelectListProps>(
- function({ list }, ref) {
+ function({ list, renderCheckboxContent }, ref) {
const form = useFormContext();
const handleServiceToggle = (serviceId: number) => {
@@ -92,15 +40,16 @@ export const ServicesSelectList = forwardRef<HTMLDivElement, ServicesSelectListP
{list.map((service: Service) => (
<Box key={service.id}>
<Flex align="center">
- <CustomCheckbox
+ <ListCheckBox
isChecked={form.getValues('services').includes(service.id)}
onChange={() => handleServiceToggle(service.id)}
- service={service}
value={String(service.id)}
+ renderContent={() => renderCheckboxContent(service)}
/>
</Flex>
</Box>
))}
</Stack>
);
- });
+ }
+);
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<UserDialogProps> = () => {
<FormProvider {...form} formState={form.formState}>
<ModalContent mx="3">
<form onSubmit={form.handleSubmit(submit)}>
- <UserDialogModalHeader HeaderIcon={isEditing ? EditUserIcon : AddUserIcon} title={isEditing ? t('userDialog.editUserTitle') : t('createNewUser')} />
+ <DialogModalHeader HeaderIcon={isEditing ? EditIcon : AddIcon} title={isEditing ? t('userDialog.editUserTitle') : t('createNewUser')} />
<ModalCloseButton mt={3} disabled={disabled} />
<ModalBody>
<Grid
@@ -288,16 +295,63 @@ export const UserDialog: FC<UserDialogProps> = () => {
</Alert>
)}
</ModalBody>
- <UserDialogModalFooter
- editingUser={isEditing ? editingUser : null}
- onClose={onClose}
- isEditing={isEditing}
- onDeletingUser={onDeletingUser}
- handleUsageToggle={handleUsageToggle}
- handleResetUsage={handleResetUsage}
- handleRevokeSubscription={handleRevokeSubscription}
- loading={loading}
- />
+ <DialogModalFooter>
+ <HStack
+ justifyContent="flex-start"
+ w={{
+ base: 'full',
+ sm: 'unset',
+ }}
+ >
+ {isEditing && editingUser !== null && (
+ <>
+ <Tooltip label={t('delete')} placement="top">
+ <IconButton
+ aria-label="Delete"
+ size="sm"
+ onClick={() => {
+ onDeletingUser(editingUser);
+ onClose();
+ }}
+ >
+ <DeleteIcon />
+ </IconButton>
+ </Tooltip>
+ <Tooltip label={t('userDialog.usage')} placement="top">
+ <IconButton
+ aria-label="usage"
+ size="sm"
+ onClick={handleUsageToggle}
+ >
+ <UserUsageIcon />
+ </IconButton>
+ </Tooltip>
+ <Button onClick={handleResetUsage} size="sm">
+ {t('userDialog.resetUsage')}
+ </Button>
+ <Button onClick={handleRevokeSubscription} size="sm">
+ {t('userDialog.revokeSubscription')}
+ </Button>
+ </>
+ )}
+ </HStack>
+ <HStack
+ w="full"
+ maxW={{ md: '50%', base: 'full' }}
+ justify="end"
+ >
+ <Button
+ type="submit"
+ size="sm"
+ px="8"
+ colorScheme="primary"
+ leftIcon={loading ? <Spinner size="xs" /> : undefined}
+ disabled={false}
+ >
+ {isEditing ? t('userDialog.editUser') : t('createUser')}
+ </Button>
+ </HStack>
+ </DialogModalFooter>
</form>
</ModalContent>
</FormProvider>
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<FilterProps> = ({ ...props }) => {
- const { loading, filters, onFilterChange, refetchUsers, onCreateUser } =
+export const UsersFilters: FC<FilterProps> = ({ ...props }) => {
+ const { loading, usersFilters: filters, onFilterChange, refetchUsers, onCreateUser } =
useDashboard();
const { t } = useTranslation();
const [search, setSearch] = useState('');
@@ -62,7 +62,7 @@ export const Filters: FC<FilterProps> = ({ ...props }) => {
};
return (
<Grid
- id="filters"
+ id="users-filters"
templateColumns={{
lg: 'repeat(3, 1fr)',
md: 'repeat(4, 1fr)',
diff --git a/app/dashboard/src/components/UsersTable.tsx b/app/dashboard/src/components/UsersTable.tsx
deleted file mode 100644
index 38a8700..0000000
--- a/app/dashboard/src/components/UsersTable.tsx
+++ /dev/null
@@ -1,802 +0,0 @@
-import {
- Accordion,
- AccordionButton,
- AccordionItem,
- AccordionPanel,
- Box,
- Button,
- chakra,
- ExpandedIndex,
- HStack,
- IconButton,
- Select,
- Slider,
- SliderFilledTrack,
- SliderProps,
- SliderTrack,
- Table,
- TableProps,
- Tbody,
- Td,
- Text,
- Th,
- Thead,
- Tooltip,
- Tr,
- useBreakpointValue,
- VStack,
-} from '@chakra-ui/react';
-import {
- CheckIcon,
- ChevronDownIcon,
- ClipboardIcon,
- LinkIcon,
- PencilIcon,
- QrCodeIcon,
-} from '@heroicons/react/24/outline';
-import { ReactComponent as AddFileIcon } from 'assets/add_file.svg';
-import classNames from 'classnames';
-import { resetStrategy, 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 { formatBytes } from 'utils/formatByte';
-import { OnlineBadge } from './OnlineBadge';
-import { OnlineStatus } from './OnlineStatus';
-import { Pagination } from './Pagination';
-import { StatusBadge } from './StatusBadge';
-
-const EmptySectionIcon = chakra(AddFileIcon);
-
-const iconProps = {
- baseStyle: {
- w: {
- base: 4,
- md: 5,
- },
- h: {
- base: 4,
- md: 5,
- },
- },
-};
-const CopyIcon = chakra(ClipboardIcon, iconProps);
-const AccordionArrowIcon = chakra(ChevronDownIcon, iconProps);
-const CopiedIcon = chakra(CheckIcon, iconProps);
-const SubscriptionLinkIcon = chakra(LinkIcon, iconProps);
-const QRIcon = chakra(QrCodeIcon, iconProps);
-const EditIcon = chakra(PencilIcon, iconProps);
-const SortIcon = chakra(ChevronDownIcon, {
- baseStyle: {
- width: '15px',
- height: '15px',
- },
-});
-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';
-};
-const UsageSliderCompact: FC<UsageSliderProps> = (props) => {
- const { used, total } = props;
- const isUnlimited = total === 0 || total === null;
- return (
- <HStack
- justifyContent="space-between"
- fontSize="xs"
- fontWeight="medium"
- color="gray.600"
- _dark={{
- color: 'gray.400',
- }}
- >
- <Text>
- {formatBytes(used)} /{' '}
- {isUnlimited ? (
- <Text as="span" fontFamily="system-ui">
- ∞
- </Text>
- ) : (
- formatBytes(total)
- )}
- </Text>
- </HStack>
- );
-};
-const UsageSlider: FC<UsageSliderProps> = (props) => {
- const {
- used,
- total,
- dataLimitResetStrategy,
- totalUsedTraffic,
- ...restOfProps
- } = props;
- const isUnlimited = total === 0 || total === null;
- const isReached = !isUnlimited && (used / total) * 100 >= 100;
- return (
- <>
- <Slider
- orientation="horizontal"
- value={isUnlimited ? 100 : Math.min((used / total) * 100, 100)}
- colorScheme={isReached ? 'red' : 'primary'}
- {...restOfProps}
- >
- <SliderTrack h="6px" borderRadius="full">
- <SliderFilledTrack borderRadius="full" />
- </SliderTrack>
- </Slider>
- <HStack
- justifyContent="space-between"
- fontSize="xs"
- fontWeight="medium"
- color="gray.600"
- _dark={{
- color: 'gray.400',
- }}
- >
- <Text>
- {formatBytes(used)} /{' '}
- {isUnlimited ? (
- <Text as="span" fontFamily="system-ui">
- ∞
- </Text>
- ) : (
- formatBytes(total) +
- (dataLimitResetStrategy && dataLimitResetStrategy !== 'no_reset'
- ? ' ' +
- t(
- 'userDialog.resetStrategy' +
- getResetStrategy(dataLimitResetStrategy)
- )
- : '')
- )}
- </Text>
- <Text>
- {t('usersTable.total')}: {formatBytes(totalUsedTraffic)}
- </Text>
- </HStack>
- </>
- );
-};
-export type SortType = {
- sort: string;
- column: string;
-};
-export const Sort: FC<SortType> = ({ sort, column }) => {
- if (sort.includes(column))
- return (
- <SortIcon
- transform={sort.startsWith('-') ? undefined : 'rotate(180deg)'}
- />
- );
- return null;
-};
-type UsersTableProps = {} & TableProps;
-export const UsersTable: FC<UsersTableProps> = (props) => {
- const {
- filters,
- users: { users },
- users: totalUsers,
- onEditingUser,
- onFilterChange,
- } = useDashboard();
-
- const { t } = useTranslation();
- const [selectedRow, setSelectedRow] = useState<ExpandedIndex | undefined>(
- 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 (
- <Box id="users-table" overflowX={{ base: 'unset', md: 'unset' }}>
- <Accordion
- allowMultiple
- display={{ base: 'block', md: 'none' }}
- index={selectedRow}
- >
- <Table orientation="vertical" zIndex="docked" {...props}>
- <Thead zIndex="docked" position="relative">
- <Tr>
- <Th
- position="sticky"
- top={top}
- minW="120px"
- pl={4}
- pr={4}
- cursor={'pointer'}
- onClick={handleSort.bind(null, 'username')}
- >
- <HStack>
- <span>{t('users')}</span>
- <Sort sort={filters.sort} column="username" />
- </HStack>
- </Th>
- <Th
- position="sticky"
- top={top}
- minW="50px"
- pl={0}
- pr={0}
- w="140px"
- cursor={'pointer'}
- >
- <HStack spacing={0} position="relative">
- <Text
- position="absolute"
- _dark={{
- bg: 'gray.750',
- }}
- _light={{
- bg: '#F9FAFB',
- }}
- userSelect="none"
- pointerEvents="none"
- zIndex={1}
- w="100%"
- >
- {t('usersTable.status')}
- {filters.status ? ': ' + filters.status : ''}
- </Text>
- <Select
- value={filters.sort}
- fontSize="xs"
- fontWeight="extrabold"
- textTransform="uppercase"
- cursor="pointer"
- p={0}
- border={0}
- h="auto"
- w="auto"
- icon={<></>}
- _focusVisible={{
- border: '0 !important',
- }}
- onChange={handleStatusFilter}
- >
- <option></option>
- <option>active</option>
- <option>disabled</option>
- <option>limited</option>
- <option>expired</option>
- </Select>
- </HStack>
- </Th>
- <Th
- position="sticky"
- top={top}
- minW="100px"
- cursor={'pointer'}
- pr={0}
- onClick={handleSort.bind(null, 'used_traffic')}
- >
- <HStack>
- <span>{t('usersTable.dataUsage')}</span>
- <Sort sort={filters.sort} column="used_traffic" />
- </HStack>
- </Th>
- <Th
- position="sticky"
- top={top}
- minW="32px"
- w="32px"
- p={0}
- cursor={'pointer'}
- ></Th>
- </Tr>
- </Thead>
- <Tbody>
- {!useTable &&
- users?.map((user, i) => {
- return (
- <Fragment key={user.username}>
- <Tr
- onClick={toggleAccordion.bind(null, i)}
- cursor="pointer"
- >
- <Td borderBottom={0} minW="100px" pl={4} pr={4}>
- <div className="flex-status">
- <OnlineBadge lastOnline={user.online_at} />
- {user.username}
- </div>
- </Td>
- <Td borderBottom={0} minW="50px" pl={0} pr={0}>
- <StatusBadge
- compact
- showDetail={false}
- expiryDate={user.expire}
- status={user.status}
- />
- </Td>
- <Td borderBottom={0} minW="100px" pr={0}>
- <UsageSliderCompact
- totalUsedTraffic={user.lifetime_used_traffic}
- dataLimitResetStrategy={
- user.data_limit_reset_strategy
- }
- used={user.used_traffic}
- total={user.data_limit}
- colorScheme={statusColors[user.status].bandWidthColor}
- />
- </Td>
- <Td p={0} borderBottom={0} w="32px" minW="32px">
- <AccordionArrowIcon
- color="gray.600"
- _dark={{
- color: 'gray.400',
- }}
- transition="transform .2s ease-out"
- transform={
- selectedRow === i ? 'rotate(180deg)' : '0deg'
- }
- />
- </Td>
- </Tr>
- <Tr
- className="collapsible"
- onClick={toggleAccordion.bind(null, i)}
- >
- <Td p={0} colSpan={4}>
- <AccordionItem border={0}>
- <AccordionButton display="none"></AccordionButton>
- <AccordionPanel
- border={0}
- cursor="pointer"
- px={6}
- py={3}
- >
- <VStack justifyContent="space-between" spacing="4">
- <VStack
- alignItems="flex-start"
- w="full"
- spacing={-1}
- >
- <Text
- textTransform="capitalize"
- fontSize="xs"
- fontWeight="bold"
- color="gray.600"
- _dark={{
- color: 'gray.400',
- }}
- >
- {t('usersTable.dataUsage')}
- </Text>
- <Box width="full" minW="230px">
- <UsageSlider
- totalUsedTraffic={
- user.lifetime_used_traffic
- }
- dataLimitResetStrategy={
- user.data_limit_reset_strategy
- }
- used={user.used_traffic}
- total={user.data_limit}
- colorScheme={
- statusColors[user.status].bandWidthColor
- }
- />
- </Box>
- </VStack>
- <HStack w="full" justifyContent="space-between">
- <Box width="full">
- <StatusBadge
- compact
- expiryDate={user.expire}
- status={user.status}
- />
- <OnlineStatus lastOnline={user.online_at} />
- </Box>
- <HStack>
- <ActionButtons user={user} />
- <Tooltip
- label={t('userDialog.editUser')}
- placement="top"
- >
- <IconButton
- p="0 !important"
- aria-label="Edit user"
- bg="transparent"
- _dark={{
- _hover: {
- bg: 'gray.700',
- },
- }}
- size={{
- base: 'sm',
- md: 'md',
- }}
- onClick={(e) => {
- e.stopPropagation();
- onEditingUser(user);
- }}
- >
- <EditIcon />
- </IconButton>
- </Tooltip>
- </HStack>
- </HStack>
- </VStack>
- </AccordionPanel>
- </AccordionItem>
- </Td>
- </Tr>
- </Fragment>
- );
- })}
- </Tbody>
- </Table>
- </Accordion>
- <Table
- orientation="vertical"
- display={{ base: 'none', md: 'table' }}
- {...props}
- >
- <Thead zIndex="docked" position="relative">
- <Tr>
- <Th
- position="sticky"
- top={{ base: 'unset', md: top }}
- minW="140px"
- cursor={'pointer'}
- onClick={handleSort.bind(null, 'username')}
- >
- <HStack>
- <span>{t('username')}</span>
- <Sort sort={filters.sort} column="username" />
- </HStack>
- </Th>
- <Th
- position="sticky"
- top={{ base: 'unset', md: top }}
- width="400px"
- minW="150px"
- cursor={'pointer'}
- >
- <HStack spacing={0} position="relative">
- <Text
- position="absolute"
- _dark={{
- bg: 'gray.750',
- }}
- _light={{
- bg: '#F9FAFB',
- }}
- userSelect="none"
- pointerEvents="none"
- zIndex={1}
- w="100%"
- >
- {t('usersTable.status')}
- {filters.status ? ': ' + filters.status : ''}
- </Text>
- <Select
- fontSize="xs"
- fontWeight="extrabold"
- textTransform="uppercase"
- cursor="pointer"
- p={0}
- border={0}
- h="auto"
- w="auto"
- icon={<></>}
- _focusVisible={{
- border: '0 !important',
- }}
- value={filters.sort}
- onChange={handleStatusFilter}
- >
- <option></option>
- <option>active</option>
- <option>disabled</option>
- <option>limited</option>
- <option>expired</option>
- </Select>
- </HStack>
- </Th>
- <Th
- position="sticky"
- top={{ base: 'unset', md: top }}
- width="350px"
- minW="230px"
- cursor={'pointer'}
- onClick={handleSort.bind(null, 'used_traffic')}
- >
- <HStack>
- <span>{t('usersTable.dataUsage')}</span>
- <Sort sort={filters.sort} column="used_traffic" />
- </HStack>
- </Th>
- <Th
- position="sticky"
- top={{ base: 'unset', md: top }}
- width="200px"
- minW="180px"
- />
- </Tr>
- </Thead>
- <Tbody>
- {useTable &&
- users?.map((user, i) => {
- return (
- <Tr
- key={user.username}
- className={classNames('interactive', {
- 'last-row': i === users.length - 1,
- })}
- onClick={() => onEditingUser(user)}
- >
- <Td minW="140px">
- <div className="flex-status">
- <OnlineBadge lastOnline={user.online_at} />
- {user.username}
- <OnlineStatus lastOnline={user.online_at} />
- </div>
- </Td>
- <Td width="400px" minW="150px">
- <StatusBadge
- expiryDate={user.expire}
- status={user.status}
- />
- </Td>
- <Td width="350px" minW="230px">
- <UsageSlider
- totalUsedTraffic={user.lifetime_used_traffic}
- dataLimitResetStrategy={user.data_limit_reset_strategy}
- used={user.used_traffic}
- total={user.data_limit}
- colorScheme={statusColors[user.status].bandWidthColor}
- />
- </Td>
- <Td width="200px" minW="180px">
- <ActionButtons user={user} />
- </Td>
- </Tr>
- );
- })}
- {users.length == 0 && (
- <Tr>
- <Td colSpan={4}>
- <EmptySection isFiltered={isFiltered} />
- </Td>
- </Tr>
- )}
- </Tbody>
- </Table>
- <Pagination />
- </Box>
- );
-};
-
-type ActionButtonsProps = {
- user: User;
-};
-
-const ActionButtons: FC<ActionButtonsProps> = ({ 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 (
- <HStack
- justifyContent="flex-end"
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- }}
- >
- <CopyToClipboard
- text={
- user.subscription_url.startsWith('/')
- ? window.location.origin + user.subscription_url
- : user.subscription_url
- }
- onCopy={() => {
- setCopied([0, true]);
- }}
- >
- <div>
- <Tooltip
- label={
- copied[0] == 0 && copied[1]
- ? t('usersTable.copied')
- : t('usersTable.copyLink')
- }
- placement="top"
- >
- <IconButton
- p="0 !important"
- aria-label="copy subscription link"
- bg="transparent"
- _dark={{
- _hover: {
- bg: 'gray.700',
- },
- }}
- size={{
- base: 'sm',
- md: 'md',
- }}
- >
- {copied[0] == 0 && copied[1] ? (
- <CopiedIcon />
- ) : (
- <SubscriptionLinkIcon />
- )}
- </IconButton>
- </Tooltip>
- </div>
- </CopyToClipboard>
- <CopyToClipboard
- text={proxyLinks}
- onCopy={() => {
- setCopied([1, true]);
- }}
- >
- <div>
- <Tooltip
- label={
- copied[0] == 1 && copied[1]
- ? t('usersTable.copied')
- : t('usersTable.copyConfigs')
- }
- placement="top"
- >
- <IconButton
- p="0 !important"
- aria-label="copy configs"
- bg="transparent"
- _dark={{
- _hover: {
- bg: 'gray.700',
- },
- }}
- size={{
- base: 'sm',
- md: 'md',
- }}
- >
- {copied[0] == 1 && copied[1] ? <CopiedIcon /> : <CopyIcon />}
- </IconButton>
- </Tooltip>
- </div>
- </CopyToClipboard>
- <Tooltip label="QR Code" placement="top">
- <IconButton
- p="0 !important"
- aria-label="qr code"
- bg="transparent"
- _dark={{
- _hover: {
- bg: 'gray.700',
- },
- }}
- size={{
- base: 'sm',
- md: 'md',
- }}
- onClick={() => {
- setQRCode(user.links);
- setSubLink(user.subscription_url);
- }}
- >
- <QRIcon />
- </IconButton>
- </Tooltip>
- </HStack>
- );
-};
-
-type EmptySectionProps = {
- isFiltered: boolean;
-};
-
-const EmptySection: FC<EmptySectionProps> = ({ isFiltered }) => {
- const { onCreateUser } = useDashboard();
- return (
- <Box
- padding="5"
- py="8"
- display="flex"
- alignItems="center"
- flexDirection="column"
- gap={4}
- w="full"
- >
- <EmptySectionIcon
- maxHeight="200px"
- maxWidth="200px"
- _dark={{
- 'path[fill="#fff"]': {
- fill: 'gray.800',
- },
- 'path[fill="#f2f2f2"], path[fill="#e6e6e6"], path[fill="#ccc"]': {
- fill: 'gray.700',
- },
- 'circle[fill="#3182CE"]': {
- fill: 'primary.300',
- },
- }}
- _light={{
- 'path[fill="#f2f2f2"], path[fill="#e6e6e6"], path[fill="#ccc"]': {
- fill: 'gray.300',
- },
- 'circle[fill="#3182CE"]': {
- fill: 'primary.500',
- },
- }}
- />
- <Text fontWeight="medium" color="gray.600" _dark={{ color: 'gray.400' }}>
- {isFiltered ? t('usersTable.noUserMatched') : t('usersTable.noUser')}
- </Text>
- {!isFiltered && (
- <Button
- size="sm"
- colorScheme="primary"
- onClick={() => onCreateUser(true)}
- >
- {t('createUser')}
- </Button>
- )}
- </Box>
- );
-};
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<EmptySectionProps> = ({ isFiltered }) => {
+ const { onCreateUser } = useDashboard();
+ const { t } = useTranslation();
+ return (
+ <Box
+ padding="5"
+ py="8"
+ display="flex"
+ alignItems="center"
+ flexDirection="column"
+ gap={4}
+ w="full"
+ >
+ <EmptySectionIcon
+ maxHeight="200px"
+ maxWidth="200px"
+ _dark={{
+ 'path[fill="#fff"]': {
+ fill: 'gray.800',
+ },
+ 'path[fill="#f2f2f2"], path[fill="#e6e6e6"], path[fill="#ccc"]': {
+ fill: 'gray.700',
+ },
+ 'circle[fill="#3182CE"]': {
+ fill: 'primary.300',
+ },
+ }}
+ _light={{
+ 'path[fill="#f2f2f2"], path[fill="#e6e6e6"], path[fill="#ccc"]': {
+ fill: 'gray.300',
+ },
+ 'circle[fill="#3182CE"]': {
+ fill: 'primary.500',
+ },
+ }}
+ />
+ <Text fontWeight="medium" color="gray.600" _dark={{ color: 'gray.400' }}>
+ {isFiltered ? t('usersTable.noUserMatched') : t('usersTable.noUser')}
+ </Text>
+ {!isFiltered && (
+ <Button
+ size="sm"
+ colorScheme="primary"
+ onClick={() => onCreateUser(true)}
+ >
+ {t('createUser')}
+ </Button>
+ )}
+ </Box>
+ );
+};
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<StatusSortSelectProps> = ({ filters, handleStatusFilter }) => {
+ return (
+ <div>
+ <Select
+ value={filters.sort}
+ fontSize="xs"
+ fontWeight="extrabold"
+ textTransform="uppercase"
+ cursor="pointer"
+ p={0}
+ border={0}
+ h="auto"
+ w="auto"
+ icon={<></>}
+ _focusVisible={{
+ border: '0 !important',
+ }}
+ onChange={handleStatusFilter}
+ >
+ <option></option>
+ <option>active</option>
+ <option>disabled</option>
+ <option>limited</option>
+ <option>expired</option>
+ </Select>
+
+ </div>
+ )
+}
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<UsageSliderProps> = (props) => {
+ const { used, total } = props;
+ const isUnlimited = total === 0 || total === null;
+ return (
+ <HStack
+ justifyContent="space-between"
+ fontSize="xs"
+ fontWeight="medium"
+ color="gray.600"
+ _dark={{
+ color: 'gray.400',
+ }}
+ >
+ <Text>
+ {formatBytes(used)} /{' '}
+ {isUnlimited ? (
+ <Text as="span" fontFamily="system-ui">
+ ∞
+ </Text>
+ ) : (
+ formatBytes(total)
+ )}
+ </Text>
+ </HStack>
+ );
+};
+
+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<UsageSliderProps> = (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 (
+ <>
+ <Slider
+ orientation="horizontal"
+ value={isUnlimited ? 100 : Math.min((used / total) * 100, 100)}
+ colorScheme={isReached ? 'red' : 'primary'}
+ {...restOfProps}
+ >
+ <SliderTrack h="6px" borderRadius="full">
+ <SliderFilledTrack borderRadius="full" />
+ </SliderTrack>
+ </Slider>
+ <HStack
+ justifyContent="space-between"
+ fontSize="xs"
+ fontWeight="medium"
+ color="gray.600"
+ _dark={{
+ color: 'gray.400',
+ }}
+ >
+ <Text>
+ {formatBytes(used)} /{' '}
+ {isUnlimited ? (
+ <Text as="span" fontFamily="system-ui">
+ ∞
+ </Text>
+ ) : (
+ formatBytes(total) +
+ (dataLimitResetStrategy && dataLimitResetStrategy !== 'no_reset'
+ ? ' ' +
+ t(
+ 'userDialog.resetStrategy' +
+ getResetStrategy(dataLimitResetStrategy)
+ )
+ : '')
+ )}
+ </Text>
+ <Text>
+ {t('usersTable.total')}: {formatBytes(totalUsedTraffic)}
+ </Text>
+ </HStack>
+ </>
+ );
+};
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<SortType> = ({ sort, column }) => {
+ if (sort.includes(column))
+ return (
+ <SortIcon
+ transform={sort.startsWith('-') ? undefined : 'rotate(180deg)'}
+ />
+ );
+ return null;
+};
+
+type UsersTableProps = {} & TableProps;
+
+export const UsersTable: FC<UsersTableProps> = (props) => {
+ const {
+ usersFilters: filters,
+ users: { users },
+ users: totalUsers,
+ onEditingUser,
+ onFilterChange,
+ } = useDashboard();
+
+ const { t } = useTranslation();
+ const [selectedRow, setSelectedRow] = useState<ExpandedIndex | undefined>(
+ 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 (
+ <Box id="users-table" overflowX={{ base: 'unset', md: 'unset' }}>
+ <Accordion
+ allowMultiple
+ display={{ base: 'block', md: 'none' }}
+ index={selectedRow}
+ >
+ <Table orientation="vertical" zIndex="docked" {...props}>
+ <Thead zIndex="docked" position="relative">
+ <Tr>
+ <Th
+ position="sticky"
+ top={top}
+ minW="120px"
+ pl={4}
+ pr={4}
+ cursor={'pointer'}
+ onClick={handleSort.bind(null, 'username')}
+ >
+ <HStack>
+ <span>{t('users')}</span>
+ <Sort sort={filters.sort} column="username" />
+ </HStack>
+ </Th>
+ <Th
+ position="sticky"
+ top={top}
+ minW="50px"
+ pl={0}
+ pr={0}
+ w="140px"
+ cursor={'pointer'}
+ >
+ <HStack spacing={0} position="relative">
+ <Text
+ position="absolute"
+ _dark={{
+ bg: 'gray.750',
+ }}
+ _light={{
+ bg: '#F9FAFB',
+ }}
+ userSelect="none"
+ pointerEvents="none"
+ zIndex={1}
+ w="100%"
+ >
+ {t('usersTable.status')}
+ {filters.status ? ': ' + filters.status : ''}
+ </Text>
+ <StatusSortSelect filters={filters} handleStatusFilter={handleStatusFilter} />
+ </HStack>
+ </Th>
+ <Th
+ position="sticky"
+ top={top}
+ minW="100px"
+ cursor={'pointer'}
+ pr={0}
+ onClick={handleSort.bind(null, 'used_traffic')}
+ >
+ <HStack>
+ <span>{t('usersTable.dataUsage')}</span>
+ <Sort sort={filters.sort} column="used_traffic" />
+ </HStack>
+ </Th>
+ <Th
+ position="sticky"
+ top={top}
+ minW="32px"
+ w="32px"
+ p={0}
+ cursor={'pointer'}
+ ></Th>
+ </Tr>
+ </Thead>
+ <Tbody>
+ {!useTable &&
+ users?.map((user, i) => {
+ return (
+ <Fragment key={user.username}>
+ <Tr
+ onClick={toggleAccordion.bind(null, i)}
+ cursor="pointer"
+ >
+ <Td borderBottom={0} minW="100px" pl={4} pr={4}>
+ <div className="flex-status">
+ <OnlineBadge lastOnline={user.online_at} />
+ {user.username}
+ </div>
+ </Td>
+ <Td borderBottom={0} minW="50px" pl={0} pr={0}>
+ <StatusBadge
+ compact
+ showDetail={false}
+ expiryDate={user.expire}
+ status={user.status}
+ />
+ </Td>
+ <Td borderBottom={0} minW="100px" pr={0}>
+ <UsageSliderCompact
+ totalUsedTraffic={user.lifetime_used_traffic}
+ dataLimitResetStrategy={
+ user.data_limit_reset_strategy
+ }
+ used={user.used_traffic}
+ total={user.data_limit}
+ colorScheme={statusColors[user.status].bandWidthColor}
+ />
+ </Td>
+ <Td p={0} borderBottom={0} w="32px" minW="32px">
+ <AccordionArrowIcon
+ color="gray.600"
+ _dark={{
+ color: 'gray.400',
+ }}
+ transition="transform .2s ease-out"
+ transform={
+ selectedRow === i ? 'rotate(180deg)' : '0deg'
+ }
+ />
+ </Td>
+ </Tr>
+ <Tr
+ className="collapsible"
+ onClick={toggleAccordion.bind(null, i)}
+ >
+ <Td p={0} colSpan={4}>
+ <AccordionItem border={0}>
+ <AccordionButton display="none"></AccordionButton>
+ <AccordionPanel
+ border={0}
+ cursor="pointer"
+ px={6}
+ py={3}
+ >
+ <VStack justifyContent="space-between" spacing="4">
+ <VStack
+ alignItems="flex-start"
+ w="full"
+ spacing={-1}
+ >
+ <Text
+ textTransform="capitalize"
+ fontSize="xs"
+ fontWeight="bold"
+ color="gray.600"
+ _dark={{
+ color: 'gray.400',
+ }}
+ >
+ {t('usersTable.dataUsage')}
+ </Text>
+ <Box width="full" minW="230px">
+ <UsageSlider
+ totalUsedTraffic={
+ user.lifetime_used_traffic
+ }
+ dataLimitResetStrategy={
+ user.data_limit_reset_strategy
+ }
+ used={user.used_traffic}
+ total={user.data_limit}
+ colorScheme={
+ statusColors[user.status].bandWidthColor
+ }
+ />
+ </Box>
+ </VStack>
+ <HStack w="full" justifyContent="space-between">
+ <Box width="full">
+ <StatusBadge
+ compact
+ expiryDate={user.expire}
+ status={user.status}
+ />
+ <OnlineStatus lastOnline={user.online_at} />
+ </Box>
+ <HStack>
+ <ActionButtons user={user} />
+ <Tooltip
+ label={t('userDialog.editUser')}
+ placement="top"
+ >
+ <IconButton
+ p="0 !important"
+ aria-label="Edit user"
+ bg="transparent"
+ _dark={{
+ _hover: {
+ bg: 'gray.700',
+ },
+ }}
+ size={{
+ base: 'sm',
+ md: 'md',
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ onEditingUser(user);
+ }}
+ >
+ <EditIcon />
+ </IconButton>
+ </Tooltip>
+ </HStack>
+ </HStack>
+ </VStack>
+ </AccordionPanel>
+ </AccordionItem>
+ </Td>
+ </Tr>
+ </Fragment>
+ );
+ })}
+ </Tbody>
+ </Table>
+ </Accordion>
+ <Table
+ orientation="vertical"
+ display={{ base: 'none', md: 'table' }}
+ {...props}
+ >
+ <Thead zIndex="docked" position="relative">
+ <Tr>
+ <Th
+ position="sticky"
+ top={{ base: 'unset', md: top }}
+ minW="140px"
+ cursor={'pointer'}
+ onClick={handleSort.bind(null, 'username')}
+ >
+ <HStack>
+ <span>{t('username')}</span>
+ <Sort sort={filters.sort} column="username" />
+ </HStack>
+ </Th>
+ <Th
+ position="sticky"
+ top={{ base: 'unset', md: top }}
+ width="400px"
+ minW="150px"
+ cursor={'pointer'}
+ >
+ <HStack spacing={0} position="relative">
+ <Text
+ position="absolute"
+ _dark={{
+ bg: 'gray.750',
+ }}
+ _light={{
+ bg: '#F9FAFB',
+ }}
+ userSelect="none"
+ pointerEvents="none"
+ zIndex={1}
+ w="100%"
+ >
+ {t('usersTable.status')}
+ {filters.status ? ': ' + filters.status : ''}
+ </Text>
+ <StatusSortSelect filters={filters} handleStatusFilter={handleStatusFilter} />
+ </HStack>
+ </Th>
+ <Th
+ position="sticky"
+ top={{ base: 'unset', md: top }}
+ width="350px"
+ minW="230px"
+ cursor={'pointer'}
+ onClick={handleSort.bind(null, 'used_traffic')}
+ >
+ <HStack>
+ <span>{t('usersTable.dataUsage')}</span>
+ <Sort sort={filters.sort} column="used_traffic" />
+ </HStack>
+ </Th>
+ <Th
+ position="sticky"
+ top={{ base: 'unset', md: top }}
+ width="200px"
+ minW="180px"
+ />
+ </Tr>
+ </Thead>
+ <Tbody>
+ {useTable &&
+ users?.map((user, i) => {
+ return (
+ <Tr
+ key={user.username}
+ className={classNames('interactive', {
+ 'last-row': i === users.length - 1,
+ })}
+ onClick={() => onEditingUser(user)}
+ >
+ <Td minW="140px">
+ <div className="flex-status">
+ <OnlineBadge lastOnline={user.online_at} />
+ {user.username}
+ <OnlineStatus lastOnline={user.online_at} />
+ </div>
+ </Td>
+ <Td width="400px" minW="150px">
+ <StatusBadge
+ expiryDate={user.expire}
+ status={user.status}
+ />
+ </Td>
+ <Td width="350px" minW="230px">
+ <UsageSlider
+ totalUsedTraffic={user.lifetime_used_traffic}
+ dataLimitResetStrategy={user.data_limit_reset_strategy}
+ used={user.used_traffic}
+ total={user.data_limit}
+ colorScheme={statusColors[user.status].bandWidthColor}
+ />
+ </Td>
+ <Td width="200px" minW="180px">
+ <ActionButtons user={user} />
+ </Td>
+ </Tr>
+ );
+ })}
+ {users.length == 0 && (
+ <Tr>
+ <Td colSpan={4}>
+ <EmptySection isFiltered={isFiltered} />
+ </Td>
+ </Tr>
+ )}
+ </Tbody>
+ </Table>
+ <Pagination />
+ </Box>
+ );
+};
+
+type ActionButtonsProps = {
+ user: User;
+};
+
+const ActionButtons: FC<ActionButtonsProps> = ({ 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 (
+ <HStack
+ justifyContent="flex-end"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ >
+ <CopyToClipboard
+ text={
+ user.subscription_url.startsWith('/')
+ ? window.location.origin + user.subscription_url
+ : user.subscription_url
+ }
+ onCopy={() => {
+ setCopied([0, true]);
+ }}
+ >
+ <div>
+ <Tooltip
+ label={
+ copied[0] == 0 && copied[1]
+ ? t('usersTable.copied')
+ : t('usersTable.copyLink')
+ }
+ placement="top"
+ >
+ <IconButton
+ p="0 !important"
+ aria-label="copy subscription link"
+ bg="transparent"
+ _dark={{
+ _hover: {
+ bg: 'gray.700',
+ },
+ }}
+ size={{
+ base: 'sm',
+ md: 'md',
+ }}
+ >
+ {copied[0] == 0 && copied[1] ? (
+ <CopiedIcon />
+ ) : (
+ <SubscriptionLinkIcon />
+ )}
+ </IconButton>
+ </Tooltip>
+ </div>
+ </CopyToClipboard>
+ <CopyToClipboard
+ text={proxyLinks}
+ onCopy={() => {
+ setCopied([1, true]);
+ }}
+ >
+ <div>
+ <Tooltip
+ label={
+ copied[0] == 1 && copied[1]
+ ? t('usersTable.copied')
+ : t('usersTable.copyConfigs')
+ }
+ placement="top"
+ >
+ <IconButton
+ p="0 !important"
+ aria-label="copy configs"
+ bg="transparent"
+ _dark={{
+ _hover: {
+ bg: 'gray.700',
+ },
+ }}
+ size={{
+ base: 'sm',
+ md: 'md',
+ }}
+ >
+ {copied[0] == 1 && copied[1] ? <CopiedIcon /> : <CopyIcon />}
+ </IconButton>
+ </Tooltip>
+ </div>
+ </CopyToClipboard>
+ <Tooltip label="QR Code" placement="top">
+ <IconButton
+ p="0 !important"
+ aria-label="qr code"
+ bg="transparent"
+ _dark={{
+ _hover: {
+ bg: 'gray.700',
+ },
+ }}
+ size={{
+ base: 'sm',
+ md: 'md',
+ }}
+ onClick={() => {
+ setQRCode(user.links);
+ setSubLink(user.subscription_url);
+ }}
+ >
+ <QRIcon />
+ </IconButton>
+ </Tooltip>
+ </HStack >
+ );
+};
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<ProtocolType, InboundType[]>;
+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<void>;
- onFilterChange: (filters: Partial<FilterType>) => void;
- deleteUser: (user: User) => Promise<void>;
- createUser: (user: UserCreate) => Promise<void>;
- editUser: (user: UserCreate) => Promise<void>;
- fetchUserUsage: (user: User, query: FilterUsageType) => Promise<void>;
- 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<void>;
- revokeSubscription: (user: User) => Promise<void>;
+ 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<void>;
+ onFilterChange: (filters: Partial<UsersFilterType>) => void;
+ deleteUser: (user: User) => Promise<void>;
+ createUser: (user: UserCreate) => Promise<void>;
+ editUser: (user: UserCreate) => Promise<void>;
+ deleteService: (service: Service) => Promise<void>;
+ createService: (service: ServiceCreate) => Promise<void>;
+ editService: (service: Service) => Promise<void>;
+ fetchUserUsage: (user: User, query: FilterUsageType) => Promise<void>;
+ 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<void>;
+ revokeSubscription: (user: User) => Promise<void>;
};
-export const fetchUsers = async (query: FilterType): Promise<User[]> => {
+export const fetchUsers = async (query: UsersFilterType): Promise<User[]> => {
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<User[]> => {
});
};
-export const fetchServices = async (): Promise<Service[]> => {
+export const fetchServices = async (query: UsersFilterType): Promise<Service[]> => {
+ 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<Service[]> => {
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<DashboardStateType>((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"
>
{<LoginIcon marginRight={1} />}
- {t('login')}
+ {t('login.login')}
</Button>
</VStack>
</form>
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 <Text>Serviced</Text>;
+ useEffect(() => {
+ useDashboard.getState().activatePage(pages.findIndex((page) => page.path === '/services'));
+ useDashboard.getState().refetchServices();
+ }, []);
+ return (
+ <VStack justifyContent="space-between" maxH="100vh" p="2rem" rowGap={4}>
+ <Box w="full" >
+ <ServiceDialog />
+ {/* <DeleteServiceModal /> */}
+ <ServicesFilters />
+ <ServicesTable />
+ </Box>
+ </VStack>
+ )
}
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 (
<VStack justifyContent="space-between" maxH="100vh" p="2rem" rowGap={4}>
<Box w="full" >
- <Filters />
+ <UsersFilters />
<UsersTable />
<UserDialog />
<QRCodeDialog />
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<Service,
+ | 'name'
+ | 'inbounds'>
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))