mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2025-07-01 12:32:09 +00:00
Jules was unable to complete the task in time. Please review the work done so far and provide feedback for Jules to continue.
This commit is contained in:
parent
29f950046a
commit
66f405353c
61 changed files with 7319 additions and 0 deletions
41
new-frontend/.gitignore
vendored
Normal file
41
new-frontend/.gitignore
vendored
Normal file
|
@ -0,0 +1,41 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
36
new-frontend/README.md
Normal file
36
new-frontend/README.md
Normal file
|
@ -0,0 +1,36 @@
|
|||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
16
new-frontend/eslint.config.mjs
Normal file
16
new-frontend/eslint.config.mjs
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
7
new-frontend/next.config.ts
Normal file
7
new-frontend/next.config.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
29
new-frontend/package.json
Normal file
29
new-frontend/package.json
Normal file
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "new-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "15.3.3",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/qrcode.react": "^3.0.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
5
new-frontend/postcss.config.mjs
Normal file
5
new-frontend/postcss.config.mjs
Normal file
|
@ -0,0 +1,5 @@
|
|||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
1
new-frontend/public/file.svg
Normal file
1
new-frontend/public/file.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 391 B |
1
new-frontend/public/globe.svg
Normal file
1
new-frontend/public/globe.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
After Width: | Height: | Size: 1 KiB |
1
new-frontend/public/next.svg
Normal file
1
new-frontend/public/next.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
After Width: | Height: | Size: 1.3 KiB |
1
new-frontend/public/vercel.svg
Normal file
1
new-frontend/public/vercel.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
After Width: | Height: | Size: 128 B |
1
new-frontend/public/window.svg
Normal file
1
new-frontend/public/window.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
After Width: | Height: | Size: 385 B |
0
new-frontend/src/app/auth/.gitkeep
Normal file
0
new-frontend/src/app/auth/.gitkeep
Normal file
164
new-frontend/src/app/auth/login/page.tsx
Normal file
164
new-frontend/src/app/auth/login/page.tsx
Normal file
|
@ -0,0 +1,164 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, FormEvent } from 'react';
|
||||
// No longer using useRouter here directly for push, AuthContext handles it.
|
||||
import { post, ApiResponse } from '@/services/api'; // Added ApiResponse for explicit typing
|
||||
import { useAuth } from '@/context/AuthContext'; // Import useAuth
|
||||
import { useRouter, usePathname } from 'next/navigation'; // Still need for potential initial redirect, added usePathname
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [twoFactorCode, setTwoFactorCode] = useState('');
|
||||
const [showTwoFactor, setShowTwoFactor] = useState(false);
|
||||
// isLoading and error are now primarily handled by AuthContext, but local form states can still be useful
|
||||
const [formIsLoading, setFormIsLoading] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [twoFactorCheckLoading, setTwoFactorCheckLoading] = useState(true);
|
||||
|
||||
const { login, isLoading: authIsLoading, isAuthenticated } = useAuth(); // Use login from context
|
||||
const router = useRouter(); // Still need for potential initial redirect if already logged in
|
||||
const pathname = usePathname(); // Get current pathname
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, [isAuthenticated, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkTwoFactorStatus = async () => {
|
||||
setTwoFactorCheckLoading(true);
|
||||
try {
|
||||
const response = await post<boolean>('/getTwoFactorEnable', {});
|
||||
if (response.success && typeof response.data === 'boolean') {
|
||||
setShowTwoFactor(response.data);
|
||||
} else {
|
||||
console.warn('Could not determine 2FA status:', response.message);
|
||||
setShowTwoFactor(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching 2FA status:', err);
|
||||
setFormError('Could not check 2FA status.');
|
||||
setShowTwoFactor(false);
|
||||
} finally {
|
||||
setTwoFactorCheckLoading(false);
|
||||
}
|
||||
};
|
||||
if (!isAuthenticated) { // Only check if not already authenticated
|
||||
checkTwoFactorStatus();
|
||||
} else {
|
||||
setTwoFactorCheckLoading(false); // If authenticated, no need to check
|
||||
}
|
||||
}, [isAuthenticated]); // Rerun if isAuthenticated changes (e.g. logout then back to login)
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormIsLoading(true);
|
||||
setFormError(null);
|
||||
|
||||
const response: ApiResponse<unknown> = await login(username, password, showTwoFactor ? twoFactorCode : undefined);
|
||||
|
||||
if (!response.success) {
|
||||
setFormError(response.message || 'Login failed. Please check your credentials.');
|
||||
}
|
||||
// Redirection is handled by AuthContext or the useEffect above
|
||||
setFormIsLoading(false);
|
||||
};
|
||||
|
||||
// If already authenticated and effect hasn't redirected yet, show loading or null
|
||||
if (isAuthenticated && !pathname.startsWith('/auth')) { // check added for pathname
|
||||
return <div className="flex items-center justify-center min-h-screen"><p>Loading dashboard...</p></div>;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="w-full max-w-md p-8 space-y-6 bg-white dark:bg-gray-800 shadow-xl rounded-lg">
|
||||
<h1 className="text-3xl font-bold text-center text-primary-600 dark:text-primary-400">
|
||||
Login
|
||||
</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{twoFactorCheckLoading && <p className="text-sm text-gray-500 dark:text-gray-400">Checking 2FA status...</p>}
|
||||
|
||||
{!twoFactorCheckLoading && showTwoFactor && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="twoFactorCode"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Two-Factor Code
|
||||
</label>
|
||||
<input
|
||||
id="twoFactorCode"
|
||||
name="twoFactorCode"
|
||||
type="text"
|
||||
autoComplete="one-time-code"
|
||||
required={showTwoFactor}
|
||||
value={twoFactorCode}
|
||||
onChange={(e) => setTwoFactorCode(e.target.value)}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formError && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900/30 p-3 rounded-md">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formIsLoading || authIsLoading || twoFactorCheckLoading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:focus:ring-offset-gray-800 disabled:opacity-50"
|
||||
>
|
||||
{formIsLoading || authIsLoading ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
0
new-frontend/src/app/dashboard/.gitkeep
Normal file
0
new-frontend/src/app/dashboard/.gitkeep
Normal file
190
new-frontend/src/app/dashboard/page.tsx
Normal file
190
new-frontend/src/app/dashboard/page.tsx
Normal file
|
@ -0,0 +1,190 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { post } from '@/services/api'; // ApiResponse removed
|
||||
import StatCard from '@/components/dashboard/StatCard';
|
||||
import ProgressBar from '@/components/ui/ProgressBar';
|
||||
import XrayStatusIndicator from '@/components/dashboard/XrayStatusIndicator';
|
||||
import LogsModal from '@/components/shared/LogsModal';
|
||||
import XrayGeoManagementModal from '@/components/dashboard/XrayGeoManagementModal'; // Import new modal
|
||||
import { formatBytes, formatUptime, formatPercentage, toFixedIfNecessary } from '@/lib/formatters';
|
||||
|
||||
// Define button styles locally
|
||||
const btnPrimaryStyles = "px-3 py-1.5 text-sm bg-primary-500 text-white font-semibold rounded-lg shadow-md hover:bg-primary-600 disabled:opacity-50 transition-colors";
|
||||
const btnDangerStyles = "px-3 py-1.5 text-sm bg-red-500 text-white font-semibold rounded-lg shadow-md hover:bg-red-600 disabled:opacity-50 transition-colors";
|
||||
const btnSecondaryStyles = "px-3 py-1.5 text-sm bg-gray-200 text-gray-800 font-semibold rounded-lg shadow-md hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 transition-colors";
|
||||
|
||||
|
||||
interface SystemResource { current: number; total: number; }
|
||||
interface NetIO { up: number; down: number; }
|
||||
interface NetTraffic { sent: number; recv: number; }
|
||||
interface PublicIP { ipv4: string; ipv6: string; }
|
||||
interface AppStats { threads: number; mem: number; uptime: number; }
|
||||
type XrayState = "running" | "stop" | "error";
|
||||
interface XrayStatusData { state: XrayState; errorMsg: string; version: string; }
|
||||
interface ServerStatus {
|
||||
cpu: number; cpuCores: number; logicalPro: number; cpuSpeedMhz: number;
|
||||
mem: SystemResource; swap: SystemResource; disk: SystemResource;
|
||||
xray: XrayStatusData; uptime: number; loads: number[];
|
||||
tcpCount: number; udpCount: number; netIO: NetIO; netTraffic: NetTraffic;
|
||||
publicIP: PublicIP; appStats: AppStats;
|
||||
}
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const [status, setStatus] = useState<ServerStatus | null>(null);
|
||||
const [isLoadingStatus, setIsLoadingStatus] = useState(true);
|
||||
const [pollingError, setPollingError] = useState<string | null>(null);
|
||||
|
||||
const [actionLoading, setActionLoading] = useState<'' | 'restart' | 'stop'>('');
|
||||
const [actionMessage, setActionMessage] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||
|
||||
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
||||
const [isXrayGeoModalOpen, setIsXrayGeoModalOpen] = useState(false);
|
||||
|
||||
|
||||
const fetchStatus = useCallback(async (isInitialLoad = false) => {
|
||||
if (!isAuthenticated) return;
|
||||
if (isInitialLoad) {
|
||||
setIsLoadingStatus(true);
|
||||
// Clear polling error only on initial load attempt, so subsequent polling errors don't wipe the whole page if stale data is shown
|
||||
setPollingError(null);
|
||||
}
|
||||
try {
|
||||
const response = await post<ServerStatus>('/server/status', {});
|
||||
if (response.success && response.data) {
|
||||
setStatus(response.data);
|
||||
if(isInitialLoad) setPollingError(null); // Clear polling error if initial load succeeds
|
||||
} else {
|
||||
const errorMsg = response.message || 'Failed to fetch server status.';
|
||||
if (isInitialLoad) {
|
||||
setStatus(null); // Clear status on initial load failure
|
||||
setPollingError(errorMsg); // Set polling error to display page-level error
|
||||
} else {
|
||||
setPollingError(errorMsg); // For subsequent polling, just set the polling error to show warning
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred.';
|
||||
if (isInitialLoad) {
|
||||
setStatus(null);
|
||||
setPollingError(errorMessage);
|
||||
} else {
|
||||
setPollingError(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
if (isInitialLoad) setIsLoadingStatus(false);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && isAuthenticated) {
|
||||
fetchStatus(true);
|
||||
const intervalId = setInterval(() => fetchStatus(false), 5000);
|
||||
return () => clearInterval(intervalId);
|
||||
} else if (!authLoading && !isAuthenticated) {
|
||||
setStatus(null); setIsLoadingStatus(false);
|
||||
}
|
||||
}, [isAuthenticated, authLoading, fetchStatus]);
|
||||
|
||||
const handleXrayAction = async (action: 'restart' | 'stop') => {
|
||||
setActionLoading(action); setActionMessage(null);
|
||||
const endpoint = action === 'restart' ? '/server/restartXrayService' : '/server/stopXrayService';
|
||||
const successMessageText = action === 'restart' ? 'Xray restarted successfully.' : 'Xray stopped successfully.';
|
||||
const errorMessageText = action === 'restart' ? 'Failed to restart Xray.' : 'Failed to stop Xray.';
|
||||
let response;
|
||||
try {
|
||||
response = await post<null>(endpoint, {}); // Explicitly type ApiResponse<null>
|
||||
if (response.success) {
|
||||
setActionMessage({ type: 'success', message: response.message || successMessageText });
|
||||
} else { setActionMessage({ type: 'error', message: response.message || errorMessageText }); }
|
||||
} catch (err) { setActionMessage({ type: 'error', message: err instanceof Error ? err.message : `Error ${action}ing Xray.` }); }
|
||||
finally {
|
||||
setActionLoading('');
|
||||
await fetchStatus(false); // Re-fetch status after action
|
||||
if(response?.success) setTimeout(() => setActionMessage(null), 5000);
|
||||
// Keep error message displayed until next action or refresh
|
||||
}
|
||||
};
|
||||
|
||||
const openLogsModal = () => { setActionMessage(null); setIsLogsModalOpen(true); };
|
||||
const openXrayGeoModal = () => { setActionMessage(null); setIsXrayGeoModalOpen(true); };
|
||||
|
||||
|
||||
if (authLoading || (isLoadingStatus && !status && !pollingError) ) {
|
||||
return <div className="p-4 text-gray-800 dark:text-gray-200 text-center">Loading dashboard data...</div>;
|
||||
}
|
||||
if (pollingError && !status && !isLoadingStatus) { // Show error prominently if initial load failed and no stale data
|
||||
return <div className="p-4 text-red-500 dark:text-red-400 text-center">Error: {pollingError}</div>;
|
||||
}
|
||||
|
||||
const getUsageColor = (percentage: number): string => {
|
||||
if (percentage > 90) return 'bg-red-500';
|
||||
if (percentage > 75) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="text-gray-800 dark:text-gray-200 p-2 md:p-0">
|
||||
<h1 className="text-2xl md:text-3xl font-semibold mb-6">Dashboard</h1>
|
||||
|
||||
{actionMessage && (
|
||||
<div className={`mb-4 p-3 rounded-md ${actionMessage.type === 'success' ? 'bg-green-100 dark:bg-green-800/60 text-green-700 dark:text-green-200' : 'bg-red-100 dark:bg-red-800/60 text-red-700 dark:text-red-200'}`}>
|
||||
{actionMessage.message}
|
||||
</div>
|
||||
)}
|
||||
{pollingError && status && ( /* Polling error when data is stale */
|
||||
<div className="mb-4 p-3 bg-yellow-100 dark:bg-yellow-800/60 text-yellow-700 dark:text-yellow-200 rounded-md">
|
||||
Warning: Could not update status. Displaying last known data. Error: {pollingError}
|
||||
</div>)
|
||||
}
|
||||
|
||||
{!status && !isLoadingStatus && !pollingError && <p className="text-center text-gray-500 dark:text-gray-400">No data available.</p>}
|
||||
|
||||
{status && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
|
||||
<StatCard title="System Information">
|
||||
<p><strong>OS Uptime:</strong> {formatUptime(status.uptime)}</p>
|
||||
<p><strong>App Uptime:</strong> {formatUptime(status.appStats.uptime)}</p>
|
||||
<p><strong>Load Avg:</strong> {status.loads?.join(' / ')}</p>
|
||||
<p><strong>CPU Cores:</strong> {status.cpuCores} ({status.logicalPro} logical)</p>
|
||||
<p><strong>CPU Speed:</strong> {toFixedIfNecessary(status.cpuSpeedMhz,0)} MHz</p>
|
||||
</StatCard>
|
||||
|
||||
<StatCard title="Xray Status" actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={() => handleXrayAction('restart')} disabled={actionLoading !== ''} className={btnPrimaryStyles}>
|
||||
{actionLoading === 'restart' ? 'Restarting...' : 'Restart Xray'}
|
||||
</button>
|
||||
<button onClick={() => handleXrayAction('stop')} disabled={actionLoading !== '' || status.xray?.state === 'stop'} className={btnDangerStyles}>
|
||||
{actionLoading === 'stop' ? 'Stopping...' : 'Stop Xray'}
|
||||
</button>
|
||||
<button onClick={openLogsModal} disabled={actionLoading !== ''} className={btnSecondaryStyles}>View Logs</button>
|
||||
<button onClick={openXrayGeoModal} disabled={actionLoading !== ''} className={btnSecondaryStyles}>Manage Xray/Geo</button>
|
||||
</div>
|
||||
}>
|
||||
<XrayStatusIndicator state={status.xray?.state} version={status.xray?.version} errorMsg={status.xray?.errorMsg} />
|
||||
</StatCard>
|
||||
|
||||
<StatCard title="CPU Usage"><div className="flex items-center justify-between"><span>{toFixedIfNecessary(status.cpu,1)}%</span></div><ProgressBar percentage={status.cpu} color={getUsageColor(status.cpu)} /></StatCard>
|
||||
<StatCard title="Memory Usage"><div className="flex items-center justify-between"><span>{formatBytes(status.mem.current)} / {formatBytes(status.mem.total)}</span><span>{formatPercentage(status.mem.current, status.mem.total)}%</span></div><ProgressBar percentage={formatPercentage(status.mem.current, status.mem.total)} color={getUsageColor(formatPercentage(status.mem.current, status.mem.total))} /></StatCard>
|
||||
<StatCard title="Swap Usage">{status.swap.total > 0 ? (<><div className="flex items-center justify-between"><span>{formatBytes(status.swap.current)} / {formatBytes(status.swap.total)}</span><span>{formatPercentage(status.swap.current, status.swap.total)}%</span></div><ProgressBar percentage={formatPercentage(status.swap.current, status.swap.total)} color={getUsageColor(formatPercentage(status.swap.current, status.swap.total))} /></>) : <p className="text-gray-500 dark:text-gray-400">Not available</p>}</StatCard>
|
||||
<StatCard title="Disk Usage (/)"><div className="flex items-center justify-between"><span>{formatBytes(status.disk.current)} / {formatBytes(status.disk.total)}</span><span>{formatPercentage(status.disk.current, status.disk.total)}%</span></div><ProgressBar percentage={formatPercentage(status.disk.current, status.disk.total)} color={getUsageColor(formatPercentage(status.disk.current, status.disk.total))} /></StatCard>
|
||||
<StatCard title="Network I/O"><p><strong>Upload:</strong> {formatBytes(status.netIO.up)}/s</p><p><strong>Download:</strong> {formatBytes(status.netIO.down)}/s</p><p className="mt-2"><strong>Total Sent:</strong> {formatBytes(status.netTraffic.sent)}</p><p><strong>Total Received:</strong> {formatBytes(status.netTraffic.recv)}</p></StatCard>
|
||||
<StatCard title="Connections"><p><strong>TCP:</strong> {status.tcpCount}</p><p><strong>UDP:</strong> {status.udpCount}</p></StatCard>
|
||||
<StatCard title="Public IP"><p><strong>IPv4:</strong> {status.publicIP.ipv4 || 'N/A'}</p><p><strong>IPv6:</strong> {status.publicIP.ipv6 || 'N/A'}</p></StatCard>
|
||||
</div>
|
||||
)}
|
||||
<LogsModal isOpen={isLogsModalOpen} onClose={() => setIsLogsModalOpen(false)} />
|
||||
{/* Conditional rendering for XrayGeoManagementModal to ensure 'status' and 'status.xray' are available */}
|
||||
{isXrayGeoModalOpen && status?.xray && <XrayGeoManagementModal
|
||||
isOpen={isXrayGeoModalOpen}
|
||||
onClose={() => setIsXrayGeoModalOpen(false)}
|
||||
currentXrayVersion={status.xray.version} // Pass version directly
|
||||
onActionComplete={() => fetchStatus(true)} // Re-fetch status on completion
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default DashboardPage;
|
BIN
new-frontend/src/app/favicon.ico
Normal file
BIN
new-frontend/src/app/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
3
new-frontend/src/app/globals.css
Normal file
3
new-frontend/src/app/globals.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
0
new-frontend/src/app/inbounds/.gitkeep
Normal file
0
new-frontend/src/app/inbounds/.gitkeep
Normal file
297
new-frontend/src/app/inbounds/[id]/clients/page.tsx
Normal file
297
new-frontend/src/app/inbounds/[id]/clients/page.tsx
Normal file
|
@ -0,0 +1,297 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { post } from '@/services/api';
|
||||
import { Inbound, ClientSetting, Protocol } from '@/types/inbound';
|
||||
import { formatBytes } from '@/lib/formatters';
|
||||
import ClientFormModal from '@/components/inbounds/ClientFormModal';
|
||||
import ClientShareModal from '@/components/inbounds/ClientShareModal'; // Import Share Modal
|
||||
|
||||
// Define button styles locally for consistency
|
||||
const btnPrimaryStyles = "px-4 py-2 bg-primary-500 text-white font-semibold rounded-lg shadow-md hover:bg-primary-600 disabled:opacity-50 transition-colors text-sm";
|
||||
const btnTextPrimaryStyles = "text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 disabled:opacity-50";
|
||||
const btnTextDangerStyles = "text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 disabled:opacity-50";
|
||||
const btnTextWarningStyles = "text-yellow-500 hover:text-yellow-700 dark:text-yellow-400 dark:hover:text-yellow-300 disabled:opacity-50";
|
||||
const btnTextIndigoStyles = "text-indigo-500 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 disabled:opacity-50";
|
||||
|
||||
|
||||
interface DisplayClient extends ClientSetting {
|
||||
up?: number; down?: number; actualTotal?: number;
|
||||
actualExpiryTime?: number; enableClientStat?: boolean;
|
||||
inboundId?: number; clientTrafficId?: number;
|
||||
originalIndex?: number;
|
||||
}
|
||||
|
||||
enum ClientAction { NONE = '', DELETING = 'deleting', RESETTING_TRAFFIC = 'resetting_traffic' }
|
||||
|
||||
const ManageClientsPage: React.FC = () => {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const inboundId = parseInt(params.id as string, 10);
|
||||
|
||||
const [inbound, setInbound] = useState<Inbound | null>(null);
|
||||
const [displayClients, setDisplayClients] = useState<DisplayClient[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [pageError, setPageError] = useState<string | null>(null);
|
||||
|
||||
const [isClientFormModalOpen, setIsClientFormModalOpen] = useState(false);
|
||||
const [editingClient, setEditingClient] = useState<DisplayClient | null>(null);
|
||||
const [clientFormModalError, setClientFormModalError] = useState<string | null>(null);
|
||||
const [clientFormModalLoading, setClientFormModalLoading] = useState(false);
|
||||
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
|
||||
const [sharingClient, setSharingClient] = useState<ClientSetting | null>(null);
|
||||
|
||||
const [currentAction, setCurrentAction] = useState<ClientAction>(ClientAction.NONE);
|
||||
const [actionTargetEmail, setActionTargetEmail] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const fetchInboundAndClients = useCallback(async () => {
|
||||
if (!isAuthenticated || !inboundId) return;
|
||||
setIsLoading(true); setPageError(null); setActionError(null);
|
||||
try {
|
||||
const response = await post<Inbound[]>('/inbound/list', {});
|
||||
if (response.success && response.data) {
|
||||
const currentInbound = response.data.find(ib => ib.id === inboundId);
|
||||
if (currentInbound) {
|
||||
setInbound(currentInbound);
|
||||
let definedClients: ClientSetting[] = [];
|
||||
if (currentInbound.protocol === 'vmess' || currentInbound.protocol === 'vless' || currentInbound.protocol === 'trojan') {
|
||||
if (currentInbound.settings) {
|
||||
try {
|
||||
const parsedSettings = JSON.parse(currentInbound.settings);
|
||||
if (Array.isArray(parsedSettings.clients)) definedClients = parsedSettings.clients;
|
||||
} catch (e) { console.error("Error parsing settings:", e); setPageError("Could not parse client definitions."); }
|
||||
}
|
||||
}
|
||||
const mergedClients: DisplayClient[] = definedClients.map((dc, index) => {
|
||||
const stat = currentInbound.clientStats?.find(cs => cs.email === dc.email);
|
||||
return {
|
||||
...dc,
|
||||
up: stat?.up, down: stat?.down, actualTotal: stat?.total,
|
||||
actualExpiryTime: stat?.expiryTime, enableClientStat: stat?.enable,
|
||||
inboundId: stat?.inboundId, clientTrafficId: stat?.id,
|
||||
originalIndex: index
|
||||
};
|
||||
});
|
||||
currentInbound.clientStats?.forEach(stat => {
|
||||
if (!mergedClients.find(mc => mc.email === stat.email)) {
|
||||
mergedClients.push({
|
||||
email: stat.email, up: stat.up, down: stat.down, actualTotal: stat.total,
|
||||
actualExpiryTime: stat.expiryTime, enableClientStat: stat.enable,
|
||||
inboundId: stat.inboundId, clientTrafficId: stat.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
setDisplayClients(mergedClients);
|
||||
} else { setPageError('Inbound not found.'); setInbound(null); setDisplayClients([]); }
|
||||
} else { setPageError(response.message || 'Failed to fetch inbound data.'); setInbound(null); setDisplayClients([]); }
|
||||
} catch (err) { setPageError(err instanceof Error ? err.message : 'An unknown error occurred.'); setInbound(null); setDisplayClients([]); }
|
||||
finally { setIsLoading(false); }
|
||||
}, [isAuthenticated, inboundId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && isAuthenticated) fetchInboundAndClients();
|
||||
else if (!authLoading && !isAuthenticated) { setIsLoading(false); router.push('/auth/login'); }
|
||||
}, [isAuthenticated, authLoading, fetchInboundAndClients, router]);
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingClient(null); setClientFormModalError(null); setIsClientFormModalOpen(true);
|
||||
};
|
||||
const openEditModal = (client: DisplayClient) => {
|
||||
setEditingClient(client); setClientFormModalError(null); setIsClientFormModalOpen(true);
|
||||
};
|
||||
const openShareModal = (client: ClientSetting) => { // ClientSetting is enough for link generation
|
||||
setSharingClient(client); setIsShareModalOpen(true);
|
||||
};
|
||||
|
||||
|
||||
const handleClientFormSubmit = async (submittedClientData: ClientSetting) => {
|
||||
if (!inbound) { setClientFormModalError("Inbound data not available."); return; }
|
||||
setClientFormModalLoading(true); setClientFormModalError(null); setActionError(null);
|
||||
try {
|
||||
let currentSettings: { clients?: ClientSetting[], [key:string]: unknown } = {};
|
||||
try { currentSettings = JSON.parse(inbound.settings || '{}'); }
|
||||
catch (e) { console.error("Corrupted inbound settings:", e); currentSettings.clients = []; }
|
||||
|
||||
const updatedClients = [...(currentSettings.clients || [])];
|
||||
let clientIdentifierForApi: string | undefined;
|
||||
|
||||
if (editingClient && editingClient.originalIndex !== undefined) {
|
||||
updatedClients[editingClient.originalIndex] = submittedClientData;
|
||||
clientIdentifierForApi = inbound.protocol === 'trojan' ? editingClient.password : editingClient.id;
|
||||
if (!clientIdentifierForApi && submittedClientData.password && inbound.protocol === 'trojan') clientIdentifierForApi = submittedClientData.password;
|
||||
if (!clientIdentifierForApi && submittedClientData.id && (inbound.protocol === 'vmess' || inbound.protocol === 'vless')) clientIdentifierForApi = submittedClientData.id;
|
||||
} else {
|
||||
if (updatedClients.some(c => c.email === submittedClientData.email)) {
|
||||
setClientFormModalError(`Client with email "${submittedClientData.email}" already exists.`);
|
||||
setClientFormModalLoading(false); return;
|
||||
}
|
||||
updatedClients.push(submittedClientData);
|
||||
}
|
||||
|
||||
const updatedSettingsJson = JSON.stringify({ ...currentSettings, clients: updatedClients }, null, 2);
|
||||
const payloadForApi: Partial<Inbound> = { ...inbound, id: inbound.id, settings: updatedSettingsJson };
|
||||
|
||||
let response;
|
||||
if (editingClient) {
|
||||
if (!clientIdentifierForApi) {
|
||||
clientIdentifierForApi = inbound.protocol === 'trojan' ? editingClient?.password : editingClient?.id;
|
||||
}
|
||||
if (!clientIdentifierForApi) {
|
||||
setClientFormModalError("Original client identifier for API is missing for editing.");
|
||||
setClientFormModalLoading(false); return;
|
||||
}
|
||||
response = await post<Inbound>(`/inbound/updateClient/${clientIdentifierForApi}`, payloadForApi);
|
||||
} else {
|
||||
response = await post<Inbound>('/inbound/addClient', payloadForApi);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
setIsClientFormModalOpen(false); setEditingClient(null);
|
||||
await fetchInboundAndClients();
|
||||
} else { setClientFormModalError(response.message || `Failed to ${editingClient ? 'update' : 'add'} client.`); }
|
||||
} catch (err) { setClientFormModalError(err instanceof Error ? err.message : `An error occurred.`); }
|
||||
finally { setClientFormModalLoading(false); }
|
||||
};
|
||||
|
||||
const handleDeleteClient = async (clientToDelete: DisplayClient) => {
|
||||
if (!inbound || !clientToDelete.email) { setActionError("Client or Inbound data is missing."); return; }
|
||||
const clientApiId = clientToDelete.email;
|
||||
if (!window.confirm(`Delete client: ${clientToDelete.email}?`)) return;
|
||||
setCurrentAction(ClientAction.DELETING); setActionTargetEmail(clientToDelete.email); setActionError(null);
|
||||
try {
|
||||
const response = await post(`/inbound/${inbound.id}/delClient/${clientApiId}`, {});
|
||||
if (response.success) { await fetchInboundAndClients(); }
|
||||
else { setActionError(response.message || "Failed to delete client."); }
|
||||
} catch (err) { setActionError(err instanceof Error ? err.message : "Error deleting client."); }
|
||||
finally { setCurrentAction(ClientAction.NONE); setActionTargetEmail(null); }
|
||||
};
|
||||
|
||||
const handleResetClientTraffic = async (clientToReset: DisplayClient) => {
|
||||
if (!inbound || !clientToReset.email) { setActionError("Client/Inbound data missing."); return; }
|
||||
if (!window.confirm(`Reset traffic for: ${clientToReset.email}?`)) return;
|
||||
setCurrentAction(ClientAction.RESETTING_TRAFFIC); setActionTargetEmail(clientToReset.email); setActionError(null);
|
||||
try {
|
||||
const response = await post(`/inbound/${inbound.id}/resetClientTraffic/${clientToReset.email}`, {});
|
||||
if (response.success) { await fetchInboundAndClients(); }
|
||||
else { setActionError(response.message || "Failed to reset traffic."); }
|
||||
} catch (err) { setActionError(err instanceof Error ? err.message : "Error resetting traffic."); }
|
||||
finally { setCurrentAction(ClientAction.NONE); setActionTargetEmail(null); }
|
||||
};
|
||||
|
||||
const getClientIdentifier = (client: DisplayClient, proto: Protocol | undefined): string => proto === 'trojan' ? client.password || 'N/A' : client.id || 'N/A';
|
||||
const getClientIdentifierLabel = (proto: Protocol | undefined): string => proto === 'trojan' ? 'Password' : 'UUID';
|
||||
|
||||
if (isLoading || authLoading) return <div className="p-4 text-center text-gray-700 dark:text-gray-300">Loading...</div>;
|
||||
if (pageError && !inbound) return <div className="p-4 text-red-500 dark:text-red-400 text-center">Error: {pageError}</div>;
|
||||
if (!inbound && !isLoading) return <div className="p-4 text-center text-gray-700 dark:text-gray-300">Inbound not found.</div>;
|
||||
|
||||
const canManageClients = inbound && (inbound.protocol === 'vmess' || inbound.protocol === 'vless' || inbound.protocol === 'trojan' || inbound.protocol === 'shadowsocks');
|
||||
|
||||
return (
|
||||
<div className="text-gray-800 dark:text-gray-200 p-2 md:p-0">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl md:text-3xl font-semibold">
|
||||
Clients for: <span className="text-primary-500 dark:text-primary-400">{inbound?.remark || `#${inbound?.id}`}</span>
|
||||
<span className="text-base ml-2 text-gray-500 dark:text-gray-400">({inbound?.protocol})</span>
|
||||
</h1>
|
||||
{canManageClients && (inbound?.protocol !== 'shadowsocks') &&
|
||||
(<button onClick={openAddModal} className={btnPrimaryStyles}>Add Client</button>)
|
||||
}
|
||||
</div>
|
||||
|
||||
{pageError && inbound && <div className="mb-4 p-3 bg-yellow-100 text-yellow-700 dark:bg-yellow-800 dark:text-yellow-200 rounded-md">Page load error: {pageError} (stale data)</div>}
|
||||
{actionError && <div className="mb-4 p-3 bg-red-100 text-red-700 dark:bg-red-800 dark:text-red-200 rounded-md">Action Error: {actionError}</div>}
|
||||
|
||||
{displayClients.length === 0 && !pageError && inbound?.protocol !== 'shadowsocks' && <p>No clients configured for this inbound.</p>}
|
||||
{inbound?.protocol === 'shadowsocks' &&
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
For Shadowsocks, client configuration (method and password) is part of the main inbound settings.
|
||||
The QR code / subscription link below uses these global settings.
|
||||
</p>
|
||||
}
|
||||
|
||||
{(displayClients.length > 0 || inbound?.protocol === 'shadowsocks') && (
|
||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 shadow-lg rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Email / Identifier</th>
|
||||
{(inbound?.protocol !== 'shadowsocks') && <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">{getClientIdentifierLabel(inbound?.protocol)}</th>}
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Traffic (Up/Down)</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Quota</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Expiry</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{ (inbound?.protocol === 'shadowsocks') ? (
|
||||
<tr className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">{inbound.remark || 'Shadowsocks Settings'}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">{formatBytes(inbound.up || 0)} / {formatBytes(inbound.down || 0)}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">{inbound.total > 0 ? formatBytes(inbound.total) : 'Unlimited'}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">{inbound.expiryTime > 0 ? new Date(inbound.expiryTime).toLocaleDateString() : 'Never'}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm">
|
||||
{inbound.enable ? <span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100">Enabled</span> : <span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100">Disabled</span> }
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-2">
|
||||
<button onClick={() => openShareModal({email: inbound.remark || 'ss_client'})} className={btnTextIndigoStyles} disabled={currentAction !== ClientAction.NONE}>QR / Link</button>
|
||||
{/* No Edit/Delete/Reset for SS "client" as it's part of inbound config */}
|
||||
</td>
|
||||
</tr>
|
||||
) : displayClients.map((client) => {
|
||||
const clientActionTargetId = client.email;
|
||||
const isCurrentActionTarget = actionTargetEmail === clientActionTargetId;
|
||||
return (
|
||||
<tr key={client.email || client.id || client.password} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">{client.email}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm font-mono text-xs break-all">{getClientIdentifier(client, inbound?.protocol)}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">{formatBytes(client.up || 0)} / {formatBytes(client.down || 0)}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">
|
||||
{ (client.totalGB !== undefined && client.totalGB > 0) ? formatBytes(client.totalGB * 1024 * 1024 * 1024) : (client.actualTotal !== undefined && client.actualTotal > 0) ? formatBytes(client.actualTotal) : 'Unlimited' }
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">
|
||||
{client.actualExpiryTime && client.actualExpiryTime > 0 ? new Date(client.actualExpiryTime).toLocaleDateString() : client.expiryTime && client.expiryTime > 0 ? new Date(client.expiryTime).toLocaleDateString() + " (Def)" : 'Never'}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm">
|
||||
{client.enableClientStat === undefined ? 'N/A' : client.enableClientStat ?
|
||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100">Enabled</span> :
|
||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100">Disabled</span>
|
||||
}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-2">
|
||||
<button onClick={() => openEditModal(client)} disabled={currentAction !== ClientAction.NONE} className={btnTextPrimaryStyles}>Edit</button>
|
||||
<button onClick={() => handleDeleteClient(client)} disabled={currentAction !== ClientAction.NONE} className={btnTextDangerStyles}>
|
||||
{isCurrentActionTarget && currentAction === ClientAction.DELETING ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
<button onClick={() => handleResetClientTraffic(client)} disabled={currentAction !== ClientAction.NONE} className={btnTextWarningStyles}>
|
||||
{isCurrentActionTarget && currentAction === ClientAction.RESETTING_TRAFFIC ? 'Resetting...' : 'Reset Traffic'}
|
||||
</button>
|
||||
<button onClick={() => openShareModal(client)} className={btnTextIndigoStyles} disabled={currentAction !== ClientAction.NONE}>QR / Link</button>
|
||||
</td>
|
||||
</tr>
|
||||
)})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{isClientFormModalOpen && inbound && (
|
||||
<ClientFormModal isOpen={isClientFormModalOpen} onClose={() => { setIsClientFormModalOpen(false); setEditingClient(null); }}
|
||||
onSubmit={handleClientFormSubmit} protocol={inbound.protocol as Protocol}
|
||||
existingClient={editingClient} formError={clientFormModalError} isLoading={clientFormModalLoading}
|
||||
/>
|
||||
)}
|
||||
{isShareModalOpen && inbound && (
|
||||
<ClientShareModal isOpen={isShareModalOpen} onClose={() => setIsShareModalOpen(false)}
|
||||
inbound={inbound} client={sharingClient}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ManageClientsPage;
|
|
@ -0,0 +1,267 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { post } from '@/services/api';
|
||||
import { Inbound, ClientSetting, Protocol } from '@/types/inbound';
|
||||
import { formatBytes } from '@/lib/formatters';
|
||||
import ClientFormModal from '@/components/inbounds/ClientFormModal';
|
||||
|
||||
// Define button styles locally for consistency
|
||||
const btnPrimaryStyles = "px-4 py-2 bg-primary-500 text-white font-semibold rounded-lg shadow-md hover:bg-primary-600 disabled:opacity-50 transition-colors text-sm";
|
||||
const btnTextPrimaryStyles = "text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 disabled:opacity-50";
|
||||
const btnTextDangerStyles = "text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 disabled:opacity-50";
|
||||
const btnTextWarningStyles = "text-yellow-500 hover:text-yellow-700 dark:text-yellow-400 dark:hover:text-yellow-300 disabled:opacity-50";
|
||||
const btnTextIndigoStyles = "text-indigo-500 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 disabled:opacity-50";
|
||||
|
||||
|
||||
interface DisplayClient extends ClientSetting {
|
||||
up?: number; down?: number; actualTotal?: number;
|
||||
actualExpiryTime?: number; enableClientStat?: boolean;
|
||||
inboundId?: number; clientTrafficId?: number;
|
||||
originalIndex?: number;
|
||||
}
|
||||
|
||||
const ManageClientsPage: React.FC = () => {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
|
||||
const inboundId = parseInt(params.id as string, 10);
|
||||
|
||||
const [inbound, setInbound] = useState<Inbound | null>(null);
|
||||
const [displayClients, setDisplayClients] = useState<DisplayClient[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true); // Page loading state
|
||||
const [pageError, setPageError] = useState<string | null>(null);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingClient, setEditingClient] = useState<DisplayClient | null>(null);
|
||||
const [modalError, setModalError] = useState<string | null>(null);
|
||||
const [modalLoading, setModalLoading] = useState(false); // For add/edit operations
|
||||
|
||||
const [actionClientId, setActionClientId] = useState<string | number | null>(null); // For delete loading state
|
||||
const [actionError, setActionError] = useState<string | null>(null); // For errors during actions like delete
|
||||
|
||||
const fetchInboundAndClients = useCallback(async () => {
|
||||
if (!isAuthenticated || !inboundId) return;
|
||||
setIsLoading(true); setPageError(null); setActionError(null); // Clear action error on refresh
|
||||
try {
|
||||
const response = await post<Inbound[]>('/inbound/list', {});
|
||||
if (response.success && response.data) {
|
||||
const currentInbound = response.data.find(ib => ib.id === inboundId);
|
||||
if (currentInbound) {
|
||||
setInbound(currentInbound);
|
||||
let definedClients: ClientSetting[] = [];
|
||||
if (currentInbound.protocol === 'vmess' || currentInbound.protocol === 'vless' || currentInbound.protocol === 'trojan') {
|
||||
if (currentInbound.settings) {
|
||||
try {
|
||||
const parsedSettings = JSON.parse(currentInbound.settings);
|
||||
if (Array.isArray(parsedSettings.clients)) definedClients = parsedSettings.clients;
|
||||
} catch (e) { console.error("Error parsing settings:", e); setPageError("Could not parse client definitions."); }
|
||||
}
|
||||
}
|
||||
const mergedClients: DisplayClient[] = definedClients.map((dc, index) => {
|
||||
const stat = currentInbound.clientStats?.find(cs => cs.email === dc.email);
|
||||
return {
|
||||
...dc,
|
||||
up: stat?.up, down: stat?.down, actualTotal: stat?.total,
|
||||
actualExpiryTime: stat?.expiryTime, enableClientStat: stat?.enable,
|
||||
inboundId: stat?.inboundId, clientTrafficId: stat?.id,
|
||||
originalIndex: index
|
||||
};
|
||||
});
|
||||
currentInbound.clientStats?.forEach(stat => {
|
||||
if (!mergedClients.find(mc => mc.email === stat.email)) {
|
||||
mergedClients.push({
|
||||
email: stat.email, up: stat.up, down: stat.down, actualTotal: stat.total,
|
||||
actualExpiryTime: stat.expiryTime, enableClientStat: stat.enable,
|
||||
inboundId: stat.inboundId, clientTrafficId: stat.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
setDisplayClients(mergedClients);
|
||||
} else { setPageError('Inbound not found.'); setInbound(null); setDisplayClients([]); }
|
||||
} else { setPageError(response.message || 'Failed to fetch inbound data.'); setInbound(null); setDisplayClients([]); }
|
||||
} catch (err) { setPageError(err instanceof Error ? err.message : 'An unknown error occurred.'); setInbound(null); setDisplayClients([]); }
|
||||
finally { setIsLoading(false); }
|
||||
}, [isAuthenticated, inboundId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && isAuthenticated) fetchInboundAndClients();
|
||||
else if (!authLoading && !isAuthenticated) { setIsLoading(false); router.push('/auth/login'); }
|
||||
}, [isAuthenticated, authLoading, fetchInboundAndClients, router]);
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingClient(null); setModalError(null); setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (client: DisplayClient) => {
|
||||
setEditingClient(client); setModalError(null); setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleClientFormSubmit = async (submittedClientData: ClientSetting) => {
|
||||
if (!inbound) { setModalError("Inbound data not available."); return; }
|
||||
setModalLoading(true); setModalError(null); setActionError(null);
|
||||
try {
|
||||
let currentSettings: { clients?: ClientSetting[], [key:string]: unknown } = {};
|
||||
try { currentSettings = JSON.parse(inbound.settings || '{}'); }
|
||||
catch (e) { console.error("Corrupted inbound settings:", e); currentSettings.clients = []; }
|
||||
|
||||
const updatedClients = [...(currentSettings.clients || [])]; // Changed to const
|
||||
// clientIdentifierForApi and related logic removed
|
||||
|
||||
if (editingClient && editingClient.originalIndex !== undefined) {
|
||||
updatedClients[editingClient.originalIndex] = submittedClientData;
|
||||
} else { // Add mode (or if originalIndex is somehow undefined for an edit - fallback to add)
|
||||
// Check if client with this email already exists to avoid duplicates if adding
|
||||
if (!editingClient && updatedClients.some(c => c.email === submittedClientData.email)) {
|
||||
setModalError(`Client with email ${submittedClientData.email} already exists.`);
|
||||
setModalLoading(false);
|
||||
return;
|
||||
}
|
||||
updatedClients.push(submittedClientData);
|
||||
}
|
||||
|
||||
const updatedSettingsJson = JSON.stringify({ ...currentSettings, clients: updatedClients }, null, 2);
|
||||
const payloadForApi: Partial<Inbound> = { ...inbound, id: inbound.id, settings: updatedSettingsJson };
|
||||
|
||||
// Using a single update endpoint for simplicity
|
||||
const response = await post<Inbound>(`/inbound/update/${inbound.id}`, payloadForApi);
|
||||
|
||||
if (response.success) {
|
||||
setIsModalOpen(false); setEditingClient(null);
|
||||
await fetchInboundAndClients();
|
||||
} else { setModalError(response.message || `Failed to ${editingClient ? 'update' : 'add'} client.`); }
|
||||
} catch (err) { setModalError(err instanceof Error ? err.message : `An error occurred.`); }
|
||||
finally { setModalLoading(false); }
|
||||
};
|
||||
|
||||
const handleDeleteClient = async (clientToDelete: DisplayClient) => {
|
||||
if (!inbound) { setActionError("Inbound data not available for delete operation."); return; }
|
||||
|
||||
// For deletion, we need the client's main identifier (email or id/password based on how backend delClient works)
|
||||
// The backend /inbound/:id/delClient/:clientId expects :clientId to be the user's email.
|
||||
// This was based on existing panel's behavior (delUser function in controllers).
|
||||
const clientIdentifierForApi = clientToDelete.email;
|
||||
|
||||
if (!clientIdentifierForApi) {
|
||||
setActionError("Client email (identifier for API) is missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(`Are you sure you want to delete client: ${clientToDelete.email}? This action might also remove associated traffic stats.`)) return;
|
||||
|
||||
setActionClientId(clientIdentifierForApi); // Use email or other unique ID for loading state
|
||||
setActionError(null);
|
||||
try {
|
||||
// API endpoint: /inbound/:id/delClient/:email
|
||||
const response = await post(`/inbound/${inbound.id}/delClient/${clientIdentifierForApi}`, {});
|
||||
if (response.success) {
|
||||
await fetchInboundAndClients(); // Refresh list
|
||||
} else {
|
||||
setActionError(response.message || "Failed to delete client.");
|
||||
}
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "An error occurred while deleting client.");
|
||||
} finally {
|
||||
setActionClientId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getClientIdentifier = (client: DisplayClient, proto: Protocol | undefined): string => proto === 'trojan' ? client.password || 'N/A' : client.id || 'N/A';
|
||||
const getClientIdentifierLabel = (proto: Protocol | undefined): string => proto === 'trojan' ? 'Password' : 'UUID';
|
||||
|
||||
if (isLoading || authLoading) return <div className="p-4 text-center text-gray-700 dark:text-gray-300">Loading client data...</div>;
|
||||
if (pageError && !inbound) return <div className="p-4 text-red-500 dark:text-red-400 text-center">Error: {pageError}</div>;
|
||||
if (!inbound && !isLoading) return <div className="p-4 text-center text-gray-700 dark:text-gray-300">Inbound data not available or not found.</div>;
|
||||
|
||||
const canManageClients = inbound && (inbound.protocol === 'vmess' || inbound.protocol === 'vless' || inbound.protocol === 'trojan');
|
||||
|
||||
return (
|
||||
<div className="text-gray-800 dark:text-gray-200 p-2 md:p-0">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl md:text-3xl font-semibold">
|
||||
Clients for: <span className="text-primary-500 dark:text-primary-400">{inbound?.remark || `#${inbound?.id}`}</span>
|
||||
<span className="text-base ml-2 text-gray-500 dark:text-gray-400">({inbound?.protocol})</span>
|
||||
</h1>
|
||||
{canManageClients && (
|
||||
<button onClick={openAddModal} className={btnPrimaryStyles}>Add New Client</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pageError && inbound && <div className="mb-4 p-3 bg-yellow-100 text-yellow-700 dark:bg-yellow-800 dark:text-yellow-200 rounded-md">Page load error: {pageError} (displaying potentially stale data)</div>}
|
||||
{actionError && <div className="mb-4 p-3 bg-red-100 text-red-700 dark:bg-red-800 dark:text-red-200 rounded-md">Action Error: {actionError}</div>}
|
||||
|
||||
|
||||
{displayClients.length === 0 && !pageError && <p>No clients configured for this inbound.</p>}
|
||||
|
||||
{displayClients.length > 0 && (
|
||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 shadow-lg rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Email</th>
|
||||
{canManageClients && <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">{getClientIdentifierLabel(inbound?.protocol)}</th>}
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Traffic (Up/Down)</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Quota</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Expiry</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{displayClients.map((client) => {
|
||||
const clientActionId = client.email; // Using email as the unique ID for delete action tracking
|
||||
return (
|
||||
<tr key={client.email || client.id || client.password} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">{client.email}</td>
|
||||
{canManageClients && <td className="px-4 py-3 whitespace-nowrap text-sm font-mono text-xs break-all">{getClientIdentifier(client, inbound?.protocol)}</td>}
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">{formatBytes(client.up || 0)} / {formatBytes(client.down || 0)}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">
|
||||
{ (client.totalGB !== undefined && client.totalGB > 0) ? formatBytes(client.totalGB * 1024 * 1024 * 1024) : (client.actualTotal !== undefined && client.actualTotal > 0) ? formatBytes(client.actualTotal) : 'Unlimited' }
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">
|
||||
{client.actualExpiryTime && client.actualExpiryTime > 0 ? new Date(client.actualExpiryTime).toLocaleDateString() : client.expiryTime && client.expiryTime > 0 ? new Date(client.expiryTime).toLocaleDateString() + " (Def)" : 'Never'}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm">
|
||||
{client.enableClientStat === undefined ? 'N/A' : client.enableClientStat ?
|
||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100">Enabled</span> :
|
||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100">Disabled</span>
|
||||
}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-2">
|
||||
{canManageClients && <button onClick={() => openEditModal(client)} disabled={actionClientId !== null} className={btnTextPrimaryStyles}>Edit</button>}
|
||||
{canManageClients &&
|
||||
<button
|
||||
onClick={() => handleDeleteClient(client)}
|
||||
disabled={actionClientId === clientActionId || actionClientId === 'all_clients_locked'} // More generic lock for any client action
|
||||
className={btnTextDangerStyles}
|
||||
>
|
||||
{actionClientId === clientActionId ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
}
|
||||
<button className={btnTextWarningStyles} disabled={actionClientId !== null}>Reset Traffic</button>
|
||||
{canManageClients && (client.id || client.password) && <button className={btnTextIndigoStyles} disabled={actionClientId !== null}>QR</button>}
|
||||
</td>
|
||||
</tr>
|
||||
)})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{isModalOpen && inbound && (
|
||||
<ClientFormModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => { setIsModalOpen(false); setEditingClient(null); }}
|
||||
onSubmit={handleClientFormSubmit}
|
||||
protocol={inbound.protocol as Protocol}
|
||||
existingClient={editingClient}
|
||||
formError={modalError}
|
||||
isLoading={modalLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ManageClientsPage;
|
42
new-frontend/src/app/inbounds/add/page.tsx
Normal file
42
new-frontend/src/app/inbounds/add/page.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
import React, { useState } from 'react';
|
||||
import InboundForm from '@/components/inbounds/InboundForm';
|
||||
import { Inbound } from '@/types/inbound';
|
||||
import { post } from '@/services/api';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
const AddInboundPage: React.FC = () => {
|
||||
const [formLoading, setFormLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (inboundData: Partial<Inbound>) => {
|
||||
setFormLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await post<Inbound>('/inbound/add', inboundData);
|
||||
if (response.success && response.data) {
|
||||
router.push('/inbounds');
|
||||
} else {
|
||||
setError(response.message || 'Failed to create inbound.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An unknown error occurred.');
|
||||
} finally {
|
||||
setFormLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-2 md:p-0 max-w-4xl mx-auto">
|
||||
<h1 className="text-2xl md:text-3xl font-semibold mb-6 text-gray-800 dark:text-gray-200">Add New Inbound</h1>
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<InboundForm onSubmitForm={handleSubmit} formLoading={formLoading} isEditMode={false} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default AddInboundPage;
|
97
new-frontend/src/app/inbounds/edit/[id]/page.tsx
Normal file
97
new-frontend/src/app/inbounds/edit/[id]/page.tsx
Normal file
|
@ -0,0 +1,97 @@
|
|||
"use client";
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import InboundForm from '@/components/inbounds/InboundForm';
|
||||
import { Inbound } from '@/types/inbound';
|
||||
import { post } from '@/services/api';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
|
||||
const EditInboundPage: React.FC = () => {
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [formProcessing, setFormProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [initialInboundData, setInitialInboundData] = useState<Inbound | undefined>(undefined);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
|
||||
const fetchInboundData = useCallback(async () => {
|
||||
if (!id) {
|
||||
setError("Inbound ID is missing.");
|
||||
setPageLoading(false);
|
||||
return;
|
||||
}
|
||||
setPageLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// The /inbound/list endpoint returns all inbounds.
|
||||
// We then find the specific one by ID.
|
||||
// This is not ideal for a single item fetch but matches current backend capabilities shown.
|
||||
// A dedicated /inbound/get/{id} would be better.
|
||||
const response = await post<Inbound[]>('/inbound/list', {});
|
||||
if (response.success && response.data) {
|
||||
const numericId = parseInt(id, 10);
|
||||
const inbound = response.data.find(ib => ib.id === numericId);
|
||||
if (inbound) {
|
||||
setInitialInboundData(inbound);
|
||||
} else {
|
||||
setError('Inbound not found.');
|
||||
}
|
||||
} else {
|
||||
setError(response.message || 'Failed to fetch inbound data.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An unknown error occurred while fetching data.');
|
||||
} finally {
|
||||
setPageLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInboundData();
|
||||
}, [fetchInboundData]);
|
||||
|
||||
const handleSubmit = async (inboundData: Partial<Inbound>) => {
|
||||
setFormProcessing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await post<Inbound>(`/inbound/update/${id}`, inboundData);
|
||||
if (response.success) {
|
||||
router.push('/inbounds');
|
||||
} else {
|
||||
setError(response.message || 'Failed to update inbound.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An unknown error occurred.');
|
||||
} finally {
|
||||
setFormProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (pageLoading) {
|
||||
return <div className="p-4 text-center text-gray-700 dark:text-gray-300">Loading inbound data...</div>;
|
||||
}
|
||||
if (error && !initialInboundData) {
|
||||
return <div className="p-4 text-red-500 dark:text-red-400 text-center">Error: {error}</div>;
|
||||
}
|
||||
if (!initialInboundData && !pageLoading) {
|
||||
return <div className="p-4 text-center text-gray-700 dark:text-gray-300">Inbound not found.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-2 md:p-0 max-w-4xl mx-auto">
|
||||
<h1 className="text-2xl md:text-3xl font-semibold mb-6 text-gray-800 dark:text-gray-200">Edit Inbound: {initialInboundData?.remark || id}</h1>
|
||||
{error && initialInboundData && (
|
||||
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded-md">
|
||||
{error} {/* Show non-critical errors above the form if data is loaded */}
|
||||
</div>
|
||||
)}
|
||||
{initialInboundData && <InboundForm
|
||||
initialData={initialInboundData}
|
||||
onSubmitForm={handleSubmit}
|
||||
formLoading={formProcessing}
|
||||
isEditMode={true}
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default EditInboundPage;
|
194
new-frontend/src/app/inbounds/page.tsx
Normal file
194
new-frontend/src/app/inbounds/page.tsx
Normal file
|
@ -0,0 +1,194 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { post } from '@/services/api'; // ApiResponse removed
|
||||
import { InboundFromList } from '@/types/inbound'; // Import the new types
|
||||
import { formatBytes } from '@/lib/formatters'; // formatUptime removed
|
||||
import Link from 'next/link'; // For "Add New Inbound" button
|
||||
|
||||
// Simple toggle switch component (can be moved to ui components later)
|
||||
const ToggleSwitch: React.FC<{ enabled: boolean; onChange: (enabled: boolean) => void; disabled?: boolean }> = ({ enabled, onChange, disabled }) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={`${enabled ? 'bg-primary-500' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex items-center h-6 rounded-full w-11 transition-colors focus:outline-none disabled:opacity-50`}
|
||||
onClick={() => onChange(!enabled)}
|
||||
>
|
||||
<span className="sr-only">Enable/Disable</span>
|
||||
<span
|
||||
className={`${enabled ? 'translate-x-6' : 'translate-x-1'} inline-block w-4 h-4 transform bg-white rounded-full transition-transform`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const InboundsPage: React.FC = () => {
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const [inbounds, setInbounds] = useState<InboundFromList[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Action loading states
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null);
|
||||
|
||||
|
||||
const fetchInbounds = useCallback(async () => {
|
||||
if (!isAuthenticated) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await post<InboundFromList[]>('/inbound/list', {});
|
||||
if (response.success && response.data) {
|
||||
setInbounds(response.data);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(response.message || 'Failed to fetch inbounds.');
|
||||
setInbounds([]);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An unknown error occurred.');
|
||||
setInbounds([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && isAuthenticated) {
|
||||
fetchInbounds();
|
||||
} else if (!authLoading && !isAuthenticated) {
|
||||
setIsLoading(false);
|
||||
setInbounds([]);
|
||||
}
|
||||
}, [isAuthenticated, authLoading, fetchInbounds]);
|
||||
|
||||
const handleToggleEnable = async (inboundId: number, currentEnableStatus: boolean) => {
|
||||
setTogglingId(inboundId);
|
||||
// Find the inbound to get all its data for the update
|
||||
const inboundToUpdate = inbounds.find(ib => ib.id === inboundId);
|
||||
if (!inboundToUpdate) {
|
||||
console.error("Inbound not found for toggling");
|
||||
setTogglingId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a payload that matches the expected structure for update,
|
||||
// only changing the 'enable' field.
|
||||
const payload = { ...inboundToUpdate, enable: !currentEnableStatus };
|
||||
|
||||
try {
|
||||
const response = await post<InboundFromList>(`/inbound/update/${inboundId}`, payload);
|
||||
if (response.success) {
|
||||
await fetchInbounds(); // Refresh list
|
||||
} else {
|
||||
setError(response.message || 'Failed to update inbound status.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error updating inbound.');
|
||||
} finally {
|
||||
setTogglingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteInbound = async (inboundId: number) => {
|
||||
if (!confirm('Are you sure you want to delete this inbound? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
setDeletingId(inboundId);
|
||||
try {
|
||||
const response = await post(`/inbound/del/${inboundId}`, {});
|
||||
if (response.success) {
|
||||
await fetchInbounds(); // Refresh list
|
||||
} else {
|
||||
setError(response.message || 'Failed to delete inbound.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error deleting inbound.');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (authLoading || isLoading) {
|
||||
return <div className="p-4 text-gray-800 dark:text-gray-200">Loading inbounds...</div>;
|
||||
}
|
||||
|
||||
// If not authenticated and not loading auth, show message (AuthContext should redirect anyway)
|
||||
if (!isAuthenticated && !authLoading) {
|
||||
return <div className="p-4 text-gray-800 dark:text-gray-200">Please login to view inbounds.</div>;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="text-gray-800 dark:text-gray-200 p-2 md:p-0">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl md:text-3xl font-semibold">Inbounds Management</h1>
|
||||
<Link href="/inbounds/add" className="px-4 py-2 bg-primary-500 text-white font-semibold rounded-lg shadow-md hover:bg-primary-600 transition-colors">
|
||||
Add New Inbound
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-300 rounded-md">Error: {error}</div>}
|
||||
|
||||
{inbounds.length === 0 && !error && <p>No inbounds found.</p>}
|
||||
|
||||
{inbounds.length > 0 && (
|
||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 shadow-lg rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Remark</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Protocol</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Port / Listen</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Traffic (Up/Down)</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Quota</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Expiry</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Status</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{inbounds.map((inbound) => (
|
||||
<tr key={inbound.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">{inbound.remark || 'N/A'}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">{inbound.protocol}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">{inbound.port}{inbound.listen ? ` (${inbound.listen})` : ''}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">{formatBytes(inbound.up)} / {formatBytes(inbound.down)}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">{inbound.total > 0 ? formatBytes(inbound.total) : 'Unlimited'}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">
|
||||
{inbound.expiryTime === 0 ? 'Never' : new Date(inbound.expiryTime).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<ToggleSwitch
|
||||
enabled={inbound.enable}
|
||||
onChange={() => handleToggleEnable(inbound.id, inbound.enable)}
|
||||
disabled={togglingId === inbound.id}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-2">
|
||||
<Link href={`/inbounds/edit/${inbound.id}`} className="text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300">Edit</Link>
|
||||
<button
|
||||
onClick={() => handleDeleteInbound(inbound.id)}
|
||||
disabled={deletingId === inbound.id}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 disabled:opacity-50"
|
||||
>
|
||||
{deletingId === inbound.id ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
{/* Placeholder for Manage Clients/Details */}
|
||||
<Link href={`/inbounds/${inbound.id}/clients`} className="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300">Clients</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InboundsPage;
|
28
new-frontend/src/app/layout.tsx
Normal file
28
new-frontend/src/app/layout.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import MainLayout from '@/components/layout/MainLayout';
|
||||
import { AuthProvider } from '@/context/AuthContext'; // Import AuthProvider
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "3X-UI New Panel",
|
||||
description: "A new panel for 3X-UI",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<AuthProvider> {/* Wrap with AuthProvider */}
|
||||
<MainLayout>{children}</MainLayout>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
13
new-frontend/src/app/page.tsx
Normal file
13
new-frontend/src/app/page.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import Link from 'next/link';
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen text-gray-800 dark:text-gray-200">
|
||||
<h1 className="text-4xl font-bold mb-6">Welcome to the New 3X-UI Panel</h1>
|
||||
<p className="text-lg mb-8">Experience a fresh new look and feel.</p>
|
||||
<Link href="/dashboard" className="px-6 py-3 bg-primary-500 text-white font-semibold rounded-lg shadow-md hover:bg-primary-600 transition-colors">
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
0
new-frontend/src/app/settings/.gitkeep
Normal file
0
new-frontend/src/app/settings/.gitkeep
Normal file
199
new-frontend/src/app/settings/page.tsx
Normal file
199
new-frontend/src/app/settings/page.tsx
Normal file
|
@ -0,0 +1,199 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { post } from '@/services/api';
|
||||
import { AllSetting, UpdateUserPayload } from '@/types/settings';
|
||||
import PanelSettingsForm from '@/components/settings/PanelSettingsForm';
|
||||
import UserAccountSettingsForm from '@/components/settings/UserAccountSettingsForm';
|
||||
import TelegramSettingsForm from '@/components/settings/TelegramSettingsForm';
|
||||
import SubscriptionSettingsForm from '@/components/settings/SubscriptionSettingsForm';
|
||||
import OtherSettingsForm from '@/components/settings/OtherSettingsForm'; // Import the new form
|
||||
|
||||
// Define button styles locally
|
||||
const btnSecondaryStyles = "px-4 py-2 bg-gray-200 text-gray-800 font-semibold rounded-lg shadow-md hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 transition-colors";
|
||||
|
||||
interface TabProps { label: string; isActive: boolean; onClick: () => void; }
|
||||
const Tab: React.FC<TabProps> = ({ label, isActive, onClick }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`px-3 py-2 text-sm font-medium rounded-t-md focus:outline-none whitespace-nowrap ${isActive ? 'border-b-2 border-primary-500 text-primary-600 dark:text-primary-400' : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { user: authUser, checkAuthState, isAuthenticated, isLoading: authContextLoading } = useAuth();
|
||||
const [settings, setSettings] = useState<Partial<AllSetting>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isUpdatingUser, setIsUpdatingUser] = useState(false);
|
||||
|
||||
const [pageError, setPageError] = useState<string | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<string>('panel');
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
if (!isAuthenticated) return;
|
||||
setIsLoading(true); setPageError(null); setFormError(null);
|
||||
try {
|
||||
const response = await post<AllSetting>('/setting/all', {});
|
||||
if (response.success && response.data) {
|
||||
setSettings(response.data);
|
||||
} else { setPageError(response.message || 'Failed to fetch settings.'); }
|
||||
} catch (err) { setPageError(err instanceof Error ? err.message : 'Unknown error fetching settings.'); }
|
||||
finally { setIsLoading(false); }
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authContextLoading && isAuthenticated) fetchSettings();
|
||||
else if (!authContextLoading && !isAuthenticated) setIsLoading(false);
|
||||
}, [isAuthenticated, authContextLoading, fetchSettings]);
|
||||
|
||||
const handleSaveSettings = async (updatedSettingsData: Partial<AllSetting>) => {
|
||||
setIsSaving(true); setFormError(null); setSuccessMessage(null);
|
||||
let response;
|
||||
try {
|
||||
const fullSettingsPayload = { ...settings, ...updatedSettingsData };
|
||||
response = await post('/setting/update', fullSettingsPayload);
|
||||
if (response.success) {
|
||||
setSuccessMessage(response.message || 'Settings updated successfully! Some changes may require a panel restart.');
|
||||
await fetchSettings();
|
||||
} else { setFormError(response.message || 'Failed to update settings.'); }
|
||||
} catch (err) { setFormError(err instanceof Error ? err.message : 'Unknown error saving settings.'); }
|
||||
finally {
|
||||
setIsSaving(false);
|
||||
if (response?.success) {
|
||||
setTimeout(()=>setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
setTimeout(()=>setFormError(null), 6000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateUserCredentials = async (payload: UpdateUserPayload): Promise<boolean> => {
|
||||
setIsUpdatingUser(true); setFormError(null); setSuccessMessage(null);
|
||||
const finalPayload = { ...payload, oldUsername: payload.oldUsername || authUser?.username };
|
||||
let response;
|
||||
try {
|
||||
response = await post('/setting/updateUser', finalPayload);
|
||||
if (response.success) {
|
||||
setSuccessMessage(response.message || 'User credentials updated. You might need to log in again.');
|
||||
await checkAuthState();
|
||||
return true;
|
||||
} else { setFormError(response.message || 'Failed to update user credentials.'); return false; }
|
||||
} catch (err) { setFormError(err instanceof Error ? err.message : 'Unknown error updating user.'); return false; }
|
||||
finally { setIsUpdatingUser(false); if (response?.success) setTimeout(()=>setSuccessMessage(null), 5000); if (formError) setTimeout(()=>setFormError(null), 6000);}
|
||||
};
|
||||
|
||||
const handleUpdateTwoFactor = async (twoFactorEnabled: boolean) : Promise<boolean> => {
|
||||
setIsSaving(true); setFormError(null); setSuccessMessage(null);
|
||||
let opSuccess = false;
|
||||
try {
|
||||
await handleSaveSettings({ twoFactorEnable: twoFactorEnabled });
|
||||
if (formError) { // Check if handleSaveSettings set an error
|
||||
opSuccess = false;
|
||||
} else {
|
||||
const refreshed = await post<AllSetting>('/setting/all', {});
|
||||
if(refreshed.success && refreshed.data){
|
||||
setSettings(refreshed.data);
|
||||
if(refreshed.data.twoFactorEnable === twoFactorEnabled){
|
||||
setSuccessMessage(`2FA status successfully ${twoFactorEnabled ? 'enabled' : 'disabled'}.`);
|
||||
opSuccess = true;
|
||||
} else {
|
||||
setFormError("2FA status change was not reflected after save. Please check.");
|
||||
}
|
||||
} else {
|
||||
setFormError("Failed to re-fetch settings after 2FA update.");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : "Error in 2FA update process.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
if (opSuccess) setTimeout(()=>setSuccessMessage(null), 5000);
|
||||
else if (formError) setTimeout(()=>setFormError(null), 8000);
|
||||
}
|
||||
return opSuccess;
|
||||
};
|
||||
|
||||
const handleRestartPanel = async () => {
|
||||
if (!window.confirm("Are you sure you want to restart the panel?")) return;
|
||||
setIsSaving(true); setFormError(null); setSuccessMessage(null);
|
||||
try {
|
||||
const response = await post('/setting/restartPanel', {});
|
||||
if (response.success) {
|
||||
setSuccessMessage(response.message || "Panel is restarting... Please wait and refresh.");
|
||||
} else { setFormError(response.message || "Failed to restart panel."); }
|
||||
} catch (err) { setFormError(err instanceof Error ? err.message : "Error restarting panel."); }
|
||||
finally { setIsSaving(false); if (successMessage) setTimeout(()=>setSuccessMessage(null), 7000); if (formError) setTimeout(()=>setFormError(null), 6000); }
|
||||
};
|
||||
|
||||
const onTabChange = (tab: string) => {
|
||||
setActiveTab(tab); setFormError(null); setSuccessMessage(null);
|
||||
};
|
||||
|
||||
const renderSettingsContent = () => {
|
||||
if (isLoading && !Object.keys(settings).length) return <p className="p-6 text-center text-gray-700 dark:text-gray-300">Loading settings data...</p>;
|
||||
|
||||
switch(activeTab) {
|
||||
case 'panel':
|
||||
return <PanelSettingsForm initialSettings={settings} onSave={handleSaveSettings} isLoading={isSaving} formError={formError} />;
|
||||
case 'user':
|
||||
return <UserAccountSettingsForm
|
||||
initialSettings={settings}
|
||||
onUpdateUser={handleUpdateUserCredentials}
|
||||
onUpdateTwoFactor={handleUpdateTwoFactor}
|
||||
isSavingUser={isUpdatingUser}
|
||||
isSavingSettings={isSaving}
|
||||
formError={formError}
|
||||
successMessage={successMessage}
|
||||
/>;
|
||||
case 'telegram':
|
||||
return <TelegramSettingsForm initialSettings={settings} onSave={handleSaveSettings} isLoading={isSaving} formError={formError} />;
|
||||
case 'subscription':
|
||||
return <SubscriptionSettingsForm initialSettings={settings} onSave={handleSaveSettings} isLoading={isSaving} formError={formError} />;
|
||||
case 'other':
|
||||
return <OtherSettingsForm initialSettings={settings} onSave={handleSaveSettings} isLoading={isSaving} formError={formError} />;
|
||||
default:
|
||||
return <div className="p-6 bg-white dark:bg-gray-800 rounded-b-lg shadow-md"><p>Please select a settings category.</p></div>;
|
||||
}
|
||||
};
|
||||
|
||||
if (authContextLoading) return <div className="p-4 text-center text-gray-700 dark:text-gray-300">Loading authentication...</div>;
|
||||
if (pageError && !Object.keys(settings).length) return <div className="p-4 text-red-500 dark:text-red-400 text-center">Error: {pageError}</div>;
|
||||
|
||||
return (
|
||||
<div className="text-gray-800 dark:text-gray-200 p-2 md:p-0 max-w-4xl mx-auto">
|
||||
<h1 className="text-2xl md:text-3xl font-semibold mb-6">Panel Settings</h1>
|
||||
|
||||
{successMessage && <div className="mb-4 p-3 bg-green-100 dark:bg-green-700/90 text-green-700 dark:text-green-100 rounded-md">{successMessage}</div>}
|
||||
{pageError && !isLoading && <div className="mb-4 p-3 bg-red-100 dark:bg-red-700/90 text-red-700 dark:text-red-100 rounded-md">Page Error: {pageError}</div>}
|
||||
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="-mb-px flex space-x-1 sm:space-x-2 overflow-x-auto" aria-label="Tabs">
|
||||
<Tab label="Panel" isActive={activeTab === 'panel'} onClick={() => onTabChange('panel')} />
|
||||
<Tab label="User Account" isActive={activeTab === 'user'} onClick={() => onTabChange('user')} />
|
||||
<Tab label="Telegram Bot" isActive={activeTab === 'telegram'} onClick={() => onTabChange('telegram')} />
|
||||
<Tab label="Subscription" isActive={activeTab === 'subscription'} onClick={() => onTabChange('subscription')} />
|
||||
<Tab label="Other" isActive={activeTab === 'other'} onClick={() => onTabChange('other')} />
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 bg-white dark:bg-gray-800 shadow-md rounded-b-lg">
|
||||
{renderSettingsContent()}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3">
|
||||
<button onClick={handleRestartPanel} disabled={isSaving || isUpdatingUser} className={`${btnSecondaryStyles} w-full sm:w-auto`}>
|
||||
{(isSaving || isUpdatingUser) ? 'Processing...' : 'Restart Panel'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
21
new-frontend/src/components/dashboard/StatCard.tsx
Normal file
21
new-frontend/src/components/dashboard/StatCard.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
const StatCard: React.FC<StatCardProps> = ({ title, children, className = '', actions }) => {
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 shadow-lg rounded-lg p-4 md:p-6 ${className}`}>
|
||||
<h3 className="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-3">{title}</h3>
|
||||
<div className="space-y-2">
|
||||
{children}
|
||||
</div>
|
||||
{actions && <div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default StatCard;
|
191
new-frontend/src/components/dashboard/XrayGeoManagementModal.tsx
Normal file
191
new-frontend/src/components/dashboard/XrayGeoManagementModal.tsx
Normal file
|
@ -0,0 +1,191 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { post } from '@/services/api';
|
||||
|
||||
// Define styles locally
|
||||
const btnSecondaryStyles = "px-3 py-1 text-xs bg-gray-200 text-gray-800 font-semibold rounded-lg shadow-md hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 transition-colors";
|
||||
|
||||
interface XrayGeoManagementModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentXrayVersion: string | undefined;
|
||||
onActionComplete: () => Promise<void>; // Callback to refresh dashboard status
|
||||
}
|
||||
|
||||
const GEO_FILES_LIST = [
|
||||
"geoip.dat",
|
||||
"geosite.dat",
|
||||
"geoip_IR.dat",
|
||||
"geosite_IR.dat",
|
||||
];
|
||||
|
||||
const XrayGeoManagementModal: React.FC<XrayGeoManagementModalProps> = ({
|
||||
isOpen, onClose, currentXrayVersion, onActionComplete
|
||||
}) => {
|
||||
const [xrayVersions, setXrayVersions] = useState<string[]>([]);
|
||||
const [isLoadingVersions, setIsLoadingVersions] = useState(false);
|
||||
const [isInstalling, setIsInstalling] = useState<string | null>(null);
|
||||
const [isUpdatingFile, setIsUpdatingFile] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'xray' | 'geo'>('xray');
|
||||
|
||||
const fetchXrayVersions = useCallback(async () => {
|
||||
if (!isOpen) return; // Ensure modal is open before fetching
|
||||
setIsLoadingVersions(true); setError(null);
|
||||
try {
|
||||
// Explicitly type the expected response structure
|
||||
const response = await post<{ versions?: string[], data?: string[] }>('/server/getXrayVersion', {});
|
||||
if (response.success) {
|
||||
const versionsData = response.data; // data might be string[] or { versions: string[] }
|
||||
if (Array.isArray(versionsData)) { // Case: data is string[]
|
||||
setXrayVersions(versionsData);
|
||||
} else if (versionsData && Array.isArray(versionsData.versions)) { // Case: data is { versions: string[] }
|
||||
setXrayVersions(versionsData.versions);
|
||||
} else {
|
||||
setError('Fetched Xray versions data is not in the expected format.');
|
||||
setXrayVersions([]);
|
||||
}
|
||||
} else {
|
||||
setError(response.message || 'Failed to fetch Xray versions.');
|
||||
setXrayVersions([]);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error fetching versions.');
|
||||
setXrayVersions([]);
|
||||
} finally {
|
||||
setIsLoadingVersions(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && activeTab === 'xray') {
|
||||
fetchXrayVersions();
|
||||
}
|
||||
}, [isOpen, activeTab, fetchXrayVersions]);
|
||||
|
||||
const handleInstallXray = async (version: string) => {
|
||||
if (!window.confirm(`Are you sure you want to install Xray version: ${version}?\nThis will restart Xray service.`)) return;
|
||||
setIsInstalling(version); setError(null); setSuccessMessage(null);
|
||||
let response; // Declare response here
|
||||
try {
|
||||
response = await post(`/server/installXray/${version}`, {});
|
||||
if (response.success) {
|
||||
setSuccessMessage(response.message || `Xray ${version} installed successfully. Xray service is restarting.`);
|
||||
onActionComplete();
|
||||
} else {
|
||||
setError(response.message || 'Failed to install Xray version.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error during Xray installation.');
|
||||
} finally {
|
||||
setIsInstalling(null);
|
||||
if (response?.success) setTimeout(() => setSuccessMessage(null), 4000);
|
||||
else if (error) setTimeout(() => setError(null), 4000); // Check local error state
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateGeoFile = async (fileName: string) => {
|
||||
if (!window.confirm(`Are you sure you want to update ${fileName}?\nThis may restart Xray service if changes are detected.`)) return;
|
||||
setIsUpdatingFile(fileName); setError(null); setSuccessMessage(null);
|
||||
let response; // Declare response here
|
||||
try {
|
||||
response = await post(`/server/updateGeofile/${fileName}`, {});
|
||||
if (response.success) {
|
||||
setSuccessMessage(response.message || `${fileName} updated successfully.`);
|
||||
onActionComplete();
|
||||
} else {
|
||||
setError(response.message || `Failed to update ${fileName}.`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : `Unknown error updating ${fileName}.`);
|
||||
} finally {
|
||||
setIsUpdatingFile(null);
|
||||
if (response?.success) setTimeout(() => setSuccessMessage(null), 4000);
|
||||
else if (error) setTimeout(() => setError(null), 4000); // Check local error state
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50" onClick={handleClose}>
|
||||
<div className="bg-white dark:bg-gray-800 p-5 rounded-lg shadow-xl w-full max-w-lg max-h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-800 dark:text-gray-100">Xray & Geo Management</h2>
|
||||
<button onClick={handleClose} className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 text-2xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-3 p-2 bg-red-100 dark:bg-red-800/60 text-red-700 dark:text-red-200 rounded text-sm">{error}</div>}
|
||||
{successMessage && <div className="mb-3 p-2 bg-green-100 dark:bg-green-800/60 text-green-700 dark:text-green-200 rounded text-sm">{successMessage}</div>}
|
||||
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="-mb-px flex space-x-4" aria-label="Tabs">
|
||||
<button type="button" onClick={() => setActiveTab('xray')} className={`px-3 py-2 text-sm font-medium rounded-t-md focus:outline-none ${activeTab === 'xray' ? 'border-b-2 border-primary-500 text-primary-600 dark:text-primary-400' : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}`}>Xray Versions</button>
|
||||
<button type="button" onClick={() => setActiveTab('geo')} className={`px-3 py-2 text-sm font-medium rounded-t-md focus:outline-none ${activeTab === 'geo' ? 'border-b-2 border-primary-500 text-primary-600 dark:text-primary-400' : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}`}>Geo Files</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow overflow-y-auto py-4">
|
||||
{activeTab === 'xray' && (
|
||||
<div>
|
||||
<p className="text-sm mb-2 text-gray-700 dark:text-gray-300">Current Xray Version: <span className="font-semibold text-primary-600 dark:text-primary-400">{currentXrayVersion || 'Unknown'}</span></p>
|
||||
{isLoadingVersions && <p className="text-gray-500 dark:text-gray-400">Loading versions...</p>}
|
||||
{!isLoadingVersions && xrayVersions.length === 0 && !error && <p className="text-gray-500 dark:text-gray-400">No versions found or failed to load.</p>}
|
||||
<ul className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{xrayVersions.map(version => (
|
||||
<li key={version} className="flex justify-between items-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded">
|
||||
<span className="text-gray-800 dark:text-gray-200">{version}</span>
|
||||
{version === currentXrayVersion ? (
|
||||
<span className="px-2 py-1 text-xs text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100 rounded-full">Current</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleInstallXray(version)}
|
||||
disabled={isInstalling === version || !!isInstalling}
|
||||
className={btnSecondaryStyles}
|
||||
>
|
||||
{isInstalling === version ? 'Installing...' : 'Install'}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'geo' && (
|
||||
<div>
|
||||
<p className="text-sm mb-3 text-gray-700 dark:text-gray-300">Manage GeoIP and GeoSite files.</p>
|
||||
<ul className="space-y-2">
|
||||
{GEO_FILES_LIST.map(fileName => (
|
||||
<li key={fileName} className="flex justify-between items-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded">
|
||||
<span className="text-gray-800 dark:text-gray-200">{fileName}</span>
|
||||
<button
|
||||
onClick={() => handleUpdateGeoFile(fileName)}
|
||||
disabled={isUpdatingFile === fileName || !!isUpdatingFile}
|
||||
className={btnSecondaryStyles}
|
||||
>
|
||||
{isUpdatingFile === fileName ? 'Updating...' : 'Update'}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button onClick={handleClose} className={btnSecondaryStyles}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default XrayGeoManagementModal;
|
|
@ -0,0 +1,45 @@
|
|||
import React from 'react';
|
||||
|
||||
type ProcessState = "running" | "stop" | "error" | undefined;
|
||||
|
||||
interface XrayStatusIndicatorProps {
|
||||
state?: ProcessState;
|
||||
version?: string;
|
||||
errorMsg?: string;
|
||||
}
|
||||
|
||||
const XrayStatusIndicator: React.FC<XrayStatusIndicatorProps> = ({ state, version, errorMsg }) => {
|
||||
let color = 'bg-gray-400'; // Default for undefined or unknown state
|
||||
let text = 'Unknown';
|
||||
|
||||
switch (state) {
|
||||
case 'running':
|
||||
color = 'bg-green-500';
|
||||
text = 'Running';
|
||||
break;
|
||||
case 'stop':
|
||||
color = 'bg-yellow-500';
|
||||
text = 'Stopped';
|
||||
break;
|
||||
case 'error':
|
||||
color = 'bg-red-500';
|
||||
text = 'Error';
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={`w-3 h-3 rounded-full ${color}`}></span>
|
||||
<span className="text-gray-800 dark:text-gray-200">{text}</span>
|
||||
</div>
|
||||
{version && <p className="text-sm text-gray-600 dark:text-gray-400">Version: {version}</p>}
|
||||
{state === 'error' && errorMsg && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400 mt-1 bg-red-100 dark:bg-red-900/20 p-2 rounded">
|
||||
Error: {errorMsg.length > 100 ? errorMsg.substring(0, 97) + "..." : errorMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default XrayStatusIndicator;
|
138
new-frontend/src/components/inbounds/ClientFormModal.tsx
Normal file
138
new-frontend/src/components/inbounds/ClientFormModal.tsx
Normal file
|
@ -0,0 +1,138 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, FormEvent } from 'react';
|
||||
import { ClientSetting, Protocol } from '@/types/inbound';
|
||||
|
||||
// Basic UUID v4 generator
|
||||
const generateUUID = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
const generateRandomPassword = (length = 12) => Math.random().toString(36).substring(2, 2 + length);
|
||||
|
||||
const inputStyles = "mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100";
|
||||
const btnPrimaryStyles = "px-4 py-2 bg-primary-500 text-white font-semibold rounded-lg shadow-md hover:bg-primary-600 disabled:opacity-50 transition-colors";
|
||||
const btnSecondaryStyles = "px-4 py-2 bg-gray-200 text-gray-800 font-semibold rounded-lg shadow-md hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 transition-colors";
|
||||
|
||||
|
||||
interface ClientFormModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (client: ClientSetting) => void;
|
||||
protocol: Protocol;
|
||||
existingClient?: ClientSetting | null;
|
||||
formError?: string | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const ClientFormModal: React.FC<ClientFormModalProps> = ({
|
||||
isOpen, onClose, onSubmit, protocol, existingClient = null, formError, isLoading
|
||||
}) => {
|
||||
const isEditMode = !!existingClient;
|
||||
const [email, setEmail] = useState('');
|
||||
const [identifier, setIdentifier] = useState('');
|
||||
const [flow, setFlow] = useState('');
|
||||
const [totalGB, setTotalGB] = useState<number | string>(0);
|
||||
const [expiryTime, setExpiryTime] = useState<number | string>(0);
|
||||
const [limitIp, setLimitIp] = useState<number | string>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) { // Only reset/populate when modal becomes visible or critical props change
|
||||
if (existingClient) {
|
||||
setEmail(existingClient.email || '');
|
||||
if (protocol === 'trojan') {
|
||||
setIdentifier(existingClient.password || '');
|
||||
} else { // vmess, vless
|
||||
setIdentifier(existingClient.id || '');
|
||||
}
|
||||
setFlow(existingClient.flow || '');
|
||||
setTotalGB(existingClient.totalGB === undefined ? 0 : existingClient.totalGB);
|
||||
setExpiryTime(existingClient.expiryTime === undefined ? 0 : existingClient.expiryTime);
|
||||
setLimitIp(existingClient.limitIp === undefined ? 0 : existingClient.limitIp);
|
||||
} else {
|
||||
setEmail('');
|
||||
setIdentifier(protocol === 'trojan' ? generateRandomPassword() : generateUUID());
|
||||
setFlow(protocol === 'vless' ? 'xtls-rprx-vision' : '');
|
||||
setTotalGB(0);
|
||||
setExpiryTime(0);
|
||||
setLimitIp(0);
|
||||
}
|
||||
}
|
||||
}, [isOpen, existingClient, protocol]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const identifierLabel = protocol === 'trojan' ? 'Password' : 'UUID';
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const clientData: ClientSetting = {
|
||||
email,
|
||||
flow: protocol === 'vless' ? flow : undefined,
|
||||
totalGB: Number(totalGB),
|
||||
expiryTime: Number(expiryTime),
|
||||
limitIp: Number(limitIp),
|
||||
};
|
||||
if (protocol === 'trojan') {
|
||||
clientData.password = identifier;
|
||||
} else { // vmess, vless
|
||||
clientData.id = identifier;
|
||||
}
|
||||
// If editing, preserve original ID if it was a UUID that shouldn't change
|
||||
if (isEditMode && existingClient?.id && protocol !== 'trojan') {
|
||||
clientData.id = existingClient.id;
|
||||
}
|
||||
onSubmit(clientData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-xl w-full max-w-md space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-800 dark:text-gray-100">
|
||||
{isEditMode ? 'Edit Client' : 'Add New Client'}
|
||||
</h2>
|
||||
{formError && <p className="text-sm text-red-500 dark:text-red-400">{formError}</p>}
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Email <span className="text-red-500">*</span></label>
|
||||
<input type="email" id="email" value={email} onChange={e => setEmail(e.target.value)} required className={`mt-1 w-full ${inputStyles}`} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="identifier" className="block text-sm font-medium text-gray-700 dark:text-gray-300">{identifierLabel} <span className="text-red-500">*</span></label>
|
||||
<div className="flex">
|
||||
<input type="text" id="identifier" value={identifier} onChange={e => setIdentifier(e.target.value)} required className={`mt-1 w-full rounded-r-none ${inputStyles}`} />
|
||||
<button type="button" onClick={() => setIdentifier(protocol === 'trojan' ? generateRandomPassword() : generateUUID())} className="mt-1 px-3 py-2 border border-l-0 border-gray-300 dark:border-gray-600 rounded-r-md bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 text-sm text-gray-700 dark:text-gray-200">
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{protocol === 'vless' && (
|
||||
<div>
|
||||
<label htmlFor="flow" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Flow (VLESS only)</label>
|
||||
<input type="text" id="flow" value={flow} onChange={e => setFlow(e.target.value)} className={`mt-1 w-full ${inputStyles}`} placeholder="e.g., xtls-rprx-vision" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="totalGB" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Quota (GB, 0 for unlimited)</label>
|
||||
<input type="number" id="totalGB" value={totalGB} onChange={e => setTotalGB(e.target.value)} min="0" className={`mt-1 w-full ${inputStyles}`} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="expiryTime" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Expiry Time (Timestamp ms, 0 for never)</label>
|
||||
<input type="number" id="expiryTime" value={expiryTime} onChange={e => setExpiryTime(e.target.value)} min="0" className={`mt-1 w-full ${inputStyles}`} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="limitIp" className="block text-sm font-medium text-gray-700 dark:text-gray-300">IP Limit (0 for unlimited)</label>
|
||||
<input type="number" id="limitIp" value={limitIp} onChange={e => setLimitIp(e.target.value)} min="0" className={`mt-1 w-full ${inputStyles}`} />
|
||||
</div>
|
||||
<div className="flex justify-end space-x-3 pt-3">
|
||||
<button type="button" onClick={onClose} disabled={isLoading} className={btnSecondaryStyles}>Cancel</button>
|
||||
<button type="submit" disabled={isLoading} className={btnPrimaryStyles}>
|
||||
{isLoading ? (isEditMode ? 'Saving...' : 'Adding...') : (isEditMode ? 'Save Changes' : 'Add Client')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ClientFormModal;
|
112
new-frontend/src/components/inbounds/ClientShareModal.tsx
Normal file
112
new-frontend/src/components/inbounds/ClientShareModal.tsx
Normal file
|
@ -0,0 +1,112 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Inbound, ClientSetting } from '@/types/inbound';
|
||||
import { generateSubscriptionLink } from '@/lib/subscriptionLink';
|
||||
import { QRCodeCanvas } from 'qrcode.react';
|
||||
|
||||
const inputStyles = "mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 select-all";
|
||||
const btnSecondaryStyles = "px-4 py-2 bg-gray-200 text-gray-800 font-semibold rounded-lg shadow-md hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 transition-colors text-sm";
|
||||
|
||||
|
||||
interface ClientShareModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
inbound: Inbound | null;
|
||||
client: ClientSetting | null; // For client-specific links (vmess, vless, trojan)
|
||||
// For shadowsocks, client info is mostly in inbound.settings
|
||||
}
|
||||
|
||||
const ClientShareModal: React.FC<ClientShareModalProps> = ({ isOpen, onClose, inbound, client }) => {
|
||||
const [link, setLink] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inbound) {
|
||||
// For Shadowsocks, the 'client' might be a conceptual representation derived from inbound settings,
|
||||
// or we can pass a minimal ClientSetting object.
|
||||
// The generateSubscriptionLink function expects a ClientSetting object.
|
||||
// If protocol is shadowsocks and client prop is null, we might need to construct one.
|
||||
let effectiveClient = client;
|
||||
if (inbound.protocol === 'shadowsocks' && !client) {
|
||||
// Construct a conceptual client for shadowsocks if client prop is null
|
||||
// The link generator for SS primarily uses inbound.settings anyway.
|
||||
effectiveClient = { email: inbound.remark || 'shadowsocks_client' };
|
||||
}
|
||||
|
||||
if (effectiveClient) {
|
||||
setLink(generateSubscriptionLink(inbound, effectiveClient));
|
||||
} else {
|
||||
setLink(null);
|
||||
}
|
||||
setCopied(false);
|
||||
}
|
||||
}, [isOpen, inbound, client]);
|
||||
|
||||
if (!isOpen || !inbound) return null;
|
||||
// If client is null for protocols that require it, link generation will fail.
|
||||
// This is handled by generateSubscriptionLink returning null.
|
||||
|
||||
const handleCopy = () => {
|
||||
if (link) {
|
||||
navigator.clipboard.writeText(link)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
})
|
||||
.catch(err => console.error('Failed to copy link: ', err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50 transition-opacity duration-300 ease-in-out"
|
||||
onClick={onClose} // Close on overlay click
|
||||
>
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-xl w-full max-w-md space-y-4 transform transition-all duration-300 ease-in-out scale-100 opacity-100"
|
||||
onClick={e => e.stopPropagation()} // Prevent modal close when clicking inside modal
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-800 dark:text-gray-100">Share Client Configuration</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 text-2xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{link ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center items-center p-4 border border-gray-200 dark:border-gray-700 rounded-md bg-gray-50 dark:bg-gray-900">
|
||||
<QRCodeCanvas value={link} size={220} bgColor={"#ffffff"} fgColor={"#000000"} level={"M"} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Subscription Link:</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={link}
|
||||
className={`${inputStyles} w-full text-xs`}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={`${btnSecondaryStyles} whitespace-nowrap`}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Note: The server address used ("YOUR_SERVER_IP_OR_DOMAIN") is a placeholder.
|
||||
For this link to work, ensure your actual server address/domain is correctly configured in the generation logic
|
||||
(<code>src/lib/subscriptionLink.ts</code>) or, ideally, fetched from panel settings in a future update.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">Could not generate subscription link for this client/protocol combination.</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-3">
|
||||
<button onClick={onClose} className={btnSecondaryStyles}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ClientShareModal;
|
239
new-frontend/src/components/inbounds/InboundForm.tsx
Normal file
239
new-frontend/src/components/inbounds/InboundForm.tsx
Normal file
|
@ -0,0 +1,239 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, FormEvent } from 'react';
|
||||
import { Inbound, Protocol, ClientSetting } from '@/types/inbound';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import ProtocolClientSettings from './ProtocolClientSettings';
|
||||
import StreamSettingsForm from './stream_settings/StreamSettingsForm'; // Import new StreamSettingsForm
|
||||
|
||||
const availableProtocols: Protocol[] = ["vmess", "vless", "trojan", "shadowsocks", "dokodemo-door", "socks", "http"];
|
||||
const availableShadowsocksCiphers: string[] = [
|
||||
"aes-256-gcm", "aes-128-gcm", "chacha20-poly1305", "xchacha20-poly1305",
|
||||
"2022-blake3-aes-128-gcm", "2022-blake3-aes-256-gcm", "none"
|
||||
];
|
||||
const inputStyles = "mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100";
|
||||
|
||||
interface InboundFormProps {
|
||||
initialData?: Partial<Inbound>;
|
||||
isEditMode?: boolean;
|
||||
formLoading?: boolean;
|
||||
onSubmitForm: (inboundData: Partial<Inbound>) => Promise<void>;
|
||||
}
|
||||
|
||||
const InboundForm: React.FC<InboundFormProps> = ({ initialData, isEditMode = false, formLoading, onSubmitForm }) => {
|
||||
const router = useRouter();
|
||||
const [remark, setRemark] = useState('');
|
||||
const [listen, setListen] = useState('');
|
||||
const [port, setPort] = useState<number | string>('');
|
||||
const [protocol, setProtocol] = useState<Protocol | ''>('');
|
||||
const [enable, setEnable] = useState(true);
|
||||
const [expiryTime, setExpiryTime] = useState<number | string>(0);
|
||||
const [total, setTotal] = useState<number | string>(0);
|
||||
|
||||
const [clientList, setClientList] = useState<ClientSetting[]>([]);
|
||||
const [ssMethod, setSsMethod] = useState(availableShadowsocksCiphers[0]);
|
||||
const [ssPassword, setSsPassword] = useState('');
|
||||
|
||||
const [settingsJson, setSettingsJson] = useState('{}');
|
||||
const [streamSettingsJson, setStreamSettingsJson] = useState('{}');
|
||||
const [sniffingJson, setSniffingJson] = useState('{}');
|
||||
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
const currentProtocol = (initialData.protocol || '') as Protocol;
|
||||
setRemark(initialData.remark || '');
|
||||
setListen(initialData.listen || '');
|
||||
setPort(initialData.port || '');
|
||||
setProtocol(currentProtocol);
|
||||
setEnable(initialData.enable !== undefined ? initialData.enable : true);
|
||||
setExpiryTime(initialData.expiryTime || 0);
|
||||
setTotal(initialData.total && initialData.total > 0 ? initialData.total / (1024 * 1024 * 1024) : 0);
|
||||
|
||||
setStreamSettingsJson(initialData.streamSettings || '{}');
|
||||
setSniffingJson(initialData.sniffing || '{}');
|
||||
|
||||
const initialSettings = initialData.settings || '{}';
|
||||
setSettingsJson(initialSettings); // Initialize settingsJson with all settings
|
||||
|
||||
if ((currentProtocol === 'vmess' || currentProtocol === 'vless' || currentProtocol === 'trojan')) {
|
||||
try {
|
||||
const parsedSettings = JSON.parse(initialSettings);
|
||||
setClientList(parsedSettings.clients || []);
|
||||
} catch (e) { console.error(`Error parsing settings for ${currentProtocol}:`, e); setClientList([]); }
|
||||
} else if (currentProtocol === 'shadowsocks') {
|
||||
try {
|
||||
const parsedSettings = JSON.parse(initialSettings);
|
||||
setSsMethod(parsedSettings.method || availableShadowsocksCiphers[0]);
|
||||
setSsPassword(parsedSettings.password || '');
|
||||
} catch (e) { console.error("Error parsing Shadowsocks settings:", e); }
|
||||
} else {
|
||||
// For other protocols, specific UI states are not used for settings
|
||||
setClientList([]);
|
||||
setSsMethod(availableShadowsocksCiphers[0]); setSsPassword('');
|
||||
}
|
||||
} else {
|
||||
// Reset all fields for a new form
|
||||
setRemark(''); setListen(''); setPort(''); setProtocol(''); setEnable(true);
|
||||
setExpiryTime(0); setTotal(0); setClientList([]);
|
||||
setSsMethod(availableShadowsocksCiphers[0]); setSsPassword('');
|
||||
setSettingsJson('{}'); setStreamSettingsJson('{}'); setSniffingJson('{}');
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
// This useEffect updates settingsJson based on UI changes for client lists or SS settings
|
||||
useEffect(() => {
|
||||
if (protocol === 'vmess' || protocol === 'vless' || protocol === 'trojan' || protocol === 'shadowsocks') {
|
||||
let baseSettings: Record<string, unknown> = {};
|
||||
try {
|
||||
baseSettings = JSON.parse(settingsJson) || {}; // Preserve other settings
|
||||
} catch { /* If settingsJson is invalid, it will be overwritten */ }
|
||||
|
||||
const newSettingsObject = { ...baseSettings }; // Changed to const
|
||||
|
||||
if (protocol === 'vmess' || protocol === 'vless' || protocol === 'trojan') {
|
||||
delete newSettingsObject.method; delete newSettingsObject.password;
|
||||
newSettingsObject.clients = clientList;
|
||||
} else if (protocol === 'shadowsocks') {
|
||||
delete newSettingsObject.clients;
|
||||
newSettingsObject.method = ssMethod;
|
||||
newSettingsObject.password = ssPassword;
|
||||
}
|
||||
|
||||
try {
|
||||
const finalJson = JSON.stringify(newSettingsObject, null, 2);
|
||||
if (finalJson !== settingsJson) { // Only update if there's an actual change
|
||||
setSettingsJson(finalJson);
|
||||
}
|
||||
} catch (e) { console.error("Error stringifying settings:", e); }
|
||||
}
|
||||
// No 'else' needed: for other protocols, settingsJson is managed by its textarea directly.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [clientList, ssMethod, ssPassword, protocol]); // settingsJson is NOT a dependency here.
|
||||
|
||||
|
||||
const handleProtocolChange = (newProtocol: Protocol) => {
|
||||
setProtocol(newProtocol);
|
||||
setFormError(null);
|
||||
|
||||
// When protocol changes, re-initialize specific UI states from current settingsJson
|
||||
let currentSettings: Record<string, unknown> = {}; // Changed any to unknown
|
||||
try { currentSettings = JSON.parse(settingsJson || '{}'); } catch {}
|
||||
|
||||
if (newProtocol === 'vmess' || newProtocol === 'vless' || newProtocol === 'trojan') {
|
||||
if (Array.isArray(currentSettings.clients)) {
|
||||
setClientList(currentSettings.clients as ClientSetting[]);
|
||||
} else {
|
||||
setClientList([]);
|
||||
}
|
||||
setSsMethod(availableShadowsocksCiphers[0]); setSsPassword('');
|
||||
} else if (newProtocol === 'shadowsocks') {
|
||||
setClientList([]);
|
||||
if (typeof currentSettings.method === 'string') {
|
||||
setSsMethod(currentSettings.method);
|
||||
} else {
|
||||
setSsMethod(availableShadowsocksCiphers[0]);
|
||||
}
|
||||
if (typeof currentSettings.password === 'string') {
|
||||
setSsPassword(currentSettings.password);
|
||||
} else {
|
||||
setSsPassword('');
|
||||
}
|
||||
} else {
|
||||
// For "other" protocols, clear all specific UI states
|
||||
setClientList([]);
|
||||
setSsMethod(availableShadowsocksCiphers[0]); setSsPassword('');
|
||||
// settingsJson remains as is, for manual editing
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault(); setFormError(null);
|
||||
if (!protocol) { setFormError("Protocol is required."); return; }
|
||||
if (port === '' || Number(port) <= 0 || Number(port) > 65535) { setFormError("Valid Port (1-65535) is required."); return; }
|
||||
|
||||
// settingsJson should already be up-to-date from the useEffect hook
|
||||
// for vmess/vless/trojan/shadowsocks.
|
||||
// For other protocols, it's taken directly from its textarea.
|
||||
if (protocol === 'shadowsocks' && !ssPassword) {
|
||||
setFormError("Password is required for Shadowsocks."); return;
|
||||
}
|
||||
|
||||
// Validate all JSON fields before submitting
|
||||
for (const [fieldName, jsonStr] of Object.entries({ settings: settingsJson, streamSettings: streamSettingsJson, sniffing: sniffingJson })) {
|
||||
try { JSON.parse(jsonStr); } catch (err) {
|
||||
setFormError(`Invalid JSON in ${fieldName === 'settings' ? 'protocol settings' : fieldName}: ${(err as Error).message}`); return;
|
||||
}
|
||||
}
|
||||
|
||||
const inboundData: Partial<Inbound> = {
|
||||
remark, listen, port: Number(port), protocol, enable,
|
||||
expiryTime: Number(expiryTime), total: Number(total) * 1024 * 1024 * 1024,
|
||||
settings: settingsJson, streamSettings: streamSettingsJson, sniffing: sniffingJson,
|
||||
};
|
||||
|
||||
if (isEditMode && initialData?.id) {
|
||||
inboundData.id = initialData.id;
|
||||
inboundData.up = initialData.up; inboundData.down = initialData.down;
|
||||
}
|
||||
await onSubmitForm(inboundData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 bg-white dark:bg-gray-800 p-4 md:p-6 rounded-lg shadow">
|
||||
{formError && <div className="p-3 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded-md">{formError}</div>}
|
||||
|
||||
<fieldset className="border border-gray-300 dark:border-gray-600 p-4 rounded-md">
|
||||
<legend className="text-lg font-medium text-primary-600 dark:text-primary-400 px-2">Basic Settings</legend>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
|
||||
<div><label htmlFor="remark" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Remark</label><input type="text" id="remark" value={remark} onChange={(e) => setRemark(e.target.value)} className={inputStyles} /></div>
|
||||
<div><label htmlFor="protocol" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Protocol <span className="text-red-500">*</span></label><select id="protocol" value={protocol} onChange={(e) => handleProtocolChange(e.target.value as Protocol)} required className={inputStyles}><option value="" disabled>Select...</option>{availableProtocols.map(p => <option key={p} value={p}>{p}</option>)}</select></div>
|
||||
<div><label htmlFor="listen" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Listen IP</label><input type="text" id="listen" value={listen} onChange={(e) => setListen(e.target.value)} placeholder="Default: 0.0.0.0" className={inputStyles} /></div>
|
||||
<div><label htmlFor="port" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Port <span className="text-red-500">*</span></label><input type="number" id="port" value={port} onChange={(e) => setPort(e.target.value === '' ? '' : Number(e.target.value))} required min="1" max="65535" className={inputStyles} /></div>
|
||||
<div><label htmlFor="total" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Quota (GB)</label><input type="number" id="total" value={total} onChange={(e) => setTotal(e.target.value === '' ? '' : Number(e.target.value))} min="0" placeholder="0 for unlimited" className={inputStyles} /></div>
|
||||
<div><label htmlFor="expiryTime" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Expiry Time (ms)</label><input type="number" id="expiryTime" value={expiryTime} onChange={(e) => setExpiryTime(e.target.value === '' ? '' : Number(e.target.value))} min="0" placeholder="0 for never" className={inputStyles} /></div>
|
||||
<div className="flex items-center space-x-2 md:col-span-2"><input type="checkbox" id="enable" checked={enable} onChange={(e) => setEnable(e.target.checked)} className="h-4 w-4 text-primary-600 border-gray-300 dark:border-gray-500 rounded focus:ring-primary-500 bg-white dark:bg-gray-700" /><label htmlFor="enable" className="text-sm font-medium text-gray-700 dark:text-gray-300">Enable</label></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{protocol && (
|
||||
<fieldset className="border border-gray-300 dark:border-gray-600 p-4 rounded-md">
|
||||
<legend className="text-lg font-medium text-primary-600 dark:text-primary-400 px-2">{protocol} Settings</legend>
|
||||
{(protocol === 'vmess' || protocol === 'vless' || protocol === 'trojan') ? (
|
||||
<ProtocolClientSettings clients={clientList} onChange={setClientList} protocol={protocol} />
|
||||
) : protocol === 'shadowsocks' ? (
|
||||
<div className="space-y-3 mt-2">
|
||||
<div><label htmlFor="ssMethod" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Cipher <span className="text-red-500">*</span></label><select id="ssMethod" value={ssMethod} onChange={(e) => setSsMethod(e.target.value)} required className={inputStyles}>{availableShadowsocksCiphers.map(c => <option key={c} value={c}>{c}</option>)}</select></div>
|
||||
<div><label htmlFor="ssPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Password <span className="text-red-500">*</span></label><input type="text" id="ssPassword" value={ssPassword} onChange={(e) => setSsPassword(e.target.value)} required className={inputStyles} /></div>
|
||||
</div>
|
||||
) : (
|
||||
<textarea value={settingsJson} onChange={(e) => setSettingsJson(e.target.value)} rows={8} className={inputStyles + " font-mono text-sm"} placeholder={`Enter JSON for '${protocol}' settings`}/>
|
||||
)}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<fieldset className="border border-gray-300 dark:border-gray-600 p-4 rounded-md">
|
||||
<legend className="text-lg font-medium text-primary-600 dark:text-primary-400 px-2">Stream Settings</legend>
|
||||
<StreamSettingsForm initialStreamSettingsJson={streamSettingsJson} onChange={setStreamSettingsJson} />
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="border border-gray-300 dark:border-gray-600 p-4 rounded-md">
|
||||
<legend className="text-lg font-medium text-primary-600 dark:text-primary-400 px-2">Sniffing Settings (JSON)</legend>
|
||||
<textarea value={sniffingJson} onChange={(e) => setSniffingJson(e.target.value)} rows={4} className={inputStyles + " font-mono text-sm"} />
|
||||
</fieldset>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button type="button" onClick={() => router.back()}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-500 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-primary-500 dark:focus:ring-offset-gray-800">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={formLoading}
|
||||
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-primary-500 dark:focus:ring-offset-gray-800 disabled:opacity-50">
|
||||
{formLoading ? (isEditMode ? 'Saving...' : 'Creating...') : (isEditMode ? 'Save Changes' : 'Create Inbound')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
export default InboundForm;
|
|
@ -0,0 +1,94 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ClientSetting, Protocol } from '@/types/inbound'; // Assuming Protocol is also in types
|
||||
|
||||
interface ProtocolClientListItemProps {
|
||||
client: ClientSetting;
|
||||
index: number;
|
||||
onUpdateClient: (index: number, updatedClient: ClientSetting) => void;
|
||||
onRemoveClient: (index: number) => void;
|
||||
protocol: Protocol; // Now includes 'trojan'
|
||||
}
|
||||
|
||||
// Helper function to apply input styles directly
|
||||
const inputStyles = "mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100";
|
||||
|
||||
const ProtocolClientListItem: React.FC<ProtocolClientListItemProps> = ({ client, index, onUpdateClient, onRemoveClient, protocol }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editableClient, setEditableClient] = useState<ClientSetting>(client);
|
||||
|
||||
useEffect(() => {
|
||||
setEditableClient(client);
|
||||
}, [client]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
let processedValue: string | number | boolean = value;
|
||||
if (type === 'number') {
|
||||
processedValue = value === '' ? '' : Number(value);
|
||||
}
|
||||
setEditableClient(prev => ({ ...prev, [name]: processedValue }));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdateClient(index, editableClient);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const clientIdentifierField = protocol === 'trojan' ? 'password' : 'id';
|
||||
const clientIdentifierLabel = protocol === 'trojan' ? 'Password:' : 'UUID:';
|
||||
|
||||
return (
|
||||
<div className="border border-gray-300 dark:border-gray-600 p-3 rounded-md mb-3 space-y-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">Email:</label>
|
||||
<input type="email" name="email" value={editableClient.email || ''} onChange={handleInputChange} className={`${inputStyles} text-sm p-1`} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">{clientIdentifierLabel}</label>
|
||||
<input type="text" name={clientIdentifierField} value={clientIdentifierField === 'id' ? editableClient.id || '' : editableClient.password || ''} onChange={handleInputChange} className={`${inputStyles} text-sm p-1`} />
|
||||
</div>
|
||||
{protocol === 'vless' && ( // Only show flow for VLESS
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">Flow:</label>
|
||||
<input type="text" name="flow" value={editableClient.flow || ''} onChange={handleInputChange} className={`${inputStyles} text-sm p-1`} placeholder="e.g., xtls-rprx-vision" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">Quota (GB):</label>
|
||||
<input type="number" name="totalGB" value={editableClient.totalGB === undefined ? '' : editableClient.totalGB} onChange={handleInputChange} className={`${inputStyles} text-sm p-1`} placeholder="0 for unlimited" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">Expiry (Timestamp ms):</label>
|
||||
<input type="number" name="expiryTime" value={editableClient.expiryTime === undefined ? '' : editableClient.expiryTime} onChange={handleInputChange} className={`${inputStyles} text-sm p-1`} placeholder="0 for never" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">Limit IP:</label>
|
||||
<input type="number" name="limitIp" value={editableClient.limitIp === undefined ? '' : editableClient.limitIp} onChange={handleInputChange} className={`${inputStyles} text-sm p-1`} placeholder="0 for unlimited" />
|
||||
</div>
|
||||
<div className="flex space-x-2 mt-2">
|
||||
<button onClick={handleSave} className="px-2 py-1 text-xs bg-green-500 hover:bg-green-600 text-white rounded">Save</button>
|
||||
<button onClick={() => setIsEditing(false)} className="px-2 py-1 text-xs bg-gray-300 hover:bg-gray-400 dark:bg-gray-600 dark:hover:bg-gray-500 rounded">Cancel</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm"><span className="font-medium text-gray-700 dark:text-gray-300">Email:</span> {client.email}</p>
|
||||
<p className="text-sm"><span className="font-medium text-gray-700 dark:text-gray-300">{clientIdentifierLabel}</span> {clientIdentifierField === 'id' ? client.id : client.password}</p>
|
||||
{client.flow && protocol === 'vless' && <p className="text-sm"><span className="font-medium text-gray-700 dark:text-gray-300">Flow:</span> {client.flow}</p>}
|
||||
{client.totalGB !== undefined && <p className="text-sm"><span className="font-medium text-gray-700 dark:text-gray-300">Quota:</span> {client.totalGB > 0 ? `${client.totalGB} GB` : 'Unlimited'}</p>}
|
||||
{client.expiryTime !== undefined && <p className="text-sm"><span className="font-medium text-gray-700 dark:text-gray-300">Expiry:</span> {client.expiryTime > 0 ? new Date(client.expiryTime).toLocaleDateString() : 'Never'}</p>}
|
||||
{client.limitIp !== undefined && <p className="text-sm"><span className="font-medium text-gray-700 dark:text-gray-300">IP Limit:</span> {client.limitIp > 0 ? client.limitIp : 'Unlimited'}</p>}
|
||||
<div className="flex space-x-2 mt-2">
|
||||
<button onClick={() => setIsEditing(true)} className="px-2 py-1 text-xs bg-blue-500 hover:bg-blue-600 text-white rounded">Edit</button>
|
||||
<button onClick={() => onRemoveClient(index)} className="px-2 py-1 text-xs bg-red-500 hover:bg-red-600 text-white rounded">Remove</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ProtocolClientListItem;
|
|
@ -0,0 +1,78 @@
|
|||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { ClientSetting, Protocol } from '@/types/inbound';
|
||||
import ProtocolClientListItem from './ProtocolClientListItem'; // Updated import
|
||||
|
||||
const generateRandomId = (length = 8) => Math.random().toString(36).substring(2, 2 + length);
|
||||
const generateUUID = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
|
||||
interface ProtocolClientSettingsProps {
|
||||
clients: ClientSetting[];
|
||||
onChange: (clients: ClientSetting[]) => void;
|
||||
protocol: Protocol; // Now 'vmess', 'vless', or 'trojan'
|
||||
}
|
||||
|
||||
const ProtocolClientSettings: React.FC<ProtocolClientSettingsProps> = ({ clients, onChange, protocol }) => {
|
||||
|
||||
const addClient = () => {
|
||||
const newClientBase: Partial<ClientSetting> = {
|
||||
email: `user${clients.length + 1}@example.com`,
|
||||
totalGB: 0,
|
||||
expiryTime: 0,
|
||||
limitIp: 0,
|
||||
};
|
||||
|
||||
let newClientSpecific: Partial<ClientSetting> = {};
|
||||
if (protocol === 'vmess' || protocol === 'vless') {
|
||||
newClientSpecific = {
|
||||
id: generateUUID(),
|
||||
flow: protocol === 'vless' ? 'xtls-rprx-vision' : undefined, // Ensure flow is undefined for vmess
|
||||
};
|
||||
} else if (protocol === 'trojan') {
|
||||
newClientSpecific = {
|
||||
password: generateRandomId(12),
|
||||
};
|
||||
}
|
||||
onChange([...clients, { ...newClientBase, ...newClientSpecific } as ClientSetting]);
|
||||
};
|
||||
|
||||
const updateClient = (index: number, updatedClient: ClientSetting) => {
|
||||
const newClients = [...clients];
|
||||
newClients[index] = updatedClient;
|
||||
onChange(newClients);
|
||||
};
|
||||
|
||||
const removeClient = (index: number) => {
|
||||
const newClients = clients.filter((_, i) => i !== index);
|
||||
onChange(newClients);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-md font-medium text-gray-700 dark:text-gray-300 mb-2">Clients</h4>
|
||||
{clients.length === 0 && <p className="text-sm text-gray-500 dark:text-gray-400">No clients configured. Click "Add Client" to begin.</p>}
|
||||
{clients.map((client, index) => (
|
||||
<ProtocolClientListItem
|
||||
key={index}
|
||||
client={client}
|
||||
index={index}
|
||||
onUpdateClient={updateClient}
|
||||
onRemoveClient={removeClient}
|
||||
protocol={protocol} // Pass the protocol down
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addClient}
|
||||
className="mt-2 px-3 py-1.5 text-sm bg-green-500 hover:bg-green-600 text-white rounded-md shadow-sm"
|
||||
>
|
||||
Add Client
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ProtocolClientSettings;
|
|
@ -0,0 +1,127 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
type NetworkType = "tcp" | "kcp" | "ws" | "http" | "grpc" | "quic" | "";
|
||||
type SecurityType = "none" | "tls" | "reality" | "";
|
||||
|
||||
interface StreamSettingsFormProps {
|
||||
initialStreamSettingsJson: string;
|
||||
onChange: (newJsonString: string) => void;
|
||||
}
|
||||
|
||||
const inputStyles = "mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100";
|
||||
|
||||
const StreamSettingsForm: React.FC<StreamSettingsFormProps> = ({ initialStreamSettingsJson, onChange }) => {
|
||||
const [network, setNetwork] = useState<NetworkType>('');
|
||||
const [security, setSecurity] = useState<SecurityType>('none');
|
||||
const [specificSettingsJson, setSpecificSettingsJson] = useState('{}');
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const parseAndSetStates = useCallback((jsonString: string) => {
|
||||
try {
|
||||
const parsed = jsonString && jsonString.trim() !== "" ? JSON.parse(jsonString) : {};
|
||||
setNetwork(parsed.network || '');
|
||||
setSecurity(parsed.security || 'none');
|
||||
|
||||
// Destructure again to get 'rest' after 'network' and 'security' have been read
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { network: _, security: __, ...rest } = parsed;
|
||||
setSpecificSettingsJson(Object.keys(rest).length > 0 ? JSON.stringify(rest, null, 2) : '{}');
|
||||
setError('');
|
||||
} catch (err) { // Use err
|
||||
setError('Stream Settings JSON is invalid. Displaying raw content for correction.');
|
||||
setSpecificSettingsJson(jsonString);
|
||||
console.error("Error parsing initial stream settings:", err); // Log error
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
parseAndSetStates(initialStreamSettingsJson);
|
||||
}, [initialStreamSettingsJson, parseAndSetStates]);
|
||||
|
||||
const reconstructAndCallback = useCallback(() => {
|
||||
try {
|
||||
const specificParsed = JSON.parse(specificSettingsJson || '{}');
|
||||
const combined: Record<string, unknown> = {}; // Use Record<string, unknown>
|
||||
if (network) combined.network = network;
|
||||
if (security && security !== 'none') combined.security = security;
|
||||
|
||||
const finalCombined = { ...specificParsed, ...combined };
|
||||
if (!finalCombined.network) delete finalCombined.network;
|
||||
if (!finalCombined.security) delete finalCombined.security;
|
||||
|
||||
|
||||
const finalJsonString = Object.keys(finalCombined).length === 0 ? '{}' : JSON.stringify(finalCombined, null, 2);
|
||||
onChange(finalJsonString);
|
||||
setError('');
|
||||
} catch (err) { // Use err
|
||||
setError('Invalid JSON in specific settings details. Fix to see combined output.');
|
||||
console.error("Error reconstructing stream settings JSON:", err); // Log error
|
||||
}
|
||||
}, [network, security, specificSettingsJson, onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
reconstructAndCallback();
|
||||
}, [network, security, specificSettingsJson, reconstructAndCallback]);
|
||||
|
||||
const handleSpecificSettingsChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setSpecificSettingsJson(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && <p className="text-sm text-red-500 dark:text-red-400 mb-2">{error}</p>}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="stream-network" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Network</label>
|
||||
<select
|
||||
id="stream-network"
|
||||
value={network}
|
||||
onChange={(e) => setNetwork(e.target.value as NetworkType)}
|
||||
className={`mt-1 w-full ${inputStyles}`}
|
||||
>
|
||||
<option value="">(Detect from JSON or None)</option>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="kcp">mKCP</option>
|
||||
<option value="ws">WebSocket</option>
|
||||
<option value="http">HTTP/2 (H2)</option>
|
||||
<option value="grpc">gRPC</option>
|
||||
<option value="quic">QUIC</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="stream-security" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Security</label>
|
||||
<select
|
||||
id="stream-security"
|
||||
value={security}
|
||||
onChange={(e) => setSecurity(e.target.value as SecurityType)}
|
||||
className={`mt-1 w-full ${inputStyles}`}
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="tls">TLS</option>
|
||||
<option value="reality">REALITY</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="stream-specific-settings" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Specific Network/Security Settings (JSON for details like tcpSettings, wsSettings, tlsSettings, etc.)
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Edit the JSON details for the selected network/security type here.
|
||||
The 'network' and 'security' fields above will be included in the final JSON.
|
||||
</p>
|
||||
<textarea
|
||||
id="stream-specific-settings"
|
||||
value={specificSettingsJson}
|
||||
onChange={handleSpecificSettingsChange}
|
||||
rows={10}
|
||||
className={`mt-1 w-full font-mono text-sm ${inputStyles}`}
|
||||
placeholder='e.g., { "wsSettings": { "path": "/ws" }, "tlsSettings": { "serverName": "domain.com" } }'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default StreamSettingsForm;
|
0
new-frontend/src/components/layout/.gitkeep
Normal file
0
new-frontend/src/components/layout/.gitkeep
Normal file
36
new-frontend/src/components/layout/Header.tsx
Normal file
36
new-frontend/src/components/layout/Header.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
import React from 'react';
|
||||
|
||||
interface HeaderProps {
|
||||
toggleSidebar: () => void;
|
||||
toggleTheme: () => void;
|
||||
currentTheme: string;
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ toggleSidebar, toggleTheme, currentTheme }) => {
|
||||
return (
|
||||
<header className="bg-primary-500 dark:bg-gray-800 text-white p-4 shadow-md">
|
||||
<div className="container mx-auto flex justify-between items-center">
|
||||
{/* Burger menu for mobile */}
|
||||
<button onClick={toggleSidebar} className="md:hidden p-2 rounded hover:bg-primary-600 dark:hover:bg-gray-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-xl font-semibold">3X-UI Panel</h1>
|
||||
<button onClick={toggleTheme} className="p-2 rounded hover:bg-primary-600 dark:hover:bg-gray-700">
|
||||
{currentTheme === 'dark' ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21c3.73 0 7.01-1.739 9.002-4.498z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
export default Header;
|
66
new-frontend/src/components/layout/MainLayout.tsx
Normal file
66
new-frontend/src/components/layout/MainLayout.tsx
Normal file
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { usePathname } from 'next/navigation'; // Import usePathname
|
||||
import Header from './Header';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
const MainLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [theme, setTheme] = useState('light');
|
||||
const pathname = usePathname(); // Get current path
|
||||
|
||||
// Determine if the current path is an authentication-related page
|
||||
const isAuthPage = pathname.startsWith('/auth');
|
||||
|
||||
useEffect(() => {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
if (storedTheme) {
|
||||
setTheme(storedTheme);
|
||||
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
setTheme('dark');
|
||||
}
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
setTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
};
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setSidebarOpen(!sidebarOpen);
|
||||
};
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
// If it's an auth page, render children directly without main layout
|
||||
if (isAuthPage) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-gray-50 dark:bg-gray-950 transition-colors duration-300">
|
||||
<Header toggleSidebar={toggleSidebar} toggleTheme={toggleTheme} currentTheme={theme} />
|
||||
<div className="flex flex-1">
|
||||
<Sidebar isOpen={sidebarOpen} toggleSidebar={toggleSidebar} />
|
||||
<main className="flex-1 p-4 md:p-6 lg:p-8 transition-all duration-300 md:ml-64">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default MainLayout;
|
69
new-frontend/src/components/layout/Sidebar.tsx
Normal file
69
new-frontend/src/components/layout/Sidebar.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
"use client";
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/context/AuthContext'; // Import useAuth
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: 'D' },
|
||||
{ name: 'Inbounds', href: '/inbounds', icon: 'I' },
|
||||
{ name: 'Settings', href: '/settings', icon: 'S' },
|
||||
];
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ isOpen, toggleSidebar }) => {
|
||||
const { logout, isLoading, user } = useAuth(); // Added user to display username
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-20 bg-black opacity-50 md:hidden"
|
||||
onClick={toggleSidebar}
|
||||
></div>
|
||||
)}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-30 w-64 bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 transform ${
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
} md:translate-x-0 transition-transform duration-300 ease-in-out shadow-lg flex flex-col`}
|
||||
>
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-bold text-primary-500 dark:text-primary-400">3X-UI Panel</h2>
|
||||
{user && <span className="text-sm text-gray-600 dark:text-gray-400">Welcome, {user.username}</span>}
|
||||
</div>
|
||||
<nav className="flex-grow p-4">
|
||||
<ul>
|
||||
{navItems.map((item) => (
|
||||
<li key={item.name} className="mb-2">
|
||||
<Link href={item.href} className="flex items-center p-2 rounded-md hover:bg-primary-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-300 transition-colors">
|
||||
<span className="mr-3 w-6 h-6 flex items-center justify-center bg-primary-200 dark:bg-gray-600 rounded-full text-sm">{/* Icon placeholder */} {item.icon}</span>
|
||||
{item.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="p-4 mt-auto border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center justify-center p-2 rounded-md bg-red-500 hover:bg-red-600 text-white dark:bg-red-600 dark:hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5 mr-2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
||||
</svg>
|
||||
{isLoading ? 'Logging out...' : 'Logout'}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default Sidebar;
|
129
new-frontend/src/components/settings/OtherSettingsForm.tsx
Normal file
129
new-frontend/src/components/settings/OtherSettingsForm.tsx
Normal file
|
@ -0,0 +1,129 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, FormEvent } from 'react';
|
||||
import { AllSetting } from '@/types/settings';
|
||||
|
||||
// Define styles locally
|
||||
const inputStyles = "mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100";
|
||||
const btnPrimaryStyles = "px-6 py-2 bg-primary-500 text-white font-semibold rounded-lg shadow-md hover:bg-primary-600 disabled:opacity-50 transition-colors";
|
||||
|
||||
interface OtherSettingsFormProps {
|
||||
initialSettings: Partial<AllSetting>;
|
||||
onSave: (updatedSettings: Partial<AllSetting>) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
formError?: string | null;
|
||||
}
|
||||
|
||||
const OtherSettingsForm: React.FC<OtherSettingsFormProps> = ({
|
||||
initialSettings, onSave, isLoading, formError
|
||||
}) => {
|
||||
const [remarkModel, setRemarkModel] = useState('');
|
||||
const [expireDiff, setExpireDiff] = useState<number | string>('');
|
||||
const [trafficDiff, setTrafficDiff] = useState<number | string>('');
|
||||
const [externalTrafficInformEnable, setExternalTrafficInformEnable] = useState(false);
|
||||
const [externalTrafficInformURI, setExternalTrafficInformURI] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setRemarkModel(initialSettings.remarkModel || '');
|
||||
setExpireDiff(initialSettings.expireDiff === undefined ? '' : initialSettings.expireDiff);
|
||||
setTrafficDiff(initialSettings.trafficDiff === undefined ? '' : initialSettings.trafficDiff);
|
||||
setExternalTrafficInformEnable(initialSettings.externalTrafficInformEnable || false);
|
||||
setExternalTrafficInformURI(initialSettings.externalTrafficInformURI || '');
|
||||
}, [initialSettings]);
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const updatedOtherSettings: Partial<AllSetting> = {
|
||||
...initialSettings,
|
||||
remarkModel,
|
||||
expireDiff: Number(expireDiff) || 0,
|
||||
trafficDiff: Number(trafficDiff) || 0,
|
||||
externalTrafficInformEnable,
|
||||
externalTrafficInformURI,
|
||||
};
|
||||
onSave(updatedOtherSettings);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 p-6">
|
||||
{formError && <div className="p-3 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded-md">{formError}</div>}
|
||||
|
||||
<fieldset className="border border-gray-300 dark:border-gray-600 p-4 rounded-md">
|
||||
<legend className="text-lg font-medium text-primary-600 dark:text-primary-400 px-2">Miscellaneous Settings</legend>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4 mt-2">
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="remarkModel" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Remark Template Model</label>
|
||||
<input
|
||||
type="text"
|
||||
id="remarkModel"
|
||||
value={remarkModel}
|
||||
onChange={e => setRemarkModel(e.target.value)}
|
||||
className={`mt-1 w-full ${inputStyles}`}
|
||||
placeholder="e.g., {protocol}-{port}-{id}"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Variables: { "{protocol}, {port}, {id}, {email}, {rand(int)}" }, etc.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="expireDiff" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Expire Notification Threshold (days)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="expireDiff"
|
||||
value={expireDiff}
|
||||
onChange={e => setExpireDiff(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
min="0"
|
||||
className={`mt-1 w-full ${inputStyles}`}
|
||||
placeholder="e.g., 7"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="trafficDiff" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Traffic Notification Threshold (GB)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="trafficDiff"
|
||||
value={trafficDiff}
|
||||
onChange={e => setTrafficDiff(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
min="0"
|
||||
className={`mt-1 w-full ${inputStyles}`}
|
||||
placeholder="e.g., 5"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Unit is assumed to be GB by panel. Backend handles interpretation.</p>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex items-center space-x-3 mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExternalTrafficInformEnable(!externalTrafficInformEnable)}
|
||||
className={`${externalTrafficInformEnable ? 'bg-primary-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex items-center h-6 rounded-full w-11 transition-colors focus:outline-none`}
|
||||
>
|
||||
<span className="sr-only">Toggle External Traffic Inform</span>
|
||||
<span className={`${externalTrafficInformEnable ? 'translate-x-6' : 'translate-x-1'} inline-block w-4 h-4 transform bg-white rounded-full transition-transform`} />
|
||||
</button>
|
||||
<label htmlFor="externalTrafficInformEnable" className="text-sm font-medium text-gray-700 dark:text-gray-300">Enable External Traffic Informer</label>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="externalTrafficInformURI" className="block text-sm font-medium text-gray-700 dark:text-gray-300">External Traffic Informer URI</label>
|
||||
<input
|
||||
type="text"
|
||||
id="externalTrafficInformURI"
|
||||
value={externalTrafficInformURI}
|
||||
onChange={e => setExternalTrafficInformURI(e.target.value)}
|
||||
className={`mt-1 w-full ${inputStyles}`}
|
||||
disabled={!externalTrafficInformEnable}
|
||||
placeholder="e.g., http://your-service.com/traffic_update"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<button type="submit" disabled={isLoading} className={btnPrimaryStyles}>
|
||||
{isLoading ? 'Saving...' : 'Save Other Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
export default OtherSettingsForm;
|
133
new-frontend/src/components/settings/PanelSettingsForm.tsx
Normal file
133
new-frontend/src/components/settings/PanelSettingsForm.tsx
Normal file
|
@ -0,0 +1,133 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, FormEvent } from 'react';
|
||||
import { AllSetting } from '@/types/settings';
|
||||
|
||||
// Define inputStyles locally
|
||||
const inputStyles = "mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100";
|
||||
const btnPrimaryStyles = "px-6 py-2 bg-primary-500 text-white font-semibold rounded-lg shadow-md hover:bg-primary-600 disabled:opacity-50 transition-colors";
|
||||
|
||||
|
||||
interface PanelSettingsFormProps {
|
||||
initialSettings: Partial<AllSetting>;
|
||||
onSave: (updatedSettings: Partial<AllSetting>) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
formError?: string | null;
|
||||
}
|
||||
|
||||
const PanelSettingsForm: React.FC<PanelSettingsFormProps> = ({ initialSettings, onSave, isLoading, formError }) => {
|
||||
const [webListen, setWebListen] = useState('');
|
||||
const [webDomain, setWebDomain] = useState('');
|
||||
const [webPort, setWebPort] = useState<number | string>('');
|
||||
const [webCertFile, setWebCertFile] = useState('');
|
||||
const [webKeyFile, setWebKeyFile] = useState('');
|
||||
const [webBasePath, setWebBasePath] = useState('');
|
||||
const [sessionMaxAge, setSessionMaxAge] = useState<number | string>('');
|
||||
const [pageSize, setPageSize] = useState<number | string>('');
|
||||
const [timeLocation, setTimeLocation] = useState('');
|
||||
const [datepicker, setDatepicker] = useState<'gregorian' | 'jalali' | string>('gregorian');
|
||||
|
||||
useEffect(() => {
|
||||
setWebListen(initialSettings.webListen || '');
|
||||
setWebDomain(initialSettings.webDomain || '');
|
||||
setWebPort(initialSettings.webPort || '');
|
||||
setWebCertFile(initialSettings.webCertFile || '');
|
||||
setWebKeyFile(initialSettings.webKeyFile || '');
|
||||
setWebBasePath(initialSettings.webBasePath || '');
|
||||
setSessionMaxAge(initialSettings.sessionMaxAge || '');
|
||||
setPageSize(initialSettings.pageSize || 10); // Default page size
|
||||
setTimeLocation(initialSettings.timeLocation || 'Asia/Tehran');
|
||||
setDatepicker(initialSettings.datepicker || 'gregorian');
|
||||
}, [initialSettings]);
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const updatedPanelSettings: Partial<AllSetting> = {
|
||||
// It's crucial to only send fields that this form is responsible for,
|
||||
// or ensure the backend handles partial updates correctly without nullifying other settings.
|
||||
// For /setting/update, we send the whole AllSettings object usually.
|
||||
// So, merge with initialSettings to keep other tabs' data.
|
||||
...initialSettings,
|
||||
webListen,
|
||||
webDomain,
|
||||
webPort: Number(webPort) || undefined,
|
||||
webCertFile,
|
||||
webKeyFile,
|
||||
webBasePath,
|
||||
sessionMaxAge: Number(sessionMaxAge) || undefined,
|
||||
pageSize: Number(pageSize) || undefined,
|
||||
timeLocation,
|
||||
datepicker,
|
||||
};
|
||||
onSave(updatedPanelSettings);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{formError && <div className="p-3 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded-md">{formError}</div>}
|
||||
|
||||
<fieldset className="border border-gray-300 dark:border-gray-600 p-4 rounded-md">
|
||||
<legend className="text-lg font-medium text-primary-600 dark:text-primary-400 px-2">Web Server Settings</legend>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4 mt-2">
|
||||
<div>
|
||||
<label htmlFor="webListen" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Listen IP</label>
|
||||
<input type="text" id="webListen" value={webListen} onChange={e => setWebListen(e.target.value)} className={`mt-1 w-full ${inputStyles}`} placeholder="0.0.0.0 for all interfaces"/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="webPort" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Listen Port <span className="text-red-500">*</span></label>
|
||||
<input type="number" id="webPort" value={webPort} onChange={e => setWebPort(e.target.value === '' ? '' : Number(e.target.value))} required min="1" max="65535" className={`mt-1 w-full ${inputStyles}`}/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="webDomain" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Panel Domain</label>
|
||||
<input type="text" id="webDomain" value={webDomain} onChange={e => setWebDomain(e.target.value)} className={`mt-1 w-full ${inputStyles}`} placeholder="e.g., yourdomain.com"/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="webBasePath" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Base Path</label>
|
||||
<input type="text" id="webBasePath" value={webBasePath} onChange={e => setWebBasePath(e.target.value)} className={`mt-1 w-full ${inputStyles}`} placeholder="e.g., /xui/"/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="webCertFile" className="block text-sm font-medium text-gray-700 dark:text-gray-300">SSL Certificate File Path</label>
|
||||
<input type="text" id="webCertFile" value={webCertFile} onChange={e => setWebCertFile(e.target.value)} className={`mt-1 w-full ${inputStyles}`} placeholder="/path/to/cert.pem"/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="webKeyFile" className="block text-sm font-medium text-gray-700 dark:text-gray-300">SSL Key File Path</label>
|
||||
<input type="text" id="webKeyFile" value={webKeyFile} onChange={e => setWebKeyFile(e.target.value)} className={`mt-1 w-full ${inputStyles}`} placeholder="/path/to/key.pem"/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="sessionMaxAge" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Session Max Age (minutes)</label>
|
||||
<input type="number" id="sessionMaxAge" value={sessionMaxAge} onChange={e => setSessionMaxAge(e.target.value === '' ? '' : Number(e.target.value))} min="1" className={`mt-1 w-full ${inputStyles}`}/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="border border-gray-300 dark:border-gray-600 p-4 rounded-md">
|
||||
<legend className="text-lg font-medium text-primary-600 dark:text-primary-400 px-2">Panel UI & General</legend>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4 mt-2">
|
||||
<div>
|
||||
<label htmlFor="pageSize" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Items Per Page (Tables)</label>
|
||||
<input type="number" id="pageSize" value={pageSize} onChange={e => setPageSize(e.target.value === '' ? '' : Number(e.target.value))} min="1" className={`mt-1 w-full ${inputStyles}`}/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="timeLocation" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Timezone</label>
|
||||
<input type="text" id="timeLocation" value={timeLocation} onChange={e => setTimeLocation(e.target.value)} className={`mt-1 w-full ${inputStyles}`} placeholder="e.g., Asia/Tehran or UTC"/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Use TZ Database Name (e.g., America/New_York, Europe/London).</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="datepicker" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Date Picker Type</label>
|
||||
<select id="datepicker" value={datepicker} onChange={e => setDatepicker(e.target.value)} className={`mt-1 w-full ${inputStyles}`}>
|
||||
<option value="gregorian">Gregorian</option>
|
||||
<option value="jalali">Jalali (Persian)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<button type="submit" disabled={isLoading} className={btnPrimaryStyles}>
|
||||
{isLoading ? 'Saving...' : 'Save Panel Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
export default PanelSettingsForm;
|
|
@ -0,0 +1,163 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, FormEvent } from 'react';
|
||||
import { AllSetting } from '@/types/settings';
|
||||
|
||||
// Define styles locally
|
||||
const inputStyles = "mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100";
|
||||
const btnPrimaryStyles = "px-6 py-2 bg-primary-500 text-white font-semibold rounded-lg shadow-md hover:bg-primary-600 disabled:opacity-50 transition-colors";
|
||||
|
||||
interface SubscriptionSettingsFormProps {
|
||||
initialSettings: Partial<AllSetting>;
|
||||
onSave: (updatedSettings: Partial<AllSetting>) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
formError?: string | null;
|
||||
}
|
||||
|
||||
const SubscriptionSettingsForm: React.FC<SubscriptionSettingsFormProps> = ({
|
||||
initialSettings, onSave, isLoading, formError
|
||||
}) => {
|
||||
const [subEnable, setSubEnable] = useState(false);
|
||||
const [subTitle, setSubTitle] = useState('');
|
||||
const [subListen, setSubListen] = useState('');
|
||||
const [subPort, setSubPort] = useState<number | string>('');
|
||||
const [subPath, setSubPath] = useState('');
|
||||
const [subDomain, setSubDomain] = useState('');
|
||||
const [subCertFile, setSubCertFile] = useState('');
|
||||
const [subKeyFile, setSubKeyFile] = useState('');
|
||||
const [subUpdates, setSubUpdates] = useState<number | string>('');
|
||||
const [subEncrypt, setSubEncrypt] = useState(false);
|
||||
const [subShowInfo, setSubShowInfo] = useState(false);
|
||||
|
||||
const [subURI, setSubURI] = useState('');
|
||||
const [subJsonPath, setSubJsonPath] = useState('');
|
||||
const [subJsonURI, setSubJsonURI] = useState('');
|
||||
const [subJsonFragment, setSubJsonFragment] = useState('');
|
||||
const [subJsonNoises, setSubJsonNoises] = useState('');
|
||||
const [subJsonMux, setSubJsonMux] = useState('');
|
||||
const [subJsonRules, setSubJsonRules] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setSubEnable(initialSettings.subEnable || false);
|
||||
setSubTitle(initialSettings.subTitle || '');
|
||||
setSubListen(initialSettings.subListen || '');
|
||||
setSubPort(initialSettings.subPort === undefined ? '' : initialSettings.subPort);
|
||||
setSubPath(initialSettings.subPath || '');
|
||||
setSubDomain(initialSettings.subDomain || '');
|
||||
setSubCertFile(initialSettings.subCertFile || '');
|
||||
setSubKeyFile(initialSettings.subKeyFile || '');
|
||||
setSubUpdates(initialSettings.subUpdates === undefined ? '' : initialSettings.subUpdates);
|
||||
setSubEncrypt(initialSettings.subEncrypt || false);
|
||||
setSubShowInfo(initialSettings.subShowInfo || false);
|
||||
|
||||
setSubURI(initialSettings.subURI || '');
|
||||
setSubJsonPath(initialSettings.subJsonPath || '');
|
||||
setSubJsonURI(initialSettings.subJsonURI || '');
|
||||
setSubJsonFragment(initialSettings.subJsonFragment || '');
|
||||
setSubJsonNoises(initialSettings.subJsonNoises || '');
|
||||
setSubJsonMux(initialSettings.subJsonMux || '');
|
||||
setSubJsonRules(initialSettings.subJsonRules || '');
|
||||
}, [initialSettings]);
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const updatedSubSettings: Partial<AllSetting> = {
|
||||
...initialSettings,
|
||||
subEnable, subTitle, subListen,
|
||||
subPort: Number(subPort) || undefined,
|
||||
subPath, subDomain, subCertFile, subKeyFile,
|
||||
subUpdates: Number(subUpdates) || undefined,
|
||||
subEncrypt, subShowInfo,
|
||||
subURI, subJsonPath, subJsonURI, subJsonFragment,
|
||||
subJsonNoises, subJsonMux, subJsonRules,
|
||||
};
|
||||
onSave(updatedSubSettings);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 p-6">
|
||||
{formError && <div className="p-3 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded-md">{formError}</div>}
|
||||
|
||||
<fieldset className="border border-gray-300 dark:border-gray-600 p-4 rounded-md">
|
||||
<legend className="text-lg font-medium text-primary-600 dark:text-primary-400 px-2">Subscription Link Settings</legend>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4 mt-2">
|
||||
<div className="md:col-span-2 flex items-center space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubEnable(!subEnable)}
|
||||
className={`${subEnable ? 'bg-primary-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex items-center h-6 rounded-full w-11 transition-colors focus:outline-none`}
|
||||
>
|
||||
<span className="sr-only">Toggle Subscription Link</span>
|
||||
<span className={`${subEnable ? 'translate-x-6' : 'translate-x-1'} inline-block w-4 h-4 transform bg-white rounded-full transition-transform`} />
|
||||
</button>
|
||||
<label htmlFor="subEnable" className="text-sm font-medium text-gray-700 dark:text-gray-300">Enable Subscription Link Server</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="subTitle" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Subscription Title</label>
|
||||
<input type="text" id="subTitle" value={subTitle} onChange={e => setSubTitle(e.target.value)} className={`mt-1 w-full ${inputStyles}`} disabled={!subEnable}/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="subDomain" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Subscription Domain</label>
|
||||
<input type="text" id="subDomain" value={subDomain} onChange={e => setSubDomain(e.target.value)} className={`mt-1 w-full ${inputStyles}`} disabled={!subEnable} placeholder="e.g., sub.yourdomain.com"/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="subListen" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Subscription Listen IP</label>
|
||||
<input type="text" id="subListen" value={subListen} onChange={e => setSubListen(e.target.value)} className={`mt-1 w-full ${inputStyles}`} disabled={!subEnable} placeholder="Leave blank for panel IP"/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="subPort" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Subscription Port</label>
|
||||
<input type="number" id="subPort" value={subPort} onChange={e => setSubPort(e.target.value === '' ? '' : Number(e.target.value))} min="1" max="65535" className={`mt-1 w-full ${inputStyles}`} disabled={!subEnable} placeholder="Leave blank for panel port"/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="subPath" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Subscription Path</label>
|
||||
<input type="text" id="subPath" value={subPath} onChange={e => setSubPath(e.target.value)} className={`mt-1 w-full ${inputStyles}`} disabled={!subEnable} placeholder="e.g., /subscribe"/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="subUpdates" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Update Interval (hours)</label>
|
||||
<input type="number" id="subUpdates" value={subUpdates} onChange={e => setSubUpdates(e.target.value === '' ? '' : Number(e.target.value))} min="1" className={`mt-1 w-full ${inputStyles}`} disabled={!subEnable}/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="subCertFile" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Subscription SSL Cert Path</label>
|
||||
<input type="text" id="subCertFile" value={subCertFile} onChange={e => setSubCertFile(e.target.value)} className={`mt-1 w-full ${inputStyles}`} disabled={!subEnable} placeholder="/path/to/sub_cert.pem"/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="subKeyFile" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Subscription SSL Key Path</label>
|
||||
<input type="text" id="subKeyFile" value={subKeyFile} onChange={e => setSubKeyFile(e.target.value)} className={`mt-1 w-full ${inputStyles}`} disabled={!subEnable} placeholder="/path/to/sub_key.pem"/>
|
||||
</div>
|
||||
<div className="md:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<input type="checkbox" id="subEncrypt" checked={subEncrypt} onChange={e => setSubEncrypt(e.target.checked)} disabled={!subEnable} className="h-4 w-4 text-primary-600 border-gray-300 dark:border-gray-600 rounded focus:ring-primary-500 bg-white dark:bg-gray-700"/>
|
||||
<label htmlFor="subEncrypt" className="text-sm font-medium text-gray-700 dark:text-gray-300">Encrypt Subscription</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<input type="checkbox" id="subShowInfo" checked={subShowInfo} onChange={e => setSubShowInfo(e.target.checked)} disabled={!subEnable} className="h-4 w-4 text-primary-600 border-gray-300 dark:border-gray-600 rounded focus:ring-primary-500 bg-white dark:bg-gray-700"/>
|
||||
<label htmlFor="subShowInfo" className="text-sm font-medium text-gray-700 dark:text-gray-300">Show More Info in Subscription</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="border border-gray-300 dark:border-gray-600 p-4 rounded-md">
|
||||
<legend className="text-lg font-medium text-primary-600 dark:text-primary-400 px-2">Advanced Subscription JSON Settings</legend>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">These settings are for advanced customization of subscription links, especially for specific client compatibility.</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4 mt-2">
|
||||
<div><label htmlFor="subURI" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Subscription URI (override)</label><input type="text" id="subURI" value={subURI} onChange={e => setSubURI(e.target.value)} className={`mt-1 w-full ${inputStyles}`} disabled={!subEnable}/></div>
|
||||
<div><label htmlFor="subJsonPath" className="block text-sm font-medium text-gray-700 dark:text-gray-300">JSON Subscription Path</label><input type="text" id="subJsonPath" value={subJsonPath} onChange={e => setSubJsonPath(e.target.value)} className={`mt-1 w-full ${inputStyles}`} disabled={!subEnable}/></div>
|
||||
<div><label htmlFor="subJsonURI" className="block text-sm font-medium text-gray-700 dark:text-gray-300">JSON Subscription URI (override)</label><input type="text" id="subJsonURI" value={subJsonURI} onChange={e => setSubJsonURI(e.target.value)} className={`mt-1 w-full ${inputStyles}`} disabled={!subEnable}/></div>
|
||||
<div><label htmlFor="subJsonFragment" className="block text-sm font-medium text-gray-700 dark:text-gray-300">JSON Fragment Mode</label><input type="text" id="subJsonFragment" value={subJsonFragment} onChange={e => setSubJsonFragment(e.target.value)} className={`mt-1 w-full ${inputStyles}`} disabled={!subEnable} placeholder="e.g., true/false or specific mode"/></div>
|
||||
<div><label htmlFor="subJsonNoises" className="block text-sm font-medium text-gray-700 dark:text-gray-300">JSON Noises</label><input type="text" id="subJsonNoises" value={subJsonNoises} onChange={e => setSubJsonNoises(e.target.value)} className={`mt-1 w-full ${inputStyles}`} disabled={!subEnable} placeholder="e.g., 5,10"/></div>
|
||||
<div><label htmlFor="subJsonMux" className="block text-sm font-medium text-gray-700 dark:text-gray-300">JSON Mux (override)</label><input type="text" id="subJsonMux" value={subJsonMux} onChange={e => setSubJsonMux(e.target.value)} className={`mt-1 w-full ${inputStyles}`} disabled={!subEnable} placeholder="e.g., true/false"/></div>
|
||||
<div className="md:col-span-2"><label htmlFor="subJsonRules" className="block text-sm font-medium text-gray-700 dark:text-gray-300">JSON Rules (JSON string)</label><textarea id="subJsonRules" value={subJsonRules} onChange={e => setSubJsonRules(e.target.value)} rows={3} className={`mt-1 w-full font-mono text-sm ${inputStyles}`} disabled={!subEnable}/></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<button type="submit" disabled={isLoading} className={btnPrimaryStyles}>
|
||||
{isLoading ? 'Saving...' : 'Save Subscription Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
export default SubscriptionSettingsForm;
|
136
new-frontend/src/components/settings/TelegramSettingsForm.tsx
Normal file
136
new-frontend/src/components/settings/TelegramSettingsForm.tsx
Normal file
|
@ -0,0 +1,136 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, FormEvent } from 'react';
|
||||
import { AllSetting } from '@/types/settings';
|
||||
|
||||
// Define styles locally
|
||||
const inputStyles = "mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100";
|
||||
const btnPrimaryStyles = "px-6 py-2 bg-primary-500 text-white font-semibold rounded-lg shadow-md hover:bg-primary-600 disabled:opacity-50 transition-colors";
|
||||
|
||||
interface TelegramSettingsFormProps {
|
||||
initialSettings: Partial<AllSetting>;
|
||||
onSave: (updatedSettings: Partial<AllSetting>) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
formError?: string | null;
|
||||
}
|
||||
|
||||
const availableTgLangs = ["en", "fa", "ru", "zh-cn"]; // Example languages
|
||||
|
||||
const TelegramSettingsForm: React.FC<TelegramSettingsFormProps> = ({
|
||||
initialSettings, onSave, isLoading, formError
|
||||
}) => {
|
||||
const [tgBotEnable, setTgBotEnable] = useState(false);
|
||||
const [tgBotToken, setTgBotToken] = useState('');
|
||||
const [tgBotProxy, setTgBotProxy] = useState('');
|
||||
const [tgBotAPIServer, setTgBotAPIServer] = useState('');
|
||||
const [tgBotChatId, setTgBotChatId] = useState('');
|
||||
const [tgRunTime, setTgRunTime] = useState('');
|
||||
const [tgBotBackup, setTgBotBackup] = useState(false);
|
||||
const [tgBotLoginNotify, setTgBotLoginNotify] = useState(false);
|
||||
const [tgCpu, setTgCpu] = useState<number | string>('');
|
||||
const [tgLang, setTgLang] = useState('en');
|
||||
|
||||
useEffect(() => {
|
||||
setTgBotEnable(initialSettings.tgBotEnable || false);
|
||||
setTgBotToken(initialSettings.tgBotToken || '');
|
||||
setTgBotProxy(initialSettings.tgBotProxy || '');
|
||||
setTgBotAPIServer(initialSettings.tgBotAPIServer || '');
|
||||
setTgBotChatId(initialSettings.tgBotChatId || '');
|
||||
setTgRunTime(initialSettings.tgRunTime || '');
|
||||
setTgBotBackup(initialSettings.tgBotBackup || false);
|
||||
setTgBotLoginNotify(initialSettings.tgBotLoginNotify || false);
|
||||
setTgCpu(initialSettings.tgCpu === undefined ? '' : initialSettings.tgCpu);
|
||||
setTgLang(initialSettings.tgLang || 'en');
|
||||
}, [initialSettings]);
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const updatedTgSettings: Partial<AllSetting> = {
|
||||
...initialSettings,
|
||||
tgBotEnable,
|
||||
tgBotToken,
|
||||
tgBotProxy,
|
||||
tgBotAPIServer,
|
||||
tgBotChatId,
|
||||
tgRunTime,
|
||||
tgBotBackup,
|
||||
tgBotLoginNotify,
|
||||
tgCpu: Number(tgCpu) || 0,
|
||||
tgLang,
|
||||
};
|
||||
onSave(updatedTgSettings);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 p-6">
|
||||
{formError && <div className="p-3 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded-md">{formError}</div>}
|
||||
|
||||
<fieldset className="border border-gray-300 dark:border-gray-600 p-4 rounded-md">
|
||||
<legend className="text-lg font-medium text-primary-600 dark:text-primary-400 px-2">Telegram Bot Settings</legend>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4 mt-2">
|
||||
|
||||
<div className="md:col-span-2 flex items-center space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTgBotEnable(!tgBotEnable)}
|
||||
className={`${tgBotEnable ? 'bg-primary-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex items-center h-6 rounded-full w-11 transition-colors focus:outline-none`}
|
||||
>
|
||||
<span className="sr-only">Toggle Telegram Bot</span>
|
||||
<span className={`${tgBotEnable ? 'translate-x-6' : 'translate-x-1'} inline-block w-4 h-4 transform bg-white rounded-full transition-transform`} />
|
||||
</button>
|
||||
<label htmlFor="tgBotEnable" className="text-sm font-medium text-gray-700 dark:text-gray-300">Enable Telegram Bot</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="tgBotToken" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Bot Token</label>
|
||||
<input type="text" id="tgBotToken" value={tgBotToken} onChange={e => setTgBotToken(e.target.value)} className={`mt-1 w-full ${inputStyles}`} disabled={!tgBotEnable}/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="tgBotChatId" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Admin Chat ID(s)</label>
|
||||
<input type="text" id="tgBotChatId" value={tgBotChatId} onChange={e => setTgBotChatId(e.target.value)} className={`mt-1 w-full ${inputStyles}`} disabled={!tgBotEnable} placeholder="e.g., 12345,67890"/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Comma-separated for multiple IDs.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="tgBotProxy" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Bot Proxy URL</label>
|
||||
<input type="text" id="tgBotProxy" value={tgBotProxy} onChange={e => setTgBotProxy(e.target.value)} className={`mt-1 w-full ${inputStyles}`} disabled={!tgBotEnable} placeholder="e.g., socks5://user:pass@host:port"/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="tgBotAPIServer" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Bot API Server</label>
|
||||
<input type="text" id="tgBotAPIServer" value={tgBotAPIServer} onChange={e => setTgBotAPIServer(e.target.value)} className={`mt-1 w-full ${inputStyles}`} disabled={!tgBotEnable} placeholder="e.g., https://api.telegram.org"/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="tgRunTime" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Notification Schedule (Cron)</label>
|
||||
<input type="text" id="tgRunTime" value={tgRunTime} onChange={e => setTgRunTime(e.target.value)} className={`mt-1 w-full ${inputStyles}`} disabled={!tgBotEnable} placeholder="e.g., @daily or 0 0 * * *"/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="tgCpu" className="block text-sm font-medium text-gray-700 dark:text-gray-300">CPU Usage Alert Threshold (%)</label>
|
||||
<input type="number" id="tgCpu" value={tgCpu} onChange={e => setTgCpu(e.target.value === '' ? '' : Number(e.target.value))} min="0" max="100" className={`mt-1 w-full ${inputStyles}`} disabled={!tgBotEnable} placeholder="0 to disable"/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="tgLang" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Bot Language</label>
|
||||
<select id="tgLang" value={tgLang} onChange={e => setTgLang(e.target.value)} className={`mt-1 w-full ${inputStyles}`} disabled={!tgBotEnable}>
|
||||
{availableTgLangs.map(lang => <option key={lang} value={lang}>{lang}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<input type="checkbox" id="tgBotLoginNotify" checked={tgBotLoginNotify} onChange={e => setTgBotLoginNotify(e.target.checked)} disabled={!tgBotEnable} className="h-4 w-4 text-primary-600 border-gray-300 dark:border-gray-600 rounded focus:ring-primary-500 bg-white dark:bg-gray-700"/>
|
||||
<label htmlFor="tgBotLoginNotify" className="text-sm font-medium text-gray-700 dark:text-gray-300">Login Notification</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<input type="checkbox" id="tgBotBackup" checked={tgBotBackup} onChange={e => setTgBotBackup(e.target.checked)} disabled={!tgBotEnable} className="h-4 w-4 text-primary-600 border-gray-300 dark:border-gray-600 rounded focus:ring-primary-500 bg-white dark:bg-gray-700"/>
|
||||
<label htmlFor="tgBotBackup" className="text-sm font-medium text-gray-700 dark:text-gray-300">Daily Backup via Bot</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<button type="submit" disabled={isLoading} className={btnPrimaryStyles}>
|
||||
{isLoading ? 'Saving...' : 'Save Telegram Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
export default TelegramSettingsForm;
|
190
new-frontend/src/components/settings/UserAccountSettingsForm.tsx
Normal file
190
new-frontend/src/components/settings/UserAccountSettingsForm.tsx
Normal file
|
@ -0,0 +1,190 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, FormEvent } from 'react';
|
||||
import { AllSetting, UpdateUserPayload } from '@/types/settings';
|
||||
import { QRCodeCanvas } from 'qrcode.react';
|
||||
|
||||
// Define styles locally
|
||||
const inputStyles = "mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100";
|
||||
const btnPrimaryStyles = "px-4 py-2 bg-primary-500 text-white font-semibold rounded-lg shadow-md hover:bg-primary-600 disabled:opacity-50 transition-colors";
|
||||
|
||||
interface UserAccountSettingsFormProps {
|
||||
initialSettings: Partial<AllSetting>;
|
||||
onUpdateUser: (payload: UpdateUserPayload) => Promise<boolean>;
|
||||
onUpdateTwoFactor: (twoFactorEnabled: boolean) => Promise<boolean>;
|
||||
isSavingUser: boolean;
|
||||
isSavingSettings: boolean;
|
||||
formError?: string | null;
|
||||
successMessage?: string | null;
|
||||
}
|
||||
|
||||
const UserAccountSettingsForm: React.FC<UserAccountSettingsFormProps> = ({
|
||||
initialSettings, onUpdateUser, onUpdateTwoFactor, isSavingUser, isSavingSettings, formError, successMessage
|
||||
}) => {
|
||||
const [oldUsername, setOldUsername] = useState('');
|
||||
const [oldPassword, setOldPassword] = useState('');
|
||||
const [newUsername, setNewUsername] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmNewPassword, setConfirmNewPassword] = useState('');
|
||||
const [twoFactorEnable, setTwoFactorEnable] = useState(false);
|
||||
const [twoFactorToken, setTwoFactorToken] = useState('');
|
||||
const [userFormError, setUserFormError] = useState<string | null>(null);
|
||||
const [twoFaFormError, setTwoFaFormError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Assuming username might come from a global auth context if it's the logged-in user's username
|
||||
// For now, if panel settings have a way to store current admin username (they don't directly), use that.
|
||||
// Otherwise, user has to type it. For simplicity, let's leave oldUsername blank initially or use a placeholder.
|
||||
// In a real app, this would ideally be pre-filled if known (e.g. from auth context).
|
||||
setOldUsername(''); // Or prefill if available from auth context
|
||||
setTwoFactorEnable(initialSettings.twoFactorEnable || false);
|
||||
setTwoFactorToken(initialSettings.twoFactorToken || '');
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmNewPassword('');
|
||||
}, [initialSettings]);
|
||||
|
||||
const handleUserUpdateSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setUserFormError(null);
|
||||
setTwoFaFormError(null); // Clear other form error
|
||||
if (newPassword !== confirmNewPassword) {
|
||||
setUserFormError("New passwords do not match.");
|
||||
return;
|
||||
}
|
||||
if (!newUsername.trim() && !newPassword.trim() && !oldUsername.trim() && !oldPassword.trim()){
|
||||
// If all fields are empty, do nothing to prevent accidental submission of empty form
|
||||
setUserFormError("Please fill in the fields to update credentials.");
|
||||
return;
|
||||
}
|
||||
if ((newUsername.trim() || newPassword.trim()) && (!oldUsername.trim() || !oldPassword.trim())){
|
||||
setUserFormError("Current username and password are required to change credentials.");
|
||||
return;
|
||||
}
|
||||
if (!newPassword.trim() && newUsername.trim() && oldUsername.trim() && oldPassword.trim()){
|
||||
// Allow changing only username if new password is not set (but current credentials must be provided)
|
||||
} else if (newPassword.trim() && !newUsername.trim() && oldUsername.trim() && oldPassword.trim()){
|
||||
// Allow changing only password if new username is not set
|
||||
setNewUsername(oldUsername); // Use old username if only password is changing
|
||||
} else if (!newUsername.trim() || !newPassword.trim()){
|
||||
setUserFormError("New username and password cannot be empty if you intend to change them.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const success = await onUpdateUser({ oldUsername, oldPassword, newUsername: newUsername.trim() || oldUsername, newPassword });
|
||||
if (success) {
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmNewPassword('');
|
||||
// Old username might need to be updated if it was changed
|
||||
setOldUsername(newUsername.trim() || oldUsername);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTwoFactorToggle = async () => {
|
||||
setUserFormError(null); // Clear other form error
|
||||
setTwoFaFormError(null);
|
||||
const new2FAStatus = !twoFactorEnable;
|
||||
const success = await onUpdateTwoFactor(new2FAStatus);
|
||||
// Parent (SettingsPage) will re-fetch settings which should update initialSettings
|
||||
// causing this component to re-render with new twoFactorEnable and twoFactorToken
|
||||
if (success && new2FAStatus && !initialSettings.twoFactorToken) {
|
||||
setTwoFaFormError("2FA enabled. Refreshing to get QR code/token...");
|
||||
} else if (success && !new2FAStatus) {
|
||||
setTwoFactorToken(''); // Clear token display immediately on disable
|
||||
}
|
||||
};
|
||||
|
||||
const getOtpAuthUrl = () => {
|
||||
if (!twoFactorToken) return null;
|
||||
const label = `XUI-Panel:${initialSettings.webDomain || oldUsername || 'user'}`;
|
||||
const issuer = "XUI-Panel";
|
||||
return `otpauth://totp/${encodeURIComponent(label)}?secret=${twoFactorToken}&issuer=${encodeURIComponent(issuer)}`;
|
||||
};
|
||||
const otpUrl = getOtpAuthUrl();
|
||||
|
||||
return (
|
||||
<div className="space-y-8 p-4 md:p-0"> {/* Added padding for consistency if this form is shown alone */}
|
||||
<form onSubmit={handleUserUpdateSubmit} className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-700 dark:text-gray-300 border-b dark:border-gray-600 pb-2">Change Login Credentials</h3>
|
||||
{userFormError && <div className="p-3 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded-md">{userFormError}</div>}
|
||||
{formError && formError.toLowerCase().includes("user") && <div className="p-3 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded-md">{formError}</div>}
|
||||
{successMessage && successMessage.toLowerCase().includes("user") && <div className="p-3 bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-200 rounded-md">{successMessage}</div>}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
|
||||
<div>
|
||||
<label htmlFor="oldUsername" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Current Username</label>
|
||||
<input type="text" id="oldUsername" value={oldUsername} onChange={e => setOldUsername(e.target.value)} className={`mt-1 w-full ${inputStyles}`} autoComplete="username"/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="oldPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Current Password</label>
|
||||
<input type="password" id="oldPassword" value={oldPassword} onChange={e => setOldPassword(e.target.value)} className={`mt-1 w-full ${inputStyles}`} autoComplete="current-password"/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="newUsername" className="block text-sm font-medium text-gray-700 dark:text-gray-300">New Username</label>
|
||||
<input type="text" id="newUsername" value={newUsername} onChange={e => setNewUsername(e.target.value)} className={`mt-1 w-full ${inputStyles}`} autoComplete="new-password"/> {/* Use new-password to prevent autofill from old username */}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300">New Password</label>
|
||||
<input type="password" id="newPassword" value={newPassword} onChange={e => setNewPassword(e.target.value)} className={`mt-1 w-full ${inputStyles}`} autoComplete="new-password"/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="confirmNewPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Confirm New Password</label>
|
||||
<input type="password" id="confirmNewPassword" value={confirmNewPassword} onChange={e => setConfirmNewPassword(e.target.value)} className={`mt-1 w-full ${inputStyles}`} autoComplete="new-password"/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<button type="submit" disabled={isSavingUser || isSavingSettings} className={btnPrimaryStyles}>
|
||||
{isSavingUser ? 'Updating...' : 'Update Credentials'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-700 dark:text-gray-300 border-b dark:border-gray-600 pb-2">Two-Factor Authentication (2FA)</h3>
|
||||
{twoFaFormError && <div className="p-3 bg-yellow-100 dark:bg-yellow-800/30 text-yellow-700 dark:text-yellow-200 rounded-md">{twoFaFormError}</div>}
|
||||
{formError && formError.toLowerCase().includes("2fa") && <div className="p-3 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded-md">{formError}</div>}
|
||||
{successMessage && successMessage.toLowerCase().includes("2fa") && <div className="p-3 bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-200 rounded-md">{successMessage}</div>}
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTwoFactorToggle}
|
||||
disabled={isSavingSettings || isSavingUser} // Disable if any save operation is in progress
|
||||
className={`${twoFactorEnable ? 'bg-primary-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex items-center h-6 rounded-full w-11 transition-colors focus:outline-none disabled:opacity-50`}
|
||||
>
|
||||
<span className="sr-only">Toggle 2FA</span>
|
||||
<span className={`${twoFactorEnable ? 'translate-x-6' : 'translate-x-1'} inline-block w-4 h-4 transform bg-white rounded-full transition-transform`} />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{twoFactorEnable ? '2FA Enabled' : '2FA Disabled'}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Click the toggle to change 2FA status. This change requires saving all settings.
|
||||
If enabling for the first time, save settings, then the QR code and token will appear.
|
||||
</p>
|
||||
|
||||
{twoFactorEnable && twoFactorToken && otpUrl && (
|
||||
<div className="mt-4 p-4 border dark:border-gray-700 rounded-md bg-gray-50 dark:bg-gray-700/30 space-y-3">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-200">Scan this QR code with your authenticator app:</p>
|
||||
<div className="flex justify-center my-2 p-2 bg-white rounded-md inline-block">
|
||||
<QRCodeCanvas value={otpUrl} size={180} bgColor={"#ffffff"} fgColor={"#000000"} level={"M"} />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-200">Or manually enter this token:</p>
|
||||
<p className="font-mono text-sm bg-gray-100 dark:bg-gray-600 p-2 rounded break-all text-gray-800 dark:text-gray-100">{twoFactorToken}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Important: Store this token securely. If you lose access to your authenticator app, you may lose access to your account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{twoFactorEnable && !twoFactorToken && (
|
||||
<p className="text-sm text-yellow-600 dark:text-yellow-400 mt-2">
|
||||
2FA is marked as enabled, but no token is available. Please save settings.
|
||||
The panel may need a restart for the token to be generated if this is the first time enabling.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default UserAccountSettingsForm;
|
118
new-frontend/src/components/shared/LogsModal.tsx
Normal file
118
new-frontend/src/components/shared/LogsModal.tsx
Normal file
|
@ -0,0 +1,118 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { post } from '@/services/api';
|
||||
|
||||
// Define styles locally
|
||||
const inputStyles = "mt-1 block w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100";
|
||||
const btnSecondaryStyles = "px-3 py-1.5 text-sm bg-gray-200 text-gray-800 font-semibold rounded-lg shadow-md hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 transition-colors";
|
||||
|
||||
interface LogsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type LogLevel = "debug" | "info" | "notice" | "warning" | "error";
|
||||
|
||||
const LogLevels: LogLevel[] = ["debug", "info", "notice", "warning", "error"];
|
||||
const LogCounts: number[] = [20, 50, 100, 200, 500];
|
||||
|
||||
const LogsModal: React.FC<LogsModalProps> = ({ isOpen, onClose }) => {
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [count, setCount] = useState<number>(100);
|
||||
const [level, setLevel] = useState<LogLevel>('info');
|
||||
const [syslog, setSyslog] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
if (!isOpen) return; // Should not fetch if modal is not open
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const apiLevel = level === 'error' ? 'err' : level;
|
||||
const response = await post<string[]>(`/server/logs/${count}`, { level: apiLevel, syslog: syslog.toString() });
|
||||
if (response.success && Array.isArray(response.data)) {
|
||||
setLogs(response.data);
|
||||
} else {
|
||||
setError(response.message || 'Failed to fetch logs.');
|
||||
setLogs([]);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An unknown error occurred.');
|
||||
setLogs([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isOpen, count, level, syslog]); // Removed fetchLogs from its own dep array
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchLogs();
|
||||
}
|
||||
}, [isOpen, fetchLogs]);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (logs.length === 0) return;
|
||||
const blob = new Blob([logs.join('\n')], { type: 'text/plain;charset=utf-8' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `xray_logs_${new Date().toISOString().split('T')[0]}.txt`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50 transition-opacity duration-300 ease-in-out" onClick={onClose}>
|
||||
<div className="bg-white dark:bg-gray-800 p-5 rounded-lg shadow-xl w-full max-w-3xl h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-800 dark:text-gray-100">Xray Logs</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 text-2xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 items-center mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<label htmlFor="log-count" className="text-sm mr-1 text-gray-700 dark:text-gray-300">Count:</label>
|
||||
<select id="log-count" value={count} onChange={e => setCount(Number(e.target.value))} className={`${inputStyles} text-sm p-1.5`}>
|
||||
{LogCounts.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="log-level" className="text-sm mr-1 text-gray-700 dark:text-gray-300">Level:</label>
|
||||
<select id="log-level" value={level} onChange={e => setLevel(e.target.value as LogLevel)} className={`${inputStyles} text-sm p-1.5`}>
|
||||
{LogLevels.map(l => <option key={l} value={l}>{l.charAt(0).toUpperCase() + l.slice(1)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input type="checkbox" id="log-syslog" checked={syslog} onChange={e => setSyslog(e.target.checked)} className="h-4 w-4 text-primary-600 border-gray-300 dark:border-gray-600 rounded focus:ring-primary-500 bg-white dark:bg-gray-700" />
|
||||
<label htmlFor="log-syslog" className="text-sm ml-2 text-gray-700 dark:text-gray-300">Use Syslog</label>
|
||||
</div>
|
||||
<button onClick={fetchLogs} disabled={isLoading} className={`${btnSecondaryStyles} ml-auto`}>
|
||||
{isLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
<button onClick={handleDownload} disabled={isLoading || logs.length === 0} className={btnSecondaryStyles}>
|
||||
Download Logs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-center text-gray-700 dark:text-gray-300">Loading logs...</p>}
|
||||
{error && <p className="text-red-500 dark:text-red-400 text-center p-2">{error}</p>}
|
||||
|
||||
{!isLoading && !error && logs.length === 0 && <p className="text-center text-gray-500 dark:text-gray-400">No logs to display with current filters.</p>}
|
||||
|
||||
{!isLoading && logs.length > 0 && (
|
||||
<div className="flex-grow overflow-auto bg-gray-100 dark:bg-gray-900 p-3 rounded text-xs font-mono leading-relaxed">
|
||||
{logs.map((log, index) => (
|
||||
<div key={index} className="whitespace-pre-wrap break-all text-gray-700 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700 py-0.5">{log}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default LogsModal;
|
0
new-frontend/src/components/ui/.gitkeep
Normal file
0
new-frontend/src/components/ui/.gitkeep
Normal file
19
new-frontend/src/components/ui/ProgressBar.tsx
Normal file
19
new-frontend/src/components/ui/ProgressBar.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
|
||||
interface ProgressBarProps {
|
||||
percentage: number;
|
||||
color?: string; // Tailwind color class e.g., 'bg-blue-500'
|
||||
}
|
||||
|
||||
const ProgressBar: React.FC<ProgressBarProps> = ({ percentage, color = 'bg-primary-500' }) => {
|
||||
const safePercentage = Math.max(0, Math.min(100, percentage));
|
||||
return (
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5">
|
||||
<div
|
||||
className={`${color} h-2.5 rounded-full transition-all duration-300 ease-out`}
|
||||
style={{ width: `${safePercentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ProgressBar;
|
127
new-frontend/src/context/AuthContext.tsx
Normal file
127
new-frontend/src/context/AuthContext.tsx
Normal file
|
@ -0,0 +1,127 @@
|
|||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { post, get, ApiResponse } from '@/services/api';
|
||||
|
||||
interface AuthUser {
|
||||
// Define based on what your backend session stores or what /api/me might return
|
||||
username: string;
|
||||
// Add other user properties if available
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
user: AuthUser | null;
|
||||
isLoading: boolean;
|
||||
login: (username: string, password: string, twoFactorCode?: string) => Promise<ApiResponse<unknown>>;
|
||||
logout: () => Promise<void>;
|
||||
checkAuthState: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true); // Start true for initial auth check
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const checkAuthState = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// A common pattern is to have a '/api/me' or '/server/me' endpoint
|
||||
// that returns user info if authenticated, or 401 if not.
|
||||
// For now, we'll assume if we can access a protected route like '/server/status' (even if it fails for other reasons)
|
||||
// it implies a session might be active. This is not ideal.
|
||||
// A dedicated "me" endpoint is better.
|
||||
// Let's try to fetch /server/status as a proxy for being logged in.
|
||||
// The actual data isn't used here, just the success of the call.
|
||||
const response = await post<unknown>('/server/status', {}); // This endpoint requires login
|
||||
if (response.success) {
|
||||
// Ideally, this response would contain user details.
|
||||
// For now, we'll mock a user object if the call succeeds.
|
||||
// This needs to be improved with a proper /api/me endpoint.
|
||||
// The current /server/status doesn't return user info, just system status.
|
||||
// So, if it succeeds, we know a session is active.
|
||||
// We can't get the username from this though.
|
||||
// This is a placeholder until a proper "me" endpoint is available.
|
||||
setUser({ username: "Authenticated User" }); // Placeholder
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
// Do not redirect here, let protected route logic handle it
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking auth state:", error);
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkAuthState();
|
||||
}, []); // Run once on mount
|
||||
|
||||
const login = async (username: string, password: string, twoFactorCode?: string) => {
|
||||
setIsLoading(true);
|
||||
const payload: Record<string, string> = { username, password };
|
||||
if (twoFactorCode) {
|
||||
payload.twoFactorCode = twoFactorCode;
|
||||
}
|
||||
const response = await post('/login', payload);
|
||||
if (response.success) {
|
||||
// Again, ideally fetch user data from a /me endpoint after login
|
||||
setUser({ username }); // Placeholder
|
||||
setIsAuthenticated(true);
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
setIsLoading(false);
|
||||
return response; // Return the full response for the login page to handle messages
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await get('/logout'); // Call backend logout
|
||||
} catch (error) {
|
||||
console.error("Logout API call failed:", error);
|
||||
// Still proceed with client-side logout
|
||||
} finally {
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
localStorage.removeItem('theme'); // Also clear theme preference on logout
|
||||
router.push('/auth/login');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Effect to redirect if not authenticated and trying to access a protected page
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated && !pathname.startsWith('/auth')) {
|
||||
router.push('/auth/login');
|
||||
}
|
||||
}, [isLoading, isAuthenticated, pathname, router]);
|
||||
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ isAuthenticated, user, isLoading, login, logout, checkAuthState }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = (): AuthContextType => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
0
new-frontend/src/hooks/.gitkeep
Normal file
0
new-frontend/src/hooks/.gitkeep
Normal file
0
new-frontend/src/lib/.gitkeep
Normal file
0
new-frontend/src/lib/.gitkeep
Normal file
33
new-frontend/src/lib/formatters.ts
Normal file
33
new-frontend/src/lib/formatters.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
export function formatBytes(bytes: number, decimals = 2): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export function formatUptime(seconds: number): string {
|
||||
if (seconds <= 0) return 'N/A';
|
||||
const d = Math.floor(seconds / (3600 * 24));
|
||||
const h = Math.floor((seconds % (3600 * 24)) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
|
||||
let result = '';
|
||||
if (d > 0) result += `${d}d `;
|
||||
if (h > 0) result += `${h}h `;
|
||||
if (m > 0) result += `${m}m `;
|
||||
if (s > 0 || result === '') result += `${s}s`; // show seconds if other units are zero or if it's the only unit
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
export function formatPercentage(value: number, total: number): number {
|
||||
if (total === 0) return 0;
|
||||
return parseFloat(((value / total) * 100).toFixed(2));
|
||||
}
|
||||
|
||||
export function toFixedIfNecessary( value: number | undefined, fractionDigits: number ): string {
|
||||
if (value === undefined) return 'N/A';
|
||||
return value.toFixed(fractionDigits);
|
||||
}
|
134
new-frontend/src/lib/subscriptionLink.ts
Normal file
134
new-frontend/src/lib/subscriptionLink.ts
Normal file
|
@ -0,0 +1,134 @@
|
|||
import { Inbound, ClientSetting } from '@/types/inbound';
|
||||
|
||||
const SERVER_ADDRESS = 'YOUR_SERVER_IP_OR_DOMAIN';
|
||||
const encode = encodeURIComponent;
|
||||
|
||||
export function generateSubscriptionLink(inbound: Inbound, client: ClientSetting): string | null {
|
||||
if (!inbound || !client) return null;
|
||||
|
||||
const protocol = inbound.protocol;
|
||||
const port = inbound.port;
|
||||
let remark = client.email || client.id || client.password || 'client';
|
||||
if (protocol === 'shadowsocks') {
|
||||
remark = inbound.remark || 'shadowsocks_client';
|
||||
}
|
||||
|
||||
let streamSettings: Record<string, unknown> = {};
|
||||
try {
|
||||
streamSettings = inbound.streamSettings ? JSON.parse(inbound.streamSettings) : {};
|
||||
} catch (e) { console.error("Error parsing streamSettings for link:", e); }
|
||||
|
||||
const network = (streamSettings.network as string) || 'tcp';
|
||||
const security = (streamSettings.security as string) || 'none';
|
||||
|
||||
let tlsSettings: Record<string, unknown> = {};
|
||||
if (security === 'tls' && typeof streamSettings.tlsSettings === 'object' && streamSettings.tlsSettings !== null) {
|
||||
tlsSettings = streamSettings.tlsSettings as Record<string, unknown>;
|
||||
}
|
||||
let realitySettings: Record<string, unknown> = {};
|
||||
if (security === 'reality' && typeof streamSettings.realitySettings === 'object' && streamSettings.realitySettings !== null) {
|
||||
realitySettings = streamSettings.realitySettings as Record<string, unknown>;
|
||||
}
|
||||
|
||||
switch (protocol) {
|
||||
case 'vmess': {
|
||||
const vmessConfig: Record<string, string | undefined> = {
|
||||
v: "2", ps: remark, add: SERVER_ADDRESS, port: port.toString(), id: client.id,
|
||||
aid: "0", scy: "auto", net: network,
|
||||
type: ((streamSettings.tcpSettings as Record<string, unknown>)?.header as Record<string, unknown>)?.type === 'http' ? 'http' : 'none',
|
||||
host: ((streamSettings.wsSettings as Record<string, unknown>)?.headers as Record<string, string>)?.Host || (network === 'h2' ? tlsSettings.serverName as string : undefined) || '',
|
||||
path: (streamSettings.wsSettings as Record<string, unknown>)?.path as string || (network === 'grpc' ? (streamSettings.grpcSettings as Record<string, unknown>)?.serviceName as string : undefined) || '',
|
||||
tls: security === 'tls' ? "tls" : "",
|
||||
sni: tlsSettings.serverName as string || '',
|
||||
};
|
||||
const alpnArray = tlsSettings.alpn as string[];
|
||||
if (alpnArray && Array.isArray(alpnArray) && alpnArray.length > 0) {
|
||||
vmessConfig.alpn = alpnArray.join(',');
|
||||
}
|
||||
if (security === 'tls' && tlsSettings.fingerprint) {
|
||||
vmessConfig.fp = tlsSettings.fingerprint as string;
|
||||
}
|
||||
const jsonString = JSON.stringify(vmessConfig);
|
||||
return `vmess://${btoa(jsonString)}`;
|
||||
}
|
||||
case 'vless': {
|
||||
let link = `vless://${client.id}@${SERVER_ADDRESS}:${port}`;
|
||||
const params = new URLSearchParams();
|
||||
params.append('type', network);
|
||||
|
||||
if (security === 'tls' || security === 'reality') {
|
||||
params.append('security', security);
|
||||
if (tlsSettings.serverName) params.append('sni', tlsSettings.serverName as string);
|
||||
if (tlsSettings.fingerprint) params.append('fp', tlsSettings.fingerprint as string);
|
||||
if (security === 'reality') {
|
||||
if (realitySettings.publicKey) params.append('pbk', realitySettings.publicKey as string);
|
||||
if (realitySettings.shortId) params.append('sid', realitySettings.shortId as string);
|
||||
}
|
||||
}
|
||||
if (client.flow) params.append('flow', client.flow);
|
||||
|
||||
if (network === 'ws' && streamSettings.wsSettings) {
|
||||
const wsOpts = streamSettings.wsSettings as Record<string, unknown>;
|
||||
params.append('path', encode(wsOpts.path as string || '/'));
|
||||
if (wsOpts.headers && typeof (wsOpts.headers as Record<string,unknown>).Host === 'string') {
|
||||
params.append('host', encode((wsOpts.headers as Record<string,unknown>).Host as string));
|
||||
}
|
||||
} else if (network === 'grpc' && streamSettings.grpcSettings) {
|
||||
const grpcOpts = streamSettings.grpcSettings as Record<string, unknown>;
|
||||
params.append('serviceName', encode(grpcOpts.serviceName as string || ''));
|
||||
if (grpcOpts.multiMode) params.append('mode', 'multi');
|
||||
}
|
||||
|
||||
const query = params.toString();
|
||||
if (query) link += `?${query}`;
|
||||
link += `#${encode(remark)}`;
|
||||
return link;
|
||||
}
|
||||
case 'trojan': {
|
||||
let link = `trojan://${client.password}@${SERVER_ADDRESS}:${port}`;
|
||||
const params = new URLSearchParams();
|
||||
if (security === 'tls' || security === 'reality') {
|
||||
if (tlsSettings.serverName) params.append('sni', tlsSettings.serverName as string);
|
||||
if (security === 'reality') params.append('security', 'reality');
|
||||
}
|
||||
|
||||
if (network !== 'tcp') params.append('type', network);
|
||||
if (network === 'ws' && streamSettings.wsSettings) {
|
||||
const wsOpts = streamSettings.wsSettings as Record<string, unknown>;
|
||||
params.append('path', encode(wsOpts.path as string || '/'));
|
||||
if (wsOpts.headers && typeof (wsOpts.headers as Record<string,unknown>).Host === 'string') {
|
||||
params.append('host', encode((wsOpts.headers as Record<string,unknown>).Host as string));
|
||||
}
|
||||
}
|
||||
// For Trojan, client.flow is not typically added to the URL query parameters in standard formats.
|
||||
// If a specific Trojan variant uses it, it would be a custom addition.
|
||||
// The VLESS case already handles client.flow correctly.
|
||||
|
||||
const query = params.toString();
|
||||
if (query) link += `?${query}`;
|
||||
link += `#${encode(remark)}`;
|
||||
return link;
|
||||
}
|
||||
case 'shadowsocks': {
|
||||
let ssSettings: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed = inbound.settings ? JSON.parse(inbound.settings) : {};
|
||||
if (typeof parsed.method === 'string' && typeof parsed.password === 'string') {
|
||||
ssSettings = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch (e) { console.error("Error parsing SS settings for link:", e); }
|
||||
const method = ssSettings.method as string || client.encryption;
|
||||
const password = ssSettings.password as string;
|
||||
|
||||
if (!method || !password) {
|
||||
console.warn("Shadowsocks method or password not found in inbound settings for link generation.");
|
||||
return null;
|
||||
}
|
||||
|
||||
const encodedPart = btoa(`${method}:${password}`);
|
||||
return `ss://${encodedPart}@${SERVER_ADDRESS}:${port}#${encode(remark)}`;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
0
new-frontend/src/services/.gitkeep
Normal file
0
new-frontend/src/services/.gitkeep
Normal file
66
new-frontend/src/services/api.ts
Normal file
66
new-frontend/src/services/api.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
// Basic API client setup
|
||||
// In a real app, this would be more robust, possibly using axios
|
||||
// and handling base URLs, interceptors for auth tokens, etc.
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
obj?: T; // Based on existing backend responses
|
||||
}
|
||||
|
||||
async function handleResponse<T>(response: Response): Promise<ApiResponse<T>> {
|
||||
if (!response.ok) {
|
||||
// Try to parse error from backend if available
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
return { success: false, message: errorData.message || 'An unknown error occurred', data: errorData };
|
||||
} catch (parseError) {
|
||||
console.error('Error parsing JSON error response:', parseError);
|
||||
return { success: false, message: `HTTP error! status: ${response.status}. Failed to parse error response.` };
|
||||
}
|
||||
}
|
||||
// The backend sometimes returns data in 'obj' and sometimes directly,
|
||||
// and sometimes just a message.
|
||||
// For login, it seems to return { success: true, message: "...", obj: null }
|
||||
// For getTwoFactorEnable, it returns { success: true, obj: boolean }
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (contentType && contentType.indexOf("application/json") !== -1) {
|
||||
const jsonData = await response.json();
|
||||
// Adapt to existing backend structure which might use 'obj' for data
|
||||
return { success: jsonData.success !== undefined ? jsonData.success : true, message: jsonData.message, data: jsonData.obj !== undefined ? jsonData.obj : jsonData };
|
||||
}
|
||||
return { success: true, message: 'Operation successful but no JSON response.' };
|
||||
}
|
||||
|
||||
|
||||
export async function post<T = unknown>(url: string, body: Record<string, unknown>): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
credentials: 'include', // Important for sending cookies
|
||||
});
|
||||
return handleResponse<T>(response);
|
||||
} catch (error) {
|
||||
console.error('API POST error:', error);
|
||||
return { success: false, message: error instanceof Error ? error.message : 'Network error' };
|
||||
}
|
||||
}
|
||||
|
||||
// GET request for logout, could be added here too if needed for consistency
|
||||
export async function get<T = unknown>(url: string): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<T>(response);
|
||||
} catch (error) {
|
||||
console.error('API GET error:', error);
|
||||
return { success: false, message: error instanceof Error ? error.message : 'Network error' };
|
||||
}
|
||||
}
|
0
new-frontend/src/styles/.gitkeep
Normal file
0
new-frontend/src/styles/.gitkeep
Normal file
62
new-frontend/src/types/inbound.ts
Normal file
62
new-frontend/src/types/inbound.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
// Based on database/model/model.go and xray/traffic.go
|
||||
|
||||
export interface ClientTraffic {
|
||||
id: number;
|
||||
inboundId: number;
|
||||
enable: boolean;
|
||||
email: string;
|
||||
up: number;
|
||||
down: number;
|
||||
expiryTime: number;
|
||||
total: number;
|
||||
reset?: number; // Optional, as it might not always be present
|
||||
}
|
||||
|
||||
export type Protocol = "vmess" | "vless" | "trojan" | "shadowsocks" | "dokodemo-door" | "socks" | "http" | "wireguard";
|
||||
|
||||
export interface Inbound {
|
||||
id: number;
|
||||
userId: number; // Assuming not directly used in UI but good to have
|
||||
up: number;
|
||||
down: number;
|
||||
total: number; // Overall total for the inbound itself
|
||||
remark: string;
|
||||
enable: boolean;
|
||||
expiryTime: number; // Overall expiry for the inbound itself
|
||||
clientStats: ClientTraffic[]; // For clients directly associated with this inbound's settings
|
||||
|
||||
// Config part
|
||||
listen: string;
|
||||
port: number;
|
||||
protocol: Protocol;
|
||||
settings: string; // JSON string, to be parsed for client details if needed
|
||||
streamSettings: string; // JSON string
|
||||
tag: string;
|
||||
sniffing: string; // JSON string
|
||||
// allocate: string; // JSON string - allocate seems to be missing in controller/model for direct form binding, might be part of settings
|
||||
}
|
||||
|
||||
// For the list API, it seems clientStats are eagerly loaded.
|
||||
export type InboundFromList = Inbound; // Alias for clarity
|
||||
|
||||
export interface InboundClientIP { // From model.InboundClientIps
|
||||
id: number;
|
||||
clientEmail: string;
|
||||
ips: string;
|
||||
}
|
||||
|
||||
// For client details within Inbound.settings JSON string
|
||||
export interface ClientSetting {
|
||||
id?: string; // UUID for vmess/vless
|
||||
password?: string; // for trojan
|
||||
email: string;
|
||||
flow?: string;
|
||||
encryption?: string; // for shadowsocks method
|
||||
totalGB?: number; // quota in GB for client
|
||||
expiryTime?: number; // client-specific expiry
|
||||
limitIp?: number;
|
||||
subId?: string;
|
||||
tgId?: string;
|
||||
enable?: boolean; // Client specific enable toggle
|
||||
comment?: string;
|
||||
}
|
56
new-frontend/src/types/settings.ts
Normal file
56
new-frontend/src/types/settings.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
// Based on web/entity/entity.go AllSetting struct
|
||||
export interface AllSetting {
|
||||
webListen?: string;
|
||||
webDomain?: string;
|
||||
webPort?: number;
|
||||
webCertFile?: string;
|
||||
webKeyFile?: string;
|
||||
webBasePath?: string;
|
||||
sessionMaxAge?: number;
|
||||
pageSize?: number;
|
||||
expireDiff?: number;
|
||||
trafficDiff?: number;
|
||||
remarkModel?: string;
|
||||
tgBotEnable?: boolean;
|
||||
tgBotToken?: string;
|
||||
tgBotProxy?: string;
|
||||
tgBotAPIServer?: string;
|
||||
tgBotChatId?: string;
|
||||
tgRunTime?: string;
|
||||
tgBotBackup?: boolean;
|
||||
tgBotLoginNotify?: boolean;
|
||||
tgCpu?: number;
|
||||
tgLang?: string;
|
||||
timeLocation?: string;
|
||||
twoFactorEnable?: boolean;
|
||||
twoFactorToken?: string;
|
||||
subEnable?: boolean;
|
||||
subTitle?: string;
|
||||
subListen?: string;
|
||||
subPort?: number;
|
||||
subPath?: string;
|
||||
subDomain?: string;
|
||||
subCertFile?: string;
|
||||
subKeyFile?: string;
|
||||
subUpdates?: number;
|
||||
externalTrafficInformEnable?: boolean;
|
||||
externalTrafficInformURI?: string;
|
||||
subEncrypt?: boolean;
|
||||
subShowInfo?: boolean;
|
||||
subURI?: string;
|
||||
subJsonPath?: string;
|
||||
subJsonURI?: string;
|
||||
subJsonFragment?: string;
|
||||
subJsonNoises?: string;
|
||||
subJsonMux?: string;
|
||||
subJsonRules?: string;
|
||||
datepicker?: string;
|
||||
}
|
||||
|
||||
// For updating username/password
|
||||
export interface UpdateUserPayload {
|
||||
oldUsername?: string;
|
||||
oldPassword?: string;
|
||||
newUsername?: string;
|
||||
newPassword?: string;
|
||||
}
|
49
new-frontend/tailwind.config.ts
Normal file
49
new-frontend/tailwind.config.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
darkMode: 'class', // Enable class-based dark mode
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
'50': '#eff6ff',
|
||||
'100': '#dbeafe',
|
||||
'200': '#bfdbfe',
|
||||
'300': '#93c5fd',
|
||||
'400': '#60a5fa',
|
||||
'500': '#3b82f6', // Our chosen primary blue
|
||||
'600': '#2563eb',
|
||||
'700': '#1d4ed8',
|
||||
'800': '#1e40af',
|
||||
'900': '#1e3a8a',
|
||||
'950': '#172554',
|
||||
},
|
||||
accent: { // Our chosen accent green
|
||||
'50': '#f0fdf4',
|
||||
'100': '#dcfce7',
|
||||
'200': '#bbf7d0',
|
||||
'300': '#86efac',
|
||||
'400': '#4ade80',
|
||||
'500': '#22c55e',
|
||||
'600': '#16a34a',
|
||||
'700': '#15803d',
|
||||
'800': '#166534',
|
||||
'900': '#14532d',
|
||||
'950': '#052e16',
|
||||
}
|
||||
},
|
||||
backgroundImage: {
|
||||
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
|
||||
"gradient-conic":
|
||||
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
export default config;
|
27
new-frontend/tsconfig.json
Normal file
27
new-frontend/tsconfig.json
Normal file
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
3028
new-frontend/yarn.lock
Normal file
3028
new-frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue