Co-authored-by: Ivan <84693047+exact01@users.noreply.github.com>
This commit is contained in:
kastov 2024-11-28 03:58:43 +03:00
parent b341bb273a
commit dea45b7121
95 changed files with 12327 additions and 11009 deletions

90
.eslintrc.js Normal file
View file

@ -0,0 +1,90 @@
// import mantine from 'eslint-config-mantine';
// import tseslint from 'typescript-eslint';
// export default tseslint.config(
// ...mantine,
// { ignores: ['**/*.{mjs,cjs,js,d.ts,d.mts}', './.storybook/main.ts'] },
// );
export default [
{
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'airbnb-base',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'plugin:storybook/recommended',
'prettier'
],
ignorePatterns: [
'dist',
'.eslintrc.cjs',
'plop',
'plop/**',
'plopfile.js',
'.stylelintrc.js'
],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh', 'import'],
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx']
},
'import/resolver': {
node: true,
typescript: {
project: '.'
}
}
},
rules: {
indent: ['error', 4, { SwitchCase: 1 }],
'max-classes-per-file': 'off',
'import/no-extraneous-dependencies': ['off'],
'import/no-unresolved': 'error',
'import/prefer-default-export': 'off',
'import/extensions': 'off',
'no-bitwise': 'off',
'no-plusplus': 'off',
'no-restricted-syntax': ['off', 'ForInStatement'],
'import/order': [
'error',
{
'newlines-between': 'never'
}
],
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'no-shadow': ['off'],
'arrow-body-style': ['off'],
'object-curly-spacing': ['error', 'always'],
'array-bracket-spacing': ['error', 'never'],
'no-underscore-dangle': [
'off',
{
allow: ['_'],
allowAfterThis: true,
allowAfterSuper: true,
allowAfterThisConstructor: true,
enforceInMethodNames: false
}
],
semi: ['error', 'never'],
'comma-dangle': ['off'],
'brace-style': ['error', '1tbs', { allowSingleLine: true }],
'object-curly-newline': ['error', { multiline: true, consistent: true }],
'react-hooks/exhaustive-deps': 'off',
'no-empty-pattern': 'warn',
'@typescript-eslint/ban-types': [
'error',
{
types: {
'{}': false
}
}
]
}
}
]

25
.prettierrc Normal file
View file

@ -0,0 +1,25 @@
{
"singleQuote": true,
"singleAttributePerLine": false,
"tabWidth": 4,
"printWidth": 100,
"semi": false,
"trailingComma": "none",
"plugins": [
"@ianvs/prettier-plugin-sort-imports"
],
"importOrderCaseSensitive": false,
"importOrder": [
"^react$",
"",
"^@mantine/(.*)$",
"^@mantinex/(.*)$",
"<THIRD_PARTY_MODULES>",
"^(@)(/.*)$",
"^../(?!.*.css$).*$",
"^./(?!.*.css$).*$",
"\\.css$",
"",
"^[.]"
]
}

View file

@ -1,36 +0,0 @@
/** @type {import("@ianvs/prettier-plugin-sort-imports").PrettierConfig} */
const config = {
printWidth: 100,
singleQuote: true,
tabWidth: 4,
trailingComma: 'es5',
plugins: ['@ianvs/prettier-plugin-sort-imports'],
importOrder: [
'.*styles.css$',
'',
'dayjs',
'^react$',
'^next$',
'^next/.*$',
'<BUILTIN_MODULES>',
'<THIRD_PARTY_MODULES>',
'^@mantine/(.*)$',
'^@mantinex/(.*)$',
'^@mantine-tests/(.*)$',
'^@docs/(.*)$',
'^@/.*$',
'^../(?!.*.css$).*$',
'^./(?!.*.css$).*$',
'\\.css$',
],
overrides: [
{
files: '*.mdx',
options: {
printWidth: 70,
},
},
],
};
export default config;

View file

@ -1,3 +0,0 @@
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.1.1.cjs

View file

@ -1,7 +0,0 @@
import mantine from 'eslint-config-mantine';
import tseslint from 'typescript-eslint';
export default tseslint.config(
...mantine,
{ ignores: ['**/*.{mjs,cjs,js,d.ts,d.mts}', './.storybook/main.ts'] },
);

View file

@ -13,10 +13,6 @@
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width, user-scalable=no"
/>
<meta
property="og:image"
content="https://raw.githubusercontent.com/nedois/mantine-dashboard/main/screenshoots/screen-1.jpeg"
/>
<title>Remnawave Dashboard</title>
</head>
<body>

15691
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,24 +4,19 @@
"type": "module",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"start:dev": "vite",
"start:build": "NODE_ENV=production tsc && vite build",
"cb": "vite build",
"start:preview": "vite preview --port 3333",
"typecheck": "tsc --noEmit",
"lint": "npm run lint:eslint && npm run lint:stylelint",
"lint:eslint": "eslint . --ext .ts,.tsx --cache",
"lint:stylelint": "stylelint '**/*.css' --cache",
"prettier": "prettier --check \"**/*.{ts,tsx}\"",
"prettier:write": "prettier --write \"**/*.{ts,tsx}\"",
"vitest": "vitest run",
"vitest:watch": "vitest",
"test": "npm run typecheck && npm run prettier && npm run lint && npm run vitest && npm run build",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build"
"prettier:write": "prettier --write \"**/*.{ts,tsx}\""
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@emotion/react": "^11.13.5",
"@hello-pangea/dnd": "^17.0.0",
"@mantine/carousel": "^7.12.2",
"@mantine/charts": "^7.12.2",
"@mantine/code-highlight": "^7.12.2",
@ -33,40 +28,21 @@
"@mantine/modals": "^7.12.2",
"@mantine/notifications": "^7.12.2",
"@mantine/nprogress": "^7.12.2",
"@mantine/spotlight": "^7.12.2",
"@mantine/tiptap": "^7.12.2",
"@mdx-js/react": "^3.0.1",
"@mdx-js/rollup": "^3.0.1",
"@paralleldrive/cuid2": "github:paralleldrive/cuid2",
"@remnawave/backend-contract": "^0.0.12",
"@tabler/icons-react": "^3.14.0",
"@tanstack/react-query": "^5.54.1",
"@tanstack/react-query-devtools": "^5.54.1",
"@tiptap/extension-link": "^2.6.6",
"@tiptap/react": "^2.6.6",
"@tiptap/starter-kit": "^2.6.6",
"@tsmx/human-readable": "^2.0.3",
"@remnawave/backend-contract": "^0.0.17",
"axios": "^1.7.7",
"byte-size": "^9.0.0",
"bytes": "^3.1.2",
"clsx": "^2.1.1",
"color-hash": "^2.0.2",
"dayjs": "^1.11.13",
"dinero.js": "^2.0.0-alpha.14",
"dotenv": "^16.4.5",
"embla-carousel-react": "^8.2.1",
"framer-motion": "^11.5.2",
"libphonenumber-js": "^1.11.7",
"mantine-datatable": "^7.12.4",
"msw": "^2.4.2",
"nanoid": "^5.0.7",
"pretty-bytes": "^6.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-helmet-async": "^2.0.5",
"react-icons": "^5.3.0",
"react-imask": "^7.6.1",
"react-router-dom": "^6.26.1",
"recharts": "^2.12.7",
"recharts": "^2.13.3",
"tiny-invariant": "^1.3.3",
"xbytes": "^1.9.1",
"zod": "^3.23.8",
@ -77,12 +53,15 @@
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
"@types/byte-size": "^8.1.2",
"@types/bytes": "^3.1.4",
"@types/color-hash": "^2.0.0",
"@types/mdx": "^2.0.13",
"@types/node": "^20.11.19",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.4.0",
"@typescript-eslint/parser": "^8.4.0",
"@vitejs/plugin-react": "^4.3.1",
"@vitejs/plugin-react-swc": "^3.7.0",
"eslint": "^9.9.1",
"eslint-config-airbnb": "19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
@ -98,13 +77,27 @@
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.3.3",
"prop-types": "^15.8.1",
"storybook": "^8.2.9",
"storybook-dark-mode": "^4.0.2",
"rollup-plugin-visualizer": "^5.12.0",
"stylelint": "^16.9.0",
"stylelint-config-standard-scss": "^13.1.0",
"typescript": "^5.5.4",
"vite": "^5.4.3",
"vite-tsconfig-paths": "^5.0.1",
"vitest": "^2.0.5"
"vite": "6.0.1",
"vite-plugin-javascript-obfuscator": "^3.1.0",
"vite-plugin-preload": "^0.4.0",
"vite-plugin-webfont-dl": "^3.9.4",
"vite-tsconfig-paths": "^5.0.1"
},
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.9.5"
},
"overrides": {
"node-plop": {
"inquirer": "9.3.5"
}
}
}

View file

@ -1,37 +1,35 @@
import '@mantine/carousel/styles.layer.css';
import '@mantine/charts/styles.layer.css';
import '@mantine/code-highlight/styles.layer.css';
import '@mantine/core/styles.layer.css';
import '@mantine/dates/styles.layer.css';
import '@mantine/dropzone/styles.layer.css';
import '@mantine/notifications/styles.layer.css';
import '@mantine/nprogress/styles.layer.css';
import '@mantine/spotlight/styles.layer.css';
import '@mantine/tiptap/styles.layer.css';
import 'mantine-datatable/styles.layer.css';
import './global.css';
import '@mantine/carousel/styles.layer.css'
import '@mantine/charts/styles.layer.css'
import '@mantine/code-highlight/styles.layer.css'
import '@mantine/core/styles.layer.css'
import '@mantine/dates/styles.layer.css'
import '@mantine/dropzone/styles.layer.css'
import '@mantine/notifications/styles.layer.css'
import '@mantine/nprogress/styles.layer.css'
import 'mantine-datatable/styles.layer.css'
import './global.css'
import { HelmetProvider } from 'react-helmet-async';
import { MantineProvider } from '@mantine/core';
import { ModalsProvider } from '@mantine/modals';
import { Notifications } from '@mantine/notifications';
import { NavigationProgress } from '@mantine/nprogress';
import { Router } from '@/app/router/router';
import { AuthProvider } from '@/shared/providers/auth-provider';
import { theme } from '@/shared/theme';
import { MantineProvider } from '@mantine/core'
import { ModalsProvider } from '@mantine/modals'
import { Notifications } from '@mantine/notifications'
import { NavigationProgress } from '@mantine/nprogress'
import { Router } from '@/app/router/router'
import { AuthProvider } from '@/shared/providers/auth-provider'
import { theme } from '@/shared/theme'
export function App() {
return (
<HelmetProvider>
<AuthProvider>
<MantineProvider theme={theme} defaultColorScheme="dark">
<Notifications position="top-right" />
<NavigationProgress />
<ModalsProvider>
<Router />
</ModalsProvider>
</MantineProvider>
</AuthProvider>
</HelmetProvider>
);
<AuthProvider>
<MantineProvider
theme={theme}
defaultColorScheme="dark"
>
<Notifications position="top-right" />
<NavigationProgress />
<ModalsProvider>
<Router />
</ModalsProvider>
</MantineProvider>
</AuthProvider>
)
}

View file

@ -1,6 +1,6 @@
import { ElementType } from 'react';
import { ROUTES } from '@shared/constants';
import { PiStarDuotone, PiUsersDuotone } from 'react-icons/pi';
import { PiScrewdriverDuotone, PiStarDuotone, PiUsersDuotone } from 'react-icons/pi';
import { MenuItem } from './interfaces';
export const menu: MenuItem[] = [
@ -15,8 +15,11 @@ export const menu: MenuItem[] = [
],
},
{
header: 'Users',
section: [{ name: 'Users', href: ROUTES.DASHBOARD.USERS, icon: PiUsersDuotone }],
header: 'Management',
section: [
{ name: 'Users', href: ROUTES.DASHBOARD.USERS, icon: PiUsersDuotone },
{ name: 'Hosts', href: ROUTES.DASHBOARD.HOSTS, icon: PiScrewdriverDuotone },
],
},
// {

View file

@ -3,15 +3,16 @@ import {
createRoutesFromElements,
Navigate,
Route,
RouterProvider,
} from 'react-router-dom';
import { AuthLayout } from '@/app/layouts/auth';
import { DashboardLayout } from '@/app/layouts/dashboard';
import { LoginPage } from '@/pages/auth/login/login.page';
import { HomePageConnectior } from '@/pages/dashboard/home/connectores/home.page.connector';
import { UsersPageConnector } from '@/pages/dashboard/users/ui/connectors/users.page.connector';
import { AuthGuard } from '@/shared/hocs/guards/auth-guard';
import { ROUTES } from '../../shared/constants';
RouterProvider
} from 'react-router-dom'
import { AuthLayout } from '@/app/layouts/auth'
import { DashboardLayout } from '@/app/layouts/dashboard'
import { LoginPage } from '@/pages/auth/login/login.page'
import { HomePageConnectior } from '@/pages/dashboard/home/connectores/home.page.connector'
import { HostsPageConnector } from '@/pages/dashboard/hosts/ui/connectors/hosts.page.connector'
import { UsersPageConnector } from '@/pages/dashboard/users/ui/connectors/users.page.connector'
import { AuthGuard } from '@/shared/hocs/guards/auth-guard'
import { ROUTES } from '../../shared/constants'
const router = createBrowserRouter(
createRoutesFromElements(
@ -26,11 +27,12 @@ const router = createBrowserRouter(
<Route index element={<Navigate to={ROUTES.DASHBOARD.HOME} replace />} />
<Route path={ROUTES.DASHBOARD.HOME} element={<HomePageConnectior />} />
<Route path={ROUTES.DASHBOARD.USERS} element={<UsersPageConnector />} />
<Route path={ROUTES.DASHBOARD.HOSTS} element={<HostsPageConnector />} />
</Route>
</Route>
)
);
)
export function Router() {
return <RouterProvider router={router} />;
return <RouterProvider router={router} />
}

View file

@ -1,29 +1,32 @@
import { notifications } from '@mantine/notifications'
import {
GetAllUsersCommand,
GetInboundsCommand,
GetStatsCommand,
} from '@remnawave/backend-contract';
import { instance } from '@shared/api';
import { AxiosError } from 'axios';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { notifications } from '@mantine/notifications';
import { IActions, IState, IUsersParams } from './interfaces';
GetStatsCommand
} from '@remnawave/backend-contract'
import { instance } from '@shared/api'
import { AxiosError } from 'axios'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { IInboundsHashMap } from '@/entitites/dashboard/dashboard-store/interfaces/inbounds-hash-map.interface'
import { IActions, IState, IUsersParams } from './interfaces'
const initialState: IState = {
isLoading: false,
isUsersLoading: false,
systemInfo: null,
users: null,
usersParams: {
limit: 10,
offset: 0,
orderBy: 'createdAt',
orderDir: 'desc',
orderDir: 'desc'
},
totalUsers: 0,
inbounds: null,
isInboundsLoading: false,
};
inboundsHashMap: null
}
export const useDashboardStore = create<IState & IActions>()(
devtools(
@ -32,31 +35,31 @@ export const useDashboardStore = create<IState & IActions>()(
actions: {
getSystemInfo: async (): Promise<boolean> => {
try {
set({ isLoading: true });
set({ isLoading: true })
const response = await instance.get<GetStatsCommand.Response>(
GetStatsCommand.url
);
)
const {
data: { response: dataResponse },
} = response;
data: { response: dataResponse }
} = response
set({ systemInfo: dataResponse });
set({ systemInfo: dataResponse })
return true;
return true
} catch (e) {
if (e instanceof AxiosError) {
throw e;
throw e
}
return false;
return false
} finally {
set({ isLoading: false });
set({ isLoading: false })
}
},
getUsers: async (params?: Partial<IUsersParams>): Promise<boolean> => {
try {
set({ isLoading: true });
const currentParams = getState().usersParams;
const newParams = { ...currentParams, ...params };
set({ isUsersLoading: true })
const currentParams = getState().usersParams
const newParams = { ...currentParams, ...params }
const response = await instance.get<GetAllUsersCommand.Response>(
GetAllUsersCommand.url,
@ -67,72 +70,87 @@ export const useDashboardStore = create<IState & IActions>()(
orderBy: newParams.orderBy,
orderDir: newParams.orderDir,
search: newParams.search,
searchBy: newParams.searchBy,
},
searchBy: newParams.searchBy
}
}
);
)
const {
data: { response: dataResponse },
} = response;
data: { response: dataResponse }
} = response
set({
users: dataResponse.users,
totalUsers: dataResponse.total,
usersParams: newParams,
});
usersParams: newParams
})
return true;
return true
} catch (e) {
if (e instanceof AxiosError) {
throw e;
throw e
}
return false;
return false
} finally {
set({ isLoading: false });
set({ isUsersLoading: false })
}
},
getInbounds: async (): Promise<boolean> => {
try {
set({ isInboundsLoading: true });
set({ isInboundsLoading: true })
const response = await instance.get<GetInboundsCommand.Response>(
GetInboundsCommand.url
);
)
const {
data: { response: dataResponse },
} = response;
data: { response: dataResponse }
} = response
set({ inbounds: dataResponse });
set({ inbounds: dataResponse })
return true;
const inboundsHashMap = new Map<string, IInboundsHashMap>(
dataResponse.map((inbound) => [
inbound.uuid,
{
tag: inbound.tag,
type: inbound.type
}
])
)
set({ inboundsHashMap })
return true
} catch (e) {
if (e instanceof AxiosError) {
throw e;
throw e
}
return false;
return false
} finally {
set({ isInboundsLoading: false });
set({ isInboundsLoading: false })
}
},
resetState: async () => {
set({ ...initialState });
},
},
set({ ...initialState })
}
}
}),
{
name: 'dashboardStore',
anonymousActionType: 'dashboardStore',
anonymousActionType: 'dashboardStore'
}
)
);
)
export const useDashboardStoreIsLoading = () => useDashboardStore((store) => store.isLoading);
export const useDashboardStoreSystemInfo = () => useDashboardStore((state) => state.systemInfo);
export const useDashboardStoreActions = () => useDashboardStore((store) => store.actions);
export const useDashboardStoreUsers = () => useDashboardStore((state) => state.users);
export const useDashboardStoreTotalUsers = () => useDashboardStore((state) => state.totalUsers);
export const useDashboardStoreParams = () => useDashboardStore((state) => state.usersParams);
export const useDashboardStoreIsLoading = () => useDashboardStore((store) => store.isLoading)
export const useDashboardStoreUsersLoading = () =>
useDashboardStore((store) => store.isUsersLoading)
export const useDashboardStoreSystemInfo = () => useDashboardStore((state) => state.systemInfo)
export const useDashboardStoreActions = () => useDashboardStore((store) => store.actions)
export const useDashboardStoreUsers = () => useDashboardStore((state) => state.users)
export const useDashboardStoreTotalUsers = () => useDashboardStore((state) => state.totalUsers)
export const useDashboardStoreParams = () => useDashboardStore((state) => state.usersParams)
// Inbounds
export const useDSInbounds = () => useDashboardStore((state) => state.inbounds);
export const useDSInboundsLoading = () => useDashboardStore((state) => state.isInboundsLoading);
export const useDSInbounds = () => useDashboardStore((state) => state.inbounds)
export const useDSInboundsLoading = () => useDashboardStore((state) => state.isInboundsLoading)
export const useDSInboundsHashMap = () => useDashboardStore((state) => state.inboundsHashMap)

View file

@ -0,0 +1,4 @@
export interface IInboundsHashMap {
tag: string;
type: string;
}

View file

@ -1,16 +1,19 @@
import {
GetAllUsersCommand,
GetInboundsCommand,
GetStatsCommand,
} from '@remnawave/backend-contract';
import { IUsersParams } from '../interfaces';
GetStatsCommand
} from '@remnawave/backend-contract'
import { IInboundsHashMap } from '@/entitites/dashboard/dashboard-store/interfaces/inbounds-hash-map.interface'
import { IUsersParams } from '../interfaces'
export interface IState {
isLoading: boolean;
isInboundsLoading: boolean;
systemInfo: GetStatsCommand.Response['response'] | null;
users: GetAllUsersCommand.Response['response']['users'] | null;
usersParams: IUsersParams;
totalUsers: number;
inbounds: GetInboundsCommand.Response['response'] | null;
isLoading: boolean
isUsersLoading: boolean
isInboundsLoading: boolean
systemInfo: GetStatsCommand.Response['response'] | null
users: GetAllUsersCommand.Response['response']['users'] | null
usersParams: IUsersParams
totalUsers: number
inbounds: GetInboundsCommand.Response['response'] | null
inboundsHashMap: Map<string, IInboundsHashMap> | null
}

View file

@ -0,0 +1,90 @@
import { GetAllHostsCommand, ReorderHostCommand } from '@remnawave/backend-contract';
import { instance } from '@shared/api';
import { AxiosError } from 'axios';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { IActions, IState } from './interfaces';
const initialState: IState = {
isHostsLoading: false,
hosts: null,
};
export const useDashboardStore = create<IState & IActions>()(
devtools(
(set, getState) => ({
...initialState,
actions: {
getHosts: async (): Promise<boolean> => {
try {
set({ isHostsLoading: true });
const response = await instance.get<GetAllHostsCommand.Response>(
GetAllHostsCommand.url
);
const {
data: { response: dataResponse },
} = response;
set({
hosts: dataResponse,
});
return true;
} catch (e) {
if (e instanceof AxiosError) {
throw e;
}
return false;
} finally {
set({ isHostsLoading: false });
}
},
reorderHosts: async (
hosts: ReorderHostCommand.Request['hosts']
): Promise<boolean> => {
try {
set({ isHostsLoading: true });
const response = await instance.post<ReorderHostCommand.Response>(
ReorderHostCommand.url,
{
hosts,
}
);
const {
data: { response: dataResponse },
} = response;
if (dataResponse.isUpdated) {
return true;
}
return false;
} catch (e) {
if (e instanceof AxiosError) {
throw e;
}
return false;
} finally {
set({ isHostsLoading: false });
}
},
resetState: async () => {
set({ ...initialState });
},
},
}),
{
name: 'hostsStore',
anonymousActionType: 'hostsStore',
}
)
);
export const useHostsStoreIsLoading = () => useDashboardStore((store) => store.isHostsLoading);
export const useHostsStoreHosts = () => useDashboardStore((state) => state.hosts);
export const useHostsStoreActions = () => useDashboardStore((store) => store.actions);

View file

@ -0,0 +1,2 @@
export * from './hosts-store';
export * from './interfaces';

View file

@ -0,0 +1,9 @@
import { ReorderHostCommand } from '@remnawave/backend-contract';
export interface IActions {
actions: {
getHosts: () => Promise<boolean>;
reorderHosts: (hosts: ReorderHostCommand.Request['hosts']) => Promise<boolean>;
resetState: () => Promise<void>;
};
}

View file

@ -0,0 +1,2 @@
export * from './action.interface';
export * from './state.interface';

View file

@ -0,0 +1,6 @@
import { GetAllHostsCommand } from '@remnawave/backend-contract';
export interface IState {
isHostsLoading: boolean;
hosts: GetAllHostsCommand.Response['response'] | null;
}

View file

@ -0,0 +1 @@
export * from './hosts-store';

View file

@ -0,0 +1 @@
export * from './hosts';

View file

@ -0,0 +1,9 @@
import { CreateUserCommand } from '@remnawave/backend-contract';
export interface IActions {
actions: {
createUser: (body: CreateUserCommand.Request) => Promise<boolean>;
changeModalState: (state: boolean) => void;
resetState: () => Promise<void>;
};
}

View file

@ -0,0 +1,2 @@
export * from './action.interface';
export * from './state.interface';

View file

@ -0,0 +1,4 @@
export interface IState {
isLoading: boolean;
isModalOpen: boolean;
}

View file

@ -0,0 +1,57 @@
import { CreateUserCommand } from '@remnawave/backend-contract';
import { instance } from '@shared/api';
import { AxiosError } from 'axios';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { IActions, IState } from './interfaces';
const initialState: IState = {
isLoading: false,
isModalOpen: false,
};
export const useUserCreationModalStore = create<IState & IActions>()(
devtools(
(set, getState) => ({
...initialState,
actions: {
createUser: async (body: CreateUserCommand.Request): Promise<boolean> => {
try {
set({ isLoading: true });
await instance.post<CreateUserCommand.Response>(
CreateUserCommand.url,
body
);
return true;
} catch (e) {
if (e instanceof AxiosError) {
throw e;
}
return false;
} finally {
set({ isLoading: false });
}
},
changeModalState: async (modalState: boolean) => {
set((state) => ({ isModalOpen: modalState }));
if (!modalState) {
await getState().actions.resetState();
}
},
resetState: async (): Promise<void> => {
set({ ...initialState });
},
},
}),
{
name: 'userCreationModalStore',
anonymousActionType: 'userCreationModalStore',
}
)
);
export const useUserCreationModalStoreIsModalOpen = () =>
useUserCreationModalStore((state) => state.isModalOpen);
export const useUserCreationModalStoreActions = () =>
useUserCreationModalStore((store) => store.actions);

View file

@ -8,6 +8,10 @@ export interface IActions {
actions: {
getUser: () => Promise<boolean>;
updateUser: (body: UpdateUserCommand.Request) => Promise<boolean>;
disableUser: () => Promise<boolean>;
enableUser: () => Promise<boolean>;
deleteUser: () => Promise<boolean>;
reveokeSubscription: () => Promise<boolean>;
changeModalState: (state: boolean) => void;
setUserUuid: (userUuid: string) => Promise<void>;
resetState: () => Promise<void>;

View file

@ -1,4 +1,12 @@
import { GetUserByUuidCommand, UpdateUserCommand } from '@remnawave/backend-contract';
import {
DeleteUserCommand,
DisableUserCommand,
EnableUserCommand,
GetUserByUuidCommand,
RevokeUserSubscriptionCommand,
UpdateUserCommand,
USERS_STATUS,
} from '@remnawave/backend-contract';
import { instance } from '@shared/api';
import { AxiosError } from 'axios';
import { create } from 'zustand';
@ -70,6 +78,108 @@ export const useUserModalStore = create<IState & IActions>()(
set({ isLoading: false });
}
},
disableUser: async (): Promise<boolean> => {
try {
const userUuid = getState().userUuid;
if (!userUuid) {
throw new Error('User UUID is required');
}
const response = await instance.patch<DisableUserCommand.Response>(
DisableUserCommand.url(userUuid)
);
const {
data: { response: dataResponse },
} = response;
set({ user: dataResponse });
return true;
} catch (e) {
if (e instanceof AxiosError) {
throw e;
}
return false;
}
},
enableUser: async (): Promise<boolean> => {
try {
const userUuid = getState().userUuid;
if (!userUuid) {
throw new Error('User UUID is required');
}
const response = await instance.patch<EnableUserCommand.Response>(
EnableUserCommand.url(userUuid)
);
const {
data: { response: dataResponse },
} = response;
set({ user: dataResponse });
return true;
} catch (e) {
if (e instanceof AxiosError) {
throw e;
}
return false;
}
},
deleteUser: async (): Promise<boolean> => {
try {
const userUuid = getState().userUuid;
if (!userUuid) {
throw new Error('User UUID is required');
}
await instance.delete<DeleteUserCommand.Response>(
DeleteUserCommand.url(userUuid)
);
getState().actions.resetState();
return true;
} catch (e) {
if (e instanceof AxiosError) {
throw e;
}
return false;
}
},
reveokeSubscription: async (): Promise<boolean> => {
try {
const userUuid = getState().userUuid;
if (!userUuid) {
throw new Error('User UUID is required');
}
const response =
await instance.patch<RevokeUserSubscriptionCommand.Response>(
RevokeUserSubscriptionCommand.url(userUuid)
);
const {
data: { response: dataResponse },
} = response;
set({ user: dataResponse });
return true;
} catch (e) {
if (e instanceof AxiosError) {
throw e;
}
return false;
}
},
changeModalState: (modalState: boolean) => {
set((state) => ({ isModalOpen: modalState }));
if (!modalState) {

View file

@ -1,3 +1 @@
export * from './data-usage';
export * from './status';
export * from './username';

View file

@ -1,13 +1,12 @@
import { RESET_PERIODS } from '@remnawave/backend-contract';
import prettyBytes from 'pretty-bytes';
import { LuCopy } from 'react-icons/lu';
import { Box, Button, Chip, CopyButton, Group, Indicator, Progress, Text } from '@mantine/core';
import { IProps } from '@/entitites/dashboard/users/ui/table-columns/username/interface';
import { LinkChip } from '@/shared/ui/stuff/link-chip';
import { CopyButton } from '@mantine/core'
import { LuCopy } from 'react-icons/lu'
import { IProps } from '@/entitites/dashboard/users/ui/table-columns/username/interface'
import { LinkChip } from '@/shared/ui/stuff/link-chip'
export function ShortUuidColumnEntity(props: IProps) {
const { user } = props;
const shortDisplay = user.shortUuid.slice(0, 5);
const { user } = props
const shortDisplay = user.shortUuid.slice(0, 5)
return (
<CopyButton
@ -19,5 +18,5 @@ export function ShortUuidColumnEntity(props: IProps) {
</LinkChip>
)}
</CopyButton>
);
)
}

View file

@ -31,7 +31,7 @@ export const LoginForm = () => {
return (
<form onSubmit={handleSubmit}>
<Container size={600} my={40}>
<Container size={'100%'} my={40}>
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<TextInput
name="username"

View file

@ -1,14 +1,58 @@
import { useState } from 'react';
import { PiTrashDuotone } from 'react-icons/pi';
import { ActionIcon, Tooltip } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
useUserModalStore,
useUserModalStoreUser,
} from '@/entitites/dashboard/user-modal-store/user-modal-store';
import { IProps } from './interfaces';
export function DeleteUserFeature(props: IProps) {
const { actions } = useUserModalStore();
const user = useUserModalStoreUser();
const [isLoading, setIsLoading] = useState(false);
if (!user) return null;
const handleDeleteUser = async () => {
try {
setIsLoading(true);
await actions.deleteUser();
notifications.show({
title: 'User deleted',
message: 'User has been deleted successfully',
color: 'green',
});
} catch (error) {
notifications.show({
title: 'Error',
message: 'Failed to delete user',
color: 'red',
});
console.error(error);
} finally {
setIsLoading(false);
}
};
return (
<Button
type="button"
variant="subtle"
color="red"
leftSection={<PiTrashDuotone size="1rem" />}
<Tooltip
label="Delete user"
arrowSize={2}
transitionProps={{ transition: 'scale-x', duration: 300 }}
>
Delete
</Button>
<ActionIcon
variant="outline"
size="xl"
radius="xs"
color="red"
onClick={handleDeleteUser}
loading={isLoading}
>
<PiTrashDuotone size="1.5rem" />
</ActionIcon>
</Tooltip>
);
}

View file

@ -1,15 +1,34 @@
import { PiClockCounterClockwiseDuotone } from 'react-icons/pi';
import { Button } from '@mantine/core';
import { ActionIcon, Tooltip } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IProps } from './interfaces';
export function ResetUsageUserFeature(props: IProps) {
const handleResetUsage = async () => {
notifications.show({
title: 'Reset usage',
message: 'Reset usage not yet implemented',
color: 'yellow',
});
};
// TODO: Implement reset usage
return (
<Button
type="button"
variant="subtle"
leftSection={<PiClockCounterClockwiseDuotone size="1rem" />}
<Tooltip
label="Reset usage"
arrowSize={2}
transitionProps={{ transition: 'scale-x', duration: 300 }}
>
Reset Usage
</Button>
<ActionIcon
variant="outline"
size="xl"
radius="xs"
color="blue"
onClick={handleResetUsage}
>
<PiClockCounterClockwiseDuotone size="1.5rem" />
</ActionIcon>
</Tooltip>
);
}

View file

@ -1,10 +1,57 @@
import { Button } from '@mantine/core';
import { useState } from 'react';
import { PiKeyDuotone } from 'react-icons/pi';
import { ActionIcon, Button, Tooltip } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
useUserModalStoreActions,
useUserModalStoreUser,
} from '@/entitites/dashboard/user-modal-store/user-modal-store';
import { IProps } from './interfaces';
export function RevokeSubscriptionUserFeature(props: IProps) {
const actions = useUserModalStoreActions();
const [isLoading, setIsLoading] = useState(false);
const user = useUserModalStoreUser();
if (!user) return null;
const handleRevokeSubscription = async () => {
setIsLoading(true);
try {
await actions.reveokeSubscription();
notifications.show({
title: 'Success',
message: 'Subscription revoked',
color: 'green',
});
} catch (error) {
notifications.show({
title: 'Error',
message: 'Failed to revoke subscription',
color: 'red',
});
} finally {
setIsLoading(false);
}
};
return (
<Button type="button" variant="subtle" color="red">
Revoke Subscription
</Button>
<Tooltip
label="Revoke subscription"
arrowSize={2}
transitionProps={{ transition: 'scale-x', duration: 300 }}
>
<ActionIcon
variant="outline"
size="xl"
radius="xs"
color="green"
loading={isLoading}
onClick={handleRevokeSubscription}
>
<PiKeyDuotone size="1.5rem" />
</ActionIcon>
</Tooltip>
);
}

View file

@ -0,0 +1 @@
export * from './toggle-user-status-button.feature';

View file

@ -0,0 +1 @@
export * from './props.interface';

View file

@ -0,0 +1,3 @@
import { GetInboundsCommand } from '@remnawave/backend-contract';
export interface IProps {}

View file

@ -0,0 +1,72 @@
import { useState } from 'react';
import { USERS_STATUS } from '@remnawave/backend-contract';
import { PiCellSignalFullDuotone, PiCellSignalSlashDuotone, PiTrashDuotone } from 'react-icons/pi';
import { Button } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
useUserModalStoreActions,
useUserModalStoreUser,
} from '@/entitites/dashboard/user-modal-store/user-modal-store';
import { IProps } from './interfaces';
export function ToggleUserStatusButtonFeature(props: IProps) {
const [isLoading, setIsLoading] = useState(false);
const user = useUserModalStoreUser();
const actions = useUserModalStoreActions();
if (!user) return null;
let buttonLabel = '';
let color = 'blue';
let icon = <PiTrashDuotone size="1rem" />;
if (user.status === USERS_STATUS.DISABLED) {
color = 'green';
buttonLabel = 'Enable';
icon = <PiCellSignalFullDuotone size="1rem" />;
} else {
color = 'red';
buttonLabel = 'Disable';
icon = <PiCellSignalSlashDuotone size="1rem" />;
}
const handleToggleUserStatus = async () => {
setIsLoading(true);
try {
if (user.status !== USERS_STATUS.DISABLED) {
await actions.disableUser();
} else {
await actions.enableUser();
}
notifications.show({
title: 'Success',
message: 'User status updated',
color: 'green',
});
} catch (error) {
console.error(error);
notifications.show({
title: 'Error',
message: 'Failed to toggle user status',
color: 'red',
});
} finally {
setIsLoading(false);
}
};
return (
<Button
type="button"
size="md"
variant="outline"
color={color}
leftSection={icon}
onClick={handleToggleUserStatus}
loading={isLoading}
>
{buttonLabel} user
</Button>
);
}

View file

@ -1,4 +1,5 @@
import ReactDOM from 'react-dom/client';
import { App } from './app';
import ReactDOM from 'react-dom/client'
import { App } from './app'
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
const root = ReactDOM.createRoot(document.getElementById('root')!)
root.render(<App />)

View file

@ -25,7 +25,7 @@ export const LoginPage = () => {
</Title>
</Group>
<Box w={500} maw={800}>
<Box w={{ base: 440, sm: 500, md: 500 }} maw={800}>
<LoginForm />
</Box>
</Stack>

View file

@ -1,117 +1,119 @@
import { Page } from '@shared/ui/page';
import prettyBytes from 'pretty-bytes';
import { BsFillClipboard2DataFill } from 'react-icons/bs';
import {
FaBan,
FaCheckCircle,
FaExclamationCircle,
FaRegDotCircle,
FaTimesCircle,
FaUsers,
} from 'react-icons/fa';
import { LuPieChart } from 'react-icons/lu';
import { SimpleGrid, Stack, Text } from '@mantine/core'
import { Page } from '@shared/ui/page'
import {
PiChartBarDuotone,
PiChartPieSliceDuotone,
PiClockCountdownDuotone,
PiClockUserDuotone,
PiDevicesDuotone,
PiMemoryDuotone,
PiProhibitDuotone,
PiPulseDuotone,
PiTimerDuotone,
PiUsersDuotone,
} from 'react-icons/pi';
import { SimpleGrid, Stack, Text } from '@mantine/core';
import { LoadingScreen, PageHeader } from '@/shared/ui';
import { formatInt } from '@/shared/utils';
import { MetricWithIcon } from '@/widgets/dashboard/home/metric-with-icons';
import { IProps } from './interfaces';
PiUsersDuotone
} from 'react-icons/pi'
import { LoadingScreen, PageHeader } from '@/shared/ui'
import { formatInt } from '@/shared/utils'
import { prettyBytesUtil } from '@/shared/utils/bytes'
import { MetricWithIcon } from '@/widgets/dashboard/home/metric-with-icons'
import { IProps } from './interfaces'
export const HomePage = (props: IProps) => {
const { systemInfo, breadcrumbs } = props;
const { systemInfo, breadcrumbs } = props
if (!systemInfo) {
return <LoadingScreen />;
return <LoadingScreen />
}
const { users, memory } = systemInfo;
const { users, memory } = systemInfo
console.log(prettyBytesUtil(memory.total))
const totalRamGB = prettyBytesUtil(memory.total) ?? 0
const availableRamGB = prettyBytesUtil(memory.available) ?? 0
const simpleMetrics = [
{
icon: PiDevicesDuotone,
title: 'Online users',
value: formatInt(users.onlineLastMinute ?? 0),
color: 'teal',
value: formatInt(users.onlineLastMinute) ?? 0,
color: 'teal'
},
{
icon: PiUsersDuotone,
title: 'Total users',
value: formatInt(users.totalUsers ?? 0),
color: 'blue',
value: formatInt(users.totalUsers) ?? 0,
color: 'blue'
},
{
icon: PiChartBarDuotone,
title: 'Total traffic',
value: prettyBytes(Number(users.totalTrafficBytes) ?? 0),
color: 'green',
value: prettyBytesUtil(Number(users.totalTrafficBytes)) ?? 0,
color: 'green'
},
{
icon: PiMemoryDuotone,
title: 'Available RAM',
value:
prettyBytes(Number(memory.available) ?? 0) +
' / ' +
prettyBytes(Number(memory.total) ?? 0),
color: 'cyan',
},
];
value: `${availableRamGB} / ${totalRamGB}`,
color: 'cyan'
}
]
const usersMetrics = [
{
icon: PiPulseDuotone,
title: 'Active users',
value: users.statusCounts.ACTIVE,
color: 'teal',
color: 'teal'
},
{
icon: PiClockUserDuotone,
title: 'Expired users',
value: users.statusCounts.EXPIRED,
color: 'red',
color: 'red'
},
{
icon: PiClockCountdownDuotone,
title: 'Limited users',
value: users.statusCounts.LIMITED,
color: 'orange',
color: 'orange'
},
{
icon: PiProhibitDuotone,
title: 'Disabled users',
value: users.statusCounts.DISABLED,
color: 'gray',
},
];
color: 'gray'
}
]
return (
<Page title="Home">
<PageHeader title="Short stats" breadcrumbs={breadcrumbs} />
<PageHeader
title="Short stats"
breadcrumbs={breadcrumbs}
/>
<Stack gap="sm" mb="xl">
<Stack
gap="sm"
mb="xl"
>
<SimpleGrid cols={{ base: 1, sm: 2, xl: 4 }}>
{simpleMetrics.map((metric) => (
<MetricWithIcon key={metric.title} {...metric} />
<MetricWithIcon
key={metric.title}
{...metric}
/>
))}
</SimpleGrid>
<Text>Users</Text>
<SimpleGrid cols={{ base: 1, sm: 2, xl: 4 }}>
{usersMetrics.map((metric) => (
<MetricWithIcon key={metric.title} {...metric} />
<MetricWithIcon
key={metric.title}
{...metric}
/>
))}
</SimpleGrid>
</Stack>
</Page>
);
};
)
}

View file

@ -0,0 +1,7 @@
import { ROUTES } from '@/shared/constants';
import { IBreadcrumb } from '@/shared/interfaces';
export const BREADCRUMBS: IBreadcrumb[] = [
{ label: 'Dashboard', href: ROUTES.DASHBOARD.HOME },
{ label: 'Users', href: ROUTES.DASHBOARD.USERS },
];

View file

@ -0,0 +1 @@
export * from './constants';

View file

@ -0,0 +1,47 @@
import { useEffect } from 'react'
import { Grid } from '@mantine/core'
import { useHostsStoreActions, useHostsStoreHosts } from '@entitites/dashboard'
import {
useDashboardStoreActions,
useDSInbounds
} from '@entitites/dashboard/dashboard-store/dashboard-store'
import { Page, PageHeader } from '@/shared/ui'
import { HostsTableWidget } from '@/widgets/dashboard/hosts/hosts-table'
import { BREADCRUMBS } from './constants'
import { IProps } from './interfaces'
export default function HostsPageComponent(props: IProps) {
const hosts = useHostsStoreHosts()
const actions = useHostsStoreActions()
const dsActions = useDashboardStoreActions()
const inbounds = useDSInbounds()
useEffect(() => {
;(async () => {
await actions.getHosts()
await dsActions.getInbounds()
})()
}, [])
if (!hosts || !inbounds) {
return null
}
return (
<Page title="Hosts">
<PageHeader
title="Hosts"
breadcrumbs={BREADCRUMBS}
/>
<Grid>
<Grid.Col span={12}>
<HostsTableWidget
hosts={hosts}
inbounds={inbounds}
/>
</Grid.Col>
</Grid>
</Page>
)
}

View file

@ -0,0 +1 @@
export * from './props.interface';

View file

@ -0,0 +1,7 @@
import { Dispatch, SetStateAction } from 'react';
import { GetAllUsersCommand } from '@remnawave/backend-contract';
import { DataTableColumn, DataTableSortStatus } from 'mantine-datatable';
import { User } from '@/entitites/dashboard/users/models';
import { DataTableReturn } from '@/pages/dashboard/users/ui/connectors/interfaces';
export interface IProps {}

View file

@ -0,0 +1,40 @@
import { useEffect, useMemo, useState } from 'react';
import { GetAllUsersCommand } from '@remnawave/backend-contract';
import { DataTableColumn } from 'mantine-datatable';
import { MultiSelect, TextInput } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import {
useDashboardStoreActions,
useDashboardStoreIsLoading,
useDashboardStoreParams,
useDashboardStoreSystemInfo,
useDashboardStoreTotalUsers,
useDashboardStoreUsers,
} from '@/entitites/dashboard/dashboard-store/dashboard-store';
import {
useHostsStoreHosts,
useHostsStoreIsLoading,
} from '@/entitites/dashboard/hosts/hosts-store/hosts-store';
import {
useUserCreationModalStoreActions,
useUserCreationModalStoreIsModalOpen,
} from '@/entitites/dashboard/user-creation-modal-store/user-creation-modal-store';
import {
useUserModalStoreActions,
useUserModalStoreIsModalOpen,
} from '@/entitites/dashboard/user-modal-store/user-modal-store';
import { getTabDataUsers, User } from '@/entitites/dashboard/users/models';
import { DataUsageColumnEntity } from '@/entitites/dashboard/users/ui';
import { ShortUuidColumnEntity } from '@/entitites/dashboard/users/ui/table-columns/short-uuid';
import { StatusColumnEntity } from '@/entitites/dashboard/users/ui/table-columns/status/status.column';
import { UsernameColumnEntity } from '@/entitites/dashboard/users/ui/table-columns/username/username.column';
import HostsPageComponent from '@/pages/dashboard/hosts/ui/components/hosts.page';
import UsersPageComponent from '@/pages/dashboard/users/ui/components/users.page';
import { DataTable } from '@/shared/ui/stuff/data-table';
export function HostsPageConnector() {
const hosts = useHostsStoreHosts();
const isHostsLoading = useHostsStoreIsLoading();
return <HostsPageComponent />;
}

View file

@ -0,0 +1,25 @@
import { DataTableSortStatus } from 'mantine-datatable';
import { DataTableFilter } from '@/shared/ui/stuff/data-table/data-table-filters';
import { DataTableTabsProps } from '@/shared/ui/stuff/data-table/data-table-tabs';
export type DataTableReturn<SortableFields> = {
readonly tabs: {
readonly value: string | undefined;
readonly change: (value: string) => void;
readonly tabs: DataTableTabsProps['tabs'];
};
readonly filters: {
readonly filters: Record<string, DataTableFilter>;
readonly clear: () => void;
readonly change: (filter: Omit<DataTableFilter, 'onRemove'>) => void;
readonly remove: (name: string) => void;
readonly query: Record<string, unknown>;
};
readonly sort: {
readonly change: (status: DataTableSortStatus<SortableFields>) => void;
readonly column: keyof SortableFields;
readonly direction: 'asc' | 'desc';
readonly status: DataTableSortStatus<SortableFields>;
readonly query: `${string}:${'asc' | 'desc'}`;
};
};

View file

@ -0,0 +1 @@
export * from './data-table-return.type';

View file

@ -1,19 +1,12 @@
import { Dispatch, SetStateAction } from 'react';
import { GetAllUsersCommand } from '@remnawave/backend-contract';
import { DataTableColumn, DataTableSortStatus } from 'mantine-datatable';
import { User } from '@/entitites/dashboard/users/models';
import { DataTableReturn } from '@/pages/dashboard/users/ui/connectors/interfaces';
import { User } from '@entitites/dashboard/users/models'
import { DataTableReturn } from '@pages/dashboard/users/ui/connectors/interfaces'
import { DataTableColumn } from 'mantine-datatable'
export interface IProps {
users: User[];
tabs: DataTableReturn<User>;
setSearch: Dispatch<SetStateAction<string>>;
search: string;
setSearchBy: Dispatch<SetStateAction<GetAllUsersCommand.SearchableField>>;
searchBy: string;
columns: DataTableColumn<User>[];
handleSortStatusChange: (status: { columnAccessor: string; direction: 'asc' | 'desc' }) => void;
handlePageChange: (page: number) => void;
handleRecordsPerPageChange: (recordsPerPage: number) => void;
handleUpdate: () => void;
tabs: DataTableReturn<User>
columns: DataTableColumn<User>[]
handleSortStatusChange: (status: { columnAccessor: string; direction: 'asc' | 'desc' }) => void
handlePageChange: (page: number) => void
handleRecordsPerPageChange: (recordsPerPage: number) => void
handleUpdate: () => void
}

View file

@ -1,26 +1,22 @@
import { Grid } from '@mantine/core';
import { BREADCRUMBS } from '@/pages/dashboard/users/ui/components/constants';
import { Page } from '@/shared/ui/page';
import { PageHeader } from '@/shared/ui/page-header';
import { UsersMetrics } from '@/widgets/dashboard/users/users-metrics';
import { UserTableWidget } from '@/widgets/dashboard/users/users-table';
import { ViewUserModal } from '@/widgets/dashboard/users/view-user-modal';
import { IProps } from './interfaces';
import { Grid } from '@mantine/core'
import { BREADCRUMBS } from '@/pages/dashboard/users/ui/components/constants'
import { Page } from '@/shared/ui/page'
import { PageHeader } from '@/shared/ui/page-header'
import { CreateUserModalWidget } from '@/widgets/dashboard/users/create-user-modal'
import { UsersMetrics } from '@/widgets/dashboard/users/users-metrics'
import { UserTableWidget } from '@/widgets/dashboard/users/users-table'
import { ViewUserModal } from '@/widgets/dashboard/users/view-user-modal'
import { IProps } from './interfaces'
export default function UsersPageComponent(props: IProps) {
const {
users,
tabs,
search,
setSearch,
searchBy,
setSearchBy,
columns,
handleSortStatusChange,
handlePageChange,
handleRecordsPerPageChange,
handleUpdate,
} = props;
handleUpdate
} = props
return (
<Page title="Users">
@ -34,10 +30,6 @@ export default function UsersPageComponent(props: IProps) {
<Grid.Col span={12}>
<UserTableWidget
tabs={tabs}
search={search}
setSearch={setSearch}
searchBy={searchBy}
setSearchBy={setSearchBy}
columns={columns}
handleSortStatusChange={handleSortStatusChange}
handlePageChange={handlePageChange}
@ -48,6 +40,7 @@ export default function UsersPageComponent(props: IProps) {
</Grid>
<ViewUserModal />
<CreateUserModalWidget />
</Page>
);
)
}

View file

@ -1,113 +1,96 @@
import { useEffect, useMemo, useState } from 'react';
import { GetAllUsersCommand } from '@remnawave/backend-contract';
import { DataTableColumn } from 'mantine-datatable';
import prettyBytes from 'pretty-bytes';
import { Box, Group, Indicator, MultiSelect, Select, Text, TextInput } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { useEffect, useMemo, useState } from 'react'
import { MultiSelect, TextInput } from '@mantine/core'
import { useDebouncedValue } from '@mantine/hooks'
import {
useDashboardStoreActions,
useDashboardStoreIsLoading,
useDashboardStoreParams,
useDashboardStoreSystemInfo,
useDashboardStoreTotalUsers,
useDashboardStoreUsers,
} from '@/entitites/dashboard/dashboard-store/dashboard-store';
useDashboardStoreSystemInfo
} from '@entitites/dashboard/dashboard-store/dashboard-store'
import { useUserCreationModalStoreIsModalOpen } from '@entitites/dashboard/user-creation-modal-store/user-creation-modal-store'
import {
useUserModalStoreActions,
useUserModalStoreIsModalOpen,
} from '@/entitites/dashboard/user-modal-store/user-modal-store';
import { getTabDataUsers, User } from '@/entitites/dashboard/users/models';
import { DataUsageColumnEntity } from '@/entitites/dashboard/users/ui';
import { ShortUuidColumnEntity } from '@/entitites/dashboard/users/ui/table-columns/short-uuid';
import { StatusColumnEntity } from '@/entitites/dashboard/users/ui/table-columns/status/status.column';
import { UsernameColumnEntity } from '@/entitites/dashboard/users/ui/table-columns/username/username.column';
import UsersPage from '@/pages/dashboard/users/ui/components/users.page';
import UsersPageComponent from '@/pages/dashboard/users/ui/components/users.page';
import { AddButton } from '@/shared/ui/stuff/add-button';
import { DataTable } from '@/shared/ui/stuff/data-table';
useUserModalStoreIsModalOpen
} from '@entitites/dashboard/user-modal-store/user-modal-store'
import { getTabDataUsers, User } from '@entitites/dashboard/users/models'
import { DataUsageColumnEntity } from '@entitites/dashboard/users/ui'
import { ShortUuidColumnEntity } from '@entitites/dashboard/users/ui/table-columns/short-uuid'
import { StatusColumnEntity } from '@entitites/dashboard/users/ui/table-columns/status'
import { UsernameColumnEntity } from '@entitites/dashboard/users/ui/table-columns/username'
import { GetAllUsersCommand } from '@remnawave/backend-contract'
import { DataTable } from '@shared/ui/stuff/data-table'
import { DataTableColumn } from 'mantine-datatable'
import UsersPageComponent from '../components/users.page'
export function UsersPageConnector() {
const users = useDashboardStoreUsers();
const isLoading = useDashboardStoreIsLoading();
const totalUsers = useDashboardStoreTotalUsers();
const params = useDashboardStoreParams();
const actions = useDashboardStoreActions();
const systemInfo = useDashboardStoreSystemInfo();
const params = useDashboardStoreParams()
const actions = useDashboardStoreActions()
const systemInfo = useDashboardStoreSystemInfo()
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 300);
const [searchBy, setSearchBy] = useState<GetAllUsersCommand.SearchableField>('username');
const [search, setSearch] = useState('')
const isModalOpen = useUserModalStoreIsModalOpen()
const userModalActions = useUserModalStoreActions()
// create user modal
const isCreateUserModalOpen = useUserCreationModalStoreIsModalOpen()
const dataTab = getTabDataUsers({
totalUsers: systemInfo?.users.totalUsers,
activeUsers: systemInfo?.users.statusCounts.ACTIVE,
disabledUsers: systemInfo?.users.statusCounts.DISABLED,
expiredUsers: systemInfo?.users.statusCounts.EXPIRED,
limitedUsers: systemInfo?.users.statusCounts.LIMITED,
});
limitedUsers: systemInfo?.users.statusCounts.LIMITED
})
const [debouncedFilters] = useDebouncedValue(dataTab.filters.filters, 300);
const [debouncedFilters] = useDebouncedValue(dataTab.filters.filters, 300)
useEffect(() => {
actions.getSystemInfo();
if (isModalOpen || isCreateUserModalOpen) return
actions.getSystemInfo()
const filterEntry = Object.entries(debouncedFilters).find(([_, filter]) => filter?.value);
const filterEntry = Object.entries(debouncedFilters).find(([_, filter]) => filter?.value)
const searchParams = filterEntry
? {
search: String(filterEntry[1].value),
searchBy: filterEntry[0] as GetAllUsersCommand.SearchableField,
searchBy: filterEntry[0] as GetAllUsersCommand.SearchableField
}
: {
search: dataTab.tabs.value === '*' ? '' : dataTab.tabs.value,
searchBy:
dataTab.tabs.value === '*' ? ('username' as const) : ('status' as const),
};
searchBy: dataTab.tabs.value === '*' ? ('username' as const) : ('status' as const)
}
actions.getUsers(searchParams);
}, [dataTab.tabs.value, debouncedFilters]);
// useEffect(() => {
// actions.getSystemInfo();
// actions.getUsers({
// search: dataTab.tabs.value === '*' ? debouncedSearch : dataTab.tabs.value,
// searchBy: dataTab.tabs.value === '*' ? searchBy : 'status',
// });
// }, [debouncedSearch, searchBy, dataTab.tabs.value]);
actions.getUsers(searchParams)
}, [dataTab.tabs.value, debouncedFilters, isModalOpen, isCreateUserModalOpen])
const handlePageChange = (page: number) => {
const offset = (page - 1) * params.limit;
actions.getUsers({ offset });
};
const offset = (page - 1) * params.limit
actions.getUsers({ offset })
}
const handleRecordsPerPageChange = (limit: number) => {
actions.getUsers({ limit, offset: 0 });
};
actions.getUsers({ limit, offset: 0 })
}
const handleSortStatusChange = (status: {
columnAccessor: string;
direction: 'asc' | 'desc';
columnAccessor: string
direction: 'asc' | 'desc'
}) => {
actions.getUsers({
orderBy: status.columnAccessor as GetAllUsersCommand.SortableField,
orderDir: status.direction,
});
};
orderDir: status.direction
})
}
const handleUpdate = () => {
actions.getUsers({ offset: 0 });
};
// User Modal
const isModalOpen = useUserModalStoreIsModalOpen();
const userModalActions = useUserModalStoreActions();
actions.getUsers({ offset: 0 })
}
const handleOpenModal = async (userUuid: string) => {
await userModalActions.setUserUuid(userUuid);
console.log('userUuid', userUuid);
userModalActions.changeModalState(true);
// !TODO: Ваня помоги
};
await userModalActions.setUserUuid(userUuid)
userModalActions.changeModalState(true)
}
const columns = useMemo<DataTableColumn<User>[]>(
() => [
@ -125,12 +108,12 @@ export function UsersPageConnector() {
dataTab.filters.change({
name: 'shortUuid',
label: 'Sub-link',
value: e.currentTarget.value,
value: e.currentTarget.value
})
}
/>
),
render: (user) => <ShortUuidColumnEntity user={user} />,
render: (user) => <ShortUuidColumnEntity user={user} />
},
{
accessor: 'username' as const,
@ -146,12 +129,12 @@ export function UsersPageConnector() {
dataTab.filters.change({
name: 'username',
label: 'Username',
value: e.currentTarget.value,
value: e.currentTarget.value
})
}
/>
),
render: (user) => <UsernameColumnEntity user={user} />,
render: (user) => <UsernameColumnEntity user={user} />
},
{
accessor: 'expireAt' as const,
@ -167,44 +150,39 @@ export function UsersPageConnector() {
dataTab.filters.change({
name: 'status',
label: 'Status',
value,
value
})
}
/>
),
render: (user) => <StatusColumnEntity user={user} />,
render: (user) => <StatusColumnEntity user={user} />
},
{
accessor: 'usedTrafficBytes' as const,
title: 'Data usage',
width: 150,
sortable: true,
render: (user) => <DataUsageColumnEntity user={user} />,
render: (user) => <DataUsageColumnEntity user={user} />
},
{
accessor: 'actions',
title: 'Actions',
textAlign: 'right',
width: 100,
render: (user) => <DataTable.Actions onView={() => handleOpenModal(user.uuid)} />,
},
render: (user) => <DataTable.Actions onView={() => handleOpenModal(user.uuid)} />
}
],
[]
);
)
return (
<UsersPageComponent
tabs={dataTab}
users={users || []}
setSearch={setSearch}
setSearchBy={setSearchBy}
search={search}
searchBy={searchBy}
columns={columns}
handleSortStatusChange={handleSortStatusChange}
handlePageChange={handlePageChange}
handleRecordsPerPageChange={handleRecordsPerPageChange}
handleUpdate={handleUpdate}
/>
);
)
}

View file

@ -1,7 +1,9 @@
import { RESET_PERIODS } from '@remnawave/backend-contract';
export const resetDataStrategy = [
{ value: 'NO_RESET', label: 'Never reset' },
{ value: 'DAILY', label: 'Reset daily' },
{ value: 'WEEKLY', label: 'Reset weekly' },
{ value: 'MONTHLY', label: 'Reset monthly' },
{ value: 'YEARLY', label: 'Reset yearly' },
{ value: RESET_PERIODS.NO_RESET, label: 'Never reset' },
{ value: RESET_PERIODS.DAY, label: 'Reset daily' },
{ value: RESET_PERIODS.WEEK, label: 'Reset weekly' },
{ value: RESET_PERIODS.MONTH, label: 'Reset monthly' },
{ value: RESET_PERIODS.YEAR, label: 'Reset yearly' },
];

View file

@ -7,5 +7,6 @@ export const ROUTES = {
ROOT: '/dashboard',
HOME: '/dashboard/home',
USERS: '/dashboard/users',
HOSTS: '/dashboard/hosts',
},
} as const;

View file

@ -1,11 +1,9 @@
import { ReactNode } from 'react';
import { ROUTES } from '@shared/constants/routes';
import { useAuth } from '@shared/hooks';
import { LoadingScreen } from '@shared/ui/loading-screen';
import { Navigate, Outlet, useLocation } from 'react-router-dom';
export function AuthGuard() {
console.log('123131');
const location = useLocation();
const { isAuthenticated, isInitialized } = useAuth();

View file

@ -1,8 +0,0 @@
import { GetAllUsersCommand } from '@remnawave/backend-contract';
import { createGetQueryHook } from '@shared/api/axios-proxy';
export const useGetSystemInfo = createGetQueryHook({
endpoint: GetAllUsersCommand.url,
responseSchema: GetAllUsersCommand.ResponseSchema,
rQueryParams: { queryKey: ['system-info'] },
});

View file

@ -1 +0,0 @@
export * from './get-system-info.query';

View file

@ -32,6 +32,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
await actions.getSystemInfo();
setIsAuthenticated(true);
} catch (error) {
setIsAuthenticated(false);
} finally {
setIsInitialized(true);
}

View file

@ -1,2 +1 @@
export * from './auth-provider';
export * from './mdx-provider';
export * from './auth-provider'

View file

@ -1,14 +0,0 @@
import { ComponentProps } from 'react';
import { MDXProvider as MDXDefaultProvider } from '@mdx-js/react';
type MDXDefaultProviderProps = ComponentProps<typeof MDXDefaultProvider>;
type MDXProviderProps = Omit<MDXDefaultProviderProps, 'components'>;
const components: MDXDefaultProviderProps['components'] = {
em: (props) => <em style={{ color: 'red' }} {...props} />,
};
export function MDXProvider(props: MDXProviderProps) {
return <MDXDefaultProvider components={components} {...props} />;
}

View file

@ -1,5 +1,4 @@
import { forwardRef, ReactNode, useEffect } from 'react';
import { Helmet } from 'react-helmet-async';
import { Box, BoxProps } from '@mantine/core';
import { nprogress } from '@mantine/nprogress';
import { app } from '@/config';
@ -18,10 +17,8 @@ export const Page = forwardRef<HTMLDivElement, PageProps>(
return (
<>
<Helmet>
<title>{`${title} | ${app.name}`}</title>
{meta}
</Helmet>
<title>{`${title} | ${app.name}`}</title>
{meta}
<Box ref={ref} {...other}>
{children}

View file

@ -1,7 +1,7 @@
import { Card, CardProps } from '@mantine/core';
import { Card, CardProps } from '@mantine/core'
type DataTableContainerProps = CardProps;
type DataTableContainerProps = CardProps
export function DataTableContainer({ children, ...props }: DataTableContainerProps) {
return <Card {...props}>{children}</Card>;
return <Card {...props}>{children}</Card>
}

View file

@ -1,26 +0,0 @@
import { Badge, BadgeProps } from '@mantine/core';
import { Customer } from '@/api/entities/customers';
import { match } from '@/utilities/match';
interface CustomerStatusBadgeProps extends Omit<BadgeProps, 'children' | 'color'> {
status: Customer['status'];
}
export function CustomerStatusBadge({
status,
variant = 'outline',
...props
}: CustomerStatusBadgeProps) {
const color = match(
[status === 'active', 'teal'],
[status === 'banned', 'orange'],
[status === 'archived', 'red'],
[true, 'gray']
);
return (
<Badge color={color} variant={variant} {...props}>
{status}
</Badge>
);
}

View file

@ -1 +0,0 @@
export * from './customer-status-badge';

View file

@ -1,69 +0,0 @@
import { ReactNode } from 'react';
import { PiCommand as CommandIcon, PiMagnifyingGlassBold as SearchIcon } from 'react-icons/pi';
import {
Button,
ElementProps,
TextInput,
UnstyledButton,
UnstyledButtonProps,
} from '@mantine/core';
import { spotlight } from '@mantine/spotlight';
import classes from './spotlight-search-bar-button.module.css';
interface SpotlightSearchBarButtonProps
extends Omit<UnstyledButtonProps, 'children'>,
ElementProps<'div', keyof UnstyledButtonProps> {
placeholder?: string;
spotlight: ReactNode;
}
export function SpotlightSearchBarButton({
placeholder,
spotlight: spotlightComponent,
...props
}: SpotlightSearchBarButtonProps) {
return (
<>
<UnstyledButton
component="div"
className={classes.input}
onClick={spotlight.open}
{...props}
>
<TextInput
placeholder={placeholder}
leftSection={<SearchIcon />}
rightSection={
<Button
component="span"
size="compact-xs"
leftSection={<CommandIcon size="1rem" />}
>
K
</Button>
}
/>
</UnstyledButton>
<Button
c="inherit"
variant="transparent"
className={classes.button}
onClick={spotlight.open}
leftSection={<SearchIcon size="1.2rem" />}
rightSection={
<Button
component="span"
variant="filled"
size="compact-md"
leftSection={<CommandIcon size="1rem" />}
>
K
</Button>
}
/>
{spotlightComponent}
</>
);
}

View file

@ -1,30 +0,0 @@
.input {
display: none;
@media (min-width: $mantine-breakpoint-xl) {
display: inline-block;
}
& input {
padding-inline-end: 4rem;
pointer-events: none;
}
& [data-position='right'] {
pointer-events: none;
--section-size: 4rem;
}
}
.button {
display: inline-block;
@media (min-width: $mantine-breakpoint-xl) {
display: none;
}
& [data-position] {
pointer-events: none;
margin-inline-end: 0;
}
}

View file

@ -1,18 +1,19 @@
import { fromBytes } from '@tsmx/human-readable';
import prettyBytes from 'pretty-bytes';
import xbytes from 'xbytes';
import xbytes from 'xbytes'
export function bytesToGbUtil(bytesInput: number | undefined | string): number | undefined {
if (!bytesInput) return undefined;
if (typeof bytesInput === 'undefined') return undefined
if (typeof bytesInput === 'string') {
bytesInput = Number(bytesInput);
bytesInput = Number(bytesInput)
}
const res = xbytes.parseBytes(bytesInput, {
sticky: true,
prefixIndex: 3,
fixed: 0,
iec: true,
space: false,
});
space: false
})
return Number(res.size.replace('GiB', ''));
return Number(res.size.replace('GiB', ''))
}

View file

@ -1,7 +1,7 @@
import xbytes from 'xbytes';
export function gbToBytesUtil(gbInput: number | undefined): number | undefined {
if (!gbInput) return undefined;
if (typeof gbInput === 'undefined') return undefined;
if (typeof gbInput === 'string') {
gbInput = Number(gbInput);
}

View file

@ -1,18 +1,17 @@
import xbytes from 'xbytes';
import xbytes from 'xbytes'
export function prettyBytesUtil(
bytesInput: number | undefined | string,
returnZero: boolean = false
): string | undefined {
if (!bytesInput) {
return returnZero ? '0' : undefined;
return returnZero ? '0' : undefined
}
if (typeof bytesInput === 'string') {
bytesInput = Number(bytesInput);
bytesInput = Number(bytesInput)
}
const res = xbytes.parseBytes(bytesInput, { sticky: true, prefixIndex: 3, iec: true });
console.log(res);
const res = xbytes.parseBytes(bytesInput, { sticky: true, prefixIndex: 3, iec: true })
return String(res.size);
return String(res.size)
}

View file

@ -1,12 +1,10 @@
export * from './boolean';
export * from './date';
export * from './factory';
export * from './form';
export * from './is';
export * from './match';
export * from './money';
export * from './number';
export * from './phone-number';
export * from './pipe';
export * from './text';
export * from './uid';
export * from './boolean'
export * from './date'
export * from './factory'
export * from './form'
export * from './is'
export * from './match'
export * from './number'
export * from './pipe'
export * from './text'
export * from './uid'

View file

@ -1,110 +0,0 @@
import { USD } from '@dinero.js/currencies';
import {
add,
dinero,
equal,
isNegative,
isPositive,
isZero,
multiply,
subtract,
toDecimal,
toSnapshot,
transformScale,
up,
type Dinero,
} from 'dinero.js';
import { formatCurrency } from './number';
const CURRENCY = USD;
export class Money {
private readonly dinero: Dinero<number>;
amountInCents: number;
amount: number;
constructor(amountInCents: number | string) {
this.dinero = dinero({ amount: Number(amountInCents), currency: CURRENCY });
this.amount = Number(toDecimal(this.dinero));
this.amountInCents = Number(amountInCents);
}
add(m: Money) {
const { amount } = toSnapshot(add(this.dinero, m.dinero));
return new Money(amount);
}
subtract(m: Money) {
const { amount } = toSnapshot(subtract(this.dinero, m.dinero));
return new Money(amount);
}
multiply(multiplier: number, scale = 0) {
const { amount } = toSnapshot(
transformScale(
multiply(this.dinero, { amount: multiplier, scale }),
CURRENCY.exponent,
up
)
);
return new Money(amount);
}
isGreaterThan(m: Money) {
return this.amountInCents > m.amountInCents;
}
isGreaterThanOrEqual(m: Money) {
return this.amountInCents >= m.amountInCents;
}
isLessThan(m: Money) {
return this.amountInCents < m.amountInCents;
}
isLessThanOrEqual(m: Money) {
return this.amountInCents <= m.amountInCents;
}
percentage(percentage: number) {
if (percentage < 0 || percentage > 100) {
throw new Error('Percentage must be between 0 and 100');
}
const normalize = Math.ceil(percentage * 100);
return this.multiply(normalize, 4);
}
isZero() {
return isZero(this.dinero);
}
isNegative() {
return isNegative(this.dinero);
}
isPositive() {
return isPositive(this.dinero);
}
equal(m: Money) {
return equal(this.dinero, m.dinero);
}
format() {
return toDecimal(this.dinero, ({ value, currency }) =>
formatCurrency(value, ` ${currency.code}`)
);
}
}
interface Options {
minorUnit?: boolean;
}
export function money(amount: number | string, options: Options = { minorUnit: true }): Money {
const arg = options.minorUnit ? amount : Number(amount) * 100;
return new Money(arg);
}

View file

@ -1,13 +0,0 @@
import parsePhoneNumber, { isValidPhoneNumber as baseIsValidPhoneNumber } from 'libphonenumber-js';
import { z } from 'zod';
export const isValidPhoneNumber = (value: string) => baseIsValidPhoneNumber(value);
export const phoneNumberSchema = z
.string()
.trim()
.refine(isValidPhoneNumber, 'Invalid phone number');
export function formatPhoneNumber(phoneNumber: string) {
return parsePhoneNumber(phoneNumber)?.formatInternational() ?? phoneNumber;
}

View file

@ -0,0 +1,23 @@
.item {
display: flex;
align-items: center;
border-radius: var(--mantine-radius-md);
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
padding: var(--mantine-spacing-sm) var(--mantine-spacing-xl);
padding-left: calc(var(--mantine-spacing-xl) - var(--mantine-spacing-md));
margin-bottom: var(--mantine-spacing-sm);
}
.itemDragging {
border: 1px solid light-dark(var(--mantine-color-blue-5), var(--mantine-color-teal-5));
box-shadow: var(--mantine-shadow-xl);
}
.dragHandle {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--mantine-color-dark-1);
padding-right: var(--mantine-spacing-md);
}

View file

@ -0,0 +1,82 @@
import { Badge, Group, Text } from '@mantine/core'
import { useDSInboundsHashMap } from '@entitites/dashboard/dashboard-store/dashboard-store'
import { Draggable } from '@hello-pangea/dnd'
import cx from 'clsx'
import ColorHash from 'color-hash'
import { PiDotsSixVertical } from 'react-icons/pi'
import { IProps } from './interfaces'
import classes from './HostCard.module.css'
export function HostCardWidget(props: IProps) {
const { item, index } = props
const inbounds = useDSInboundsHashMap()
if (!inbounds) {
return null
}
const inbound = inbounds.get(item.inboundUuid)
if (!inbound) {
return null
}
const ch = new ColorHash()
return (
<Draggable
key={item.uuid}
index={index}
draggableId={item.uuid}
>
{(provided, snapshot) => (
<div
className={cx(classes.item, {
[classes.itemDragging]: snapshot.isDragging
})}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div
{...provided.dragHandleProps}
className={classes.dragHandle}
>
<PiDotsSixVertical
size="2rem"
color="white"
/>
</div>
<div>
<Group>
<Badge
miw={'15ch'}
size="lg"
autoContrast
variant="light"
radius="md"
color={ch.hex(item.inboundUuid)}
>
{inbound.tag}
</Badge>
<Text
fw={400}
miw={'30ch'}
c={'white'}
>
{item.remark}
</Text>
<Text
c="dimmed"
size="sm"
>
{item.address}
{item.port ? `:${item.port}` : ''}
</Text>
</Group>
</div>
</div>
)}
</Draggable>
)
}

View file

@ -0,0 +1 @@
export * from './host-card.widget';

View file

@ -0,0 +1 @@
export * from './props.interface';

View file

@ -0,0 +1,6 @@
import { GetAllHostsCommand } from '@remnawave/backend-contract';
export interface IProps {
item: GetAllHostsCommand.Response['response'][number];
index: number;
}

View file

@ -0,0 +1,89 @@
import { useEffect } from 'react'
import { Badge, Button, Group, Text } from '@mantine/core'
import { useListState } from '@mantine/hooks'
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'
import { PiArrowsClockwise, PiDotsSixVertical, PiHandDuotone, PiPlus } from 'react-icons/pi'
import { useHostsStoreActions } from '@/entitites/dashboard'
import { DataTable } from '@/shared/ui/stuff/data-table'
import { HostCardWidget } from '@/widgets/dashboard/hosts/host-card'
import { IProps } from './interfaces'
export function HostsTableWidget(props: IProps) {
const { hosts, inbounds } = props
const actions = useHostsStoreActions()
const [state, handlers] = useListState(hosts)
const handleDragEnd = async (result: DropResult) => {
const { source, destination } = result
handlers.reorder({ from: source.index, to: destination?.index || 0 })
}
useEffect(() => {
;(async () => {
console.log('State updated:', state)
const updatedHosts = hosts.map((host) => ({
uuid: host.uuid,
viewPosition: state.findIndex((stateItem) => stateItem.uuid === host.uuid)
}))
console.log(updatedHosts)
const res = await actions.reorderHosts(updatedHosts)
console.log(res)
})()
}, [state])
return (
<>
<DataTable.Container mb="xl">
<DataTable.Title
title="Users"
description="List of all users"
actions={
<>
<Group>
<Button
variant="default"
size="xs"
leftSection={<PiArrowsClockwise size="1rem" />}
>
Update
</Button>
<Button
variant="default"
size="xs"
leftSection={<PiPlus size="1rem" />}
>
Create new user
</Button>
</Group>
</>
}
/>
</DataTable.Container>
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable
droppableId="dnd-list"
direction="vertical"
>
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
>
{state.map((item, index) => (
<HostCardWidget
item={item}
index={index}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</>
)
}

View file

@ -0,0 +1 @@
export * from './hosts-table.widget';

View file

@ -0,0 +1 @@
export * from './props.interface';

View file

@ -0,0 +1,14 @@
import { Dispatch, SetStateAction } from 'react';
import {
GetAllHostsCommand,
GetAllUsersCommand,
GetInboundsCommand,
} from '@remnawave/backend-contract';
import { DataTableColumn, DataTableSortStatus } from 'mantine-datatable';
import { User } from '@/entitites/dashboard/users/models';
import { DataTableReturn } from '@/pages/dashboard/users/ui/connectors/interfaces';
export interface IProps {
hosts: GetAllHostsCommand.Response['response'];
inbounds: GetInboundsCommand.Response['response'];
}

View file

@ -0,0 +1,242 @@
import { useEffect, useState } from 'react'
import {
Button,
Checkbox,
Divider,
Group,
Modal,
NumberInput,
Select,
SimpleGrid,
Stack,
Text,
TextInput
} from '@mantine/core'
import { DateTimePicker } from '@mantine/dates'
import { useForm, zodResolver } from '@mantine/form'
import { notifications } from '@mantine/notifications'
import { CreateUserCommand, USERS_STATUS } from '@remnawave/backend-contract'
import { LoaderModalShared } from '@shared/ui/loader-modal'
import {
PiCalendarDuotone,
PiClockDuotone,
PiFloppyDiskDuotone,
PiUserDuotone
} from 'react-icons/pi'
import { z } from 'zod'
import {
useDashboardStoreActions,
useDSInbounds
} from '@/entitites/dashboard/dashboard-store/dashboard-store'
import {
useUserCreationModalStoreActions,
useUserCreationModalStoreIsModalOpen
} from '@/entitites/dashboard/user-creation-modal-store/user-creation-modal-store'
import { resetDataStrategy } from '@/shared/constants'
import { handleFormErrors } from '@/shared/utils'
import { gbToBytesUtil } from '@/shared/utils/bytes'
import { InboundCheckboxCardWidget } from '../inbound-checkbox-card'
export const CreateUserModalWidget = () => {
const isModalOpen = useUserCreationModalStoreIsModalOpen()
const actions = useUserCreationModalStoreActions()
const inbounds = useDSInbounds()
const actionsDS = useDashboardStoreActions()
const [isLoading, setIsLoading] = useState(true)
const [isDataSubmitting, setIsDataSubmitting] = useState(false)
const form = useForm<CreateUserCommand.Request>({
name: 'create-user-form',
mode: 'uncontrolled',
validate: zodResolver(CreateUserCommand.RequestSchema),
initialValues: {
status: USERS_STATUS.ACTIVE,
username: '',
trafficLimitStrategy: 'NO_RESET',
expireAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
trafficLimitBytes: 0,
activeUserInbounds: []
}
})
useEffect(() => {
let timeout: NodeJS.Timeout | undefined
if (isModalOpen) {
;(async () => {
setIsLoading(true)
try {
await actionsDS.getInbounds()
} catch (error) {
console.error(error)
} finally {
timeout = setTimeout(() => {
setIsLoading(false)
}, 300)
}
})()
if (!isModalOpen) {
if (timeout) {
clearTimeout(timeout)
}
}
}
}, [isModalOpen])
const handleSubmit = form.onSubmit(async (values) => {
try {
setIsDataSubmitting(true)
const createData = {
username: values.username,
trafficLimitStrategy: values.trafficLimitStrategy,
trafficLimitBytes: gbToBytesUtil(values.trafficLimitBytes),
expireAt: values.expireAt,
activeUserInbounds: values.activeUserInbounds,
status: values.status
}
const res = await actions.createUser(createData)
if (res) {
notifications.show({
title: 'Success',
message: 'User created successfully',
color: 'green'
})
handleCloseModal()
}
} catch (error) {
if (error instanceof z.ZodError) {
console.error('Zod validation error:', error.errors)
}
if (error instanceof Error) {
console.error('Error message:', error.message)
console.error('Error stack:', error.stack)
}
handleFormErrors(form, error)
notifications.show({
title: 'Error',
message: error instanceof Error ? error.message : 'Failed to update user',
color: 'red'
})
} finally {
setIsDataSubmitting(false)
}
})
const handleCloseModal = () => {
actions.changeModalState(false)
form.reset()
}
return (
<Modal opened={isModalOpen} onClose={handleCloseModal} title="Create user" centered>
{isLoading ? (
<LoaderModalShared text="Loading user creation..." h="500" />
) : (
<form onSubmit={handleSubmit}>
<Group align="flex-start" grow={false}>
<Stack gap="md" w={400}>
<TextInput
label="Username"
description="Username cannot be changed later"
key={form.key('username')}
{...form.getInputProps('username')}
radius="xs"
leftSection={<PiUserDuotone size="1rem" />}
/>
<NumberInput
leftSection={
<>
<Text
ta="center"
size="0.75rem"
w={26}
display="flex"
style={{ justifyContent: 'center' }}
>
GB
</Text>
<Divider orientation="vertical" />
</>
}
radius="xs"
label="Data Limit"
description="Enter data limit in GB, 0 for unlimited"
allowDecimal={false}
defaultValue={0}
decimalScale={0}
key={form.key('trafficLimitBytes')}
{...form.getInputProps('trafficLimitBytes')}
/>
<Select
label="Traffic reset strategy"
description="How often the user's traffic should be reset"
placeholder="Pick value"
radius="xs"
allowDeselect={false}
defaultValue={form.values.trafficLimitStrategy}
data={resetDataStrategy}
leftSection={<PiClockDuotone size="1rem" />}
key={form.key('trafficLimitStrategy')}
{...form.getInputProps('trafficLimitStrategy')}
/>
<DateTimePicker
label="Expiry Date"
valueFormat="MMMM D, YYYY - HH:mm"
key={form.key('expireAt')}
{...form.getInputProps('expireAt')}
leftSection={<PiCalendarDuotone size="1rem" />}
/>
<Checkbox.Group
key={form.key('activeUserInbounds')}
{...form.getInputProps('activeUserInbounds')}
label="Inbounds"
description="Select available inbounds for this user"
>
<SimpleGrid
pt="md"
cols={{
base: 1,
sm: 1,
md: 2
}}
>
{inbounds?.map((inbound) => (
<>
<InboundCheckboxCardWidget
key={inbound.uuid}
inbound={inbound}
/>
</>
))}
</SimpleGrid>
</Checkbox.Group>
</Stack>
</Group>
<Group justify="right" mt="xl">
<Button
type="submit"
color="teal"
leftSection={<PiFloppyDiskDuotone size="1rem" />}
variant="outline"
size="md"
loading={isDataSubmitting}
>
Create user
</Button>
</Group>
</form>
)}
</Modal>
)
}

View file

@ -0,0 +1 @@
export * from './create-user-modal.widget';

View file

@ -0,0 +1,3 @@
import { CreateUserCommand } from '@remnawave/backend-contract';
export interface IFormValues extends CreateUserCommand.Request {}

View file

@ -0,0 +1 @@
export * from './form-values.interface';

View file

@ -1,28 +1,28 @@
.root {
position: relative;
padding: var(--mantine-spacing-md);
transition: border-color 150ms ease;
max-width: 50%;
position: relative;
padding: var(--mantine-spacing-xs);
transition: border-color 150ms ease;
&[data-checked] {
border-color: var(--mantine-primary-color-filled);
}
@mixin hover {
@mixin light {
background-color: var(--mantine-color-gray-0);
&[data-checked] {
border-color: var(--mantine-primary-color-filled);
}
@mixin dark {
background-color: var(--mantine-color-dark-6);
@mixin hover {
@mixin light {
background-color: var(--mantine-color-gray-0);
}
@mixin dark {
background-color: var(--mantine-color-dark-6);
}
}
}
}
.label {
font-family: var(--mantine-font-family-monospace);
font-weight: bold;
font-size: var(--mantine-font-size-md);
line-height: 1.3;
color: var(--mantine-color-bright);
font-family: var(--mantine-font-family-monospace);
font-weight: bold;
font-size: var(--mantine-font-size-md);
line-height: 1.3;
color: var(--mantine-color-bright);
max-width: 13ch;
}

View file

@ -6,11 +6,13 @@ export function InboundCheckboxCardWidget(props: IProps) {
const { inbound } = props;
return (
<Checkbox.Card className={classes.root} radius="md" value={inbound.uuid} key={inbound.uuid}>
<Group wrap="nowrap" align="flex-start">
<Group align="flex-start" gap="xs">
<Checkbox.Indicator />
<div>
<Text className={classes.label}>{inbound.tag}</Text>
<Badge variant="outline" size="sm" color="gray">
<Text size="sm" className={classes.label} truncate="end">
{inbound.tag}
</Text>
<Badge variant="outline" size="xs" color="gray">
{inbound.type}
</Badge>
</div>

View file

@ -1,18 +1,12 @@
import { Dispatch, SetStateAction } from 'react';
import { GetAllUsersCommand } from '@remnawave/backend-contract';
import { DataTableColumn, DataTableSortStatus } from 'mantine-datatable';
import { User } from '@/entitites/dashboard/users/models';
import { DataTableReturn } from '@/pages/dashboard/users/ui/connectors/interfaces';
import { User } from '@entitites/dashboard/users/models'
import { DataTableReturn } from '@pages/dashboard/users/ui/connectors/interfaces'
import { DataTableColumn } from 'mantine-datatable'
export interface IProps {
tabs: DataTableReturn<User>;
setSearch: Dispatch<SetStateAction<string>>;
search: string;
setSearchBy: Dispatch<SetStateAction<GetAllUsersCommand.SearchableField>>;
searchBy: string;
columns: DataTableColumn<User>[];
handleSortStatusChange: (status: { columnAccessor: string; direction: 'asc' | 'desc' }) => void;
handlePageChange: (page: number) => void;
handleRecordsPerPageChange: (recordsPerPage: number) => void;
handleUpdate: () => void;
tabs: DataTableReturn<User>
columns: DataTableColumn<User>[]
handleSortStatusChange: (status: { columnAccessor: string; direction: 'asc' | 'desc' }) => void
handlePageChange: (page: number) => void
handleRecordsPerPageChange: (recordsPerPage: number) => void
handleUpdate: () => void
}

View file

@ -1,69 +1,58 @@
import { ChangeEvent, useState } from 'react';
import { GetAllUsersCommand } from '@remnawave/backend-contract';
import { LuRefreshCcw } from 'react-icons/lu';
import { PiArrowCircleDownDuotone, PiDownload } from 'react-icons/pi';
import { Box, Button, Group, Select, TextInput } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { ChangeEvent, useState } from 'react'
import { Button, Group } from '@mantine/core'
import { notifications } from '@mantine/notifications'
import {
useDashboardStoreIsLoading,
useDashboardStoreParams,
useDashboardStoreTotalUsers,
useDashboardStoreUsers,
} from '@/entitites/dashboard/dashboard-store/dashboard-store';
import {
useUserModalStoreActions,
useUserModalStoreIsModalOpen,
} from '@/entitites/dashboard/user-modal-store/user-modal-store';
import { AddButton } from '@/shared/ui/stuff/add-button';
import { DataTable } from '@/shared/ui/stuff/data-table';
import { IProps } from './interfaces';
useDashboardStoreUsersLoading
} from '@entitites/dashboard/dashboard-store/dashboard-store'
import { useUserCreationModalStoreActions } from '@entitites/dashboard/user-creation-modal-store/user-creation-modal-store'
import { GetAllUsersCommand } from '@remnawave/backend-contract'
import { PiArrowsClockwise, PiPlus } from 'react-icons/pi'
import { DataTable } from '@/shared/ui/stuff/data-table'
import { IProps } from './interfaces'
export function UserTableWidget(props: IProps) {
const {
search,
setSearch,
searchBy,
setSearchBy,
tabs: tabsProps,
columns,
handleSortStatusChange,
handlePageChange,
handleRecordsPerPageChange,
handleUpdate,
} = props;
handleUpdate
} = props
const { tabs, filters } = tabsProps;
const [isRefreshing, setIsRefreshing] = useState(false);
const { tabs, filters } = tabsProps
const params = useDashboardStoreParams();
const isLoading = useDashboardStoreIsLoading();
const users = useDashboardStoreUsers();
const totalUsers = useDashboardStoreTotalUsers();
const [isRefreshing, setIsRefreshing] = useState(false)
const handleSearch = (e: ChangeEvent<HTMLInputElement>) => {
setSearch(e.currentTarget.value);
};
const handleSelectSearch = (value: string | null) => {
if (!value) {
return;
}
setSearchBy(value as GetAllUsersCommand.SearchableField);
};
const params = useDashboardStoreParams()
const isLoading = useDashboardStoreIsLoading()
const isUsersLoading = useDashboardStoreUsersLoading()
const users = useDashboardStoreUsers()
const totalUsers = useDashboardStoreTotalUsers()
const userCreationModalActions = useUserCreationModalStoreActions()
const handleRefresh = async () => {
setIsRefreshing(true);
setIsRefreshing(true)
handleUpdate();
handleUpdate()
setTimeout(() => {
notifications.show({
title: 'Success',
message: 'Users fetched successfully',
});
message: 'Users fetched successfully'
})
setIsRefreshing(false);
}, 1000);
};
setIsRefreshing(false)
}, 500)
}
const handleOpenCreateUserModal = async () => {
userCreationModalActions.changeModalState(true)
}
return (
<DataTable.Container>
@ -76,16 +65,21 @@ export function UserTableWidget(props: IProps) {
<Button
variant="default"
size="xs"
leftSection={<LuRefreshCcw size="1rem" />}
leftSection={<PiArrowsClockwise size="1rem" />}
onClick={handleRefresh}
loading={isRefreshing}
>
Update
</Button>
<AddButton variant="default" size="xs">
<Button
variant="default"
size="xs"
leftSection={<PiPlus size="1rem" />}
onClick={handleOpenCreateUserModal}
>
Create new user
</AddButton>
</Button>
</Group>
</>
}
@ -94,15 +88,6 @@ export function UserTableWidget(props: IProps) {
<DataTable.Filters filters={filters.filters} onClear={filters.clear} />
<DataTable.Tabs tabs={tabs.tabs} onChange={tabs.change} />
{/* <Group mb="md">
<TextInput placeholder="Search..." value={search} onChange={handleSearch} />
<Select
data={GetAllUsersCommand.SearchableFields}
value={searchBy}
onChange={handleSelectSearch}
/>
</Group> */}
<DataTable.Content>
<DataTable.Table
withTableBorder
@ -121,17 +106,17 @@ export function UserTableWidget(props: IProps) {
page={params.offset / params.limit + 1}
onPageChange={handlePageChange}
records={users || []}
fetching={isLoading}
fetching={isUsersLoading}
recordsPerPage={params.limit}
totalRecords={totalUsers}
sortStatus={{
columnAccessor: params.orderBy || 'createdAt',
direction: params.orderDir || 'desc',
direction: params.orderDir || 'desc'
}}
onSortStatusChange={handleSortStatusChange}
columns={columns}
/>
</DataTable.Content>
</DataTable.Container>
);
)
}

View file

@ -2,4 +2,6 @@ import { UpdateUserCommand } from '@remnawave/backend-contract';
export interface IFormValues extends UpdateUserCommand.Request {
username: string;
shortUuid: string;
trojanPassword: string;
}

View file

@ -1,8 +1,16 @@
import { useEffect, useState } from 'react';
import { UpdateUserCommand } from '@remnawave/backend-contract';
import { PiUserDuotone } from 'react-icons/pi';
import { LoaderModalShared } from '@shared/ui/loader-modal';
import {
PiCalendarDuotone,
PiClockDuotone,
PiFloppyDiskDuotone,
PiLinkDuotone,
PiUserDuotone,
} from 'react-icons/pi';
import { z } from 'zod';
import {
ActionIcon,
Box,
Button,
Checkbox,
@ -10,7 +18,9 @@ import {
Group,
Modal,
NumberInput,
Progress,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
@ -30,10 +40,11 @@ import {
import { DeleteUserFeature } from '@/features/ui/dashboard/users/delete-user';
import { ResetUsageUserFeature } from '@/features/ui/dashboard/users/reset-usage-user';
import { RevokeSubscriptionUserFeature } from '@/features/ui/dashboard/users/revoke-subscription-user';
import { ToggleUserStatusButtonFeature } from '@/features/ui/dashboard/users/toggle-user-status-button';
import { resetDataStrategy } from '@/shared/constants';
import { handleFormErrors } from '@/shared/utils';
import { bytesToGbUtil, gbToBytesUtil } from '@/shared/utils/bytes';
import { LoaderModalShared } from '../../../../shared/ui/loader-modal';
import { bytesToGbUtil, gbToBytesUtil, prettyBytesUtil } from '@/shared/utils/bytes';
import { UserStatusBadge } from '@/widgets/dashboard/users/user-status-badge';
import { InboundCheckboxCardWidget } from '../inbound-checkbox-card';
import { IFormValues } from './interfaces';
@ -48,6 +59,7 @@ export const ViewUserModal = () => {
const [isDataSubmitting, setIsDataSubmitting] = useState(false);
const form = useForm<IFormValues>({
name: 'edit-user-form',
mode: 'uncontrolled',
validate: zodResolver(UpdateUserCommand.RequestSchema),
});
@ -77,6 +89,7 @@ export const ViewUserModal = () => {
useEffect(() => {
if (user && inbounds) {
const activeInboundUuids = user.activeUserInbounds.map((inbound) => inbound.uuid);
form.setValues({
uuid: user.uuid,
username: user.username,
@ -84,10 +97,19 @@ export const ViewUserModal = () => {
trafficLimitStrategy: user.trafficLimitStrategy,
expireAt: user.expireAt ? new Date(user.expireAt) : new Date(),
activeUserInbounds: activeInboundUuids,
shortUuid: user.shortUuid,
trojanPassword: user.trojanPassword,
});
}
}, [user, inbounds]);
if (!user) {
return null;
}
const usedTrafficPercentage = (user.usedTrafficBytes / user.trafficLimitBytes) * 100;
const totalUsedTraffic = prettyBytesUtil(user.usedTrafficBytes, true);
const handleSubmit = form.onSubmit(async (values) => {
try {
setIsDataSubmitting(true);
@ -107,8 +129,6 @@ export const ViewUserModal = () => {
color: 'green',
});
} catch (error) {
console.error('Full error:', error);
if (error instanceof z.ZodError) {
console.error('Zod validation error:', error.errors);
}
@ -119,6 +139,7 @@ export const ViewUserModal = () => {
}
handleFormErrors(form, error);
notifications.show({
title: 'Error',
message: error instanceof Error ? error.message : 'Failed to update user',
@ -126,7 +147,6 @@ export const ViewUserModal = () => {
});
} finally {
setIsDataSubmitting(false);
actions.changeModalState(false);
}
});
@ -135,32 +155,54 @@ export const ViewUserModal = () => {
opened={isModalOpen}
onClose={() => actions.changeModalState(false)}
title="Edit user"
size="lg"
size="900px"
centered
>
{isLoading ? (
<LoaderModalShared text="Loading user data..." h="400" />
) : (
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label="Username"
description="Username cannot be changed"
{...form.getInputProps('username')}
disabled
radius="xs"
leftSection={<PiUserDuotone size="1rem" />}
/>
<Group align="flex-start" grow={false}>
{/* Left Section - User Settings */}
<Stack gap="md" w={400}>
<Group gap="xs" justify="space-between" w="100%">
<Text fw={500}>User details</Text>
<UserStatusBadge status={user.status} />
</Group>
<TextInput
label="Username"
description="Username cannot be changed"
key={form.key('username')}
{...form.getInputProps('username')}
disabled
radius="xs"
leftSection={<PiUserDuotone size="1rem" />}
/>
<TextInput
label="Subscription short uuid"
key={form.key('shortUuid')}
{...form.getInputProps('shortUuid')}
disabled
radius="xs"
leftSection={<PiLinkDuotone size="1rem" />}
/>
</Stack>
<Divider orientation="vertical" />
{/* Right Section - Connection Details */}
<Stack gap="md" w={400}>
<Text fw={500}>Connection Details</Text>
<Group grow align="flex-start">
<NumberInput
{...form.getInputProps('trafficLimitBytes')}
leftSection={
<>
<Text
ta="center"
size="1rem"
w={28}
size="0.75rem"
w={26}
display="flex"
style={{ justifyContent: 'center' }}
>
@ -169,55 +211,100 @@ export const ViewUserModal = () => {
<Divider orientation="vertical" />
</>
}
radius="xs"
label="Data Limit"
description="Enter data limit in GB, 0 for unlimited"
allowDecimal={false}
decimalScale={0}
key={form.key('trafficLimitBytes')}
{...form.getInputProps('trafficLimitBytes')}
/>
<Box>
<Progress
radius="xs"
size="xl"
value={usedTrafficPercentage}
color={usedTrafficPercentage > 100 ? 'yellow' : 'cyan'}
striped
/>
<Group gap="xs" justify="center" mt={2}>
<Text size="sm" c="white" fw={500}>
{totalUsedTraffic === '0' ? '' : totalUsedTraffic}
</Text>
</Group>
</Box>
<Select
label="Traffic reset strategy"
description="How often the user's traffic should be reset"
placeholder="Pick value"
radius="xs"
allowDeselect={false}
defaultValue={form.values.trafficLimitStrategy}
data={resetDataStrategy}
leftSection={<PiClockDuotone size="1rem" />}
key={form.key('trafficLimitStrategy')}
{...form.getInputProps('trafficLimitStrategy')}
/>
</Group>
<DateTimePicker
label="Expiry Date"
valueFormat="MMMM D, YYYY - HH:mm"
{...form.getInputProps('expireAt')}
clearable
/>
<DateTimePicker
label="Expiry Date"
valueFormat="MMMM D, YYYY - HH:mm"
key={form.key('expireAt')}
{...form.getInputProps('expireAt')}
leftSection={<PiCalendarDuotone size="1rem" />}
/>
<Box>
<Stack gap="xs">
<Checkbox.Group
{...form.getInputProps('activeUserInbounds')}
label="Inbounds"
description="Select available inbounds for this user"
<Checkbox.Group
key={form.key('activeUserInbounds')}
{...form.getInputProps('activeUserInbounds')}
label="Inbounds"
description="Select available inbounds for this user"
>
<SimpleGrid
pt="md"
cols={{
base: 1,
sm: 1,
md: 2,
}}
>
<Stack pt="md" gap="xs">
{inbounds?.map((inbound) => (
<InboundCheckboxCardWidget inbound={inbound} />
))}
</Stack>
</Checkbox.Group>
</Stack>
</Box>
{inbounds?.map((inbound) => (
<>
<InboundCheckboxCardWidget
key={inbound.uuid}
inbound={inbound}
/>
</>
))}
</SimpleGrid>
</Checkbox.Group>
</Stack>
</Group>
<Group justify="space-between" mt="xl">
<Group>
<Group justify="space-between" mt="xl">
<Group>
<ActionIcon.Group>
<DeleteUserFeature />
<ResetUsageUserFeature />
<RevokeSubscriptionUserFeature />
</Group>
<Button type="submit" color="blue" loading={isDataSubmitting}>
</ActionIcon.Group>
</Group>
<Group>
<ToggleUserStatusButtonFeature />
<Button
type="submit"
color="blue"
leftSection={<PiFloppyDiskDuotone size="1rem" />}
variant="outline"
size="md"
loading={isDataSubmitting}
>
Edit user
</Button>
</Group>
</Stack>
</Group>
</form>
)}
</Modal>

4842
stats.html Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,29 +1,53 @@
{
"compilerOptions": {
"types": [
"node",
],
"target": "ESNext",
"useDefineForClassFields": true,
"removeComments": true,
"target": "ES2020",
"lib": [
"ESNext",
"DOM",
"DOM.Iterable",
"ESNext"
],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"skipLibCheck": true,
/* ------------------------------------------------------------ */
/* Bundler mode */
/* ------------------------------------------------------------ */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* ------------------------------------------------------------ */
/* "useDefineForClassFields": true,
/* "esModuleInterop": false,
/* "allowSyntheticDefaultImports": true,
"allowJs": false,
/* ------------------------------------------------------------ */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false, // TURN THIS
"noFallthroughCasesInSwitch": true,
"strictPropertyInitialization": false,
"forceConsistentCasingInFileNames": false,
/* ------------------------------------------------------------ */
/* Paths */
/* ------------------------------------------------------------ */
"baseUrl": ".",
"paths": {
"@entitites/*": [
"./src/entitites/*"
],
"@features/*": [
"./src/features/*"
],
"@pages/*": [
"./src/pages/*"
],
"@widgets/*": [
"./src/widgets/*"
],
"@/*": [
"./src/*"
],
@ -38,5 +62,10 @@
"include": [
"src",
"client.d.ts",
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

13
tsconfig.node.json Normal file
View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": [
"vite.config.ts"
]
}

View file

@ -1,29 +0,0 @@
import { fileURLToPath, URL } from 'node:url';
import mdx from '@mdx-js/rollup';
import react from '@vitejs/plugin-react';
import * as dotenv from 'dotenv';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
dotenv.config({ path: `${__dirname}/.env` });
export default defineConfig({
plugins: [react(), tsconfigPaths(), mdx()],
define: {
__DOMAIN_BACKEND__: JSON.stringify(process.env.DOMAIN_BACKEND).trim(),
__NODE_ENV__: JSON.stringify(process.env.NODE_ENV).trim(),
},
server: {
host: '0.0.0.0',
port: 3333,
cors: false,
strictPort: true,
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@public': fileURLToPath(new URL('./public', import.meta.url)),
'@shared': fileURLToPath(new URL('./src/shared', import.meta.url)),
},
},
});

56
vite.config.ts Normal file
View file

@ -0,0 +1,56 @@
/* eslint-disable indent */
import { fileURLToPath, URL } from 'node:url'
import react from '@vitejs/plugin-react-swc'
import * as dotenv from 'dotenv'
import { visualizer } from 'rollup-plugin-visualizer'
import { defineConfig, PluginOption } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'
dotenv.config({ path: `${__dirname}/.env` })
export default defineConfig({
plugins: [react(), visualizer() as PluginOption, tsconfigPaths()],
build: {
rollupOptions: {
output: {
manualChunks: {
react: ['react', 'react-dom', 'react-router-dom', 'zustand'],
axios: ['axios'],
zod: ['zod'],
mantine: [
'@mantine/core',
'@mantine/hooks',
'@mantine/dates',
'@mantine/nprogress',
'@mantine/notifications',
'@mantine/modals'
],
recharts: ['recharts'],
dnd: ['@hello-pangea/dnd']
}
}
}
},
define: {
__DOMAIN_BACKEND__: JSON.stringify(process.env.DOMAIN_BACKEND).trim(),
__NODE_ENV__: JSON.stringify(process.env.NODE_ENV).trim()
},
server: {
host: '0.0.0.0',
port: 3333,
cors: false,
strictPort: true
},
resolve: {
alias: {
'@entitites': fileURLToPath(new URL('./src/entitites', import.meta.url)),
'@features': fileURLToPath(new URL('./src/features', import.meta.url)),
'@pages': fileURLToPath(new URL('./src/pages', import.meta.url)),
'@widgets': fileURLToPath(new URL('./src/widgets', import.meta.url)),
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@public': fileURLToPath(new URL('./public', import.meta.url)),
'@shared': fileURLToPath(new URL('./src/shared', import.meta.url))
}
}
})