diff --git a/new-frontend/.gitignore b/new-frontend/.gitignore new file mode 100644 index 00000000..5ef6a520 --- /dev/null +++ b/new-frontend/.gitignore @@ -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 diff --git a/new-frontend/README.md b/new-frontend/README.md new file mode 100644 index 00000000..e215bc4c --- /dev/null +++ b/new-frontend/README.md @@ -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. diff --git a/new-frontend/eslint.config.mjs b/new-frontend/eslint.config.mjs new file mode 100644 index 00000000..c85fb67c --- /dev/null +++ b/new-frontend/eslint.config.mjs @@ -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; diff --git a/new-frontend/next.config.ts b/new-frontend/next.config.ts new file mode 100644 index 00000000..e9ffa308 --- /dev/null +++ b/new-frontend/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/new-frontend/package.json b/new-frontend/package.json new file mode 100644 index 00000000..9b3af537 --- /dev/null +++ b/new-frontend/package.json @@ -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" + } +} diff --git a/new-frontend/postcss.config.mjs b/new-frontend/postcss.config.mjs new file mode 100644 index 00000000..c7bcb4b1 --- /dev/null +++ b/new-frontend/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/new-frontend/public/file.svg b/new-frontend/public/file.svg new file mode 100644 index 00000000..004145cd --- /dev/null +++ b/new-frontend/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/new-frontend/public/globe.svg b/new-frontend/public/globe.svg new file mode 100644 index 00000000..567f17b0 --- /dev/null +++ b/new-frontend/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/new-frontend/public/next.svg b/new-frontend/public/next.svg new file mode 100644 index 00000000..5174b28c --- /dev/null +++ b/new-frontend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/new-frontend/public/vercel.svg b/new-frontend/public/vercel.svg new file mode 100644 index 00000000..77053960 --- /dev/null +++ b/new-frontend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/new-frontend/public/window.svg b/new-frontend/public/window.svg new file mode 100644 index 00000000..b2b2a44f --- /dev/null +++ b/new-frontend/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/new-frontend/src/app/auth/.gitkeep b/new-frontend/src/app/auth/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/new-frontend/src/app/auth/login/page.tsx b/new-frontend/src/app/auth/login/page.tsx new file mode 100644 index 00000000..a87da095 --- /dev/null +++ b/new-frontend/src/app/auth/login/page.tsx @@ -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(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('/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 = 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

Loading dashboard...

; + } + + + return ( +
+
+

+ Login +

+
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + {twoFactorCheckLoading &&

Checking 2FA status...

} + + {!twoFactorCheckLoading && showTwoFactor && ( +
+ + 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" + /> +
+ )} + + {formError && ( +

+ {formError} +

+ )} + +
+ +
+
+
+
+ ); +}; + +export default LoginPage; diff --git a/new-frontend/src/app/dashboard/.gitkeep b/new-frontend/src/app/dashboard/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/new-frontend/src/app/dashboard/page.tsx b/new-frontend/src/app/dashboard/page.tsx new file mode 100644 index 00000000..833f0221 --- /dev/null +++ b/new-frontend/src/app/dashboard/page.tsx @@ -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(null); + const [isLoadingStatus, setIsLoadingStatus] = useState(true); + const [pollingError, setPollingError] = useState(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('/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(endpoint, {}); // Explicitly type ApiResponse + 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
Loading dashboard data...
; + } + if (pollingError && !status && !isLoadingStatus) { // Show error prominently if initial load failed and no stale data + return
Error: {pollingError}
; + } + + const getUsageColor = (percentage: number): string => { + if (percentage > 90) return 'bg-red-500'; + if (percentage > 75) return 'bg-yellow-500'; + return 'bg-green-500'; + }; + + return ( +
+

Dashboard

+ + {actionMessage && ( +
+ {actionMessage.message} +
+ )} + {pollingError && status && ( /* Polling error when data is stale */ +
+ Warning: Could not update status. Displaying last known data. Error: {pollingError} +
) + } + + {!status && !isLoadingStatus && !pollingError &&

No data available.

} + + {status && ( +
+ +

OS Uptime: {formatUptime(status.uptime)}

+

App Uptime: {formatUptime(status.appStats.uptime)}

+

Load Avg: {status.loads?.join(' / ')}

+

CPU Cores: {status.cpuCores} ({status.logicalPro} logical)

+

CPU Speed: {toFixedIfNecessary(status.cpuSpeedMhz,0)} MHz

+
+ + + + + + +
+ }> + + + +
{toFixedIfNecessary(status.cpu,1)}%
+
{formatBytes(status.mem.current)} / {formatBytes(status.mem.total)}{formatPercentage(status.mem.current, status.mem.total)}%
+ {status.swap.total > 0 ? (<>
{formatBytes(status.swap.current)} / {formatBytes(status.swap.total)}{formatPercentage(status.swap.current, status.swap.total)}%
) :

Not available

}
+
{formatBytes(status.disk.current)} / {formatBytes(status.disk.total)}{formatPercentage(status.disk.current, status.disk.total)}%
+

Upload: {formatBytes(status.netIO.up)}/s

Download: {formatBytes(status.netIO.down)}/s

Total Sent: {formatBytes(status.netTraffic.sent)}

Total Received: {formatBytes(status.netTraffic.recv)}

+

TCP: {status.tcpCount}

UDP: {status.udpCount}

+

IPv4: {status.publicIP.ipv4 || 'N/A'}

IPv6: {status.publicIP.ipv6 || 'N/A'}

+
+ )} + setIsLogsModalOpen(false)} /> + {/* Conditional rendering for XrayGeoManagementModal to ensure 'status' and 'status.xray' are available */} + {isXrayGeoModalOpen && status?.xray && setIsXrayGeoModalOpen(false)} + currentXrayVersion={status.xray.version} // Pass version directly + onActionComplete={() => fetchStatus(true)} // Re-fetch status on completion + />} + + ); +}; +export default DashboardPage; diff --git a/new-frontend/src/app/favicon.ico b/new-frontend/src/app/favicon.ico new file mode 100644 index 00000000..718d6fea Binary files /dev/null and b/new-frontend/src/app/favicon.ico differ diff --git a/new-frontend/src/app/globals.css b/new-frontend/src/app/globals.css new file mode 100644 index 00000000..b5c61c95 --- /dev/null +++ b/new-frontend/src/app/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/new-frontend/src/app/inbounds/.gitkeep b/new-frontend/src/app/inbounds/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/new-frontend/src/app/inbounds/[id]/clients/page.tsx b/new-frontend/src/app/inbounds/[id]/clients/page.tsx new file mode 100644 index 00000000..d422f5a0 --- /dev/null +++ b/new-frontend/src/app/inbounds/[id]/clients/page.tsx @@ -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(null); + const [displayClients, setDisplayClients] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [pageError, setPageError] = useState(null); + + const [isClientFormModalOpen, setIsClientFormModalOpen] = useState(false); + const [editingClient, setEditingClient] = useState(null); + const [clientFormModalError, setClientFormModalError] = useState(null); + const [clientFormModalLoading, setClientFormModalLoading] = useState(false); + + const [isShareModalOpen, setIsShareModalOpen] = useState(false); + const [sharingClient, setSharingClient] = useState(null); + + const [currentAction, setCurrentAction] = useState(ClientAction.NONE); + const [actionTargetEmail, setActionTargetEmail] = useState(null); + const [actionError, setActionError] = useState(null); + + const fetchInboundAndClients = useCallback(async () => { + if (!isAuthenticated || !inboundId) return; + setIsLoading(true); setPageError(null); setActionError(null); + try { + const response = await post('/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, 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/updateClient/${clientIdentifierForApi}`, payloadForApi); + } else { + response = await post('/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
Loading...
; + if (pageError && !inbound) return
Error: {pageError}
; + if (!inbound && !isLoading) return
Inbound not found.
; + + const canManageClients = inbound && (inbound.protocol === 'vmess' || inbound.protocol === 'vless' || inbound.protocol === 'trojan' || inbound.protocol === 'shadowsocks'); + + return ( +
+
+

+ Clients for: {inbound?.remark || `#${inbound?.id}`} + ({inbound?.protocol}) +

+ {canManageClients && (inbound?.protocol !== 'shadowsocks') && + () + } +
+ + {pageError && inbound &&
Page load error: {pageError} (stale data)
} + {actionError &&
Action Error: {actionError}
} + + {displayClients.length === 0 && !pageError && inbound?.protocol !== 'shadowsocks' &&

No clients configured for this inbound.

} + {inbound?.protocol === 'shadowsocks' && +

+ For Shadowsocks, client configuration (method and password) is part of the main inbound settings. + The QR code / subscription link below uses these global settings. +

+ } + + {(displayClients.length > 0 || inbound?.protocol === 'shadowsocks') && ( +
+ + + + + {(inbound?.protocol !== 'shadowsocks') && } + + + + + + + + + { (inbound?.protocol === 'shadowsocks') ? ( + + + + + + + + + ) : displayClients.map((client) => { + const clientActionTargetId = client.email; + const isCurrentActionTarget = actionTargetEmail === clientActionTargetId; + return ( + + + + + + + + + + )})} + +
Email / Identifier{getClientIdentifierLabel(inbound?.protocol)}Traffic (Up/Down)QuotaExpiryStatusActions
{inbound.remark || 'Shadowsocks Settings'}{formatBytes(inbound.up || 0)} / {formatBytes(inbound.down || 0)}{inbound.total > 0 ? formatBytes(inbound.total) : 'Unlimited'}{inbound.expiryTime > 0 ? new Date(inbound.expiryTime).toLocaleDateString() : 'Never'} + {inbound.enable ? Enabled : Disabled } + + + {/* No Edit/Delete/Reset for SS "client" as it's part of inbound config */} +
{client.email}{getClientIdentifier(client, inbound?.protocol)}{formatBytes(client.up || 0)} / {formatBytes(client.down || 0)} + { (client.totalGB !== undefined && client.totalGB > 0) ? formatBytes(client.totalGB * 1024 * 1024 * 1024) : (client.actualTotal !== undefined && client.actualTotal > 0) ? formatBytes(client.actualTotal) : 'Unlimited' } + + {client.actualExpiryTime && client.actualExpiryTime > 0 ? new Date(client.actualExpiryTime).toLocaleDateString() : client.expiryTime && client.expiryTime > 0 ? new Date(client.expiryTime).toLocaleDateString() + " (Def)" : 'Never'} + + {client.enableClientStat === undefined ? 'N/A' : client.enableClientStat ? + Enabled : + Disabled + } + + + + + +
+
+ )} + {isClientFormModalOpen && inbound && ( + { setIsClientFormModalOpen(false); setEditingClient(null); }} + onSubmit={handleClientFormSubmit} protocol={inbound.protocol as Protocol} + existingClient={editingClient} formError={clientFormModalError} isLoading={clientFormModalLoading} + /> + )} + {isShareModalOpen && inbound && ( + setIsShareModalOpen(false)} + inbound={inbound} client={sharingClient} + /> + )} +
+ ); +}; +export default ManageClientsPage; diff --git a/new-frontend/src/app/inbounds/[id]/clients/page.tsx.bak-reset-traffic-1748992960 b/new-frontend/src/app/inbounds/[id]/clients/page.tsx.bak-reset-traffic-1748992960 new file mode 100644 index 00000000..c5b2e3a0 --- /dev/null +++ b/new-frontend/src/app/inbounds/[id]/clients/page.tsx.bak-reset-traffic-1748992960 @@ -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(null); + const [displayClients, setDisplayClients] = useState([]); + const [isLoading, setIsLoading] = useState(true); // Page loading state + const [pageError, setPageError] = useState(null); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingClient, setEditingClient] = useState(null); + const [modalError, setModalError] = useState(null); + const [modalLoading, setModalLoading] = useState(false); // For add/edit operations + + const [actionClientId, setActionClientId] = useState(null); // For delete loading state + const [actionError, setActionError] = useState(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/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, id: inbound.id, settings: updatedSettingsJson }; + + // Using a single update endpoint for simplicity + const response = await post(`/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
Loading client data...
; + if (pageError && !inbound) return
Error: {pageError}
; + if (!inbound && !isLoading) return
Inbound data not available or not found.
; + + const canManageClients = inbound && (inbound.protocol === 'vmess' || inbound.protocol === 'vless' || inbound.protocol === 'trojan'); + + return ( +
+
+

+ Clients for: {inbound?.remark || `#${inbound?.id}`} + ({inbound?.protocol}) +

+ {canManageClients && ( + + )} +
+ + {pageError && inbound &&
Page load error: {pageError} (displaying potentially stale data)
} + {actionError &&
Action Error: {actionError}
} + + + {displayClients.length === 0 && !pageError &&

No clients configured for this inbound.

} + + {displayClients.length > 0 && ( +
+ + + + + {canManageClients && } + + + + + + + + + {displayClients.map((client) => { + const clientActionId = client.email; // Using email as the unique ID for delete action tracking + return ( + + + {canManageClients && } + + + + + + + )})} + +
Email{getClientIdentifierLabel(inbound?.protocol)}Traffic (Up/Down)QuotaExpiryStatusActions
{client.email}{getClientIdentifier(client, inbound?.protocol)}{formatBytes(client.up || 0)} / {formatBytes(client.down || 0)} + { (client.totalGB !== undefined && client.totalGB > 0) ? formatBytes(client.totalGB * 1024 * 1024 * 1024) : (client.actualTotal !== undefined && client.actualTotal > 0) ? formatBytes(client.actualTotal) : 'Unlimited' } + + {client.actualExpiryTime && client.actualExpiryTime > 0 ? new Date(client.actualExpiryTime).toLocaleDateString() : client.expiryTime && client.expiryTime > 0 ? new Date(client.expiryTime).toLocaleDateString() + " (Def)" : 'Never'} + + {client.enableClientStat === undefined ? 'N/A' : client.enableClientStat ? + Enabled : + Disabled + } + + {canManageClients && } + {canManageClients && + + } + + {canManageClients && (client.id || client.password) && } +
+
+ )} + {isModalOpen && inbound && ( + { setIsModalOpen(false); setEditingClient(null); }} + onSubmit={handleClientFormSubmit} + protocol={inbound.protocol as Protocol} + existingClient={editingClient} + formError={modalError} + isLoading={modalLoading} + /> + )} +
+ ); +}; +export default ManageClientsPage; diff --git a/new-frontend/src/app/inbounds/add/page.tsx b/new-frontend/src/app/inbounds/add/page.tsx new file mode 100644 index 00000000..5a96217d --- /dev/null +++ b/new-frontend/src/app/inbounds/add/page.tsx @@ -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(null); + const router = useRouter(); + + const handleSubmit = async (inboundData: Partial) => { + setFormLoading(true); + setError(null); + try { + const response = await post('/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 ( +
+

Add New Inbound

+ {error && ( +
+ {error} +
+ )} + +
+ ); +}; +export default AddInboundPage; diff --git a/new-frontend/src/app/inbounds/edit/[id]/page.tsx b/new-frontend/src/app/inbounds/edit/[id]/page.tsx new file mode 100644 index 00000000..0face4f7 --- /dev/null +++ b/new-frontend/src/app/inbounds/edit/[id]/page.tsx @@ -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(null); + const [initialInboundData, setInitialInboundData] = useState(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/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) => { + setFormProcessing(true); + setError(null); + try { + const response = await post(`/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
Loading inbound data...
; + } + if (error && !initialInboundData) { + return
Error: {error}
; + } + if (!initialInboundData && !pageLoading) { + return
Inbound not found.
; + } + + return ( +
+

Edit Inbound: {initialInboundData?.remark || id}

+ {error && initialInboundData && ( +
+ {error} {/* Show non-critical errors above the form if data is loaded */} +
+ )} + {initialInboundData && } +
+ ); +}; +export default EditInboundPage; diff --git a/new-frontend/src/app/inbounds/page.tsx b/new-frontend/src/app/inbounds/page.tsx new file mode 100644 index 00000000..17660238 --- /dev/null +++ b/new-frontend/src/app/inbounds/page.tsx @@ -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 ( + + ); +}; + + +const InboundsPage: React.FC = () => { + const { isAuthenticated, isLoading: authLoading } = useAuth(); + const [inbounds, setInbounds] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Action loading states + const [deletingId, setDeletingId] = useState(null); + const [togglingId, setTogglingId] = useState(null); + + + const fetchInbounds = useCallback(async () => { + if (!isAuthenticated) return; + setIsLoading(true); + try { + const response = await post('/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(`/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
Loading inbounds...
; + } + + // If not authenticated and not loading auth, show message (AuthContext should redirect anyway) + if (!isAuthenticated && !authLoading) { + return
Please login to view inbounds.
; + } + + + return ( +
+
+

Inbounds Management

+ + Add New Inbound + +
+ + {error &&
Error: {error}
} + + {inbounds.length === 0 && !error &&

No inbounds found.

} + + {inbounds.length > 0 && ( +
+ + + + + + + + + + + + + + + {inbounds.map((inbound) => ( + + + + + + + + + + + ))} + +
RemarkProtocolPort / ListenTraffic (Up/Down)QuotaExpiryStatusActions
{inbound.remark || 'N/A'}{inbound.protocol}{inbound.port}{inbound.listen ? ` (${inbound.listen})` : ''}{formatBytes(inbound.up)} / {formatBytes(inbound.down)}{inbound.total > 0 ? formatBytes(inbound.total) : 'Unlimited'} + {inbound.expiryTime === 0 ? 'Never' : new Date(inbound.expiryTime).toLocaleDateString()} + + handleToggleEnable(inbound.id, inbound.enable)} + disabled={togglingId === inbound.id} + /> + + Edit + + {/* Placeholder for Manage Clients/Details */} + Clients +
+
+ )} +
+ ); +}; + +export default InboundsPage; diff --git a/new-frontend/src/app/layout.tsx b/new-frontend/src/app/layout.tsx new file mode 100644 index 00000000..6bfeb8d8 --- /dev/null +++ b/new-frontend/src/app/layout.tsx @@ -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 ( + + + {/* Wrap with AuthProvider */} + {children} + + + + ); +} diff --git a/new-frontend/src/app/page.tsx b/new-frontend/src/app/page.tsx new file mode 100644 index 00000000..4d8f249f --- /dev/null +++ b/new-frontend/src/app/page.tsx @@ -0,0 +1,13 @@ +import Link from 'next/link'; + +export default function HomePage() { + return ( +
+

Welcome to the New 3X-UI Panel

+

Experience a fresh new look and feel.

+ + Go to Dashboard + +
+ ); +} diff --git a/new-frontend/src/app/settings/.gitkeep b/new-frontend/src/app/settings/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/new-frontend/src/app/settings/page.tsx b/new-frontend/src/app/settings/page.tsx new file mode 100644 index 00000000..f4dc9654 --- /dev/null +++ b/new-frontend/src/app/settings/page.tsx @@ -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 = ({ label, isActive, onClick }) => ( + +); + +const SettingsPage: React.FC = () => { + const { user: authUser, checkAuthState, isAuthenticated, isLoading: authContextLoading } = useAuth(); + const [settings, setSettings] = useState>({}); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isUpdatingUser, setIsUpdatingUser] = useState(false); + + const [pageError, setPageError] = useState(null); + const [formError, setFormError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [activeTab, setActiveTab] = useState('panel'); + + const fetchSettings = useCallback(async () => { + if (!isAuthenticated) return; + setIsLoading(true); setPageError(null); setFormError(null); + try { + const response = await post('/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) => { + 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 => { + 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 => { + 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('/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

Loading settings data...

; + + switch(activeTab) { + case 'panel': + return ; + case 'user': + return ; + case 'telegram': + return ; + case 'subscription': + return ; + case 'other': + return ; + default: + return

Please select a settings category.

; + } + }; + + if (authContextLoading) return
Loading authentication...
; + if (pageError && !Object.keys(settings).length) return
Error: {pageError}
; + + return ( +
+

Panel Settings

+ + {successMessage &&
{successMessage}
} + {pageError && !isLoading &&
Page Error: {pageError}
} + +
+ +
+ +
+ {renderSettingsContent()} +
+ +
+ +
+
+ ); +}; + +export default SettingsPage; diff --git a/new-frontend/src/components/dashboard/StatCard.tsx b/new-frontend/src/components/dashboard/StatCard.tsx new file mode 100644 index 00000000..31cbf735 --- /dev/null +++ b/new-frontend/src/components/dashboard/StatCard.tsx @@ -0,0 +1,21 @@ +import React, { ReactNode } from 'react'; + +interface StatCardProps { + title: string; + children: ReactNode; + className?: string; + actions?: ReactNode; +} + +const StatCard: React.FC = ({ title, children, className = '', actions }) => { + return ( +
+

{title}

+
+ {children} +
+ {actions &&
{actions}
} +
+ ); +}; +export default StatCard; diff --git a/new-frontend/src/components/dashboard/XrayGeoManagementModal.tsx b/new-frontend/src/components/dashboard/XrayGeoManagementModal.tsx new file mode 100644 index 00000000..3a8662e4 --- /dev/null +++ b/new-frontend/src/components/dashboard/XrayGeoManagementModal.tsx @@ -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; // Callback to refresh dashboard status +} + +const GEO_FILES_LIST = [ + "geoip.dat", + "geosite.dat", + "geoip_IR.dat", + "geosite_IR.dat", +]; + +const XrayGeoManagementModal: React.FC = ({ + isOpen, onClose, currentXrayVersion, onActionComplete +}) => { + const [xrayVersions, setXrayVersions] = useState([]); + const [isLoadingVersions, setIsLoadingVersions] = useState(false); + const [isInstalling, setIsInstalling] = useState(null); + const [isUpdatingFile, setIsUpdatingFile] = useState(null); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(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 ( +
+
e.stopPropagation()}> +
+

Xray & Geo Management

+ +
+ + {error &&
{error}
} + {successMessage &&
{successMessage}
} + +
+ +
+ +
+ {activeTab === 'xray' && ( +
+

Current Xray Version: {currentXrayVersion || 'Unknown'}

+ {isLoadingVersions &&

Loading versions...

} + {!isLoadingVersions && xrayVersions.length === 0 && !error &&

No versions found or failed to load.

} +
    + {xrayVersions.map(version => ( +
  • + {version} + {version === currentXrayVersion ? ( + Current + ) : ( + + )} +
  • + ))} +
+
+ )} + + {activeTab === 'geo' && ( +
+

Manage GeoIP and GeoSite files.

+
    + {GEO_FILES_LIST.map(fileName => ( +
  • + {fileName} + +
  • + ))} +
+
+ )} +
+
+ +
+
+
+ ); +}; +export default XrayGeoManagementModal; diff --git a/new-frontend/src/components/dashboard/XrayStatusIndicator.tsx b/new-frontend/src/components/dashboard/XrayStatusIndicator.tsx new file mode 100644 index 00000000..a9b08d38 --- /dev/null +++ b/new-frontend/src/components/dashboard/XrayStatusIndicator.tsx @@ -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 = ({ 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 ( +
+
+ + {text} +
+ {version &&

Version: {version}

} + {state === 'error' && errorMsg && ( +

+ Error: {errorMsg.length > 100 ? errorMsg.substring(0, 97) + "..." : errorMsg} +

+ )} +
+ ); +}; +export default XrayStatusIndicator; diff --git a/new-frontend/src/components/inbounds/ClientFormModal.tsx b/new-frontend/src/components/inbounds/ClientFormModal.tsx new file mode 100644 index 00000000..9ec2f182 --- /dev/null +++ b/new-frontend/src/components/inbounds/ClientFormModal.tsx @@ -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 = ({ + 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(0); + const [expiryTime, setExpiryTime] = useState(0); + const [limitIp, setLimitIp] = useState(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 ( +
+
+

+ {isEditMode ? 'Edit Client' : 'Add New Client'} +

+ {formError &&

{formError}

} +
+
+ + setEmail(e.target.value)} required className={`mt-1 w-full ${inputStyles}`} /> +
+
+ +
+ setIdentifier(e.target.value)} required className={`mt-1 w-full rounded-r-none ${inputStyles}`} /> + +
+
+ {protocol === 'vless' && ( +
+ + setFlow(e.target.value)} className={`mt-1 w-full ${inputStyles}`} placeholder="e.g., xtls-rprx-vision" /> +
+ )} +
+ + setTotalGB(e.target.value)} min="0" className={`mt-1 w-full ${inputStyles}`} /> +
+
+ + setExpiryTime(e.target.value)} min="0" className={`mt-1 w-full ${inputStyles}`} /> +
+
+ + setLimitIp(e.target.value)} min="0" className={`mt-1 w-full ${inputStyles}`} /> +
+
+ + +
+
+
+
+ ); +}; +export default ClientFormModal; diff --git a/new-frontend/src/components/inbounds/ClientShareModal.tsx b/new-frontend/src/components/inbounds/ClientShareModal.tsx new file mode 100644 index 00000000..243b3d8b --- /dev/null +++ b/new-frontend/src/components/inbounds/ClientShareModal.tsx @@ -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 = ({ isOpen, onClose, inbound, client }) => { + const [link, setLink] = useState(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 ( +
+
e.stopPropagation()} // Prevent modal close when clicking inside modal + > +
+

Share Client Configuration

+ +
+ + {link ? ( +
+
+ +
+
+ +
+ + +
+
+

+ Note: The server address used ("YOUR_SERVER_IP_OR_DOMAIN") is a placeholder. + For this link to work, ensure your actual server address/domain is correctly configured in the generation logic + (src/lib/subscriptionLink.ts) or, ideally, fetched from panel settings in a future update. +

+
+ ) : ( +

Could not generate subscription link for this client/protocol combination.

+ )} + +
+ +
+
+
+ ); +}; +export default ClientShareModal; diff --git a/new-frontend/src/components/inbounds/InboundForm.tsx b/new-frontend/src/components/inbounds/InboundForm.tsx new file mode 100644 index 00000000..d7678a0a --- /dev/null +++ b/new-frontend/src/components/inbounds/InboundForm.tsx @@ -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; + isEditMode?: boolean; + formLoading?: boolean; + onSubmitForm: (inboundData: Partial) => Promise; +} + +const InboundForm: React.FC = ({ initialData, isEditMode = false, formLoading, onSubmitForm }) => { + const router = useRouter(); + const [remark, setRemark] = useState(''); + const [listen, setListen] = useState(''); + const [port, setPort] = useState(''); + const [protocol, setProtocol] = useState(''); + const [enable, setEnable] = useState(true); + const [expiryTime, setExpiryTime] = useState(0); + const [total, setTotal] = useState(0); + + const [clientList, setClientList] = useState([]); + 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(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 = {}; + 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 = {}; // 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 = { + 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 ( +
+ {formError &&
{formError}
} + +
+ Basic Settings +
+
setRemark(e.target.value)} className={inputStyles} />
+
+
setListen(e.target.value)} placeholder="Default: 0.0.0.0" className={inputStyles} />
+
setPort(e.target.value === '' ? '' : Number(e.target.value))} required min="1" max="65535" className={inputStyles} />
+
setTotal(e.target.value === '' ? '' : Number(e.target.value))} min="0" placeholder="0 for unlimited" className={inputStyles} />
+
setExpiryTime(e.target.value === '' ? '' : Number(e.target.value))} min="0" placeholder="0 for never" className={inputStyles} />
+
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" />
+
+
+ + {protocol && ( +
+ {protocol} Settings + {(protocol === 'vmess' || protocol === 'vless' || protocol === 'trojan') ? ( + + ) : protocol === 'shadowsocks' ? ( +
+
+
setSsPassword(e.target.value)} required className={inputStyles} />
+
+ ) : ( +