diff --git a/nixserver/src/App.tsx b/nixserver/src/App.tsx index 8c3f20c..a89a130 100644 --- a/nixserver/src/App.tsx +++ b/nixserver/src/App.tsx @@ -23,6 +23,22 @@ const System = React.lazy(() => import('./pages/System')) const WebServer = React.lazy(() => import('./pages/WebServer')) const Settings = React.lazy(() => import('./pages/Settings')) +// Server Configuration section +const ServerConfiguration = React.lazy(() => import('./pages/server-configuration/index')) +const SCBasicSetup = React.lazy(() => import('./pages/server-configuration/BasicSetup')) +const SCChangeRoot = React.lazy(() => import('./pages/server-configuration/ChangeRoot')) +const SCAnalytics = React.lazy(() => import('./pages/server-configuration/Analytics')) +const SCCronJobs = React.lazy(() => import('./pages/server-configuration/CronJobs')) +const SCQuotaSetup = React.lazy(() => import('./pages/server-configuration/QuotaSetup')) +const SCLinkNodes = React.lazy(() => import('./pages/server-configuration/LinkNodes')) +const SCServerProfile = React.lazy(() => import('./pages/server-configuration/ServerProfile')) +const SCServerTime = React.lazy(() => import('./pages/server-configuration/ServerTime')) +const SCStatisticsSoftware = React.lazy(() => import('./pages/server-configuration/StatisticsSoftware')) +const SCTerminal = React.lazy(() => import('./pages/server-configuration/Terminal')) +const SCTweakSettings = React.lazy(() => import('./pages/server-configuration/TweakSettings')) +const SCUpdatePreferences = React.lazy(() => import('./pages/server-configuration/UpdatePreferences')) +const SCMarketplace = React.lazy(() => import('./pages/server-configuration/Marketplace')) + // Accounts sub-pages const AccountList = React.lazy(() => import('./pages/accounts/List')) const AccountParkedDomains = React.lazy(() => import('./pages/accounts/ParkedDomains')) @@ -181,6 +197,52 @@ function AppRoutes() { }> } /> + {/* Server Configuration section */} + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + {/* Redirect old /settings to new canonical route */} + } /> + {/* Other sections */} } /> + + {title} + {description && {description}} + + + + + + + Coming Soon + This feature is under development. + + + + ) +} diff --git a/nixserver/src/components/Layout.tsx b/nixserver/src/components/Layout.tsx index 1ff01bd..966bf6c 100644 --- a/nixserver/src/components/Layout.tsx +++ b/nixserver/src/components/Layout.tsx @@ -1,781 +1,15 @@ -import { useState, useMemo, useEffect, useCallback } from 'react' -import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom' -import { - LayoutDashboard, Settings, HelpCircle, Network, BookUser, UserCog, - Wrench, Languages, HardDrive, GitFork, RefreshCw, Activity, - Users, UserCheck, ArrowRightLeft, Paintbrush, Package, - Globe, Database, Wifi, Box, Mail, HeartPulse, Lock, - ShoppingBag, Code2, Puzzle, LogOut, Menu, X, - ChevronDown, ChevronRight, Search, RotateCcw, RefreshCcw, - ArrowUpCircle, CheckCircle2, AlertCircle, Loader2 -} from 'lucide-react' -import { useAuth } from '../contexts/AuthContext' -import { api } from '../api/client' - -// ─── Nav data ──────────────────────────────────────────────────────────────── - -type NavItem = { label: string; to: string } -type NavCategory = { - id: string - label: string - icon: React.ComponentType<{ size?: number | string; className?: string }> - items: NavItem[] -} - -const NAV: NavCategory[] = [ - { - id: 'server-config', - label: 'Server Configuration', - icon: Settings, - items: [ - { label: 'Basic NixServer Setup', to: '/settings' }, - { label: 'Change Root Password', to: '/security' }, - { label: 'Configure Analytics', to: '/settings' }, - { label: 'Configure Cron Jobs', to: '/settings' }, - { label: 'Initial Quota Setup', to: '/settings' }, - { label: 'Link Server Nodes', to: '/settings' }, - { label: 'Server Profile', to: '/settings' }, - { label: 'Server Time', to: '/system' }, - { label: 'Statistics Software', to: '/settings' }, - { label: 'Terminal', to: '/system' }, - { label: 'Tweak Settings', to: '/settings' }, - { label: 'Update Preferences', to: '/settings' }, - { label: 'NixServer Marketplace', to: '/settings' }, - ], - }, - { - id: 'support', - label: 'Support', - icon: HelpCircle, - items: [ - { label: 'Grant NixPanel Support Access', to: '/security' }, - { label: 'Support Center', to: '/settings' }, - ], - }, - { - id: 'networking', - label: 'Networking Setup', - icon: Network, - items: [ - { label: 'Change Hostname', to: '/security' }, - { label: 'Resolver Configuration', to: '/security' }, - { label: 'Security Center', to: '/security' }, - { label: 'Apache mod_userdir Tweak', to: '/webserver' }, - { label: 'SMTP Restrictions', to: '/email' }, - { label: 'Compiler Access', to: '/security' }, - { label: 'Configure Security Policies', to: '/security' }, - { label: 'mXSTOP Brute Force Protection', to: '/security' }, - { label: 'Host Access Control', to: '/security' }, - { label: 'Manage External Authentications', to: '/security' }, - { label: "Manage root's SSH Keys", to: '/security' }, - { label: 'Manage Wheel Group Users', to: '/security' }, - { label: 'ModSecurity™ Configuration', to: '/security' }, - { label: 'ModSecurity™ Tools', to: '/security' }, - { label: 'ModSecurity™ Vendors', to: '/security' }, - { label: 'Password Strength Configuration', to: '/security' }, - { label: 'Security Advisor', to: '/security' }, - { label: 'Security Questions', to: '/security' }, - { label: 'Shell Fork Bomb Protection', to: '/security' }, - { label: 'SSH Password Authorization Tweak', to: '/security' }, - { label: 'Traceroute Enable/Disable', to: '/security' }, - { label: 'Two-Factor Authentication', to: '/security' }, - ], - }, - { - id: 'contacts', - label: 'Server Contacts', - icon: BookUser, - items: [ - { label: 'Contact Manager', to: '/settings' }, - { label: 'Edit System Mail Preferences', to: '/email' }, - ], - }, - { - id: 'resellers', - label: 'Resellers', - icon: UserCog, - items: [ - { label: 'Change Ownership of an Account', to: '/resellers' }, - { label: 'Change Ownership of Multiple Accounts', to: '/resellers' }, - { label: 'Edit Reseller Nameservers and Privileges', to: '/resellers' }, - { label: 'Email All Resellers', to: '/resellers' }, - { label: "Manage Reseller's IP Delegation", to: '/resellers' }, - { label: "Manage Reseller's Shared IP", to: '/resellers' }, - { label: 'Reseller Center', to: '/resellers' }, - { label: 'Reset Resellers', to: '/resellers' }, - { label: 'Show Reseller Accounts', to: '/resellers' }, - { label: 'View Reseller Usage', to: '/resellers' }, - ], - }, - { - id: 'service-config', - label: 'Service Configuration', - icon: Wrench, - items: [ - { label: 'Apache Configuration', to: '/webserver' }, - { label: 'Log Rotation Configuration', to: '/settings' }, - { label: 'Web Disk Configuration', to: '/settings' }, - { label: 'Web Services Configuration', to: '/settings' }, - { label: 'Exim Configuration Manager', to: '/email' }, - { label: 'FTP Server Selection', to: '/settings' }, - { label: 'Mailserver Configuration', to: '/email' }, - { label: 'Manage Service SSL Certificates', to: '/ssl' }, - { label: 'Nameserver Selection', to: '/dns' }, - { label: 'Service Manager', to: '/services' }, - ], - }, - { - id: 'locales', - label: 'Locales', - icon: Languages, - items: [ - { label: 'Configure Application Locales', to: '/settings' }, - { label: 'Copy a Locale', to: '/settings' }, - { label: 'Delete a Locale', to: '/settings' }, - { label: 'Edit a Locale', to: '/settings' }, - { label: 'Locale XML Download', to: '/settings' }, - { label: 'Locale XML Upload', to: '/settings' }, - { label: 'View Available Locales', to: '/settings' }, - ], - }, - { - id: 'backup', - label: 'Backup', - icon: HardDrive, - items: [ - { label: 'Backup Configuration', to: '/backup' }, - { label: 'Backup Restoration', to: '/backup' }, - { label: 'Backup User Selection', to: '/backup' }, - { label: 'File and Directory Restoration', to: '/backup' }, - ], - }, - { - id: 'clusters', - label: 'Clusters', - icon: GitFork, - items: [ - { label: 'Configuration Cluster', to: '/settings' }, - { label: 'DNS Cluster', to: '/dns' }, - { label: 'Remote Access Key', to: '/security' }, - ], - }, - { - id: 'reboot', - label: 'System Reboot', - icon: RotateCcw, - items: [ - { label: 'Forceful Server Reboot', to: '/system' }, - { label: 'Graceful Server Reboot', to: '/system' }, - ], - }, - { - id: 'server-status', - label: 'Server Status', - icon: Activity, - items: [ - { label: 'Apache Status', to: '/webserver' }, - { label: 'Daily Process Log', to: '/system' }, - { label: 'Server Information',to: '/system' }, - { label: 'Service Status', to: '/services' }, - { label: 'Task Queue Monitor',to: '/system' }, - ], - }, - { - id: 'account-info', - label: 'Account Information', - icon: Users, - items: [ - { label: 'List Accounts', to: '/accounts/list' }, - { label: 'List Parked Domains', to: '/accounts/parked-domains'}, - { label: 'List Subdomains', to: '/accounts/subdomains' }, - { label: 'List Suspended Accounts', to: '/accounts/suspended' }, - { label: 'Show Accounts Over Quota', to: '/accounts/over-quota' }, - { label: 'View Bandwidth Usage', to: '/accounts/bandwidth' }, - ], - }, - { - id: 'account-functions', - label: 'Account Functions', - icon: UserCheck, - items: [ - { label: "Change Site's IP Address", to: '/accounts/change-ip' }, - { label: 'Create a New Account', to: '/accounts/create' }, - { label: 'Email All Users', to: '/accounts/email-users' }, - { label: 'Force Password Change', to: '/accounts/force-password' }, - { label: 'Limit Bandwidth Usage', to: '/accounts/limit-bandwidth' }, - { label: 'Manage Account Suspension', to: '/accounts/suspension' }, - { label: 'Manage Demo Mode', to: '/accounts/demo-mode' }, - { label: 'Manage Shell Access', to: '/accounts/shell-access' }, - { label: 'Modify an Account', to: '/accounts/modify' }, - { label: 'Password Modification', to: '/accounts/password' }, - { label: 'Quota Modification', to: '/accounts/quota' }, - { label: 'Raw Apache Log Download', to: '/accounts/apache-logs' }, - { label: 'Raw NGINX® Log Download', to: '/accounts/nginx-logs' }, - { label: 'Rearrange an Account', to: '/accounts/rearrange' }, - { label: 'Reset Account Bandwidth', to: '/accounts/reset-bandwidth' }, - { label: 'Terminate Accounts', to: '/accounts/terminate' }, - { label: 'Unsuspend Bandwidth Exceeders', to: '/accounts/unsuspend-bandwidth' }, - { label: 'Upgrade/Downgrade an Account', to: '/accounts/upgrade' }, - { label: 'Web Template Editor', to: '/accounts/web-template' }, - ], - }, - { - id: 'multi-account', - label: 'Multi Account Functions', - icon: Users, - items: [ - { label: "Change Multiple Sites' IP Addresses", to: '/accounts/bulk-ip' }, - { label: 'Modify/Upgrade Multiple Accounts', to: '/accounts/bulk-modify' }, - ], - }, - { - id: 'transfers', - label: 'Transfers', - icon: ArrowRightLeft, - items: [ - { label: 'Convert Addon Domain to Account', to: '/accounts' }, - { label: 'Review Transfers and Restores', to: '/accounts' }, - { label: 'Transfer or Restore a cPanel Account', to: '/accounts' }, - { label: 'Transfer Tool', to: '/accounts' }, - ], - }, - { - id: 'themes', - label: 'Themes', - icon: Paintbrush, - items: [ - { label: 'Theme Manager', to: '/settings' }, - ], - }, - { - id: 'packages', - label: 'Packages', - icon: Package, - items: [ - { label: 'Add a Package', to: '/packages' }, - { label: 'Delete a Package', to: '/packages' }, - { label: 'Edit a Package', to: '/packages' }, - { label: 'Feature Manager', to: '/packages' }, - ], - }, - { - id: 'dns', - label: 'DNS Functions', - icon: Globe, - items: [ - { label: 'Add a DNS Zone', to: '/dns' }, - { label: 'Add an A Entry for Hostname', to: '/dns' }, - { label: 'Delete a DNS Zone', to: '/dns' }, - { label: 'DNS Zone Manager', to: '/dns' }, - { label: 'Edit Zone Templates', to: '/dns' }, - { label: 'Email DNS Record Manager', to: '/dns' }, - { label: 'Email Routing Configuration', to: '/email' }, - { label: 'Enable DKIM/SPF Globally', to: '/email' }, - { label: 'Nameserver Record Report', to: '/dns' }, - { label: 'Park a Domain', to: '/dns' }, - { label: 'Perform a DNS Cleanup', to: '/dns' }, - { label: 'Set Zone TTL', to: '/dns' }, - { label: 'Setup/Edit Domain Forwarding',to: '/dns' }, - { label: 'Synchronize DNS Records', to: '/dns' }, - ], - }, - { - id: 'databases', - label: 'Database Services', - icon: Database, - items: [ - { label: 'Change Database Root Password', to: '/settings' }, - { label: 'Change Database User Password', to: '/settings' }, - { label: 'Database Map Tool', to: '/settings' }, - { label: 'Edit Database Configuration', to: '/settings' }, - { label: 'Manage Database Access Hosts', to: '/settings' }, - { label: 'Manage Database Profiles', to: '/settings' }, - { label: 'Manage Database Users', to: '/settings' }, - { label: 'Manage Databases', to: '/settings' }, - { label: 'phpMyAdmin', to: '/settings' }, - { label: 'Repair Databases', to: '/settings' }, - { label: 'Show Database Processes', to: '/settings' }, - { label: 'Upgrade Database Version', to: '/settings' }, - ], - }, - { - id: 'ip', - label: 'IP Functions', - icon: Wifi, - items: [ - { label: 'IPv6 Ranges', to: '/security' }, - { label: 'Add a New IP Address', to: '/security' }, - { label: 'Assign IPv6 Address', to: '/security' }, - { label: 'Configure Remote Service IPs', to: '/security' }, - { label: 'IP Migration Wizard', to: '/security' }, - { label: 'Rebuild the IP Address Pool', to: '/security' }, - { label: 'Show IP Address Usage', to: '/security' }, - { label: 'Show or Delete Current IPs', to: '/security' }, - { label: 'Show/Edit Reserved IPs', to: '/security' }, - ], - }, - { - id: 'software', - label: 'Software', - icon: Box, - items: [ - { label: 'Install a Perl Module', to: '/system' }, - { label: 'Install Distro Packages', to: '/system' }, - { label: 'Module Installers', to: '/system' }, - { label: 'MultiPHP INI Editor', to: '/php' }, - { label: 'MultiPHP Manager', to: '/php' }, - { label: 'NGINX® Manager', to: '/webserver' }, - { label: 'Rebuild RPM Database', to: '/system' }, - { label: 'System Update', to: '/system' }, - { label: 'Update Server Software', to: '/system' }, - { label: 'EasyApache 4', to: '/webserver' }, - ], - }, - { - id: 'email', - label: 'Email', - icon: Mail, - items: [ - { label: 'Email Deliverability', to: '/email' }, - { label: 'Filter Incoming Emails by Country', to: '/email' }, - { label: 'Filter Incoming Emails by Domain', to: '/email' }, - { label: 'Greylisting', to: '/email' }, - { label: 'Mail Delivery Reports', to: '/email' }, - { label: 'Mail Queue Manager', to: '/email' }, - { label: 'Mail Troubleshooter', to: '/email' }, - { label: 'Mailbox Conversion', to: '/email' }, - { label: 'Repair Mailbox Permissions', to: '/email' }, - { label: 'Spamd Startup Configuration', to: '/email' }, - { label: 'View Mail Statistics Summary', to: '/email' }, - { label: 'View Relayers', to: '/email' }, - { label: 'View Sent Summary', to: '/email' }, - ], - }, - { - id: 'system-health', - label: 'System Health', - icon: HeartPulse, - items: [ - { label: 'Background Process Killer', to: '/system' }, - { label: 'Process Manager', to: '/system' }, - { label: 'Show Current Disk Usage', to: '/system' }, - { label: 'Show Current Running Processes', to: '/system' }, - ], - }, - { - id: 'nixpanel', - label: 'NixPanel', - icon: LayoutDashboard, - items: [ - { label: 'Change Log', to: '/settings' }, - { label: 'Customization', to: '/settings' }, - { label: 'Manage Plugins', to: '/settings' }, - { label: 'Modify NixPanel News', to: '/settings' }, - { label: 'Reset a Mailman Password', to: '/email' }, - { label: 'Upgrade to Latest Version', to: '/settings' }, - ], - }, - { - id: 'ssl', - label: 'SSL/TLS', - icon: Lock, - items: [ - { label: 'Generate an SSL Certificate and Signing Request', to: '/ssl' }, - { label: 'Install an SSL Certificate on a Domain', to: '/ssl' }, - { label: 'Manage AutoSSL', to: '/ssl' }, - { label: 'Manage SSL Hosts', to: '/ssl' }, - { label: 'Purchase and Install an SSL Certificate', to: '/ssl' }, - { label: 'SSL Storage Manager', to: '/ssl' }, - { label: 'SSL/TLS Configuration', to: '/ssl' }, - ], - }, - { - id: 'market', - label: 'Market', - icon: ShoppingBag, - items: [ - { label: 'Market Provider Manager', to: '/settings' }, - ], - }, - { - id: 'restart-services', - label: 'Restart Services', - icon: RefreshCw, - items: [ - { label: 'Database Server', to: '/services' }, - { label: 'DNS Server', to: '/services' }, - { label: 'HTTP Server (Apache)', to: '/services' }, - { label: 'IMAP Server', to: '/services' }, - { label: 'Mail Server (Exim)', to: '/services' }, - { label: 'Mailing List Manager (Mailman)', to: '/services' }, - { label: 'PHP-FPM service for Apache', to: '/services' }, - { label: 'SSH Server (OpenSSH)', to: '/services' }, - ], - }, - { - id: 'development', - label: 'Development', - icon: Code2, - items: [ - { label: 'Apps Managed by AppConfig', to: '/settings' }, - { label: 'NixPanel Development Forum', to: '/settings' }, - { label: 'NixPanel Plugin File Generator', to: '/settings' }, - { label: 'Developer Documentation', to: '/settings' }, - { label: 'Manage API Tokens', to: '/settings' }, - { label: 'Manage Hooks', to: '/settings' }, - { label: 'OpenAPI Documentation for NixPanel', to: '/settings' }, - { label: 'OpenAPI Documentation for NixServer', to: '/settings' }, - ], - }, - { - id: 'plugins', - label: 'Plugins', - icon: Puzzle, - items: [ - { label: 'Backuply by Softaculous', to: '/backup' }, - { label: 'ImunifyAV', to: '/security' }, - { label: 'Server Monitoring', to: '/system' }, - { label: 'SitePad Website Builder', to: '/settings' }, - { label: 'Softaculous - Instant Installs', to: '/settings' }, - { label: 'WP Toolkit', to: '/settings' }, - ], - }, -] - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -function isItemActive(to: string, pathname: string) { - if (to === '/') return pathname === '/' - return pathname === to || pathname.startsWith(to + '/') -} - -// ─── Component ─────────────────────────────────────────────────────────────── +import { useState } from 'react' +import { Outlet } from 'react-router-dom' +import { Menu, X } from 'lucide-react' +import Sidebar from './Sidebar' export default function Layout() { - const { user, logout } = useAuth() - const navigate = useNavigate() - const location = useLocation() - const [sidebarOpen, setSidebarOpen] = useState(true) - const [search, setSearch] = useState('') - - // ── Version / upgrade state ────────────────────────────────────────────── - type VersionInfo = { currentVersion: string; latestVersion: string | null; updateAvailable: boolean } - const [versionInfo, setVersionInfo] = useState(null) - const [versionError, setVersionError] = useState(false) - const [upgradeState, setUpgradeState] = useState<'idle' | 'confirm' | 'running' | 'done' | 'error'>('idle') - - const fetchVersion = useCallback(async (manual = false) => { - setVersionError(false) - // On a manual refresh, clear the stale value so the spinner shows - if (manual) setVersionInfo(null) - try { - const { data } = await api.get('/nixserver/system/version', { - timeout: 10_000, - // Cache-bust so the browser never serves a stale version response - params: { _t: Date.now() }, - }) - setVersionInfo(data.data) - } catch { - setVersionError(true) - } - }, []) - - // Initial fetch - useEffect(() => { fetchVersion() }, [fetchVersion]) - - // Auto-retry every 5 s while in error state (handles the service-restart window after an upgrade) - useEffect(() => { - if (!versionError) return - const t = setTimeout(fetchVersion, 5_000) - return () => clearTimeout(t) - }, [versionError, fetchVersion]) - - const handleUpgrade = async () => { - if (upgradeState === 'confirm') { - setUpgradeState('running') - try { - await api.post('/nixserver/system/upgrade') - setUpgradeState('done') - } catch { - setUpgradeState('error') - } - } else { - setUpgradeState('confirm') - } - } - - // ───────────────────────────────────────────────────────────────────────── - - const [openCats, setOpenCats] = useState>(() => { - // Auto-open the category that contains the active route on first render - const active = NAV.find(cat => - cat.items.some(item => isItemActive(item.to, location.pathname)) - ) - return active ? new Set([active.id]) : new Set() - }) - - // When the route changes (e.g. user navigates), auto-open the matching category - useEffect(() => { - const active = NAV.find(cat => - cat.items.some(item => isItemActive(item.to, location.pathname)) - ) - if (active) { - setOpenCats(prev => prev.has(active.id) ? prev : new Set([...prev, active.id])) - } - }, [location.pathname]) - - const toggleCat = (id: string) => { - setOpenCats(prev => { - const next = new Set(prev) - next.has(id) ? next.delete(id) : next.add(id) - return next - }) - } - - // Filter nav based on search query - const filteredNav = useMemo(() => { - const q = search.trim().toLowerCase() - if (!q) return NAV - return NAV.map(cat => ({ - ...cat, - items: cat.items.filter(item => item.label.toLowerCase().includes(q)), - })).filter(cat => - cat.items.length > 0 || cat.label.toLowerCase().includes(q) - ) - }, [search]) - - // When searching, all matching categories are forced open - const effectiveOpen = (id: string) => - search.trim() ? filteredNav.some(c => c.id === id) : openCats.has(id) - - const handleLogout = async () => { - await logout() - navigate('/login') - } return ( + setSidebarOpen(v => !v)} /> - {/* ── Sidebar ─────────────────────────────────────────────────────── */} - - - {/* ── Main ────────────────────────────────────────────────────────── */} {/* Topbar */} @@ -791,7 +25,7 @@ export default function Layout() { - {/* Content */} + {/* Page content */} diff --git a/nixserver/src/components/SectionCard.tsx b/nixserver/src/components/SectionCard.tsx new file mode 100644 index 0000000..bf53f24 --- /dev/null +++ b/nixserver/src/components/SectionCard.tsx @@ -0,0 +1,46 @@ +import { Link } from 'react-router-dom' +import type { ComponentType } from 'react' + +interface SectionCardProps { + label: string + description?: string + to: string + icon?: ComponentType<{ size?: number; className?: string }> + comingSoon?: boolean +} + +export default function SectionCard({ label, description, to, icon: Icon, comingSoon }: SectionCardProps) { + if (comingSoon) { + return ( + + {Icon && ( + + + + )} + + {label} + {description && {description}} + Coming soon + + + ) + } + + return ( + + {Icon && ( + + + + )} + + {label} + {description && {description}} + + + ) +} diff --git a/nixserver/src/components/Sidebar.tsx b/nixserver/src/components/Sidebar.tsx new file mode 100644 index 0000000..531c67d --- /dev/null +++ b/nixserver/src/components/Sidebar.tsx @@ -0,0 +1,340 @@ +import { useState, useMemo, useEffect, useCallback } from 'react' +import { Link, useLocation, useNavigate } from 'react-router-dom' +import { + LayoutDashboard, LogOut, Menu, X, + ChevronDown, ChevronRight, Search, RefreshCcw, + ArrowUpCircle, CheckCircle2, AlertCircle, Loader2, ExternalLink, +} from 'lucide-react' +import { useAuth } from '../contexts/AuthContext' +import { api } from '../api/client' +import { NAV, isItemActive } from './navData' + +type VersionInfo = { + currentVersion: string + latestVersion: string | null + updateAvailable: boolean +} + +type UpgradeState = 'idle' | 'confirm' | 'running' | 'done' | 'error' + +interface SidebarProps { + open: boolean + onToggle: () => void +} + +export default function Sidebar({ open, onToggle }: SidebarProps) { + const { user, logout } = useAuth() + const location = useLocation() + const navigate = useNavigate() + + const [search, setSearch] = useState('') + const [versionInfo, setVersionInfo] = useState(null) + const [versionError, setVersionError] = useState(false) + const [upgradeState, setUpgradeState] = useState('idle') + + // ── Version checking ─────────────────────────────────────────────────────── + + const fetchVersion = useCallback(async (manual = false) => { + setVersionError(false) + if (manual) setVersionInfo(null) + try { + const { data } = await api.get('/nixserver/system/version', { + timeout: 10_000, + params: { _t: Date.now() }, + }) + setVersionInfo(data.data) + } catch { + setVersionError(true) + } + }, []) + + useEffect(() => { fetchVersion() }, [fetchVersion]) + + useEffect(() => { + if (!versionError) return + const t = setTimeout(fetchVersion, 5_000) + return () => clearTimeout(t) + }, [versionError, fetchVersion]) + + const handleUpgrade = async () => { + if (upgradeState === 'confirm') { + setUpgradeState('running') + try { + await api.post('/nixserver/system/upgrade') + setUpgradeState('done') + } catch { + setUpgradeState('error') + } + } else { + setUpgradeState('confirm') + } + } + + // ── Category open/close ──────────────────────────────────────────────────── + + const [openCats, setOpenCats] = useState>(() => { + const active = NAV.find(cat => + cat.items.some(item => isItemActive(item.to, location.pathname)) + ) + return active ? new Set([active.id]) : new Set() + }) + + useEffect(() => { + const active = NAV.find(cat => + cat.items.some(item => isItemActive(item.to, location.pathname)) + ) + if (active) { + setOpenCats(prev => prev.has(active.id) ? prev : new Set([...prev, active.id])) + } + }, [location.pathname]) + + const toggleCat = (id: string) => { + setOpenCats(prev => { + const next = new Set(prev) + next.has(id) ? next.delete(id) : next.add(id) + return next + }) + } + + // ── Search filter ────────────────────────────────────────────────────────── + + const filteredNav = useMemo(() => { + const q = search.trim().toLowerCase() + if (!q) return NAV + return NAV.map(cat => ({ + ...cat, + items: cat.items.filter(item => item.label.toLowerCase().includes(q)), + })).filter(cat => cat.items.length > 0 || cat.label.toLowerCase().includes(q)) + }, [search]) + + const effectiveOpen = (id: string) => + search.trim() ? filteredNav.some(c => c.id === id) : openCats.has(id) + + const handleLogout = async () => { + await logout() + navigate('/login') + } + + // ── Render ───────────────────────────────────────────────────────────────── + + return ( + + ) +} diff --git a/nixserver/src/components/navData.ts b/nixserver/src/components/navData.ts new file mode 100644 index 0000000..637b702 --- /dev/null +++ b/nixserver/src/components/navData.ts @@ -0,0 +1,443 @@ +import type { ComponentType } from 'react' +import { + LayoutDashboard, Settings, HelpCircle, Network, BookUser, UserCog, + Wrench, Languages, HardDrive, GitFork, RefreshCw, Activity, + Users, UserCheck, ArrowRightLeft, Paintbrush, Package, + Globe, Database, Wifi, Box, Mail, HeartPulse, Lock, + ShoppingBag, Code2, Puzzle, RotateCcw, +} from 'lucide-react' + +export type NavItem = { label: string; to: string } +export type NavCategory = { + id: string + label: string + icon: ComponentType<{ size?: number | string; className?: string }> + to?: string // optional link to a section index page + items: NavItem[] +} + +export function isItemActive(to: string, pathname: string): boolean { + if (to === '/') return pathname === '/' + return pathname === to || pathname.startsWith(to + '/') +} + +export const NAV: NavCategory[] = [ + { + id: 'server-config', + label: 'Server Configuration', + icon: Settings, + to: '/server-configuration', + items: [ + { label: 'Basic NixServer Setup', to: '/server-configuration/basic-setup' }, + { label: 'Change Root Password', to: '/server-configuration/change-root' }, + { label: 'Configure Analytics', to: '/server-configuration/analytics' }, + { label: 'Configure Cron Jobs', to: '/server-configuration/cron-jobs' }, + { label: 'Initial Quota Setup', to: '/server-configuration/quota-setup' }, + { label: 'Link Server Nodes', to: '/server-configuration/link-nodes' }, + { label: 'Server Profile', to: '/server-configuration/server-profile' }, + { label: 'Server Time', to: '/server-configuration/server-time' }, + { label: 'Statistics Software', to: '/server-configuration/statistics-software' }, + { label: 'Terminal', to: '/server-configuration/terminal' }, + { label: 'Tweak Settings', to: '/server-configuration/tweak-settings' }, + { label: 'Update Preferences', to: '/server-configuration/update-preferences' }, + { label: 'NixServer Marketplace', to: '/server-configuration/marketplace' }, + ], + }, + { + id: 'support', + label: 'Support', + icon: HelpCircle, + items: [ + { label: 'Grant NixPanel Support Access', to: '/security' }, + { label: 'Support Center', to: '/settings' }, + ], + }, + { + id: 'networking', + label: 'Networking Setup', + icon: Network, + items: [ + { label: 'Change Hostname', to: '/security' }, + { label: 'Resolver Configuration', to: '/security' }, + { label: 'Security Center', to: '/security' }, + { label: 'Apache mod_userdir Tweak', to: '/webserver' }, + { label: 'SMTP Restrictions', to: '/email' }, + { label: 'Compiler Access', to: '/security' }, + { label: 'Configure Security Policies', to: '/security' }, + { label: 'mXSTOP Brute Force Protection', to: '/security' }, + { label: 'Host Access Control', to: '/security' }, + { label: 'Manage External Authentications', to: '/security' }, + { label: "Manage root's SSH Keys", to: '/security' }, + { label: 'Manage Wheel Group Users', to: '/security' }, + { label: 'ModSecurity™ Configuration', to: '/security' }, + { label: 'ModSecurity™ Tools', to: '/security' }, + { label: 'ModSecurity™ Vendors', to: '/security' }, + { label: 'Password Strength Configuration', to: '/security' }, + { label: 'Security Advisor', to: '/security' }, + { label: 'Security Questions', to: '/security' }, + { label: 'Shell Fork Bomb Protection', to: '/security' }, + { label: 'SSH Password Authorization Tweak', to: '/security' }, + { label: 'Traceroute Enable/Disable', to: '/security' }, + { label: 'Two-Factor Authentication', to: '/security' }, + ], + }, + { + id: 'contacts', + label: 'Server Contacts', + icon: BookUser, + items: [ + { label: 'Contact Manager', to: '/settings' }, + { label: 'Edit System Mail Preferences', to: '/email' }, + ], + }, + { + id: 'resellers', + label: 'Resellers', + icon: UserCog, + items: [ + { label: 'Change Ownership of an Account', to: '/resellers' }, + { label: 'Change Ownership of Multiple Accounts', to: '/resellers' }, + { label: 'Edit Reseller Nameservers and Privileges', to: '/resellers' }, + { label: 'Email All Resellers', to: '/resellers' }, + { label: "Manage Reseller's IP Delegation", to: '/resellers' }, + { label: "Manage Reseller's Shared IP", to: '/resellers' }, + { label: 'Reseller Center', to: '/resellers' }, + { label: 'Reset Resellers', to: '/resellers' }, + { label: 'Show Reseller Accounts', to: '/resellers' }, + { label: 'View Reseller Usage', to: '/resellers' }, + ], + }, + { + id: 'service-config', + label: 'Service Configuration', + icon: Wrench, + items: [ + { label: 'Apache Configuration', to: '/webserver' }, + { label: 'Log Rotation Configuration', to: '/settings' }, + { label: 'Web Disk Configuration', to: '/settings' }, + { label: 'Web Services Configuration', to: '/settings' }, + { label: 'Exim Configuration Manager', to: '/email' }, + { label: 'FTP Server Selection', to: '/settings' }, + { label: 'Mailserver Configuration', to: '/email' }, + { label: 'Manage Service SSL Certificates', to: '/ssl' }, + { label: 'Nameserver Selection', to: '/dns' }, + { label: 'Service Manager', to: '/services' }, + ], + }, + { + id: 'locales', + label: 'Locales', + icon: Languages, + items: [ + { label: 'Configure Application Locales', to: '/settings' }, + { label: 'Copy a Locale', to: '/settings' }, + { label: 'Delete a Locale', to: '/settings' }, + { label: 'Edit a Locale', to: '/settings' }, + { label: 'Locale XML Download', to: '/settings' }, + { label: 'Locale XML Upload', to: '/settings' }, + { label: 'View Available Locales', to: '/settings' }, + ], + }, + { + id: 'backup', + label: 'Backup', + icon: HardDrive, + items: [ + { label: 'Backup Configuration', to: '/backup' }, + { label: 'Backup Restoration', to: '/backup' }, + { label: 'Backup User Selection', to: '/backup' }, + { label: 'File and Directory Restoration', to: '/backup' }, + ], + }, + { + id: 'clusters', + label: 'Clusters', + icon: GitFork, + items: [ + { label: 'Configuration Cluster', to: '/settings' }, + { label: 'DNS Cluster', to: '/dns' }, + { label: 'Remote Access Key', to: '/security' }, + ], + }, + { + id: 'reboot', + label: 'System Reboot', + icon: RotateCcw, + items: [ + { label: 'Forceful Server Reboot', to: '/system' }, + { label: 'Graceful Server Reboot', to: '/system' }, + ], + }, + { + id: 'server-status', + label: 'Server Status', + icon: Activity, + items: [ + { label: 'Apache Status', to: '/webserver' }, + { label: 'Daily Process Log', to: '/system' }, + { label: 'Server Information', to: '/system' }, + { label: 'Service Status', to: '/services' }, + { label: 'Task Queue Monitor', to: '/system' }, + ], + }, + { + id: 'account-info', + label: 'Account Information', + icon: Users, + items: [ + { label: 'List Accounts', to: '/accounts/list' }, + { label: 'List Parked Domains', to: '/accounts/parked-domains' }, + { label: 'List Subdomains', to: '/accounts/subdomains' }, + { label: 'List Suspended Accounts', to: '/accounts/suspended' }, + { label: 'Show Accounts Over Quota', to: '/accounts/over-quota' }, + { label: 'View Bandwidth Usage', to: '/accounts/bandwidth' }, + ], + }, + { + id: 'account-functions', + label: 'Account Functions', + icon: UserCheck, + items: [ + { label: "Change Site's IP Address", to: '/accounts/change-ip' }, + { label: 'Create a New Account', to: '/accounts/create' }, + { label: 'Email All Users', to: '/accounts/email-users' }, + { label: 'Force Password Change', to: '/accounts/force-password' }, + { label: 'Limit Bandwidth Usage', to: '/accounts/limit-bandwidth' }, + { label: 'Manage Account Suspension', to: '/accounts/suspension' }, + { label: 'Manage Demo Mode', to: '/accounts/demo-mode' }, + { label: 'Manage Shell Access', to: '/accounts/shell-access' }, + { label: 'Modify an Account', to: '/accounts/modify' }, + { label: 'Password Modification', to: '/accounts/password' }, + { label: 'Quota Modification', to: '/accounts/quota' }, + { label: 'Raw Apache Log Download', to: '/accounts/apache-logs' }, + { label: 'Raw NGINX® Log Download', to: '/accounts/nginx-logs' }, + { label: 'Rearrange an Account', to: '/accounts/rearrange' }, + { label: 'Reset Account Bandwidth', to: '/accounts/reset-bandwidth' }, + { label: 'Terminate Accounts', to: '/accounts/terminate' }, + { label: 'Unsuspend Bandwidth Exceeders',to: '/accounts/unsuspend-bandwidth'}, + { label: 'Upgrade/Downgrade an Account', to: '/accounts/upgrade' }, + { label: 'Web Template Editor', to: '/accounts/web-template' }, + ], + }, + { + id: 'multi-account', + label: 'Multi Account Functions', + icon: Users, + items: [ + { label: "Change Multiple Sites' IP Addresses", to: '/accounts/bulk-ip' }, + { label: 'Modify/Upgrade Multiple Accounts', to: '/accounts/bulk-modify' }, + ], + }, + { + id: 'transfers', + label: 'Transfers', + icon: ArrowRightLeft, + items: [ + { label: 'Convert Addon Domain to Account', to: '/accounts' }, + { label: 'Review Transfers and Restores', to: '/accounts' }, + { label: 'Transfer or Restore a cPanel Account', to: '/accounts' }, + { label: 'Transfer Tool', to: '/accounts' }, + ], + }, + { + id: 'themes', + label: 'Themes', + icon: Paintbrush, + items: [ + { label: 'Theme Manager', to: '/settings' }, + ], + }, + { + id: 'packages', + label: 'Packages', + icon: Package, + items: [ + { label: 'Add a Package', to: '/packages' }, + { label: 'Delete a Package', to: '/packages' }, + { label: 'Edit a Package', to: '/packages' }, + { label: 'Feature Manager', to: '/packages' }, + ], + }, + { + id: 'dns', + label: 'DNS Functions', + icon: Globe, + items: [ + { label: 'Add a DNS Zone', to: '/dns' }, + { label: 'Add an A Entry for Hostname', to: '/dns' }, + { label: 'Delete a DNS Zone', to: '/dns' }, + { label: 'DNS Zone Manager', to: '/dns' }, + { label: 'Edit Zone Templates', to: '/dns' }, + { label: 'Email DNS Record Manager', to: '/dns' }, + { label: 'Email Routing Configuration', to: '/email' }, + { label: 'Enable DKIM/SPF Globally', to: '/email' }, + { label: 'Nameserver Record Report', to: '/dns' }, + { label: 'Park a Domain', to: '/dns' }, + { label: 'Perform a DNS Cleanup', to: '/dns' }, + { label: 'Set Zone TTL', to: '/dns' }, + { label: 'Setup/Edit Domain Forwarding', to: '/dns' }, + { label: 'Synchronize DNS Records', to: '/dns' }, + ], + }, + { + id: 'databases', + label: 'Database Services', + icon: Database, + items: [ + { label: 'Change Database Root Password', to: '/settings' }, + { label: 'Change Database User Password', to: '/settings' }, + { label: 'Database Map Tool', to: '/settings' }, + { label: 'Edit Database Configuration', to: '/settings' }, + { label: 'Manage Database Access Hosts', to: '/settings' }, + { label: 'Manage Database Profiles', to: '/settings' }, + { label: 'Manage Database Users', to: '/settings' }, + { label: 'Manage Databases', to: '/settings' }, + { label: 'phpMyAdmin', to: '/settings' }, + { label: 'Repair Databases', to: '/settings' }, + { label: 'Show Database Processes', to: '/settings' }, + { label: 'Upgrade Database Version', to: '/settings' }, + ], + }, + { + id: 'ip', + label: 'IP Functions', + icon: Wifi, + items: [ + { label: 'IPv6 Ranges', to: '/security' }, + { label: 'Add a New IP Address', to: '/security' }, + { label: 'Assign IPv6 Address', to: '/security' }, + { label: 'Configure Remote Service IPs', to: '/security' }, + { label: 'IP Migration Wizard', to: '/security' }, + { label: 'Rebuild the IP Address Pool', to: '/security' }, + { label: 'Show IP Address Usage', to: '/security' }, + { label: 'Show or Delete Current IPs', to: '/security' }, + { label: 'Show/Edit Reserved IPs', to: '/security' }, + ], + }, + { + id: 'software', + label: 'Software', + icon: Box, + items: [ + { label: 'Install a Perl Module', to: '/system' }, + { label: 'Install Distro Packages', to: '/system' }, + { label: 'Module Installers', to: '/system' }, + { label: 'MultiPHP INI Editor', to: '/php' }, + { label: 'MultiPHP Manager', to: '/php' }, + { label: 'NGINX® Manager', to: '/webserver' }, + { label: 'Rebuild RPM Database', to: '/system' }, + { label: 'System Update', to: '/system' }, + { label: 'Update Server Software', to: '/system' }, + { label: 'EasyApache 4', to: '/webserver' }, + ], + }, + { + id: 'email', + label: 'Email', + icon: Mail, + items: [ + { label: 'Email Deliverability', to: '/email' }, + { label: 'Filter Incoming Emails by Country', to: '/email' }, + { label: 'Filter Incoming Emails by Domain', to: '/email' }, + { label: 'Greylisting', to: '/email' }, + { label: 'Mail Delivery Reports', to: '/email' }, + { label: 'Mail Queue Manager', to: '/email' }, + { label: 'Mail Troubleshooter', to: '/email' }, + { label: 'Mailbox Conversion', to: '/email' }, + { label: 'Repair Mailbox Permissions', to: '/email' }, + { label: 'Spamd Startup Configuration', to: '/email' }, + { label: 'View Mail Statistics Summary', to: '/email' }, + { label: 'View Relayers', to: '/email' }, + { label: 'View Sent Summary', to: '/email' }, + ], + }, + { + id: 'system-health', + label: 'System Health', + icon: HeartPulse, + items: [ + { label: 'Background Process Killer', to: '/system' }, + { label: 'Process Manager', to: '/system' }, + { label: 'Show Current Disk Usage', to: '/system' }, + { label: 'Show Current Running Processes', to: '/system' }, + ], + }, + { + id: 'nixpanel', + label: 'NixPanel', + icon: LayoutDashboard, + items: [ + { label: 'Change Log', to: '/settings' }, + { label: 'Customization', to: '/settings' }, + { label: 'Manage Plugins', to: '/settings' }, + { label: 'Modify NixPanel News', to: '/settings' }, + { label: 'Reset a Mailman Password', to: '/email' }, + { label: 'Upgrade to Latest Version',to: '/settings' }, + ], + }, + { + id: 'ssl', + label: 'SSL/TLS', + icon: Lock, + items: [ + { label: 'Generate an SSL Certificate and Signing Request', to: '/ssl' }, + { label: 'Install an SSL Certificate on a Domain', to: '/ssl' }, + { label: 'Manage AutoSSL', to: '/ssl' }, + { label: 'Manage SSL Hosts', to: '/ssl' }, + { label: 'Purchase and Install an SSL Certificate', to: '/ssl' }, + { label: 'SSL Storage Manager', to: '/ssl' }, + { label: 'SSL/TLS Configuration', to: '/ssl' }, + ], + }, + { + id: 'market', + label: 'Market', + icon: ShoppingBag, + items: [ + { label: 'Market Provider Manager', to: '/settings' }, + ], + }, + { + id: 'restart-services', + label: 'Restart Services', + icon: RefreshCw, + items: [ + { label: 'Database Server', to: '/services' }, + { label: 'DNS Server', to: '/services' }, + { label: 'HTTP Server (Apache)', to: '/services' }, + { label: 'IMAP Server', to: '/services' }, + { label: 'Mail Server (Exim)', to: '/services' }, + { label: 'Mailing List Manager (Mailman)', to: '/services' }, + { label: 'PHP-FPM service for Apache', to: '/services' }, + { label: 'SSH Server (OpenSSH)', to: '/services' }, + ], + }, + { + id: 'development', + label: 'Development', + icon: Code2, + items: [ + { label: 'Apps Managed by AppConfig', to: '/settings' }, + { label: 'NixPanel Development Forum', to: '/settings' }, + { label: 'NixPanel Plugin File Generator', to: '/settings' }, + { label: 'Developer Documentation', to: '/settings' }, + { label: 'Manage API Tokens', to: '/settings' }, + { label: 'Manage Hooks', to: '/settings' }, + { label: 'OpenAPI Documentation for NixPanel', to: '/settings' }, + { label: 'OpenAPI Documentation for NixServer', to: '/settings' }, + ], + }, + { + id: 'plugins', + label: 'Plugins', + icon: Puzzle, + items: [ + { label: 'Backuply by Softaculous', to: '/backup' }, + { label: 'ImunifyAV', to: '/security' }, + { label: 'Server Monitoring', to: '/system' }, + { label: 'SitePad Website Builder', to: '/settings' }, + { label: 'Softaculous - Instant Installs', to: '/settings' }, + { label: 'WP Toolkit', to: '/settings' }, + ], + }, +] diff --git a/nixserver/src/pages/Settings.tsx b/nixserver/src/pages/Settings.tsx index 931f78a..74749f1 100644 --- a/nixserver/src/pages/Settings.tsx +++ b/nixserver/src/pages/Settings.tsx @@ -1,49 +1,125 @@ import React, { useEffect, useState } from 'react' -import { Save, RefreshCw } from 'lucide-react' +import { Save, RefreshCw, AlertTriangle } from 'lucide-react' import { api } from '../api/client' +import ContactTab from './settings/ContactTab' +import BasicTab from './settings/BasicTab' +import NameserversSection from './settings/NameserversSection' + +type Tab = 'contact' | 'basic' + +const TABS: { id: Tab; label: string }[] = [ + { id: 'contact', label: 'Contact Information' }, + { id: 'basic', label: 'Basic Config' }, +] export default function Settings() { const [settings, setSettings] = useState>({}) + const [interfaces, setInterfaces] = useState([]) + const [tab, setTab] = useState('contact') const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) + const [error, setError] = useState('') - const fetch = async () => { + const load = async () => { setLoading(true) - try { const r = await api.get('/nixserver/system/settings'); setSettings(r.data.data) } - finally { setLoading(false) } + setError('') + try { + const [settingsRes, ifaceRes] = await Promise.allSettled([ + api.get('/nixserver/system/settings'), + api.get('/nixserver/system/network-interfaces'), + ]) + if (settingsRes.status === 'fulfilled') setSettings(settingsRes.value.data.data) + if (ifaceRes.status === 'fulfilled') setInterfaces(ifaceRes.value.data.data ?? []) + } catch { + setError('Failed to load settings') + } finally { + setLoading(false) + } } - useEffect(() => { fetch() }, []) + useEffect(() => { load() }, []) + + const handleChange = (key: string, value: string) => + setSettings(s => ({ ...s, [key]: value })) const save = async (e: React.FormEvent) => { - e.preventDefault(); setSaving(true) - try { await api.put('/nixserver/system/settings', settings); setSaved(true); setTimeout(() => setSaved(false), 2000) } - finally { setSaving(false) } + e.preventDefault() + setSaving(true) + setError('') + try { + await api.put('/nixserver/system/settings', settings) + setSaved(true) + setTimeout(() => setSaved(false), 2500) + } catch { + setError('Failed to save settings') + } finally { + setSaving(false) + } } return ( - + - SettingsSystem-wide configuration - + + Basic NixServer Setup + Configure server-wide defaults + + + + - - {Object.entries(settings).map(([key, value]) => ( - - {key} - setSettings(s => ({ ...s, [key]: e.target.value }))} - className="flex-1 bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-indigo-500" - /> - - ))} - - - {saving ? 'Saving...' : saved ? 'Saved!' : 'Save Settings'} + {error && ( + + + {error} + + )} + + + {/* Tab bar */} + + {TABS.map(t => ( + setTab(t.id)} + className={[ + 'px-5 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors', + tab === t.id + ? 'border-indigo-500 text-white' + : 'border-transparent text-[#64748b] hover:text-[#94a3b8]', + ].join(' ')} + > + {t.label} + + ))} + + + + {tab === 'contact' + ? + : + } + + + + + + + + {saving ? 'Saving…' : saved ? 'Saved!' : 'Save Changes'} + {saved && Settings saved successfully.} diff --git a/nixserver/src/pages/server-configuration/Analytics.tsx b/nixserver/src/pages/server-configuration/Analytics.tsx new file mode 100644 index 0000000..b2e05bc --- /dev/null +++ b/nixserver/src/pages/server-configuration/Analytics.tsx @@ -0,0 +1,10 @@ +import ComingSoon from '../../components/ComingSoon' + +export default function Analytics() { + return ( + + ) +} diff --git a/nixserver/src/pages/server-configuration/BasicSetup.tsx b/nixserver/src/pages/server-configuration/BasicSetup.tsx new file mode 100644 index 0000000..e39a675 --- /dev/null +++ b/nixserver/src/pages/server-configuration/BasicSetup.tsx @@ -0,0 +1,3 @@ +// Basic NixServer Setup — full implementation lives in the settings/ sub-components. +// This file is the route entry point at /server-configuration/basic-setup. +export { default } from '../Settings' diff --git a/nixserver/src/pages/server-configuration/ChangeRoot.tsx b/nixserver/src/pages/server-configuration/ChangeRoot.tsx new file mode 100644 index 0000000..d9aba81 --- /dev/null +++ b/nixserver/src/pages/server-configuration/ChangeRoot.tsx @@ -0,0 +1,10 @@ +import ComingSoon from '../../components/ComingSoon' + +export default function ChangeRoot() { + return ( + + ) +} diff --git a/nixserver/src/pages/server-configuration/CronJobs.tsx b/nixserver/src/pages/server-configuration/CronJobs.tsx new file mode 100644 index 0000000..34fb217 --- /dev/null +++ b/nixserver/src/pages/server-configuration/CronJobs.tsx @@ -0,0 +1,10 @@ +import ComingSoon from '../../components/ComingSoon' + +export default function CronJobs() { + return ( + + ) +} diff --git a/nixserver/src/pages/server-configuration/LinkNodes.tsx b/nixserver/src/pages/server-configuration/LinkNodes.tsx new file mode 100644 index 0000000..b29a425 --- /dev/null +++ b/nixserver/src/pages/server-configuration/LinkNodes.tsx @@ -0,0 +1,10 @@ +import ComingSoon from '../../components/ComingSoon' + +export default function LinkNodes() { + return ( + + ) +} diff --git a/nixserver/src/pages/server-configuration/Marketplace.tsx b/nixserver/src/pages/server-configuration/Marketplace.tsx new file mode 100644 index 0000000..679244c --- /dev/null +++ b/nixserver/src/pages/server-configuration/Marketplace.tsx @@ -0,0 +1,10 @@ +import ComingSoon from '../../components/ComingSoon' + +export default function Marketplace() { + return ( + + ) +} diff --git a/nixserver/src/pages/server-configuration/QuotaSetup.tsx b/nixserver/src/pages/server-configuration/QuotaSetup.tsx new file mode 100644 index 0000000..1563a98 --- /dev/null +++ b/nixserver/src/pages/server-configuration/QuotaSetup.tsx @@ -0,0 +1,10 @@ +import ComingSoon from '../../components/ComingSoon' + +export default function QuotaSetup() { + return ( + + ) +} diff --git a/nixserver/src/pages/server-configuration/ServerProfile.tsx b/nixserver/src/pages/server-configuration/ServerProfile.tsx new file mode 100644 index 0000000..153cee8 --- /dev/null +++ b/nixserver/src/pages/server-configuration/ServerProfile.tsx @@ -0,0 +1,10 @@ +import ComingSoon from '../../components/ComingSoon' + +export default function ServerProfile() { + return ( + + ) +} diff --git a/nixserver/src/pages/server-configuration/ServerTime.tsx b/nixserver/src/pages/server-configuration/ServerTime.tsx new file mode 100644 index 0000000..1e11ac4 --- /dev/null +++ b/nixserver/src/pages/server-configuration/ServerTime.tsx @@ -0,0 +1,10 @@ +import ComingSoon from '../../components/ComingSoon' + +export default function ServerTime() { + return ( + + ) +} diff --git a/nixserver/src/pages/server-configuration/StatisticsSoftware.tsx b/nixserver/src/pages/server-configuration/StatisticsSoftware.tsx new file mode 100644 index 0000000..27b01b3 --- /dev/null +++ b/nixserver/src/pages/server-configuration/StatisticsSoftware.tsx @@ -0,0 +1,10 @@ +import ComingSoon from '../../components/ComingSoon' + +export default function StatisticsSoftware() { + return ( + + ) +} diff --git a/nixserver/src/pages/server-configuration/Terminal.tsx b/nixserver/src/pages/server-configuration/Terminal.tsx new file mode 100644 index 0000000..95b7b3a --- /dev/null +++ b/nixserver/src/pages/server-configuration/Terminal.tsx @@ -0,0 +1,10 @@ +import ComingSoon from '../../components/ComingSoon' + +export default function Terminal() { + return ( + + ) +} diff --git a/nixserver/src/pages/server-configuration/TweakSettings.tsx b/nixserver/src/pages/server-configuration/TweakSettings.tsx new file mode 100644 index 0000000..670063d --- /dev/null +++ b/nixserver/src/pages/server-configuration/TweakSettings.tsx @@ -0,0 +1,10 @@ +import ComingSoon from '../../components/ComingSoon' + +export default function TweakSettings() { + return ( + + ) +} diff --git a/nixserver/src/pages/server-configuration/UpdatePreferences.tsx b/nixserver/src/pages/server-configuration/UpdatePreferences.tsx new file mode 100644 index 0000000..9590805 --- /dev/null +++ b/nixserver/src/pages/server-configuration/UpdatePreferences.tsx @@ -0,0 +1,10 @@ +import ComingSoon from '../../components/ComingSoon' + +export default function UpdatePreferences() { + return ( + + ) +} diff --git a/nixserver/src/pages/server-configuration/index.tsx b/nixserver/src/pages/server-configuration/index.tsx new file mode 100644 index 0000000..f62294e --- /dev/null +++ b/nixserver/src/pages/server-configuration/index.tsx @@ -0,0 +1,116 @@ +import { + Settings, Lock, BarChart2, Clock, HardDrive, Link2, + User, Timer, PieChart, Terminal, Sliders, RefreshCw, ShoppingBag, +} from 'lucide-react' +import SectionCard from '../../components/SectionCard' + +const TOOLS = [ + { + label: 'Basic NixServer Setup', + description: 'Configure contact info, shared IP, nameservers, DNS TTL, home directory, and Apache log style.', + to: '/server-configuration/basic-setup', + icon: Settings, + comingSoon: false, + }, + { + label: 'Change Root Password', + description: 'Update the root account password for this server.', + to: '/server-configuration/change-root', + icon: Lock, + comingSoon: false, + }, + { + label: 'Configure Analytics', + description: 'Enable and configure server-side analytics and statistics collection.', + to: '/server-configuration/analytics', + icon: BarChart2, + comingSoon: true, + }, + { + label: 'Configure Cron Jobs', + description: 'Schedule recurring system-level tasks and maintenance scripts.', + to: '/server-configuration/cron-jobs', + icon: Clock, + comingSoon: true, + }, + { + label: 'Initial Quota Setup', + description: 'Configure disk quota enforcement and default account limits.', + to: '/server-configuration/quota-setup', + icon: HardDrive, + comingSoon: true, + }, + { + label: 'Link Server Nodes', + description: 'Connect additional server nodes for clustering or load distribution.', + to: '/server-configuration/link-nodes', + icon: Link2, + comingSoon: true, + }, + { + label: 'Server Profile', + description: 'Set server-wide identity, locale, and branding preferences.', + to: '/server-configuration/server-profile', + icon: User, + comingSoon: true, + }, + { + label: 'Server Time', + description: 'Configure the system clock, timezone, and NTP synchronization.', + to: '/server-configuration/server-time', + icon: Timer, + comingSoon: true, + }, + { + label: 'Statistics Software', + description: 'Install and configure AWStats, Webalizer, or other log analysis tools.', + to: '/server-configuration/statistics-software', + icon: PieChart, + comingSoon: true, + }, + { + label: 'Terminal', + description: 'Open a browser-based terminal session with server access.', + to: '/server-configuration/terminal', + icon: Terminal, + comingSoon: true, + }, + { + label: 'Tweak Settings', + description: 'Fine-tune advanced server behaviours and feature toggles.', + to: '/server-configuration/tweak-settings', + icon: Sliders, + comingSoon: true, + }, + { + label: 'Update Preferences', + description: 'Control automatic update behaviour, channels, and scheduling.', + to: '/server-configuration/update-preferences', + icon: RefreshCw, + comingSoon: true, + }, + { + label: 'NixServer Marketplace', + description: 'Browse and install official extensions, plugins, and integrations.', + to: '/server-configuration/marketplace', + icon: ShoppingBag, + comingSoon: true, + }, +] + +export default function ServerConfiguration() { + return ( + + + Server Configuration + Server-wide settings and system configuration tools + + + + {TOOLS.map(tool => ( + + ))} + + + ) +} diff --git a/nixserver/src/pages/settings/BasicTab.tsx b/nixserver/src/pages/settings/BasicTab.tsx new file mode 100644 index 0000000..fef44f5 --- /dev/null +++ b/nixserver/src/pages/settings/BasicTab.tsx @@ -0,0 +1,130 @@ +import { AlertTriangle } from 'lucide-react' +import { Field, TextInput, RadioGroup, get } from './FormControls' + +interface Props { + settings: Record + onChange: (key: string, value: string) => void + interfaces: string[] +} + +export default function BasicTab({ settings, onChange, interfaces }: Props) { + const ethdev = get(settings, 'ETHDEV') + const isOther = ethdev !== '' && !interfaces.includes(ethdev) + + return ( + + + onChange('ADDR', v)} + placeholder="192.168.1.100" + /> + + + + onChange('ADDR6', v)} + placeholder="2001:db8::1" + /> + + + + + { + if (e.target.value !== '__other__') onChange('ETHDEV', e.target.value) + else onChange('ETHDEV', '') + }} + className="bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500" + > + — select — + {interfaces.map(iface => ( + {iface} + ))} + Other… + + {isOther && ( + onChange('ETHDEV', v)} + placeholder="eth0" + /> + )} + + + + + onChange('HOMEDIR', v)} + placeholder="/home" + /> + + + + onChange('HOMEMATCH', v)} + placeholder="home" + /> + + + + onChange('SCRIPTALIAS', v)} + options={[ + { value: 'yes', label: 'Yes' }, + { value: 'no', label: 'No' }, + ]} + /> + + + + onChange('TTL', v)} + placeholder="14400" + /> + + + + onChange('NSTTL', v)} + placeholder="86400" + /> + + + + + onChange('LOGSTYLE', v)} + options={[ + { value: 'combined', label: 'Combined' }, + { value: 'common', label: 'Common' }, + ]} + /> + {get(settings, 'LOGSTYLE') === 'common' && ( + + + + Warning: "Common" log format omits the referrer and user-agent + fields. "Combined" is recommended for most setups. + + + )} + + + + ) +} diff --git a/nixserver/src/pages/settings/ContactTab.tsx b/nixserver/src/pages/settings/ContactTab.tsx new file mode 100644 index 0000000..88145bd --- /dev/null +++ b/nixserver/src/pages/settings/ContactTab.tsx @@ -0,0 +1,107 @@ +import { Field, TextInput, get } from './FormControls' + +interface Props { + settings: Record + onChange: (key: string, value: string) => void +} + +export default function ContactTab({ settings, onChange }: Props) { + return ( + + + onChange('CONTACTEMAIL', v)} + placeholder="admin@example.com" + type="email" + /> + + Enter the email address(es) to which you would like server notification messages to be + sent. Separate multiple addresses with spaces. + + + + + onChange('EMAILFROMNAME', v)} + placeholder="NixPanel" + /> + + + + onChange('EMAILREPLYTO', v)} + placeholder="noreply@example.com" + type="email" + /> + + + + onChange('CONTACTPAGER', v)} + placeholder="5551234567@txt.att.net" + /> + + + + onChange('CONTACTPUSHBULLET', v)} + placeholder="o.xxxxxxxxxxxxxxxxxxxxxx" + type="password" + /> + + + + onChange('CONTACTSLACK', e.target.value)} + placeholder="https://hooks.slack.com/services/..." + rows={3} + className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500 placeholder-[#374151] resize-y" + /> + + + + onChange('CONTACTPOSTURL', e.target.value)} + placeholder="https://example.com/webhook" + rows={3} + className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500 placeholder-[#374151] resize-y" + /> + + + + onChange('ICQUSER', v)} + placeholder="123456789" + /> + + + + onChange('ICQPASS', v)} + placeholder="••••••••" + type="password" + /> + + + + onChange('CONTACTUIN', v)} + placeholder="123456789 987654321" + /> + + + ) +} diff --git a/nixserver/src/pages/settings/FormControls.tsx b/nixserver/src/pages/settings/FormControls.tsx new file mode 100644 index 0000000..2b63273 --- /dev/null +++ b/nixserver/src/pages/settings/FormControls.tsx @@ -0,0 +1,83 @@ +// Shared form primitives used across Settings tabs + +export function Field({ + label, + hint, + children, +}: { + label: string + hint?: string + children: React.ReactNode +}) { + return ( + + + {label} + {hint && {hint}} + + {children} + + ) +} + +export function TextInput({ + value, + onChange, + placeholder, + type = 'text', +}: { + value: string + onChange: (v: string) => void + placeholder?: string + type?: string +}) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500 placeholder-[#374151]" + /> + ) +} + +export function RadioGroup({ + value, + onChange, + options, +}: { + value: string + onChange: (v: string) => void + options: { value: string; label: string }[] +}) { + return ( + + {options.map(opt => ( + + onChange(opt.value)} + className="accent-indigo-500" + /> + {opt.label} + + ))} + + ) +} + +export const FIELD_DEFAULTS: Record = { + HOMEDIR: '/home', + HOMEMATCH: 'home', + TTL: '14400', + NSTTL: '86400', + SCRIPTALIAS: 'yes', + LOGSTYLE: 'combined', +} + +export function get(settings: Record, key: string): string { + return settings[key] ?? FIELD_DEFAULTS[key] ?? '' +} diff --git a/nixserver/src/pages/settings/NameserversSection.tsx b/nixserver/src/pages/settings/NameserversSection.tsx new file mode 100644 index 0000000..cc075cd --- /dev/null +++ b/nixserver/src/pages/settings/NameserversSection.tsx @@ -0,0 +1,57 @@ +import { Server } from 'lucide-react' +import { TextInput, get } from './FormControls' + +const NS_KEYS = ['NS', 'NS2', 'NS3', 'NS4'] as const +const NS_LABELS: Record = { + NS: 'Nameserver 1', + NS2: 'Nameserver 2', + NS3: 'Nameserver 3 (optional)', + NS4: 'Nameserver 4 (optional)', +} +const NS_PLACEHOLDERS: Record = { + NS: 'ns1.example.com', + NS2: 'ns2.example.com', + NS3: '', + NS4: '', +} + +interface Props { + settings: Record + onChange: (key: string, value: string) => void +} + +export default function NameserversSection({ settings, onChange }: Props) { + return ( + + + + Nameservers + + + {NS_KEYS.map(key => ( + + {NS_LABELS[key]} + onChange(key, v)} + placeholder={NS_PLACEHOLDERS[key]} + /> + { + const ns = get(settings, key) + if (ns) alert(`Configure A record for ${ns} — DNS management coming soon.`) + }} + className="text-xs text-indigo-400 hover:text-indigo-300 border border-indigo-500/30 hover:border-indigo-400 rounded px-3 py-1.5 whitespace-nowrap transition-colors" + > + Configure Address Records + + + ))} + + + ) +} diff --git a/server/src/routes/nixserver/accounts.ts b/server/src/routes/nixserver/accounts.ts deleted file mode 100644 index 0ff6b73..0000000 --- a/server/src/routes/nixserver/accounts.ts +++ /dev/null @@ -1,671 +0,0 @@ -import type { FastifyInstance } from 'fastify' -import bcrypt from 'bcryptjs' -import { z } from 'zod' -import prisma from '../../db/client.js' -import { requireAdminOrReseller } from '../../middleware/auth.js' -import { exec, validateUsername, validateDomain } from '../../core/exec.js' -import { enableVhost, disableVhost, suspendVhost, enablePhpFpmPool, disablePhpFpmPool } from '../../core/apache.js' -import { createZone, removeZone } from '../../core/dns.js' -import { config } from '../../config.js' - -const CreateAccountSchema = z.object({ - username: z.string().min(1).max(32), - password: z.string().min(8), - email: z.string().email(), - domain: z.string().min(4), - packageId: z.number().int().positive().optional(), - resellerId: z.number().int().positive().optional(), - ipId: z.number().int().positive().optional(), -}) - -const UpdateAccountSchema = z.object({ - email: z.string().email().optional(), - packageId: z.number().int().positive().optional(), - password: z.string().min(8).optional(), - notes: z.string().max(500).optional(), -}) - -export default async function accountRoutes(fastify: FastifyInstance) { - const preHandler = [requireAdminOrReseller] - - // GET /api/nixserver/accounts - fastify.get('/', { preHandler }, async (request, reply) => { - const q = request.query as Record - const page = Math.max(1, parseInt(q.page ?? '1', 10)) - const limit = Math.min(100, Math.max(1, parseInt(q.limit ?? '25', 10))) - const skip = (page - 1) * limit - - const where: Record = {} - if (q.search) { - where.OR = [ - { username: { contains: q.search } }, - { domain: { contains: q.search } }, - { email: { contains: q.search } }, - ] - } - if (q.status) where.status = q.status - if (request.user!.role === 'reseller') where.resellerId = request.user!.sub - - const [items, total] = await Promise.all([ - prisma.account.findMany({ - where, - skip, - take: limit, - orderBy: { createdAt: 'desc' }, - select: { - id: true, username: true, domain: true, email: true, status: true, - diskUsedMb: true, bandwidthUsedMb: true, ipAddress: true, - createdAt: true, suspendedAt: true, suspendReason: true, - package: { select: { name: true, diskMb: true, bandwidthMb: true } }, - reseller: { select: { username: true } }, - }, - }), - prisma.account.count({ where }), - ]) - - return reply.send({ - success: true, - data: { items, total, page, limit, totalPages: Math.ceil(total / limit) }, - }) - }) - - // GET /api/nixserver/accounts/stats - fastify.get('/stats', { preHandler }, async (request, reply) => { - const where = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {} - - const [total, active, suspended, terminated] = await Promise.all([ - prisma.account.count({ where }), - prisma.account.count({ where: { ...where, status: 'active' } }), - prisma.account.count({ where: { ...where, status: 'suspended' } }), - prisma.account.count({ where: { ...where, status: 'terminated' } }), - ]) - - return reply.send({ success: true, data: { total, active, suspended, terminated } }) - }) - - // GET /api/nixserver/accounts/parked-domains - fastify.get('/parked-domains', { preHandler }, async (request, reply) => { - const where = request.user!.role === 'reseller' - ? { account: { resellerId: request.user!.sub } } - : {} - - const items = await prisma.parkedDomain.findMany({ - where, - orderBy: { createdAt: 'desc' }, - include: { - account: { select: { username: true, domain: true } }, - }, - }) - - return reply.send({ success: true, data: items }) - }) - - // GET /api/nixserver/accounts/subdomains - fastify.get('/subdomains', { preHandler }, async (request, reply) => { - const where = request.user!.role === 'reseller' - ? { account: { resellerId: request.user!.sub } } - : {} - - const items = await prisma.subdomain.findMany({ - where, - orderBy: { createdAt: 'desc' }, - include: { - account: { select: { username: true } }, - }, - }) - - return reply.send({ success: true, data: items }) - }) - - // GET /api/nixserver/accounts/over-quota - fastify.get('/over-quota', { preHandler }, async (request, reply) => { - const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {} - - const accounts = await prisma.account.findMany({ - where: { - ...resellerWhere, - package: { isNot: null }, - }, - select: { - id: true, username: true, domain: true, status: true, - diskUsedMb: true, - package: { select: { name: true, diskMb: true, bandwidthMb: true } }, - }, - }) - - const overQuota = accounts.filter((a: typeof accounts[number]) => a.package && a.diskUsedMb > a.package.diskMb) - - return reply.send({ success: true, data: overQuota }) - }) - - // GET /api/nixserver/accounts/bandwidth - fastify.get('/bandwidth', { preHandler }, async (request, reply) => { - const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {} - - const items = await prisma.account.findMany({ - where: resellerWhere, - orderBy: { bandwidthUsedMb: 'desc' }, - select: { - id: true, username: true, domain: true, status: true, - bandwidthUsedMb: true, suspendReason: true, - package: { select: { name: true, bandwidthMb: true } }, - }, - }) - - return reply.send({ success: true, data: items }) - }) - - // POST /api/nixserver/accounts/:id/change-ip - fastify.post('/:id/change-ip', { preHandler }, async (request, reply) => { - const { id } = request.params as { id: string } - const body = z.object({ ipAddress: z.string().min(1) }).safeParse(request.body) - if (!body.success) { - return reply.code(400).send({ success: false, error: body.error.issues[0]?.message }) - } - - const where: Record = { id: parseInt(id, 10) } - if (request.user!.role === 'reseller') where.resellerId = request.user!.sub - - const account = await prisma.account.findFirst({ where }) - if (!account) return reply.code(404).send({ success: false, error: 'Account not found' }) - - const updated = await prisma.account.update({ - where: { id: account.id }, - data: { ipAddress: body.data.ipAddress }, - }) - - await prisma.auditLog.create({ - data: { - action: 'account.change-ip', - target: account.username, - details: `IP changed to ${body.data.ipAddress}`, - sourceIp: request.ip, - adminId: request.user!.role === 'admin' ? request.user!.sub : null, - }, - }) - - const { passwordHash: _, ...safe } = updated - return reply.send({ success: true, data: safe }) - }) - - // POST /api/nixserver/accounts/:id/reset-bandwidth - fastify.post('/:id/reset-bandwidth', { preHandler }, async (request, reply) => { - const { id } = request.params as { id: string } - - const where: Record = { id: parseInt(id, 10) } - if (request.user!.role === 'reseller') where.resellerId = request.user!.sub - - const account = await prisma.account.findFirst({ where }) - if (!account) return reply.code(404).send({ success: false, error: 'Account not found' }) - - await prisma.account.update({ - where: { id: account.id }, - data: { bandwidthUsedMb: 0 }, - }) - - await prisma.auditLog.create({ - data: { - action: 'account.reset-bandwidth', - target: account.username, - sourceIp: request.ip, - adminId: request.user!.role === 'admin' ? request.user!.sub : null, - }, - }) - - return reply.send({ success: true, data: { message: `Bandwidth reset for ${account.username}` } }) - }) - - // PUT /api/nixserver/accounts/:id/quota - fastify.put('/:id/quota', { preHandler }, async (request, reply) => { - const { id } = request.params as { id: string } - const body = z.object({ - diskMb: z.number().int().positive(), - bandwidthMb: z.number().int().positive(), - }).safeParse(request.body) - - if (!body.success) { - return reply.code(400).send({ success: false, error: body.error.issues[0]?.message }) - } - - const where: Record = { id: parseInt(id, 10) } - if (request.user!.role === 'reseller') where.resellerId = request.user!.sub - - const account = await prisma.account.findFirst({ - where, - include: { package: true }, - }) - if (!account) return reply.code(404).send({ success: false, error: 'Account not found' }) - - if (account.packageId) { - await prisma.package.update({ - where: { id: account.packageId }, - data: { diskMb: body.data.diskMb, bandwidthMb: body.data.bandwidthMb }, - }) - } - - await prisma.auditLog.create({ - data: { - action: 'account.quota-modification', - target: account.username, - details: `diskMb=${body.data.diskMb}, bandwidthMb=${body.data.bandwidthMb}`, - sourceIp: request.ip, - adminId: request.user!.role === 'admin' ? request.user!.sub : null, - }, - }) - - return reply.send({ success: true, data: { message: 'Quota updated', ...body.data } }) - }) - - // POST /api/nixserver/accounts/unsuspend-bandwidth-exceeded - fastify.post('/unsuspend-bandwidth-exceeded', { preHandler }, async (request, reply) => { - const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {} - - const accounts = await prisma.account.findMany({ - where: { - ...resellerWhere, - status: 'suspended', - suspendReason: { contains: 'bandwidth' }, - }, - }) - - const results: { id: number; username: string; success: boolean; error?: string }[] = [] - - for (const account of accounts) { - try { - await exec('usermod', ['-U', account.username]) - await prisma.account.update({ - where: { id: account.id }, - data: { status: 'active', suspendedAt: null, suspendReason: null }, - }) - results.push({ id: account.id, username: account.username, success: true }) - } catch (e) { - results.push({ id: account.id, username: account.username, success: false, error: String(e) }) - } - } - - await prisma.auditLog.create({ - data: { - action: 'account.unsuspend-bandwidth-exceeded', - target: `${results.length} accounts`, - sourceIp: request.ip, - adminId: request.user!.role === 'admin' ? request.user!.sub : null, - }, - }) - - return reply.send({ success: true, data: results }) - }) - - // GET /api/nixserver/accounts/:id - fastify.get('/:id', { preHandler }, async (request, reply) => { - const { id } = request.params as { id: string } - const where: Record = { id: parseInt(id, 10) } - if (request.user!.role === 'reseller') where.resellerId = request.user!.sub - - const account = await prisma.account.findFirst({ - where, - include: { - package: true, - reseller: { select: { username: true, company: true } }, - ip: true, - dnsZones: { select: { id: true, domain: true, status: true } }, - _count: { - select: { - emailAccounts: true, databases: true, ftpAccounts: true, - subdomains: true, addonDomains: true, sslCertificates: true, - }, - }, - }, - }) - - if (!account) return reply.code(404).send({ success: false, error: 'Account not found' }) - - const { passwordHash, ...safe } = account - return reply.send({ success: true, data: safe }) - }) - - // POST /api/nixserver/accounts - fastify.post('/', { preHandler }, async (request, reply) => { - const body = CreateAccountSchema.safeParse(request.body) - if (!body.success) { - return reply.code(400).send({ success: false, error: body.error.issues[0]?.message }) - } - - const { username, password, email, domain, packageId, resellerId, ipId } = body.data - - if (!validateUsername(username)) { - return reply.code(400).send({ success: false, error: 'Invalid username format' }) - } - if (!validateDomain(domain)) { - return reply.code(400).send({ success: false, error: 'Invalid domain format' }) - } - - const exists = await prisma.account.findFirst({ - where: { OR: [{ username }, { domain }] }, - }) - if (exists) { - return reply.code(409).send({ success: false, error: 'Username or domain already in use' }) - } - - const passwordHash = await bcrypt.hash(password, 12) - const homedir = `${config.paths.homeDir}/${username}` - - // Create system user - const userAdd = await exec('useradd', [ - '-m', '-d', homedir, '-s', '/usr/sbin/nologin', username, - ]) - if (userAdd.exitCode !== 0 && !config.isDev) { - return reply.code(500).send({ success: false, error: `Failed to create system user: ${userAdd.stderr}` }) - } - - // Create directory structure - const dirs = [ - `${homedir}/public_html`, - `${homedir}/logs`, - `${homedir}/tmp`, - `${homedir}/mail`, - `${homedir}/.ssh`, - ] - for (const dir of dirs) { - await exec('mkdir', ['-p', dir]) - } - await exec('chown', ['-R', `${username}:${username}`, homedir]) - await exec('chmod', ['750', homedir]) - await exec('chmod', ['755', `${homedir}/public_html`]) - - // Resolve IP for vhost - let ipAddress: string | undefined - if (ipId) { - const ip = await prisma.ipAddress.findUnique({ where: { id: ipId } }) - ipAddress = ip?.address - } - - // Create account in DB - const account = await prisma.account.create({ - data: { - username, - passwordHash, - email, - domain, - homedir, - status: 'active', - packageId: packageId ?? null, - resellerId: resellerId ?? (request.user!.role === 'reseller' ? request.user!.sub : null), - ipId: ipId ?? null, - ipAddress: ipAddress ?? null, - }, - }) - - // Create DNS zone - if (ipAddress) { - const serverHostname = (await exec('hostname', ['-f'])).stdout || 'ns1.server.com' - try { - const zoneFile = await createZone({ - domain, - ip: ipAddress, - ns1: `ns1.${domain}`, - ns2: `ns2.${domain}`, - }) - await prisma.dnsZone.create({ - data: { domain, zoneFile, accountId: account.id }, - }) - } catch (e) { - // Non-fatal — log and continue - console.warn(`DNS zone creation failed for ${domain}:`, e) - } - } - - // Create Apache vhost + PHP-FPM pool - const phpVersion = (await prisma.package.findUnique({ where: { id: packageId ?? 0 } }))?.phpVersion ?? '8.3' - try { - await enablePhpFpmPool(username, phpVersion) - await enableVhost({ domain, username, documentRoot: `${homedir}/public_html`, phpVersion }) - } catch (e) { - console.warn(`Apache/PHP-FPM setup failed for ${domain}:`, e) - } - - // Audit log - await prisma.auditLog.create({ - data: { - action: 'account.create', - target: username, - details: `Created hosting account for domain ${domain}`, - sourceIp: request.ip, - adminId: request.user!.role === 'admin' ? request.user!.sub : null, - }, - }) - - const { passwordHash: _, ...safe } = account - return reply.code(201).send({ success: true, data: safe }) - }) - - // PUT /api/nixserver/accounts/:id - fastify.put('/:id', { preHandler }, async (request, reply) => { - const { id } = request.params as { id: string } - const body = UpdateAccountSchema.safeParse(request.body) - if (!body.success) { - return reply.code(400).send({ success: false, error: body.error.issues[0]?.message }) - } - - const where: Record = { id: parseInt(id, 10) } - if (request.user!.role === 'reseller') where.resellerId = request.user!.sub - - const existing = await prisma.account.findFirst({ where }) - if (!existing) return reply.code(404).send({ success: false, error: 'Account not found' }) - - const updateData: Record = {} - if (body.data.email) updateData.email = body.data.email - if (body.data.packageId) updateData.packageId = body.data.packageId - if (body.data.notes !== undefined) updateData.notes = body.data.notes - if (body.data.password) { - updateData.passwordHash = await bcrypt.hash(body.data.password, 12) - // Update system user password - await exec('passwd', ['--stdin', existing.username]) - } - - const updated = await prisma.account.update({ - where: { id: parseInt(id, 10) }, - data: updateData, - }) - - const { passwordHash: _, ...safe } = updated - return reply.send({ success: true, data: safe }) - }) - - // POST /api/nixserver/accounts/:id/suspend - fastify.post('/:id/suspend', { preHandler }, async (request, reply) => { - const { id } = request.params as { id: string } - const { reason } = (request.body as Record) ?? {} - - const account = await prisma.account.findFirst({ - where: { id: parseInt(id, 10), status: 'active' }, - }) - if (!account) return reply.code(404).send({ success: false, error: 'Active account not found' }) - - await exec('usermod', ['-L', account.username]) - try { await suspendVhost(account.domain, account.username) } catch (e) { - console.warn(`suspendVhost failed for ${account.domain}:`, e) - } - - await prisma.account.update({ - where: { id: account.id }, - data: { status: 'suspended', suspendedAt: new Date(), suspendReason: reason ?? null }, - }) - - await prisma.auditLog.create({ - data: { - action: 'account.suspend', - target: account.username, - details: reason ?? 'No reason given', - sourceIp: request.ip, - adminId: request.user!.role === 'admin' ? request.user!.sub : null, - }, - }) - - return reply.send({ success: true, data: { message: `Account ${account.username} suspended` } }) - }) - - // POST /api/nixserver/accounts/:id/unsuspend - fastify.post('/:id/unsuspend', { preHandler }, async (request, reply) => { - const { id } = request.params as { id: string } - - const account = await prisma.account.findFirst({ - where: { id: parseInt(id, 10), status: 'suspended' }, - }) - if (!account) return reply.code(404).send({ success: false, error: 'Suspended account not found' }) - - await exec('usermod', ['-U', account.username]) - try { - await enableVhost({ domain: account.domain, username: account.username, documentRoot: `${account.homedir}/public_html` }) - } catch (e) { - console.warn(`enableVhost failed on unsuspend for ${account.domain}:`, e) - } - - await prisma.account.update({ - where: { id: account.id }, - data: { status: 'active', suspendedAt: null, suspendReason: null }, - }) - - await prisma.auditLog.create({ - data: { - action: 'account.unsuspend', - target: account.username, - sourceIp: request.ip, - adminId: request.user!.role === 'admin' ? request.user!.sub : null, - }, - }) - - return reply.send({ success: true, data: { message: `Account ${account.username} unsuspended` } }) - }) - - // DELETE /api/nixserver/accounts/:id - fastify.delete('/:id', { preHandler }, async (request, reply) => { - const { id } = request.params as { id: string } - const { removeFiles } = (request.body as Record) ?? {} - - const account = await prisma.account.findUnique({ where: { id: parseInt(id, 10) } }) - if (!account) return reply.code(404).send({ success: false, error: 'Account not found' }) - - // Remove Apache vhost + PHP-FPM pool - try { - await disableVhost(account.domain) - } catch (e) { - console.warn(`Failed to remove vhost for ${account.domain}:`, e) - } - try { - const phpVersion = account.packageId - ? (await prisma.package.findUnique({ where: { id: account.packageId } }))?.phpVersion ?? '8.3' - : '8.3' - await disablePhpFpmPool(account.username, phpVersion) - } catch (e) { - console.warn(`Failed to remove PHP-FPM pool for ${account.username}:`, e) - } - - // Remove DNS zone - try { - await removeZone(account.domain) - } catch (e) { - console.warn(`Failed to remove DNS zone for ${account.domain}:`, e) - } - - // Remove system user - const delArgs = removeFiles ? ['-r', account.username] : [account.username] - await exec('userdel', delArgs) - - // Mark as terminated in DB (cascade deletes related records) - await prisma.account.update({ - where: { id: account.id }, - data: { status: 'terminated', terminatedAt: new Date() }, - }) - - await prisma.auditLog.create({ - data: { - action: 'account.terminate', - target: account.username, - details: removeFiles ? 'Files removed' : 'Files retained', - sourceIp: request.ip, - adminId: request.user!.role === 'admin' ? request.user!.sub : null, - }, - }) - - return reply.send({ success: true, data: { message: `Account ${account.username} terminated` } }) - }) - - // PUT /api/nixserver/accounts/:id/shell — enable or disable shell access - fastify.put('/:id/shell', { preHandler }, async (request, reply) => { - const { id } = request.params as { id: string } - const { enabled } = (request.body ?? {}) as { enabled?: boolean } - if (typeof enabled !== 'boolean') { - return reply.code(400).send({ success: false, error: '"enabled" boolean required' }) - } - - const account = await prisma.account.findUnique({ where: { id: parseInt(id, 10) } }) - if (!account) return reply.code(404).send({ success: false, error: 'Account not found' }) - - const shell = enabled ? '/bin/bash' : '/usr/sbin/nologin' - await exec('usermod', ['-s', shell, account.username]) - await prisma.account.update({ where: { id: account.id }, data: { shell } }) - - await prisma.auditLog.create({ - data: { - action: enabled ? 'account.shell.enable' : 'account.shell.disable', - target: account.username, - sourceIp: request.ip, - adminId: request.user!.role === 'admin' ? request.user!.sub : null, - }, - }) - - return reply.send({ success: true, data: { shell } }) - }) - - // POST /api/nixserver/accounts/bulk - fastify.post('/bulk', { preHandler }, async (request, reply) => { - const body = z.object({ - action: z.enum(['suspend', 'unsuspend', 'terminate']), - ids: z.array(z.number().int().positive()).min(1).max(100), - reason: z.string().optional(), - }).safeParse(request.body) - - if (!body.success) { - return reply.code(400).send({ success: false, error: body.error.issues[0]?.message }) - } - - const { action, ids, reason } = body.data - const results: { id: number; success: boolean; error?: string }[] = [] - - for (const id of ids) { - try { - const account = await prisma.account.findUnique({ where: { id } }) - if (!account) { - results.push({ id, success: false, error: 'Not found' }) - continue - } - - if (action === 'suspend') { - await exec('usermod', ['-L', account.username]) - await prisma.account.update({ - where: { id }, - data: { status: 'suspended', suspendedAt: new Date(), suspendReason: reason ?? null }, - }) - } else if (action === 'unsuspend') { - await exec('usermod', ['-U', account.username]) - await prisma.account.update({ - where: { id }, - data: { status: 'active', suspendedAt: null, suspendReason: null }, - }) - } else if (action === 'terminate') { - await exec('userdel', [account.username]) - await prisma.account.update({ - where: { id }, - data: { status: 'terminated', terminatedAt: new Date() }, - }) - } - - results.push({ id, success: true }) - } catch (e) { - results.push({ id, success: false, error: String(e) }) - } - } - - return reply.send({ success: true, data: results }) - }) -} diff --git a/server/src/routes/nixserver/accounts/create.ts b/server/src/routes/nixserver/accounts/create.ts new file mode 100644 index 0000000..71a4d70 --- /dev/null +++ b/server/src/routes/nixserver/accounts/create.ts @@ -0,0 +1,110 @@ +import type { FastifyInstance } from 'fastify' +import bcrypt from 'bcryptjs' +import { z } from 'zod' +import prisma from '../../../db/client.js' +import { requireAdminOrReseller } from '../../../middleware/auth.js' +import { exec, validateUsername, validateDomain } from '../../../core/exec.js' +import { enableVhost, enablePhpFpmPool } from '../../../core/apache.js' +import { createZone } from '../../../core/dns.js' +import { config } from '../../../config.js' + +const CreateAccountSchema = z.object({ + username: z.string().min(1).max(32), + password: z.string().min(8), + email: z.string().email(), + domain: z.string().min(4), + packageId: z.number().int().positive().optional(), + resellerId: z.number().int().positive().optional(), + ipId: z.number().int().positive().optional(), +}) + +export default async function createRoute(fastify: FastifyInstance) { + // POST /api/nixserver/accounts + fastify.post('/', { preHandler: [requireAdminOrReseller] }, async (request, reply) => { + const body = CreateAccountSchema.safeParse(request.body) + if (!body.success) { + return reply.code(400).send({ success: false, error: body.error.issues[0]?.message }) + } + + const { username, password, email, domain, packageId, resellerId, ipId } = body.data + + if (!validateUsername(username)) { + return reply.code(400).send({ success: false, error: 'Invalid username format' }) + } + if (!validateDomain(domain)) { + return reply.code(400).send({ success: false, error: 'Invalid domain format' }) + } + + const exists = await prisma.account.findFirst({ where: { OR: [{ username }, { domain }] } }) + if (exists) { + return reply.code(409).send({ success: false, error: 'Username or domain already in use' }) + } + + const passwordHash = await bcrypt.hash(password, 12) + const homedir = `${config.paths.homeDir}/${username}` + + const userAdd = await exec('useradd', ['-m', '-d', homedir, '-s', '/usr/sbin/nologin', username]) + if (userAdd.exitCode !== 0 && !config.isDev) { + return reply.code(500).send({ success: false, error: `Failed to create system user: ${userAdd.stderr}` }) + } + + const dirs = [ + `${homedir}/public_html`, + `${homedir}/logs`, + `${homedir}/tmp`, + `${homedir}/mail`, + `${homedir}/.ssh`, + ] + for (const dir of dirs) await exec('mkdir', ['-p', dir]) + await exec('chown', ['-R', `${username}:${username}`, homedir]) + await exec('chmod', ['750', homedir]) + await exec('chmod', ['755', `${homedir}/public_html`]) + + let ipAddress: string | undefined + if (ipId) { + const ip = await prisma.ipAddress.findUnique({ where: { id: ipId } }) + ipAddress = ip?.address + } + + const account = await prisma.account.create({ + data: { + username, passwordHash, email, domain, homedir, + status: 'active', + packageId: packageId ?? null, + resellerId: resellerId ?? (request.user!.role === 'reseller' ? request.user!.sub : null), + ipId: ipId ?? null, + ipAddress: ipAddress ?? null, + }, + }) + + if (ipAddress) { + try { + const zoneFile = await createZone({ domain, ip: ipAddress, ns1: `ns1.${domain}`, ns2: `ns2.${domain}` }) + await prisma.dnsZone.create({ data: { domain, zoneFile, accountId: account.id } }) + } catch (e) { + console.warn(`DNS zone creation failed for ${domain}:`, e) + } + } + + const phpVersion = (await prisma.package.findUnique({ where: { id: packageId ?? 0 } }))?.phpVersion ?? '8.3' + try { + await enablePhpFpmPool(username, phpVersion) + await enableVhost({ domain, username, documentRoot: `${homedir}/public_html`, phpVersion }) + } catch (e) { + console.warn(`Apache/PHP-FPM setup failed for ${domain}:`, e) + } + + await prisma.auditLog.create({ + data: { + action: 'account.create', + target: username, + details: `Created hosting account for domain ${domain}`, + sourceIp: request.ip, + adminId: request.user!.role === 'admin' ? request.user!.sub : null, + }, + }) + + const { passwordHash: _, ...safe } = account + return reply.code(201).send({ success: true, data: safe }) + }) +} diff --git a/server/src/routes/nixserver/accounts/index.ts b/server/src/routes/nixserver/accounts/index.ts new file mode 100644 index 0000000..082beef --- /dev/null +++ b/server/src/routes/nixserver/accounts/index.ts @@ -0,0 +1,12 @@ +import type { FastifyInstance } from 'fastify' +import listRoutes from './list.js' +import createRoute from './create.js' +import modifyRoutes from './modify.js' +import statusRoutes from './status.js' + +export default async function accountRoutes(fastify: FastifyInstance) { + fastify.register(listRoutes) + fastify.register(createRoute) + fastify.register(modifyRoutes) + fastify.register(statusRoutes) +} diff --git a/server/src/routes/nixserver/accounts/list.ts b/server/src/routes/nixserver/accounts/list.ts new file mode 100644 index 0000000..667a324 --- /dev/null +++ b/server/src/routes/nixserver/accounts/list.ts @@ -0,0 +1,147 @@ +import type { FastifyInstance } from 'fastify' +import prisma from '../../../db/client.js' +import { requireAdminOrReseller } from '../../../middleware/auth.js' + +const preHandler = [requireAdminOrReseller] + +export default async function listRoutes(fastify: FastifyInstance) { + // GET /api/nixserver/accounts + fastify.get('/', { preHandler }, async (request, reply) => { + const q = request.query as Record + const page = Math.max(1, parseInt(q.page ?? '1', 10)) + const limit = Math.min(100, Math.max(1, parseInt(q.limit ?? '25', 10))) + const skip = (page - 1) * limit + + const where: Record = {} + if (q.search) { + where.OR = [ + { username: { contains: q.search } }, + { domain: { contains: q.search } }, + { email: { contains: q.search } }, + ] + } + if (q.status) where.status = q.status + if (request.user!.role === 'reseller') where.resellerId = request.user!.sub + + const [items, total] = await Promise.all([ + prisma.account.findMany({ + where, skip, take: limit, + orderBy: { createdAt: 'desc' }, + select: { + id: true, username: true, domain: true, email: true, status: true, + diskUsedMb: true, bandwidthUsedMb: true, ipAddress: true, + createdAt: true, suspendedAt: true, suspendReason: true, + package: { select: { name: true, diskMb: true, bandwidthMb: true } }, + reseller: { select: { username: true } }, + }, + }), + prisma.account.count({ where }), + ]) + + return reply.send({ + success: true, + data: { items, total, page, limit, totalPages: Math.ceil(total / limit) }, + }) + }) + + // GET /api/nixserver/accounts/stats + fastify.get('/stats', { preHandler }, async (request, reply) => { + const where = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {} + + const [total, active, suspended, terminated] = await Promise.all([ + prisma.account.count({ where }), + prisma.account.count({ where: { ...where, status: 'active' } }), + prisma.account.count({ where: { ...where, status: 'suspended' } }), + prisma.account.count({ where: { ...where, status: 'terminated' } }), + ]) + + return reply.send({ success: true, data: { total, active, suspended, terminated } }) + }) + + // GET /api/nixserver/accounts/parked-domains + fastify.get('/parked-domains', { preHandler }, async (request, reply) => { + const where = request.user!.role === 'reseller' + ? { account: { resellerId: request.user!.sub } } : {} + + const items = await prisma.parkedDomain.findMany({ + where, + orderBy: { createdAt: 'desc' }, + include: { account: { select: { username: true, domain: true } } }, + }) + return reply.send({ success: true, data: items }) + }) + + // GET /api/nixserver/accounts/subdomains + fastify.get('/subdomains', { preHandler }, async (request, reply) => { + const where = request.user!.role === 'reseller' + ? { account: { resellerId: request.user!.sub } } : {} + + const items = await prisma.subdomain.findMany({ + where, + orderBy: { createdAt: 'desc' }, + include: { account: { select: { username: true } } }, + }) + return reply.send({ success: true, data: items }) + }) + + // GET /api/nixserver/accounts/over-quota + fastify.get('/over-quota', { preHandler }, async (request, reply) => { + const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {} + + const accounts = await prisma.account.findMany({ + where: { ...resellerWhere, package: { isNot: null } }, + select: { + id: true, username: true, domain: true, status: true, diskUsedMb: true, + package: { select: { name: true, diskMb: true, bandwidthMb: true } }, + }, + }) + + const overQuota = accounts.filter( + (a: typeof accounts[number]) => a.package && a.diskUsedMb > a.package.diskMb + ) + return reply.send({ success: true, data: overQuota }) + }) + + // GET /api/nixserver/accounts/bandwidth + fastify.get('/bandwidth', { preHandler }, async (request, reply) => { + const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {} + + const items = await prisma.account.findMany({ + where: resellerWhere, + orderBy: { bandwidthUsedMb: 'desc' }, + select: { + id: true, username: true, domain: true, status: true, + bandwidthUsedMb: true, suspendReason: true, + package: { select: { name: true, bandwidthMb: true } }, + }, + }) + return reply.send({ success: true, data: items }) + }) + + // GET /api/nixserver/accounts/:id + fastify.get('/:id', { preHandler }, async (request, reply) => { + const { id } = request.params as { id: string } + const where: Record = { id: parseInt(id, 10) } + if (request.user!.role === 'reseller') where.resellerId = request.user!.sub + + const account = await prisma.account.findFirst({ + where, + include: { + package: true, + reseller: { select: { username: true, company: true } }, + ip: true, + dnsZones: { select: { id: true, domain: true, status: true } }, + _count: { + select: { + emailAccounts: true, databases: true, ftpAccounts: true, + subdomains: true, addonDomains: true, sslCertificates: true, + }, + }, + }, + }) + + if (!account) return reply.code(404).send({ success: false, error: 'Account not found' }) + const { passwordHash, ...safe } = account + return reply.send({ success: true, data: safe }) + }) +} diff --git a/server/src/routes/nixserver/accounts/modify.ts b/server/src/routes/nixserver/accounts/modify.ts new file mode 100644 index 0000000..e43a971 --- /dev/null +++ b/server/src/routes/nixserver/accounts/modify.ts @@ -0,0 +1,171 @@ +import type { FastifyInstance } from 'fastify' +import bcrypt from 'bcryptjs' +import { z } from 'zod' +import prisma from '../../../db/client.js' +import { requireAdminOrReseller } from '../../../middleware/auth.js' +import { exec } from '../../../core/exec.js' + +const UpdateAccountSchema = z.object({ + email: z.string().email().optional(), + packageId: z.number().int().positive().optional(), + password: z.string().min(8).optional(), + notes: z.string().max(500).optional(), +}) + +const preHandler = [requireAdminOrReseller] + +export default async function modifyRoutes(fastify: FastifyInstance) { + // PUT /api/nixserver/accounts/:id + fastify.put('/:id', { preHandler }, async (request, reply) => { + const { id } = request.params as { id: string } + const body = UpdateAccountSchema.safeParse(request.body) + if (!body.success) { + return reply.code(400).send({ success: false, error: body.error.issues[0]?.message }) + } + + const where: Record = { id: parseInt(id, 10) } + if (request.user!.role === 'reseller') where.resellerId = request.user!.sub + + const existing = await prisma.account.findFirst({ where }) + if (!existing) return reply.code(404).send({ success: false, error: 'Account not found' }) + + const updateData: Record = {} + if (body.data.email) updateData.email = body.data.email + if (body.data.packageId) updateData.packageId = body.data.packageId + if (body.data.notes !== undefined) updateData.notes = body.data.notes + if (body.data.password) { + updateData.passwordHash = await bcrypt.hash(body.data.password, 12) + await exec('passwd', ['--stdin', existing.username]) + } + + const updated = await prisma.account.update({ + where: { id: parseInt(id, 10) }, + data: updateData, + }) + + const { passwordHash: _, ...safe } = updated + return reply.send({ success: true, data: safe }) + }) + + // PUT /api/nixserver/accounts/:id/quota + fastify.put('/:id/quota', { preHandler }, async (request, reply) => { + const { id } = request.params as { id: string } + const body = z.object({ + diskMb: z.number().int().positive(), + bandwidthMb: z.number().int().positive(), + }).safeParse(request.body) + + if (!body.success) { + return reply.code(400).send({ success: false, error: body.error.issues[0]?.message }) + } + + const where: Record = { id: parseInt(id, 10) } + if (request.user!.role === 'reseller') where.resellerId = request.user!.sub + + const account = await prisma.account.findFirst({ where, include: { package: true } }) + if (!account) return reply.code(404).send({ success: false, error: 'Account not found' }) + + if (account.packageId) { + await prisma.package.update({ + where: { id: account.packageId }, + data: { diskMb: body.data.diskMb, bandwidthMb: body.data.bandwidthMb }, + }) + } + + await prisma.auditLog.create({ + data: { + action: 'account.quota-modification', + target: account.username, + details: `diskMb=${body.data.diskMb}, bandwidthMb=${body.data.bandwidthMb}`, + sourceIp: request.ip, + adminId: request.user!.role === 'admin' ? request.user!.sub : null, + }, + }) + + return reply.send({ success: true, data: { message: 'Quota updated', ...body.data } }) + }) + + // POST /api/nixserver/accounts/:id/change-ip + fastify.post('/:id/change-ip', { preHandler }, async (request, reply) => { + const { id } = request.params as { id: string } + const body = z.object({ ipAddress: z.string().min(1) }).safeParse(request.body) + if (!body.success) { + return reply.code(400).send({ success: false, error: body.error.issues[0]?.message }) + } + + const where: Record = { id: parseInt(id, 10) } + if (request.user!.role === 'reseller') where.resellerId = request.user!.sub + + const account = await prisma.account.findFirst({ where }) + if (!account) return reply.code(404).send({ success: false, error: 'Account not found' }) + + const updated = await prisma.account.update({ + where: { id: account.id }, + data: { ipAddress: body.data.ipAddress }, + }) + + await prisma.auditLog.create({ + data: { + action: 'account.change-ip', + target: account.username, + details: `IP changed to ${body.data.ipAddress}`, + sourceIp: request.ip, + adminId: request.user!.role === 'admin' ? request.user!.sub : null, + }, + }) + + const { passwordHash: _, ...safe } = updated + return reply.send({ success: true, data: safe }) + }) + + // POST /api/nixserver/accounts/:id/reset-bandwidth + fastify.post('/:id/reset-bandwidth', { preHandler }, async (request, reply) => { + const { id } = request.params as { id: string } + + const where: Record = { id: parseInt(id, 10) } + if (request.user!.role === 'reseller') where.resellerId = request.user!.sub + + const account = await prisma.account.findFirst({ where }) + if (!account) return reply.code(404).send({ success: false, error: 'Account not found' }) + + await prisma.account.update({ where: { id: account.id }, data: { bandwidthUsedMb: 0 } }) + + await prisma.auditLog.create({ + data: { + action: 'account.reset-bandwidth', + target: account.username, + sourceIp: request.ip, + adminId: request.user!.role === 'admin' ? request.user!.sub : null, + }, + }) + + return reply.send({ success: true, data: { message: `Bandwidth reset for ${account.username}` } }) + }) + + // PUT /api/nixserver/accounts/:id/shell + fastify.put('/:id/shell', { preHandler }, async (request, reply) => { + const { id } = request.params as { id: string } + const { enabled } = (request.body ?? {}) as { enabled?: boolean } + if (typeof enabled !== 'boolean') { + return reply.code(400).send({ success: false, error: '"enabled" boolean required' }) + } + + const account = await prisma.account.findUnique({ where: { id: parseInt(id, 10) } }) + if (!account) return reply.code(404).send({ success: false, error: 'Account not found' }) + + const shell = enabled ? '/bin/bash' : '/usr/sbin/nologin' + await exec('usermod', ['-s', shell, account.username]) + await prisma.account.update({ where: { id: account.id }, data: { shell } }) + + await prisma.auditLog.create({ + data: { + action: enabled ? 'account.shell.enable' : 'account.shell.disable', + target: account.username, + sourceIp: request.ip, + adminId: request.user!.role === 'admin' ? request.user!.sub : null, + }, + }) + + return reply.send({ success: true, data: { shell } }) + }) +} diff --git a/server/src/routes/nixserver/accounts/status.ts b/server/src/routes/nixserver/accounts/status.ts new file mode 100644 index 0000000..bad7de1 --- /dev/null +++ b/server/src/routes/nixserver/accounts/status.ts @@ -0,0 +1,204 @@ +import type { FastifyInstance } from 'fastify' +import { z } from 'zod' +import prisma from '../../../db/client.js' +import { requireAdminOrReseller } from '../../../middleware/auth.js' +import { exec } from '../../../core/exec.js' +import { enableVhost, disableVhost, suspendVhost, disablePhpFpmPool } from '../../../core/apache.js' +import { removeZone } from '../../../core/dns.js' + +const preHandler = [requireAdminOrReseller] + +export default async function statusRoutes(fastify: FastifyInstance) { + // POST /api/nixserver/accounts/:id/suspend + fastify.post('/:id/suspend', { preHandler }, async (request, reply) => { + const { id } = request.params as { id: string } + const { reason } = (request.body as Record) ?? {} + + const account = await prisma.account.findFirst({ + where: { id: parseInt(id, 10), status: 'active' }, + }) + if (!account) return reply.code(404).send({ success: false, error: 'Active account not found' }) + + await exec('usermod', ['-L', account.username]) + try { await suspendVhost(account.domain, account.username) } catch (e) { + console.warn(`suspendVhost failed for ${account.domain}:`, e) + } + + await prisma.account.update({ + where: { id: account.id }, + data: { status: 'suspended', suspendedAt: new Date(), suspendReason: reason ?? null }, + }) + + await prisma.auditLog.create({ + data: { + action: 'account.suspend', + target: account.username, + details: reason ?? 'No reason given', + sourceIp: request.ip, + adminId: request.user!.role === 'admin' ? request.user!.sub : null, + }, + }) + + return reply.send({ success: true, data: { message: `Account ${account.username} suspended` } }) + }) + + // POST /api/nixserver/accounts/:id/unsuspend + fastify.post('/:id/unsuspend', { preHandler }, async (request, reply) => { + const { id } = request.params as { id: string } + + const account = await prisma.account.findFirst({ + where: { id: parseInt(id, 10), status: 'suspended' }, + }) + if (!account) return reply.code(404).send({ success: false, error: 'Suspended account not found' }) + + await exec('usermod', ['-U', account.username]) + try { + await enableVhost({ domain: account.domain, username: account.username, documentRoot: `${account.homedir}/public_html` }) + } catch (e) { + console.warn(`enableVhost failed on unsuspend for ${account.domain}:`, e) + } + + await prisma.account.update({ + where: { id: account.id }, + data: { status: 'active', suspendedAt: null, suspendReason: null }, + }) + + await prisma.auditLog.create({ + data: { + action: 'account.unsuspend', + target: account.username, + sourceIp: request.ip, + adminId: request.user!.role === 'admin' ? request.user!.sub : null, + }, + }) + + return reply.send({ success: true, data: { message: `Account ${account.username} unsuspended` } }) + }) + + // POST /api/nixserver/accounts/unsuspend-bandwidth-exceeded + fastify.post('/unsuspend-bandwidth-exceeded', { preHandler }, async (request, reply) => { + const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {} + + const accounts = await prisma.account.findMany({ + where: { ...resellerWhere, status: 'suspended', suspendReason: { contains: 'bandwidth' } }, + }) + + const results: { id: number; username: string; success: boolean; error?: string }[] = [] + + for (const account of accounts) { + try { + await exec('usermod', ['-U', account.username]) + await prisma.account.update({ + where: { id: account.id }, + data: { status: 'active', suspendedAt: null, suspendReason: null }, + }) + results.push({ id: account.id, username: account.username, success: true }) + } catch (e) { + results.push({ id: account.id, username: account.username, success: false, error: String(e) }) + } + } + + await prisma.auditLog.create({ + data: { + action: 'account.unsuspend-bandwidth-exceeded', + target: `${results.length} accounts`, + sourceIp: request.ip, + adminId: request.user!.role === 'admin' ? request.user!.sub : null, + }, + }) + + return reply.send({ success: true, data: results }) + }) + + // DELETE /api/nixserver/accounts/:id + fastify.delete('/:id', { preHandler }, async (request, reply) => { + const { id } = request.params as { id: string } + const { removeFiles } = (request.body as Record) ?? {} + + const account = await prisma.account.findUnique({ where: { id: parseInt(id, 10) } }) + if (!account) return reply.code(404).send({ success: false, error: 'Account not found' }) + + try { await disableVhost(account.domain) } catch (e) { + console.warn(`Failed to remove vhost for ${account.domain}:`, e) + } + try { + const phpVersion = account.packageId + ? (await prisma.package.findUnique({ where: { id: account.packageId } }))?.phpVersion ?? '8.3' + : '8.3' + await disablePhpFpmPool(account.username, phpVersion) + } catch (e) { + console.warn(`Failed to remove PHP-FPM pool for ${account.username}:`, e) + } + try { await removeZone(account.domain) } catch (e) { + console.warn(`Failed to remove DNS zone for ${account.domain}:`, e) + } + + await exec('userdel', removeFiles ? ['-r', account.username] : [account.username]) + + await prisma.account.update({ + where: { id: account.id }, + data: { status: 'terminated', terminatedAt: new Date() }, + }) + + await prisma.auditLog.create({ + data: { + action: 'account.terminate', + target: account.username, + details: removeFiles ? 'Files removed' : 'Files retained', + sourceIp: request.ip, + adminId: request.user!.role === 'admin' ? request.user!.sub : null, + }, + }) + + return reply.send({ success: true, data: { message: `Account ${account.username} terminated` } }) + }) + + // POST /api/nixserver/accounts/bulk + fastify.post('/bulk', { preHandler }, async (request, reply) => { + const body = z.object({ + action: z.enum(['suspend', 'unsuspend', 'terminate']), + ids: z.array(z.number().int().positive()).min(1).max(100), + reason: z.string().optional(), + }).safeParse(request.body) + + if (!body.success) { + return reply.code(400).send({ success: false, error: body.error.issues[0]?.message }) + } + + const { action, ids, reason } = body.data + const results: { id: number; success: boolean; error?: string }[] = [] + + for (const id of ids) { + try { + const account = await prisma.account.findUnique({ where: { id } }) + if (!account) { results.push({ id, success: false, error: 'Not found' }); continue } + + if (action === 'suspend') { + await exec('usermod', ['-L', account.username]) + await prisma.account.update({ + where: { id }, + data: { status: 'suspended', suspendedAt: new Date(), suspendReason: reason ?? null }, + }) + } else if (action === 'unsuspend') { + await exec('usermod', ['-U', account.username]) + await prisma.account.update({ + where: { id }, + data: { status: 'active', suspendedAt: null, suspendReason: null }, + }) + } else if (action === 'terminate') { + await exec('userdel', [account.username]) + await prisma.account.update({ + where: { id }, + data: { status: 'terminated', terminatedAt: new Date() }, + }) + } + + results.push({ id, success: true }) + } catch (e) { + results.push({ id, success: false, error: String(e) }) + } + } + + return reply.send({ success: true, data: results }) + }) +} diff --git a/server/src/routes/nixserver/index.ts b/server/src/routes/nixserver/index.ts index 229fb86..059f5be 100644 --- a/server/src/routes/nixserver/index.ts +++ b/server/src/routes/nixserver/index.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from 'fastify' -import accountRoutes from './accounts.js' +import accountRoutes from './accounts/index.js' import packageRoutes from './packages.js' import resellerRoutes from './resellers.js' import dnsRoutes from './dns.js' diff --git a/server/src/routes/nixserver/system.ts b/server/src/routes/nixserver/system.ts index 5970621..1f8b7ee 100644 --- a/server/src/routes/nixserver/system.ts +++ b/server/src/routes/nixserver/system.ts @@ -99,6 +99,20 @@ export default async function systemRoutes(fastify: FastifyInstance) { }) }) + // GET /api/nixserver/system/network-interfaces + fastify.get('/network-interfaces', { preHandler: [requireAdmin] }, async (_request, reply) => { + try { + const result = await exec('ls', ['/sys/class/net']) + const ifaces = result.stdout + .split('\n') + .map(s => s.trim()) + .filter(s => s && s !== 'lo') // exclude loopback + return reply.send({ success: true, data: ifaces }) + } catch { + return reply.send({ success: true, data: [] }) + } + }) + // GET /api/nixserver/system/settings fastify.get('/settings', { preHandler: [requireAdmin] }, async (request, reply) => { const settings = await prisma.setting.findMany({ orderBy: { key: 'asc' } })
{description}
Coming Soon
This feature is under development.
{label}
System-wide configuration
Configure server-wide defaults
Server-wide settings and system configuration tools
+ Enter the email address(es) to which you would like server notification messages to be + sent. Separate multiple addresses with spaces. +