From 16245b2d4be97909cd37a1b2241866f0a5434eee Mon Sep 17 00:00:00 2001 From: NixReaper Date: Sun, 5 Apr 2026 10:45:21 -0500 Subject: [PATCH] Initial NixPanel Build --- .env.example | 36 ++ .gitignore | 20 + cpanel/index.html | 13 + cpanel/package.json | 29 ++ cpanel/postcss.config.js | 3 + cpanel/src/App.tsx | 56 +++ cpanel/src/api/client.ts | 34 ++ cpanel/src/components/Layout.tsx | 92 ++++ cpanel/src/contexts/AuthContext.tsx | 67 +++ cpanel/src/index.css | 31 ++ cpanel/src/main.tsx | 10 + cpanel/src/pages/CronJobs.tsx | 123 +++++ cpanel/src/pages/Databases.tsx | 150 ++++++ cpanel/src/pages/Domains.tsx | 119 +++++ cpanel/src/pages/Email.tsx | 157 ++++++ cpanel/src/pages/FTP.tsx | 90 ++++ cpanel/src/pages/FileManager.tsx | 184 +++++++ cpanel/src/pages/Login.tsx | 56 +++ cpanel/src/pages/Overview.tsx | 89 ++++ cpanel/src/pages/SSL.tsx | 78 +++ cpanel/src/pages/Stats.tsx | 79 +++ cpanel/tailwind.config.js | 16 + cpanel/tsconfig.json | 21 + cpanel/tsconfig.node.json | 11 + cpanel/vite.config.ts | 17 + package.json | 22 + scripts/install.sh | 687 ++++++++++++++++++++++++++ scripts/uninstall.sh | 65 +++ scripts/update.sh | 49 ++ server/package.json | 39 ++ server/prisma/schema.prisma | 438 ++++++++++++++++ server/src/app.ts | 126 +++++ server/src/config.ts | 59 +++ server/src/core/dns.ts | 125 +++++ server/src/core/exec.ts | 172 +++++++ server/src/core/nginx.ts | 138 ++++++ server/src/core/system.ts | 132 +++++ server/src/db/client.ts | 15 + server/src/db/seed.ts | 103 ++++ server/src/index.ts | 39 ++ server/src/middleware/auth.ts | 41 ++ server/src/routes/auth/index.ts | 250 ++++++++++ server/src/routes/cpanel/cron.ts | 132 +++++ server/src/routes/cpanel/databases.ts | 157 ++++++ server/src/routes/cpanel/domains.ts | 197 ++++++++ server/src/routes/cpanel/email.ts | 125 +++++ server/src/routes/cpanel/files.ts | 252 ++++++++++ server/src/routes/cpanel/ftp.ts | 101 ++++ server/src/routes/cpanel/index.ts | 18 + server/src/routes/cpanel/stats.ts | 78 +++ server/src/routes/whm/accounts.ts | 418 ++++++++++++++++ server/src/routes/whm/backup.ts | 116 +++++ server/src/routes/whm/dns.ts | 144 ++++++ server/src/routes/whm/email.ts | 145 ++++++ server/src/routes/whm/index.ts | 28 ++ server/src/routes/whm/packages.ts | 96 ++++ server/src/routes/whm/php.ts | 116 +++++ server/src/routes/whm/resellers.ts | 147 ++++++ server/src/routes/whm/security.ts | 124 +++++ server/src/routes/whm/services.ts | 48 ++ server/src/routes/whm/ssl.ts | 145 ++++++ server/src/routes/whm/system.ts | 124 +++++ server/src/routes/whm/webserver.ts | 74 +++ server/src/types/index.ts | 69 +++ server/tsconfig.json | 24 + whm/index.html | 13 + whm/package.json | 29 ++ whm/postcss.config.js | 6 + whm/src/App.tsx | 105 ++++ whm/src/api/client.ts | 42 ++ whm/src/components/Layout.tsx | 120 +++++ whm/src/contexts/AuthContext.tsx | 72 +++ whm/src/index.css | 47 ++ whm/src/main.tsx | 10 + whm/src/pages/Accounts.tsx | 199 ++++++++ whm/src/pages/Backup.tsx | 100 ++++ whm/src/pages/CreateAccount.tsx | 100 ++++ whm/src/pages/DNS.tsx | 119 +++++ whm/src/pages/Dashboard.tsx | 169 +++++++ whm/src/pages/Email.tsx | 100 ++++ whm/src/pages/Login.tsx | 89 ++++ whm/src/pages/PHP.tsx | 79 +++ whm/src/pages/Packages.tsx | 144 ++++++ whm/src/pages/Resellers.tsx | 112 +++++ whm/src/pages/SSL.tsx | 134 +++++ whm/src/pages/Security.tsx | 111 +++++ whm/src/pages/Services.tsx | 94 ++++ whm/src/pages/Settings.tsx | 51 ++ whm/src/pages/System.tsx | 72 +++ whm/src/pages/WebServer.tsx | 84 ++++ whm/tailwind.config.js | 17 + whm/tsconfig.json | 23 + whm/tsconfig.node.json | 11 + whm/vite.config.ts | 17 + 94 files changed, 9228 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 cpanel/index.html create mode 100644 cpanel/package.json create mode 100644 cpanel/postcss.config.js create mode 100644 cpanel/src/App.tsx create mode 100644 cpanel/src/api/client.ts create mode 100644 cpanel/src/components/Layout.tsx create mode 100644 cpanel/src/contexts/AuthContext.tsx create mode 100644 cpanel/src/index.css create mode 100644 cpanel/src/main.tsx create mode 100644 cpanel/src/pages/CronJobs.tsx create mode 100644 cpanel/src/pages/Databases.tsx create mode 100644 cpanel/src/pages/Domains.tsx create mode 100644 cpanel/src/pages/Email.tsx create mode 100644 cpanel/src/pages/FTP.tsx create mode 100644 cpanel/src/pages/FileManager.tsx create mode 100644 cpanel/src/pages/Login.tsx create mode 100644 cpanel/src/pages/Overview.tsx create mode 100644 cpanel/src/pages/SSL.tsx create mode 100644 cpanel/src/pages/Stats.tsx create mode 100644 cpanel/tailwind.config.js create mode 100644 cpanel/tsconfig.json create mode 100644 cpanel/tsconfig.node.json create mode 100644 cpanel/vite.config.ts create mode 100644 package.json create mode 100755 scripts/install.sh create mode 100755 scripts/uninstall.sh create mode 100755 scripts/update.sh create mode 100644 server/package.json create mode 100644 server/prisma/schema.prisma create mode 100644 server/src/app.ts create mode 100644 server/src/config.ts create mode 100644 server/src/core/dns.ts create mode 100644 server/src/core/exec.ts create mode 100644 server/src/core/nginx.ts create mode 100644 server/src/core/system.ts create mode 100644 server/src/db/client.ts create mode 100644 server/src/db/seed.ts create mode 100644 server/src/index.ts create mode 100644 server/src/middleware/auth.ts create mode 100644 server/src/routes/auth/index.ts create mode 100644 server/src/routes/cpanel/cron.ts create mode 100644 server/src/routes/cpanel/databases.ts create mode 100644 server/src/routes/cpanel/domains.ts create mode 100644 server/src/routes/cpanel/email.ts create mode 100644 server/src/routes/cpanel/files.ts create mode 100644 server/src/routes/cpanel/ftp.ts create mode 100644 server/src/routes/cpanel/index.ts create mode 100644 server/src/routes/cpanel/stats.ts create mode 100644 server/src/routes/whm/accounts.ts create mode 100644 server/src/routes/whm/backup.ts create mode 100644 server/src/routes/whm/dns.ts create mode 100644 server/src/routes/whm/email.ts create mode 100644 server/src/routes/whm/index.ts create mode 100644 server/src/routes/whm/packages.ts create mode 100644 server/src/routes/whm/php.ts create mode 100644 server/src/routes/whm/resellers.ts create mode 100644 server/src/routes/whm/security.ts create mode 100644 server/src/routes/whm/services.ts create mode 100644 server/src/routes/whm/ssl.ts create mode 100644 server/src/routes/whm/system.ts create mode 100644 server/src/routes/whm/webserver.ts create mode 100644 server/src/types/index.ts create mode 100644 server/tsconfig.json create mode 100644 whm/index.html create mode 100644 whm/package.json create mode 100644 whm/postcss.config.js create mode 100644 whm/src/App.tsx create mode 100644 whm/src/api/client.ts create mode 100644 whm/src/components/Layout.tsx create mode 100644 whm/src/contexts/AuthContext.tsx create mode 100644 whm/src/index.css create mode 100644 whm/src/main.tsx create mode 100644 whm/src/pages/Accounts.tsx create mode 100644 whm/src/pages/Backup.tsx create mode 100644 whm/src/pages/CreateAccount.tsx create mode 100644 whm/src/pages/DNS.tsx create mode 100644 whm/src/pages/Dashboard.tsx create mode 100644 whm/src/pages/Email.tsx create mode 100644 whm/src/pages/Login.tsx create mode 100644 whm/src/pages/PHP.tsx create mode 100644 whm/src/pages/Packages.tsx create mode 100644 whm/src/pages/Resellers.tsx create mode 100644 whm/src/pages/SSL.tsx create mode 100644 whm/src/pages/Security.tsx create mode 100644 whm/src/pages/Services.tsx create mode 100644 whm/src/pages/Settings.tsx create mode 100644 whm/src/pages/System.tsx create mode 100644 whm/src/pages/WebServer.tsx create mode 100644 whm/tailwind.config.js create mode 100644 whm/tsconfig.json create mode 100644 whm/tsconfig.node.json create mode 100644 whm/vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ce39a6e --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +# Database +DATABASE_URL="mysql://nixpanel:password@localhost:3306/nixpanel" + +# JWT +JWT_SECRET="change-this-to-a-long-random-secret" +JWT_EXPIRES_IN="15m" +JWT_REFRESH_SECRET="change-this-to-another-long-random-secret" +JWT_REFRESH_EXPIRES_IN="7d" + +# Server +NODE_ENV="production" +WHM_PORT=2087 +CPANEL_PORT=2083 +HOST="0.0.0.0" + +# Admin +ADMIN_USERNAME="root" +ADMIN_PASSWORD="change-this-password" +ADMIN_EMAIL="admin@localhost" + +# Paths +HOME_DIR="/home" +NGINX_SITES_DIR="/etc/nginx/sites-available" +NGINX_ENABLED_DIR="/etc/nginx/sites-enabled" +APACHE_SITES_DIR="/etc/apache2/sites-available" +APACHE_ENABLED_DIR="/etc/apache2/sites-enabled" +BIND_ZONES_DIR="/etc/bind/zones" +CERTBOT_WEBROOT="/var/www/letsencrypt" + +# Rate limiting +RATE_LIMIT_MAX=100 +RATE_LIMIT_WINDOW_MS=60000 + +# Features +ENABLE_TERMINAL=true +WEBSOCKET_STATS_INTERVAL=5000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6992477 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +node_modules/ +dist/ +.env +*.env.local +*.log +.DS_Store +Thumbs.db + +# Prisma +server/prisma/dev.db +server/prisma/dev.db-journal + +# Build outputs +server/dist/ +whm/dist/ +cpanel/dist/ + +# Vite +whm/.vite/ +cpanel/.vite/ diff --git a/cpanel/index.html b/cpanel/index.html new file mode 100644 index 0000000..c8e4c04 --- /dev/null +++ b/cpanel/index.html @@ -0,0 +1,13 @@ + + + + + + + NixPanel + + +
+ + + diff --git a/cpanel/package.json b/cpanel/package.json new file mode 100644 index 0000000..cdc56a6 --- /dev/null +++ b/cpanel/package.json @@ -0,0 +1,29 @@ +{ + "name": "@nixpanel/cpanel", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 5174", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.7.7", + "lucide-react": "^0.460.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "recharts": "^2.13.3" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "typescript": "^5.7.2", + "vite": "^6.0.1" + } +} diff --git a/cpanel/postcss.config.js b/cpanel/postcss.config.js new file mode 100644 index 0000000..be56e0e --- /dev/null +++ b/cpanel/postcss.config.js @@ -0,0 +1,3 @@ +export default { + plugins: { tailwindcss: {}, autoprefixer: {} }, +} diff --git a/cpanel/src/App.tsx b/cpanel/src/App.tsx new file mode 100644 index 0000000..a6bd6f4 --- /dev/null +++ b/cpanel/src/App.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { AuthProvider, useAuth } from './contexts/AuthContext' +import Layout from './components/Layout' +import Login from './pages/Login' +import Overview from './pages/Overview' +import FileManager from './pages/FileManager' +import Databases from './pages/Databases' +import Email from './pages/Email' +import SSL from './pages/SSL' +import Domains from './pages/Domains' +import FTP from './pages/FTP' +import CronJobs from './pages/CronJobs' +import Stats from './pages/Stats' + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth() + if (loading) return ( +
+
+
+ ) + if (!user) return + return <>{children} +} + +function AppRoutes() { + const { user } = useAuth() + return ( + + : } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + ) +} + +export default function App() { + return ( + + + + + + ) +} diff --git a/cpanel/src/api/client.ts b/cpanel/src/api/client.ts new file mode 100644 index 0000000..365250d --- /dev/null +++ b/cpanel/src/api/client.ts @@ -0,0 +1,34 @@ +import axios from 'axios' + +export const api = axios.create({ + baseURL: '/api', + headers: { 'Content-Type': 'application/json' }, +}) + +api.interceptors.request.use(config => { + const token = localStorage.getItem('access_token') + if (token) config.headers.Authorization = `Bearer ${token}` + return config +}) + +api.interceptors.response.use( + res => res, + async error => { + const original = error.config + if (error.response?.status === 401 && !original._retry) { + original._retry = true + const refreshToken = localStorage.getItem('refresh_token') + if (!refreshToken) { localStorage.clear(); window.location.href = '/login'; return Promise.reject(error) } + try { + const { data } = await axios.post('/api/auth/refresh', { refreshToken }) + localStorage.setItem('access_token', data.data.accessToken) + localStorage.setItem('refresh_token', data.data.refreshToken) + original.headers.Authorization = `Bearer ${data.data.accessToken}` + return api(original) + } catch { + localStorage.clear(); window.location.href = '/login'; return Promise.reject(error) + } + } + return Promise.reject(error) + } +) diff --git a/cpanel/src/components/Layout.tsx b/cpanel/src/components/Layout.tsx new file mode 100644 index 0000000..db6a9ee --- /dev/null +++ b/cpanel/src/components/Layout.tsx @@ -0,0 +1,92 @@ +import React, { useState } from 'react' +import { Outlet, NavLink, useNavigate } from 'react-router-dom' +import { + LayoutDashboard, FolderOpen, Database, Mail, Lock, + Globe, Anchor, Clock, LogOut, Menu, X, BarChart3, User +} from 'lucide-react' +import { useAuth } from '../contexts/AuthContext' + +const nav = [ + { label: 'Overview', to: '/', icon: LayoutDashboard }, + { label: 'File Manager',to: '/files', icon: FolderOpen }, + { label: 'Databases', to: '/databases', icon: Database }, + { label: 'Email', to: '/email', icon: Mail }, + { label: 'SSL', to: '/ssl', icon: Lock }, + { label: 'Domains', to: '/domains', icon: Globe }, + { label: 'FTP Accounts',to: '/ftp', icon: Anchor }, + { label: 'Cron Jobs', to: '/cron', icon: Clock }, + { label: 'Statistics', to: '/stats', icon: BarChart3 }, +] + +export default function Layout() { + const { user, logout } = useAuth() + const navigate = useNavigate() + const [sidebarOpen, setSidebarOpen] = useState(true) + + const handleLogout = async () => { await logout(); navigate('/login') } + + return ( +
+ + +
+
+ +
+ {user?.domain} +
+
+ +
+
+
+ ) +} diff --git a/cpanel/src/contexts/AuthContext.tsx b/cpanel/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..519bc57 --- /dev/null +++ b/cpanel/src/contexts/AuthContext.tsx @@ -0,0 +1,67 @@ +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react' +import { api } from '../api/client' + +interface User { + id: number + username: string + email: string + domain: string + role: 'user' +} + +interface AuthContextValue { + user: User | null + loading: boolean + login: (username: string, password: string) => Promise + logout: () => Promise +} + +const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + const fetchMe = useCallback(async () => { + const token = localStorage.getItem('access_token') + if (!token) { setLoading(false); return } + try { + const { data } = await api.get('/auth/me') + if (data.data.role !== 'user') { localStorage.clear(); setLoading(false); return } + setUser(data.data) + } catch { + localStorage.clear() + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { fetchMe() }, [fetchMe]) + + const login = async (username: string, password: string) => { + const { data } = await api.post('/auth/login', { username, password }) + if (data.data.role !== 'user') throw new Error('Use WHM to log in as admin or reseller') + localStorage.setItem('access_token', data.data.accessToken) + localStorage.setItem('refresh_token', data.data.refreshToken) + setUser(data.data.user) + } + + const logout = async () => { + const refreshToken = localStorage.getItem('refresh_token') + try { await api.post('/auth/logout', { refreshToken }) } catch {} + localStorage.clear() + setUser(null) + } + + return ( + + {children} + + ) +} + +export function useAuth() { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth must be inside AuthProvider') + return ctx +} diff --git a/cpanel/src/index.css b/cpanel/src/index.css new file mode 100644 index 0000000..b763ba1 --- /dev/null +++ b/cpanel/src/index.css @@ -0,0 +1,31 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --bg-primary: #0a0f1a; + --bg-secondary: #111827; + --bg-card: #1a2235; + --border: #1e2d45; + --text-primary: #e2e8f0; + --text-muted: #64748b; + --accent: #0ea5e9; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background-color: var(--bg-primary); + color: var(--text-primary); + font-family: 'Inter', system-ui, -apple-system, sans-serif; + font-size: 14px; + line-height: 1.5; +} + +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: var(--bg-secondary); } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #2a3d5e; } diff --git a/cpanel/src/main.tsx b/cpanel/src/main.tsx new file mode 100644 index 0000000..4a1b150 --- /dev/null +++ b/cpanel/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) diff --git a/cpanel/src/pages/CronJobs.tsx b/cpanel/src/pages/CronJobs.tsx new file mode 100644 index 0000000..c281024 --- /dev/null +++ b/cpanel/src/pages/CronJobs.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useState } from 'react' +import { Clock, Plus, Trash2, RefreshCw, ToggleLeft, ToggleRight } from 'lucide-react' +import { api } from '../api/client' + +interface CronJob { id: number; schedule: string; command: string; comment: string | null; enabled: boolean } + +const PRESETS = [ + { label: 'Every minute', value: '* * * * *' }, + { label: 'Every hour', value: '0 * * * *' }, + { label: 'Daily at midnight', value: '0 0 * * *' }, + { label: 'Weekly (Sunday)', value: '0 0 * * 0' }, + { label: 'Monthly (1st)', value: '0 0 1 * *' }, +] + +export default function CronJobs() { + const [jobs, setJobs] = useState([]) + const [loading, setLoading] = useState(false) + const [showModal, setShowModal] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [form, setForm] = useState({ schedule: '0 * * * *', command: '', comment: '', enabled: true }) + + const fetch = async () => { + setLoading(true) + try { const r = await api.get('/cpanel/cron'); setJobs(r.data.data) } + finally { setLoading(false) } + } + + useEffect(() => { fetch() }, []) + + const save = async (e: React.FormEvent) => { + e.preventDefault(); setSaving(true); setError('') + try { await api.post('/cpanel/cron', form); setShowModal(false); setForm({ schedule: '0 * * * *', command: '', comment: '', enabled: true }); fetch() } + catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') } + finally { setSaving(false) } + } + + const del = async (id: number) => { + if (!confirm('Delete this cron job?')) return + try { await api.delete(`/cpanel/cron/${id}`); fetch() } catch {} + } + + const toggle = async (job: CronJob) => { + try { await api.put(`/cpanel/cron/${job.id}`, { enabled: !job.enabled }); fetch() } catch {} + } + + return ( +
+
+

Cron Jobs

{jobs.length} jobs

+
+ + +
+
+ +
+ + + {['Schedule', 'Command', 'Comment', 'Status', ''].map(h => )} + + + {jobs.map(j => ( + + + + + + + + ))} + {!loading && jobs.length === 0 && } + +
{h}
{j.schedule}{j.command}{j.comment ?? '—'} + +
No cron jobs
+
+ + {showModal && ( +
+
+
+

New Cron Job

+ +
+
+ {error &&
{error}
} +
+ + +
+
+ + setForm(f => ({ ...f, schedule: e.target.value }))} required placeholder="* * * * *" + className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white font-mono text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" /> +

minute hour day month weekday

+
+
+ + setForm(f => ({ ...f, command: e.target.value }))} required placeholder="/usr/bin/php /home/user/public_html/cron.php" + className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white font-mono text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" /> +
+
+ + setForm(f => ({ ...f, comment: e.target.value }))} placeholder="What this job does" + className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" /> +
+
+ + +
+
+
+
+ )} +
+ ) +} diff --git a/cpanel/src/pages/Databases.tsx b/cpanel/src/pages/Databases.tsx new file mode 100644 index 0000000..446065a --- /dev/null +++ b/cpanel/src/pages/Databases.tsx @@ -0,0 +1,150 @@ +import React, { useEffect, useState } from 'react' +import { Database, Plus, Trash2, RefreshCw, ChevronDown, ChevronRight } from 'lucide-react' +import { api } from '../api/client' + +interface DbUser { id: number; username: string; host: string; privileges: string } +interface Db { id: number; name: string; charset: string; sizeMb: number; dbUsers: DbUser[] } + +export default function Databases() { + const [dbs, setDbs] = useState([]) + const [loading, setLoading] = useState(false) + const [expanded, setExpanded] = useState>(new Set()) + const [showDbModal, setShowDbModal] = useState(false) + const [showUserModal, setShowUserModal] = useState(null) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [dbForm, setDbForm] = useState({ name: '' }) + const [userForm, setUserForm] = useState({ username: '', password: '', privileges: 'ALL' }) + + const fetch = async () => { + setLoading(true) + try { const r = await api.get('/cpanel/databases'); setDbs(r.data.data) } + finally { setLoading(false) } + } + + useEffect(() => { fetch() }, []) + + const createDb = async (e: React.FormEvent) => { + e.preventDefault(); setSaving(true); setError('') + try { await api.post('/cpanel/databases', dbForm); setShowDbModal(false); setDbForm({ name: '' }); fetch() } + catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') } + finally { setSaving(false) } + } + + const deleteDb = async (id: number) => { + if (!confirm('Delete this database and all its data?')) return + try { await api.delete(`/cpanel/databases/${id}`); fetch() } + catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') } + } + + const createUser = async (e: React.FormEvent) => { + e.preventDefault(); if (!showUserModal) return + setSaving(true); setError('') + try { await api.post(`/cpanel/databases/${showUserModal}/users`, userForm); setShowUserModal(null); setUserForm({ username: '', password: '', privileges: 'ALL' }); fetch() } + catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') } + finally { setSaving(false) } + } + + const deleteUser = async (dbId: number, userId: number) => { + try { await api.delete(`/cpanel/databases/${dbId}/users/${userId}`); fetch() } + catch {} + } + + const toggle = (id: number) => { + const next = new Set(expanded) + next.has(id) ? next.delete(id) : next.add(id) + setExpanded(next) + } + + return ( +
+
+

Databases

{dbs.length} databases

+
+ + +
+
+ +
+ {dbs.map(db => ( +
+
toggle(db.id)}> + {expanded.has(db.id) ? : } + + {db.name} + {db.dbUsers.length} user(s) + + +
+ {expanded.has(db.id) && db.dbUsers.length > 0 && ( +
+ {db.dbUsers.map(u => ( +
+
+ {u.username}@{u.host} + {u.privileges} +
+ +
+ ))} +
+ )} +
+ ))} + {!loading && dbs.length === 0 &&
No databases yet
} +
+ + {/* Create DB modal */} + {showDbModal && ( +
+
+
+

New Database

+ +
+
+ {error &&
{error}
} +
+ +

Will be prefixed with your username

+ setDbForm({ name: e.target.value })} required placeholder="mydb" + className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" /> +
+
+ + +
+
+
+
+ )} + + {/* Create User modal */} + {showUserModal && ( +
+
+
+

Add Database User

+ +
+
+ {error &&
{error}
} + {[['Username', 'username', 'text'], ['Password', 'password', 'password']].map(([l, k, t]) => ( +
+ + setUserForm(f => ({ ...f, [k]: e.target.value }))} required + className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" /> +
+ ))} +
+ + +
+
+
+
+ )} +
+ ) +} diff --git a/cpanel/src/pages/Domains.tsx b/cpanel/src/pages/Domains.tsx new file mode 100644 index 0000000..a3a5014 --- /dev/null +++ b/cpanel/src/pages/Domains.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useState } from 'react' +import { Globe, Plus, Trash2, RefreshCw } from 'lucide-react' +import { api } from '../api/client' + +interface Item { id: number; fqdn?: string; domain?: string; subdomain?: string; documentRoot?: string } + +type Tab = 'subdomains' | 'addon' | 'parked' + +export default function Domains() { + const [tab, setTab] = useState('subdomains') + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(false) + const [showModal, setShowModal] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [form, setForm] = useState({ subdomain: '', domain: '' }) + + const fetch = async () => { + setLoading(true) + try { + const r = await api.get(`/cpanel/domains/${tab}`) + setItems(r.data.data) + } finally { setLoading(false) } + } + + useEffect(() => { fetch() }, [tab]) + + const save = async (e: React.FormEvent) => { + e.preventDefault(); setSaving(true); setError('') + try { + if (tab === 'subdomains') await api.post('/cpanel/domains/subdomains', { subdomain: form.subdomain }) + else if (tab === 'addon') await api.post('/cpanel/domains/addon', { domain: form.domain }) + else await api.post('/cpanel/domains/parked', { domain: form.domain }) + setShowModal(false); setForm({ subdomain: '', domain: '' }); fetch() + } catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') } + finally { setSaving(false) } + } + + const del = async (id: number) => { + if (!confirm('Remove this domain?')) return + try { + if (tab === 'subdomains') await api.delete(`/cpanel/domains/subdomains/${id}`) + else if (tab === 'addon') await api.delete(`/cpanel/domains/addon/${id}`) + else await api.delete(`/cpanel/domains/parked/${id}`) + fetch() + } catch {} + } + + const tabs: { key: Tab; label: string }[] = [ + { key: 'subdomains', label: 'Subdomains' }, + { key: 'addon', label: 'Addon Domains' }, + { key: 'parked', label: 'Parked Domains' }, + ] + + return ( +
+
+

Domains

+
+ + +
+
+ +
+ {tabs.map(t => ( + + ))} +
+ +
+ + + {['Domain / FQDN', 'Document Root', ''].map(h => )} + + + {items.map(item => ( + + + + + + ))} + {!loading && items.length === 0 && } + +
{h}
{item.fqdn ?? item.domain}{item.documentRoot ?? '—'}
No {tab} yet
+
+ + {showModal && ( +
+
+
+

Add {tabs.find(t => t.key === tab)?.label.slice(0, -1)}

+ +
+
+ {error &&
{error}
} + {tab === 'subdomains' ? ( +
+ setForm(f => ({ ...f, subdomain: e.target.value }))} required placeholder="blog" + className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" />
+ ) : ( +
+ setForm(f => ({ ...f, domain: e.target.value }))} required placeholder="example.com" + className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" />
+ )} +
+ + +
+
+
+
+ )} +
+ ) +} diff --git a/cpanel/src/pages/Email.tsx b/cpanel/src/pages/Email.tsx new file mode 100644 index 0000000..b727857 --- /dev/null +++ b/cpanel/src/pages/Email.tsx @@ -0,0 +1,157 @@ +import React, { useEffect, useState } from 'react' +import { Mail, Plus, Trash2, RefreshCw, ArrowRight } from 'lucide-react' +import { api } from '../api/client' + +interface EmailAccount { id: number; address: string; quotaMb: number; usedMb: number; status: string } +interface Forwarder { id: number; source: string; destination: string } + +export default function Email() { + const [accounts, setAccounts] = useState([]) + const [forwarders, setForwarders] = useState([]) + const [tab, setTab] = useState<'accounts' | 'forwarders'>('accounts') + const [loading, setLoading] = useState(false) + const [showModal, setShowModal] = useState(false) + const [showFwd, setShowFwd] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [form, setForm] = useState({ username: '', password: '', quotaMb: 500 }) + const [fwdForm, setFwdForm] = useState({ source: '', destination: '' }) + + const fetch = async () => { + setLoading(true) + try { + const [em, fw] = await Promise.all([api.get('/cpanel/email'), api.get('/cpanel/email/forwarders')]) + setAccounts(em.data.data) + setForwarders(fw.data.data) + } finally { setLoading(false) } + } + + useEffect(() => { fetch() }, []) + + const createAccount = async (e: React.FormEvent) => { + e.preventDefault(); setSaving(true); setError('') + try { + await api.post('/cpanel/email', form) + setShowModal(false); fetch() + } catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') } + finally { setSaving(false) } + } + + const del = async (id: number) => { + if (!confirm('Delete this email account?')) return + try { await api.delete(`/cpanel/email/${id}`); fetch() } catch {} + } + + const addForwarder = async (e: React.FormEvent) => { + e.preventDefault(); setSaving(true); setError('') + try { await api.post('/cpanel/email/forwarders', fwdForm); setShowFwd(false); fetch() } + catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') } + finally { setSaving(false) } + } + + const delFwd = async (id: number) => { + try { await api.delete(`/cpanel/email/forwarders/${id}`); fetch() } catch {} + } + + return ( +
+
+

Email

+
+ + {tab === 'accounts' + ? + : } +
+
+ +
+ {(['accounts', 'forwarders'] as const).map(t => ( + + ))} +
+ + {tab === 'accounts' ? ( +
+ + + {['Address', 'Quota', 'Used', ''].map(h => )} + + + {accounts.map(a => ( + + + + + + + ))} + {!loading && accounts.length === 0 && } + +
{h}
{a.address}{a.quotaMb} MB{a.usedMb} MB
No email accounts
+
+ ) : ( +
+ + + {['Source', '', 'Destination', ''].map((h, i) => )} + + + {forwarders.map(f => ( + + + + + + + ))} + {!loading && forwarders.length === 0 && } + +
{h}
{f.source}{f.destination}
No forwarders
+
+ )} + + {(showModal || showFwd) && ( +
+
+
+

{showModal ? 'New Email Account' : 'New Forwarder'}

+ +
+ {showModal ? ( +
+ {error &&
{error}
} +
+ setForm(f => ({ ...f, username: e.target.value }))} required placeholder="info" + className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" />
+
+ setForm(f => ({ ...f, password: e.target.value }))} required + className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" />
+
+ + +
+
+ ) : ( +
+ {error &&
{error}
} +
+ setFwdForm(f => ({ ...f, source: e.target.value }))} required placeholder="from@domain.com" + className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" />
+
+ setFwdForm(f => ({ ...f, destination: e.target.value }))} required placeholder="to@example.com" + className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" />
+
+ + +
+
+ )} +
+
+ )} +
+ ) +} diff --git a/cpanel/src/pages/FTP.tsx b/cpanel/src/pages/FTP.tsx new file mode 100644 index 0000000..748fa04 --- /dev/null +++ b/cpanel/src/pages/FTP.tsx @@ -0,0 +1,90 @@ +import React, { useEffect, useState } from 'react' +import { Anchor, Plus, Trash2, RefreshCw } from 'lucide-react' +import { api } from '../api/client' + +interface FtpAccount { id: number; username: string; homedir: string; quotaMb: number; status: string } + +export default function FTP() { + const [accounts, setAccounts] = useState([]) + const [loading, setLoading] = useState(false) + const [showModal, setShowModal] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [form, setForm] = useState({ username: '', password: '', homedir: '/', quotaMb: 0 }) + + const fetch = async () => { + setLoading(true) + try { const r = await api.get('/cpanel/ftp'); setAccounts(r.data.data) } + finally { setLoading(false) } + } + + useEffect(() => { fetch() }, []) + + const save = async (e: React.FormEvent) => { + e.preventDefault(); setSaving(true); setError('') + try { await api.post('/cpanel/ftp', form); setShowModal(false); fetch() } + catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') } + finally { setSaving(false) } + } + + const del = async (id: number) => { + if (!confirm('Delete this FTP account?')) return + try { await api.delete(`/cpanel/ftp/${id}`); fetch() } catch {} + } + + return ( +
+
+

FTP Accounts

{accounts.length} accounts

+
+ + +
+
+ +
+ + + {['Username', 'Directory', 'Quota', ''].map(h => )} + + + {accounts.map(a => ( + + + + + + + ))} + {!loading && accounts.length === 0 && } + +
{h}
{a.username}{a.homedir}{a.quotaMb === 0 ? 'Unlimited' : `${a.quotaMb} MB`}
No FTP accounts
+
+ + {showModal && ( +
+
+
+

New FTP Account

+ +
+
+ {error &&
{error}
} + {[['Username', 'username', 'text', 'ftpuser'], ['Password', 'password', 'password', ''], ['Directory', 'homedir', 'text', '/public_html']].map(([l, k, t, p]) => ( +
+ + setForm(f => ({ ...f, [k]: e.target.value }))} required={k !== 'homedir'} placeholder={p} + className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" /> +
+ ))} +
+ + +
+
+
+
+ )} +
+ ) +} diff --git a/cpanel/src/pages/FileManager.tsx b/cpanel/src/pages/FileManager.tsx new file mode 100644 index 0000000..d2e06c1 --- /dev/null +++ b/cpanel/src/pages/FileManager.tsx @@ -0,0 +1,184 @@ +import React, { useEffect, useState } from 'react' +import { Folder, FileText, ChevronRight, Trash2, Pencil, Plus, Upload, RefreshCw, FolderPlus, Save, X } from 'lucide-react' +import { api } from '../api/client' + +interface FileEntry { name: string; type: 'file' | 'directory'; size: number; mtime: string; path: string } + +const fmtSize = (b: number) => b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)} MB` : b > 1024 ? `${(b / 1024).toFixed(0)} KB` : `${b} B` + +export default function FileManager() { + const [path, setPath] = useState('/') + const [files, setFiles] = useState([]) + const [loading, setLoading] = useState(false) + const [selected, setSelected] = useState>(new Set()) + const [editing, setEditing] = useState<{ path: string; content: string } | null>(null) + const [saving, setSaving] = useState(false) + const [newDir, setNewDir] = useState('') + const [showNewDir, setShowNewDir] = useState(false) + + const loadDir = async (p: string) => { + setLoading(true); setSelected(new Set()) + try { + const r = await api.get(`/cpanel/files?path=${encodeURIComponent(p)}`) + setFiles(r.data.data.files); setPath(r.data.data.path) + } catch (e: unknown) { + alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed to load directory') + } finally { setLoading(false) } + } + + useEffect(() => { loadDir('/') }, []) + + const navigate = (entry: FileEntry) => { + if (entry.type === 'directory') loadDir(entry.path) + else openFile(entry.path) + } + + const openFile = async (filePath: string) => { + try { + const r = await api.get(`/cpanel/files/content?path=${encodeURIComponent(filePath)}`) + setEditing({ path: filePath, content: r.data.data.content }) + } catch (e: unknown) { + alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Cannot open file') + } + } + + const saveFile = async () => { + if (!editing) return + setSaving(true) + try { + await api.put('/cpanel/files/content', { path: editing.path, content: editing.content }) + setEditing(null) + } catch (e: unknown) { + alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Save failed') + } finally { setSaving(false) } + } + + const deleteSelected = async () => { + if (!selected.size || !confirm(`Delete ${selected.size} item(s)?`)) return + try { + await api.delete('/cpanel/files', { data: { paths: Array.from(selected) } }) + loadDir(path) + } catch {} + } + + const createDir = async (e: React.FormEvent) => { + e.preventDefault() + try { + await api.post('/cpanel/files/mkdir', { path: `${path}/${newDir}` }) + setNewDir(''); setShowNewDir(false); loadDir(path) + } catch (e: unknown) { + alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') + } + } + + // Breadcrumb + const parts = path.split('/').filter(Boolean) + + return ( +
+
+

File Manager

+
+ {selected.size > 0 && ( + + )} + + +
+
+ + {/* Breadcrumb */} +
+ + {parts.map((part, i) => ( + + + + + ))} +
+ + {/* New dir form */} + {showNewDir && ( +
+ setNewDir(e.target.value)} placeholder="folder-name" autoFocus required + className="bg-[#111827] border border-[#1e2d45] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-sky-500" /> + + +
+ )} + + {/* File list */} +
+ + + + {['Name', 'Size', 'Modified', ''].map(h => )} + + + {path !== '/' && ( + loadDir(path.split('/').slice(0, -1).join('/') || '/')}> + + + + )} + {files.map(f => ( + + + + + + + + ))} + {!loading && files.length === 0 && } + +
{h}
..
e.stopPropagation()}> + { + const next = new Set(selected) + e.target.checked ? next.add(f.path) : next.delete(f.path) + setSelected(next) + }} className="accent-sky-500" /> + navigate(f)}> + + {f.type === 'directory' + ? + : } + {f.name} + + {f.type === 'file' ? fmtSize(f.size) : '—'}{new Date(f.mtime).toLocaleDateString()} + {f.type === 'file' && ( + + )} +
Empty directory
+
+ + {/* File editor modal */} + {editing && ( +
+
+ {editing.path} +
+ + +
+
+