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:
google-labs-jules[bot] 2025-06-04 08:47:54 +00:00
parent 29f950046a
commit 66f405353c
61 changed files with 7319 additions and 0 deletions

41
new-frontend/.gitignore vendored Normal file
View 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
View 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.

View 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;

View 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
View 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"
}
}

View file

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View 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

View 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

View 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

View 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

View 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

View file

View 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;

View file

View 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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

View 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;

View file

@ -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;

View 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;

View 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;

View 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;

View 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>
);
}

View 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>
);
}

View file

View 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;

View 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;

View 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">&times;</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;

View file

@ -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;

View 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;

View 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">&times;</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 (&quot;YOUR_SERVER_IP_OR_DOMAIN&quot;) 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;

View 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;

View file

@ -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;

View file

@ -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 &quot;Add Client&quot; 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;

View file

@ -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 &apos;network&apos; and &apos;security&apos; 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View file

@ -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;

View 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;

View 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;

View 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">&times;</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;

View file

View 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;

View 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;
};

View file

View file

View 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);
}

View 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;
}
}

View file

View 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' };
}
}

View file

View 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;
}

View 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;
}

View 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;

View 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

File diff suppressed because it is too large Load diff