diff --git a/app/dashboard/public/locales/en.json b/app/dashboard/public/locales/en.json index 66a7620..b69e343 100644 --- a/app/dashboard/public/locales/en.json +++ b/app/dashboard/public/locales/en.json @@ -75,6 +75,16 @@ "copyConfigs": "Copy Configs", "total": "Total" }, + "hostsTable": { + "status": "status", + "editHost": "Edit host", + "createHosts": "Create host", + "address": "Address", + "noHostMatched": "It seems there is no host matched with what you are looking for", + "noHost": "There is no host in the system", + "port": "Port", + "total": "Total" + }, "userDialog": { "dataLimit": "Data Limit", "periodicUsageReset": "Periodic Usage Reset", @@ -175,8 +185,9 @@ "port": "Port", "path": "Path", "sni": "SNI", - "advancedOptions": "Advanced options", - "addHost": "Add host", + "createSubmit": "Submit", + "editSubmit": "Submit", + "createHost": "Create host", "savedSuccess": "Hosts saved successfully", "loading": "loading...", "apply": "Apply", diff --git a/app/dashboard/src/components/footer/index.tsx b/app/dashboard/src/components/footer/index.tsx index 94f674b..b381270 100644 --- a/app/dashboard/src/components/footer/index.tsx +++ b/app/dashboard/src/components/footer/index.tsx @@ -1,16 +1,14 @@ import { BoxProps, HStack, Link, Text } from '@chakra-ui/react'; -import { ORGANIZATION_URL, REPO_URL } from 'constants/Project'; +import { ORGANIZATION_URL, CO_CONTRIBUTOR, REPO_URL } from 'constants/Project'; import { useDashboard } from 'stores'; import { FC } from 'react'; export const Footer: FC = (props) => { const { version } = useDashboard(); return ( - + @@ -18,10 +16,14 @@ export const Footer: FC = (props) => { Marzneshin {version ? ` (v${version}), ` : ', '} - Made in{' '} + Made by{' '} khodedawsh + {' & '} + + mardin.cc + ); diff --git a/app/dashboard/src/components/inbounds-card/index.tsx b/app/dashboard/src/components/inbounds-card/index.tsx index 448417f..a474861 100644 --- a/app/dashboard/src/components/inbounds-card/index.tsx +++ b/app/dashboard/src/components/inbounds-card/index.tsx @@ -22,7 +22,7 @@ const InboundsIcon = chakra(ServerStackIcon, IconBaseStyle); export const BadgeTag: FC = ({ children }) => { return ( - + {children} ) diff --git a/app/dashboard/src/components/table/pagination.tsx b/app/dashboard/src/components/table/pagination.tsx index c734d1b..f00f14e 100644 --- a/app/dashboard/src/components/table/pagination.tsx +++ b/app/dashboard/src/components/table/pagination.tsx @@ -14,7 +14,7 @@ import { import { ChangeEvent, FC } from 'react'; import { useTranslation } from 'react-i18next'; import { FilterType } from 'types'; -import { setUsersPerPageLimitSize } from 'utils/userPreferenceStorage'; +import { PageSizeManager } from 'utils/userPreferenceStorage'; const PrevIcon = chakra(ArrowLongLeftIcon, { baseStyle: { @@ -43,35 +43,41 @@ const MINIMAL_PAGE_ITEM_COUNT = 5; * - Always include first and last page * - Add ellipsis if needed */ -function generatePageItems(total: number, current: number, width: number) { - if (width < MINIMAL_PAGE_ITEM_COUNT) { - throw new Error( - `Must allow at least ${MINIMAL_PAGE_ITEM_COUNT} page items` - ); - } - if (width % 2 === 0) { - throw new Error('Must allow odd number of page items'); +interface PageItem { + value: string | number; + isPlaceholder?: boolean; +} + +function validateWidth(width: number): void { + if (width < MINIMAL_PAGE_ITEM_COUNT || width % 2 === 0) { + throw new Error(`Must allow at least ${MINIMAL_PAGE_ITEM_COUNT} odd page items`); } +} + +function generatePageItems(total: number, current: number, width: number): PageItem[] { + validateWidth(width); + if (total < width) { - return [...new Array(total).keys()]; + return Array.from({ length: total }, (_, index) => ({ value: index })); } - const left = Math.max( - 0, - Math.min(total - width, current - Math.floor(width / 2)) - ); - const items: (string | number)[] = new Array(width); - for (let i = 0; i < width; i += 1) { - items[i] = i + left; + + const left = Math.max(0, Math.min(total - width, current - Math.floor(width / 2))); + const right = Math.min(left + width, total); + + const items: PageItem[] = []; + + if (left > 0) { + items.push({ value: 0 }, { value: 'prev-more', isPlaceholder: true }); } - // replace non-ending items with placeholders - if (items[0] > 0) { - items[0] = 0; - items[1] = 'prev-more'; + + for (let i = left; i < right; i++) { + items.push({ value: i }); } - if (items[items.length - 1] < total - 1) { - items[items.length - 1] = total - 1; - items[items.length - 2] = 'next-more'; + + if (right < total) { + items.push({ value: 'next-more', isPlaceholder: true }, { value: total - 1 }); } + return items; } @@ -79,9 +85,17 @@ type PaginationProps = { total: number; onFilterChange: (filters: Partial) => void; filters: FilterType; + pageSizeManager: PageSizeManager; + pageListsNumbers?: number[]; } -export const Pagination: FC = ({ filters, total, onFilterChange }) => { +export const Pagination: FC = ({ + filters, + total, + onFilterChange, + pageSizeManager, + pageListsNumbers = [10, 20, 30], +}) => { const { limit: perPage, offset } = filters; const page = (offset || 0) / (perPage || 1); @@ -100,7 +114,7 @@ export const Pagination: FC = ({ filters, total, onFilterChange ...filters, limit: parseInt(e.target.value), }); - setUsersPerPageLimitSize(e.target.value); + pageSizeManager.setPageSize(e.target.value); }; const { t } = useTranslation(); @@ -124,9 +138,9 @@ export const Pagination: FC = ({ filters, total, onFilterChange size="sm" rounded="md" > - - - + {pageListsNumbers.map((list, i) => { + return () + })} {t('itemsPerPage')} @@ -142,20 +156,19 @@ export const Pagination: FC = ({ filters, total, onFilterChange > {t('previous')} - {pages.map((pageIndex) => { - if (typeof pageIndex === 'string') - return ; - return ( - - ); - })} - + {pages.map((pageItem) => ( + + ))} - - {error && ( - { + if (selectedHost !== null) { + onDeletingHost(true, selectedHost); + onClose(); + } + }} > - - {error} - - )} - - - + + + + + + {error && ( + + + {error} + + )} + + diff --git a/app/dashboard/src/pages/hosts/filters.tsx b/app/dashboard/src/pages/hosts/filters.tsx new file mode 100644 index 0000000..ec1a186 --- /dev/null +++ b/app/dashboard/src/pages/hosts/filters.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, useInbounds } from 'stores'; +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((tag: string) => { + useInbounds.getState().onInboundsFilterChange({ + ...useInbounds.getState().inboundsFilters, + offset: 0, + tag, + }); +}, 300); + +export const InboundsHostsFilters: FC = ({ ...props }) => { + const { loading } = useDashboard(); + const { inboundsFilters: filters, onInboundsFilterChange, refetchHosts, onCreatingHost } = useInbounds(); + const { t } = useTranslation(); + const [search, setSearch] = useState(''); + const onChange = (e: React.ChangeEvent) => { + setSearch(e.target.value); + setSearchField(e.target.value); + }; + const clear = () => { + setSearch(''); + onInboundsFilterChange({ + ...filters, + offset: 0, + tag: '', + }); + }; + + return ( + + + + } /> + + + {loading && } + {filters.tag && filters.tag.length > 0 && ( + + + + )} + + + + + + + + + + + + + ); +}; diff --git a/app/dashboard/src/pages/hosts/inbounds-sidebar/index.tsx b/app/dashboard/src/pages/hosts/inbounds-sidebar/index.tsx new file mode 100644 index 0000000..97ec2fd --- /dev/null +++ b/app/dashboard/src/pages/hosts/inbounds-sidebar/index.tsx @@ -0,0 +1,61 @@ +import { Table, Thead, Tbody, Th, Td, Tr, HStack } from '@chakra-ui/react'; +import classNames from 'classnames'; +import { InboundCard } from 'components/inbounds-card'; +import { handleSort } from 'components/table'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useInbounds } from 'stores'; + +export const InboundsSidebar = () => { + const { + inbounds, + onInboundsFilterChange, + inboundsFilters: filters, + selectedInbound, + selectInbound, + refetchInbounds + } = useInbounds(); + const { t } = useTranslation(); + useEffect(() => { + inbounds.length === 0 && refetchInbounds(); + }, [inbounds.length]) + return ( + + + + + + + + {inbounds.map((inbound, i) => { + const active = inbounds[i] == selectedInbound + return ( + { selectInbound(inbound) }}> + + ); + })} + +
+ + {t('inbounds')} + +
+ +
+ ) +} diff --git a/app/dashboard/src/pages/hosts/index.tsx b/app/dashboard/src/pages/hosts/index.tsx index 65d8c53..8229892 100644 --- a/app/dashboard/src/pages/hosts/index.tsx +++ b/app/dashboard/src/pages/hosts/index.tsx @@ -1,11 +1,13 @@ import { HostsDialog } from './dialog'; import Page from 'components/page'; import { pages } from 'stores'; +import { InboundsHostsManager } from './container'; const HostsPage = () => { return ( + ) } diff --git a/app/dashboard/src/pages/hosts/table/index.tsx b/app/dashboard/src/pages/hosts/table/index.tsx new file mode 100644 index 0000000..4033be2 --- /dev/null +++ b/app/dashboard/src/pages/hosts/table/index.tsx @@ -0,0 +1,303 @@ +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 { FC, Fragment, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + EmptySection, + AccordionArrowIcon, + EditIcon, + Sort, + handleSort, +} from 'components/table'; +import { useInbounds, fetchInboundHosts } from 'stores'; +import { useQuery } from 'react-query'; + +type ExpandedIndex = number | number[]; + +type NodesTableProps = {} & TableProps; + +export const HostsTable: FC = (props) => { + const { + hostsFilters: filters, + selectedInbound, + onEditingHost, + onCreatingHost, + onHostsFilterChange, + refetchHosts, + } = useInbounds(); + const { data: hosts } = useQuery('hosts', () => { + if (selectedInbound !== null) { + return fetchInboundHosts(selectedInbound.id); + } + }); + + useEffect(() => { refetchHosts() }, [selectedInbound]); + + const { t } = useTranslation(); + const [selectedRow, setSelectedRow] = useState( + undefined + ); + + const useTable = useBreakpointValue({ base: false, md: true }); + + // TODO: Find a different mechancism to to detect ftiler + // const isFiltered = users.length !== total; + const toggleAccordion = (index: number) => { + setSelectedRow(index === selectedRow ? undefined : index); + }; + + return ( + + + + + + + + + {/* Action buttons (certificate, core settings)*/} + + + + {!useTable && hosts?.map((host, i) => { + return ( + + + + + + + + + + + ); + })} + +
+ + {t('remark')} + + + + + {t('hostsTable.address')} + + + + + {t('hostsTable.port')} + + +
+ {host.remark} + + {host.address} + + +
+ + + + + + + {t('nodesTable.ipPort')} + + + {host.port} + + + + + + { + e.stopPropagation(); + onEditingHost(true, host); + }} + > + + + + + + + + +
+
+ + + + + + + + + + {useTable && hosts?.map((host, i) => { + return ( + onEditingHost(true, host)} + > + + + + + ); + })} + {(hosts?.length === 0 || hosts === undefined) && ( + + + + )} + +
+ + {t('remark')} + + + + + {t('hostsTable.address')} + + + + + {t('hostsTable.port')} + + +
+
+ {host.remark} +
+
+ {host.address} + + {host.port} +
+ +
+ {/* TODO: + * + */} +
+ ); +}; diff --git a/app/dashboard/src/pages/hosts/users/dialog/data-limit-field.tsx b/app/dashboard/src/pages/hosts/users/dialog/data-limit-field.tsx new file mode 100644 index 0000000..d225b53 --- /dev/null +++ b/app/dashboard/src/pages/hosts/users/dialog/data-limit-field.tsx @@ -0,0 +1,39 @@ +import { FormControl, FormLabel } from '@chakra-ui/react'; +import { Input } from 'components/input'; +import { TFunction } from 'i18next'; +import { Controller } from 'react-hook-form'; + + +interface DataLimitFieldProps { + form: any; + disabled: boolean; + t: TFunction<'translation', undefined, 'translation'>; +} + +export const DataLimitField = ({ form, disabled, t }: DataLimitFieldProps) => { + return ( + + {t('userDialog.dataLimit')} + { + return ( + + ); + }} + /> + + ) +} diff --git a/app/dashboard/src/pages/hosts/users/dialog/default-values.ts b/app/dashboard/src/pages/hosts/users/dialog/default-values.ts new file mode 100644 index 0000000..3c516d3 --- /dev/null +++ b/app/dashboard/src/pages/hosts/users/dialog/default-values.ts @@ -0,0 +1,15 @@ + +import { FormType } from './form-schema'; + +export const getDefaultValues = (): FormType => { + + return { + services: [], + data_limit: 0, + expire: null, + username: '', + data_limit_reset_strategy: 'no_reset', + status: 'active', + note: '', + }; +}; diff --git a/app/dashboard/src/pages/hosts/users/dialog/expire-date-field.tsx b/app/dashboard/src/pages/hosts/users/dialog/expire-date-field.tsx new file mode 100644 index 0000000..7019df8 --- /dev/null +++ b/app/dashboard/src/pages/hosts/users/dialog/expire-date-field.tsx @@ -0,0 +1,91 @@ +import { + FormControl, + FormHelperText, + FormLabel, +} from '@chakra-ui/react'; +import dayjs from 'dayjs'; +import { i18n, TFunction } from 'i18next'; +import ReactDatePicker from 'react-datepicker'; +import { Controller } from 'react-hook-form'; +import { relativeExpiryDate } from 'utils/dateFormatter'; +import { Input } from 'components/input'; + +interface ExpireDateFieldProps { + form: any; + disabled: boolean; + t: TFunction<'translation', undefined, 'translation'>; + i18n: i18n; +} + +export const ExpireDateField = ({ form, t, i18n, disabled }: ExpireDateFieldProps) => { + return ( + + {t('userDialog.expiryDate')} + { + function createDateAsUTC(num: number) { + return dayjs( + dayjs(num * 1000).utc() + // .format("MMMM D, YYYY") // exception with: dayjs.locale(lng); + ).toDate(); + } + const { status, time } = relativeExpiryDate( + field.value + ); + return ( + <> + { + field.onChange({ + target: { + value: date + ? dayjs( + dayjs(date) + .set('hour', 23) + .set('minute', 59) + .set('second', 59) + ) + .utc() + .valueOf() / 1000 + : 0, + name: 'expire', + }, + }); + }} + customInput={ + + } + /> + {field.value ? ( + + {t(status, { time: time })} + + ) : ( + '' + )} + + ); + }} + /> + + ) +} diff --git a/app/dashboard/src/pages/hosts/users/dialog/form-schema.ts b/app/dashboard/src/pages/hosts/users/dialog/form-schema.ts new file mode 100644 index 0000000..9f452c8 --- /dev/null +++ b/app/dashboard/src/pages/hosts/users/dialog/form-schema.ts @@ -0,0 +1,22 @@ + +import { z } from 'zod'; +import { UserCreate } from 'types'; + +export type FormType = Pick; +export const schema = z.object({ + username: z.string().min(1, { message: 'Username is required' }), + note: z.string().nullable(), + data_limit: z + .string() + .min(0, 'The minimum number is 0') + .or(z.number()) + .nullable() + .transform((str) => { + if (str) return Number((parseFloat(String(str)) * 1073741824).toFixed(5)); + return 0; + }), + expire: z.number().nullable(), + data_limit_reset_strategy: z.string(), + status: z.string(), + services: z.array(z.number()).nonempty({ message: 'At least one service is required' }), +}); diff --git a/app/dashboard/src/pages/hosts/users/dialog/icons.ts b/app/dashboard/src/pages/hosts/users/dialog/icons.ts new file mode 100644 index 0000000..8f02730 --- /dev/null +++ b/app/dashboard/src/pages/hosts/users/dialog/icons.ts @@ -0,0 +1,11 @@ + +import { chakra } from '@chakra-ui/react'; +import { + ChartPieIcon, +} from '@heroicons/react/24/outline'; +export const UserUsageIcon = chakra(ChartPieIcon, { + baseStyle: { + w: 5, + h: 5, + }, +}); diff --git a/app/dashboard/src/pages/hosts/users/dialog/index.tsx b/app/dashboard/src/pages/hosts/users/dialog/index.tsx new file mode 100644 index 0000000..6ca47dc --- /dev/null +++ b/app/dashboard/src/pages/hosts/users/dialog/index.tsx @@ -0,0 +1,363 @@ +import { + Alert, + Button, + Tooltip, + IconButton, + Spinner, + AlertIcon, + Box, + Collapse, + Flex, + Grid, + GridItem, + ModalBody, + ModalCloseButton, + ModalContent, + Modal, + VStack, + useColorMode, + useToast, + ModalOverlay, + HStack, +} from '@chakra-ui/react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import dayjs from 'dayjs'; +import { FC, useEffect, useState } from 'react'; +import ReactApexChart from 'react-apexcharts'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { + User, + FilterUsageType, + UserCreate, + Service +} from 'types'; +import { UsageFilter, createUsageConfig } from 'components/usage-filter'; +import { + AddIcon, + EditIcon, + DialogModalHeader, + DialogModalFooter, + DeleteIcon +} from 'components/dialog'; +import { UserUsageIcon } from './icons'; +import { getDefaultValues } from './default-values'; +import { schema, FormType } from './form-schema'; +import { ServicesField } from './services-field'; +import { UsernameField } from './username-field'; +import { DataLimitField } from './data-limit-field'; +import { PeriodicUsageReset } from './periodic-usage-reset'; +import { ExpireDateField } from './expire-date-field'; +import { NoteField } from './note-field'; +import { useUsers, useServices } from 'stores'; + +const formatUser = (user: User): FormType => { + const services: number[] = user.services.map((service: number | Service): number => { + return (typeof service !== 'number') ? service.id : service; + }); + return { + ...user, + services, + data_limit: user.data_limit + ? Number((user.data_limit / 1073741824).toFixed(5)) + : user.data_limit, + }; +}; + +export type UserDialogProps = {}; + +export const UserDialog: FC = () => { + const { + editingUser, + isCreatingNewUser, + onCreateUser, + editUser, + fetchUserUsage, + onEditingUser, + createUser, + onDeletingUser, + } = useUsers(); + const { refetchServices, services } = useServices(); + const isEditing = !!editingUser; + const isOpen = isCreatingNewUser || isEditing; + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const toast = useToast(); + const { t, i18n } = useTranslation(); + + const { colorMode } = useColorMode(); + + const [usageVisible, setUsageVisible] = useState(false); + const handleUsageToggle = () => { + setUsageVisible((current) => !current); + }; + + const form = useForm({ + defaultValues: getDefaultValues(), + resolver: zodResolver(schema), + }); + + const [dataLimit] = useWatch({ + control: form.control, + name: ['data_limit'], + }); + + const usageTitle = t('userDialog.total'); + const [usage, setUsage] = useState(createUsageConfig(colorMode, usageTitle)); + const [usageFilter, setUsageFilter] = useState('1m'); + const fetchUsageWithFilter = (query: FilterUsageType) => { + fetchUserUsage(editingUser!, query).then((data: any) => { + const labels = []; + const series = []; + for (const key in data.usages) { + series.push(data.usages[key].used_traffic); + labels.push(data.usages[key].node_name); + } + setUsage(createUsageConfig(colorMode, usageTitle, series, labels)); + }); + }; + + useEffect(() => { + if (isOpen) { + refetchServices(); + } + }, [isOpen]); + + + useEffect(() => { + if (editingUser) { + form.reset(formatUser(editingUser)); + + fetchUsageWithFilter({ + start: dayjs().utc().subtract(30, 'day').format('YYYY-MM-DDTHH:00:00'), + }); + } + }, [editingUser]); + + const submit = async (values: FormType) => { + setLoading(true); + const methods = { edited: editUser, created: createUser }; + const method = isEditing ? 'edited' : 'created'; + setError(null); + const { services, username, ...rest } = values; + + let body: UserCreate = { + ...rest, + username, + services, + data_limit: values.data_limit, + data_limit_reset_strategy: + values.data_limit && values.data_limit > 0 + ? values.data_limit_reset_strategy + : 'no_reset', + status: + values.status === 'active' || values.status === 'disabled' + ? values.status + : 'active', + }; + + await methods[method](body) + .then(() => { + toast({ + title: t( + isEditing ? 'userDialog.userEdited' : 'userDialog.userCreated', + { username: values.username } + ), + 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 'services' | 'username' | 'data_limit' | 'expire', + { + type: 'custom', + message: err.response._data.detail[key], + } + ); + }); + } + }) + .finally(() => { + setLoading(false); + }); + }; + + const onClose = () => { + form.reset(getDefaultValues()); + onCreateUser(false); + onEditingUser(null); + setError(null); + setUsageVisible(false); + setUsageFilter('1m'); + }; + + const handleResetUsage = () => { + useUsers.setState({ resetUsageUser: editingUser }); + }; + + const handleRevokeSubscription = () => { + useUsers.setState({ revokeSubscriptionUser: editingUser }); + }; + + const disabled = loading; + + return ( + + + + +
+ + + + + + + + + + + 0)} + animateOpacity + style={{ width: '100%' }} + > + + + + + + {error && ( + + + {error} + + )} + + + + + + + + {isEditing && usageVisible && ( + + + { + setUsageFilter(filter); + fetchUsageWithFilter(query); + }} + /> + + + + + + )} + + + {error && ( + + + {error} + + )} + + + + {isEditing && editingUser !== null && ( + <> + + { + onDeletingUser(editingUser); + onClose(); + }} + > + + + + + + + + + + + + )} + + + + + + +
+
+
+ ); +}; diff --git a/app/dashboard/src/pages/hosts/users/dialog/note-field.tsx b/app/dashboard/src/pages/hosts/users/dialog/note-field.tsx new file mode 100644 index 0000000..618a0f6 --- /dev/null +++ b/app/dashboard/src/pages/hosts/users/dialog/note-field.tsx @@ -0,0 +1,22 @@ +import { FormControl, FormLabel, Textarea, FormErrorMessage } from '@chakra-ui/react'; +import { TFunction } from 'i18next'; + +interface NoteFieldProps { + form: any; + t: TFunction<'translation', undefined, 'translation'>; +} + +export const NoteField = ({ t, form }: NoteFieldProps) => { + return ( + + {t('userDialog.note')} +