8 Commits
Author SHA1 Message Date
NixReaper da576bdbdc feat(nixserver): WHM-style collapsible category sidebar
Replace the flat 14-item nav with a full category sidebar matching
the WHM layout. 29 collapsible categories, ~210 menu items, live
search that filters across all categories and auto-expands results,
active route auto-expands the matching category, sidebar toggle
hides/shows the full panel.
2026-04-11 11:51:18 -05:00
NixReaper 489755136c chore: bump version to 0.4.1, update CHANGELOG 2026-04-11 09:05:14 -05:00
NixReaper 2911cab755 fix(install): enable proxy_http and proxy_wstunnel Apache modules
Without proxy_http, ProxyPass to HTTP backends silently fails and Apache
returns 500 for all API requests. Without proxy_wstunnel, the WebSocket
RewriteRule for live stats cannot proxy ws:// connections.
2026-04-11 09:01:31 -05:00
NixReaper 8d6fe60168 fix(install): set world-readable permissions on frontend dist dirs
Apache (www-data) couldn't traverse /opt/nixpanel/nixserver because the
directories lacked execute permission for other. Add an explicit chmod
step after the build to ensure all dist directories are 755 and files
are 644 so Apache can serve the static assets.
2026-04-11 08:53:47 -05:00
NixReaper 6e59552e4f fix(install): handle pre-existing install dir during git clone
The 'Creating directory structure' step runs mkdir -p on INSTALL_DIR
before the clone step, so git clone fails if the directory is non-empty.
Clone to a temp directory and copy contents into place to preserve any
existing data/ and logs/ subdirectories.
2026-04-11 08:31:42 -05:00
NixReaper eebcce027b fix(install): correct SpamAssassin service name and resolve PowerDNS port 53 conflict
On Ubuntu 24.04 the SpamAssassin service unit is 'spamd', not 'spamassassin'.
The old 'systemctl enable spamassassin' call exited with an error because the
unit file does not exist, killing the install due to set -euo pipefail.

Also moved the systemd-resolved stub listener disable to a dedicated step that
runs before the package installation step. Previously it ran inside the PowerDNS
config block — after apt had already tried (and failed) to restart pdns during
post-install because systemd-resolved was still holding port 53.
2026-04-11 08:19:49 -05:00
NixReaper 87edf902a1 fix: correct server port config and seed default settings
- config.ts: rename nixserverPort/nixclientPort to single port, default
  HOST to 127.0.0.1 (installer binds to loopback, Apache proxies)
- index.ts: use config.server.port
- seed.ts: fix wrong default settings (nginx->apache2, bind9->powerdns,
  postfix->exim4, vsftpd->disabled)
2026-04-11 08:05:26 -05:00
NixReaper d235d27fe3 fix: nixclient SSL calls correct user-scoped route, not admin endpoint
- Add /api/nixclient/ssl (GET) — returns certs for authenticated user only
- Add /api/nixclient/ssl/:id/renew (POST) — validates cert belongs to user
- Register sslRoutes in nixclient index.ts
- nixclient SSL.tsx was calling /nixserver/ssl (requireAdminOrReseller)
  which would always 401 for regular users — fixed to /nixclient/ssl
2026-04-11 05:39:01 -05:00
14 changed files with 715 additions and 88 deletions
+18
View File
@@ -6,6 +6,24 @@ Pre-1.0 versions treat MINOR as feature releases and PATCH as bug fixes.
--- ---
## [0.4.2] — 2026-04-11
### Changes
- **NixServer sidebar redesigned** — replaced the flat 14-item nav with a full WHM-style collapsible category sidebar (29 categories, ~210 menu items). Categories fold/unfold on click; the active route's category auto-expands on navigation. A live search box at the top of the sidebar filters items across all categories and auto-expands matching results. Sidebar can be hidden entirely via the topbar toggle. All existing routes are wired to their correct menu items; future feature items route to the closest related hub page.
---
## [0.4.1] — 2026-04-11
### Bug Fixes (Installer)
- **SpamAssassin service name**: On Ubuntu 24.04 the service unit is `spamd`, not `spamassassin`. The old `systemctl enable spamassassin` caused the install to abort due to `set -euo pipefail`.
- **PowerDNS port 53 conflict**: `systemd-resolved` stub listener now disabled in a dedicated step that runs *before* package installation. Previously it ran after apt had already tried (and failed) to restart `pdns` during post-install because port 53 was still held by `systemd-resolved`.
- **Git clone into pre-existing directory**: The directory structure step (`mkdir -p`) creates `$INSTALL_DIR` before the clone step, causing `git clone` to fail with "destination path already exists". Fixed by cloning to a temp directory and copying contents into place (preserving any existing `data/` and `logs/` subdirectories).
- **Apache 403 on frontend assets**: Frontend dist files were created with root-only permissions. Added a post-build step that sets 755/644 on all dist directories and files so Apache (`www-data`) can read them.
- **Apache 500 on all API requests**: `mod_proxy_http` was not enabled. Without it, `ProxyPass http://...` directives silently fail and Apache returns 500 for every API call. Also enabled `mod_proxy_wstunnel` (required for the `ws://` WebSocket reverse proxy).
---
## [0.4.0] — 2026-04-11 ## [0.4.0] — 2026-04-11
### New Features ### New Features
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@nixpanel/cpanel", "name": "@nixpanel/cpanel",
"version": "0.4.0", "version": "0.4.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+2 -2
View File
@@ -10,14 +10,14 @@ export default function SSL() {
const fetch = async () => { const fetch = async () => {
setLoading(true) setLoading(true)
try { const r = await api.get('/nixserver/ssl'); setCerts(r.data.data) } try { const r = await api.get('/nixclient/ssl'); setCerts(r.data.data) }
finally { setLoading(false) } finally { setLoading(false) }
} }
useEffect(() => { fetch() }, []) useEffect(() => { fetch() }, [])
const renew = async (id: number) => { const renew = async (id: number) => {
try { await api.post(`/nixserver/ssl/${id}/renew`); fetch() } try { await api.post(`/nixclient/ssl/${id}/renew`); fetch() }
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Renewal failed') } catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Renewal failed') }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@nixpanel/whm", "name": "@nixpanel/whm",
"version": "0.4.0", "version": "0.4.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+602 -62
View File
@@ -1,33 +1,502 @@
import { useState } from 'react' import { useState, useMemo, useEffect } from 'react'
import { Outlet, NavLink, useNavigate } from 'react-router-dom' import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
import { import {
LayoutDashboard, Users, Package, UserCog, Globe, Mail, Shield, LayoutDashboard, Settings, HelpCircle, Network, BookUser, UserCog,
Server, HardDrive, Settings, LogOut, Menu, X, Wrench, Languages, HardDrive, GitFork, RefreshCw, Activity,
Activity, Lock, BarChart3, Cpu Users, UserCheck, ArrowRightLeft, Paintbrush, Package,
Globe, Database, Wifi, Box, Mail, HeartPulse, Lock,
ShoppingBag, Code2, Puzzle, LogOut, Menu, X,
ChevronDown, ChevronRight, Search, RotateCcw
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
const nav = [ // ─── Nav data ────────────────────────────────────────────────────────────────
{ label: 'Dashboard', to: '/', icon: LayoutDashboard },
{ label: 'Accounts', to: '/accounts', icon: Users }, type NavItem = { label: string; to: string }
{ label: 'Packages', to: '/packages', icon: Package }, type NavCategory = {
{ label: 'Resellers', to: '/resellers', icon: UserCog }, id: string
{ label: 'DNS', to: '/dns', icon: Globe }, label: string
{ label: 'Email', to: '/email', icon: Mail }, icon: React.ComponentType<{ size?: number; className?: string }>
{ label: 'SSL', to: '/ssl', icon: Lock }, items: NavItem[]
{ label: 'PHP', to: '/php', icon: Cpu }, }
{ label: 'Web Server', to: '/webserver', icon: Server },
{ label: 'Services', to: '/services', icon: Activity }, const NAV: NavCategory[] = [
{ label: 'Backup', to: '/backup', icon: HardDrive }, {
{ label: 'Security', to: '/security', icon: Shield }, id: 'server-config',
{ label: 'System', to: '/system', icon: BarChart3 }, label: 'Server Configuration',
{ label: 'Settings', to: '/settings', icon: Settings }, icon: Settings,
items: [
{ label: 'Basic WebHost Manager® 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: 'WHM 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 WHM', 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 ───────────────────────────────────────────────────────────────
export default function Layout() { export default function Layout() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const [sidebarOpen, setSidebarOpen] = useState(true) const [sidebarOpen, setSidebarOpen] = useState(true)
const [search, setSearch] = useState('')
const [openCats, setOpenCats] = useState<Set<string>>(() => {
// 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 () => { const handleLogout = async () => {
await logout() await logout()
@@ -36,68 +505,139 @@ export default function Layout() {
return ( return (
<div className="flex h-screen overflow-hidden bg-[#0f1117]"> <div className="flex h-screen overflow-hidden bg-[#0f1117]">
{/* Sidebar */}
{/* ── Sidebar ─────────────────────────────────────────────────────── */}
<aside <aside
className={`flex flex-col transition-all duration-200 bg-[#1a1d27] border-r border-[#2a2d3e] ${ className={`flex flex-col transition-all duration-200 bg-[#1a1d27] border-r border-[#2a2d3e] overflow-hidden flex-shrink-0 ${
sidebarOpen ? 'w-56' : 'w-14' sidebarOpen ? 'w-72' : 'w-0'
}`} }`}
> >
{/* Logo */} {/* Branding */}
<div className="flex items-center gap-2 px-4 py-4 border-b border-[#2a2d3e] min-h-[57px]"> <div className="flex items-center gap-2 px-4 py-4 border-b border-[#2a2d3e] min-h-[57px] flex-shrink-0">
<div className="w-7 h-7 rounded-lg bg-indigo-600 flex items-center justify-center flex-shrink-0"> <div className="w-7 h-7 rounded-lg bg-indigo-600 flex items-center justify-center flex-shrink-0">
<span className="text-white text-xs font-bold">N</span> <span className="text-white text-xs font-bold">N</span>
</div> </div>
{sidebarOpen && ( <div className="overflow-hidden">
<div className="overflow-hidden"> <p className="text-white text-sm font-semibold leading-tight whitespace-nowrap">NixServer</p>
<p className="text-white text-sm font-semibold leading-tight">NixServer</p> <p className="text-[#64748b] text-xs whitespace-nowrap">Admin Panel</p>
<p className="text-[#64748b] text-xs">Admin Panel</p> </div>
</div> </div>
)}
{/* Search */}
<div className="px-3 py-2 border-b border-[#2a2d3e] flex-shrink-0">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#4a5568] pointer-events-none" />
<input
type="text"
placeholder="Search..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full bg-[#0f1117] border border-[#2a2d3e] rounded-md text-xs text-[#94a3b8] placeholder-[#4a5568] pl-7 pr-3 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[#4a5568] hover:text-[#94a3b8]"
>
<X size={11} />
</button>
)}
</div>
</div> </div>
{/* Nav */} {/* Nav */}
<nav className="flex-1 overflow-y-auto py-2"> <nav className="flex-1 overflow-y-auto py-1 scrollbar-thin">
{nav.map(({ label, to, icon: Icon }) => (
<NavLink {/* Dashboard — always at the top */}
key={to} <Link
to={to} to="/"
end={to === '/'} className={`flex items-center gap-2.5 px-3 py-1.5 mx-2 my-0.5 rounded-md text-xs transition-colors ${
className={({ isActive }) => location.pathname === '/'
`flex items-center gap-3 px-4 py-2 mx-2 my-0.5 rounded-lg text-sm transition-colors ${ ? 'bg-indigo-600/20 text-indigo-400'
isActive : 'text-[#94a3b8] hover:bg-[#1e2130] hover:text-white'
? 'bg-indigo-600/20 text-indigo-400' }`}
: 'text-[#94a3b8] hover:bg-[#1e2130] hover:text-white' >
}` <LayoutDashboard size={13} className="flex-shrink-0" />
} <span className="truncate">Dashboard</span>
> </Link>
<Icon size={16} className="flex-shrink-0" />
{sidebarOpen && <span className="truncate">{label}</span>} {/* Categories */}
</NavLink> {filteredNav.map(cat => {
))} const isOpen = effectiveOpen(cat.id)
const Icon = cat.icon
const hasActive = cat.items.some(item => isItemActive(item.to, location.pathname))
return (
<div key={cat.id} className="mx-2 my-0.5">
{/* Category header */}
<button
onClick={() => !search.trim() && toggleCat(cat.id)}
className={`flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-xs font-medium transition-colors ${
hasActive && !search.trim()
? 'text-indigo-300'
: 'text-[#64748b] hover:text-[#94a3b8]'
} ${search.trim() ? 'cursor-default' : ''}`}
>
<Icon size={13} className="flex-shrink-0" />
<span className="flex-1 text-left truncate">{cat.label}</span>
{!search.trim() && (
isOpen
? <ChevronDown size={11} className="flex-shrink-0 opacity-60" />
: <ChevronRight size={11} className="flex-shrink-0 opacity-60" />
)}
</button>
{/* Items */}
{isOpen && (
<div className="ml-3 pl-2 border-l border-[#2a2d3e] mt-0.5 mb-1">
{cat.items.map(item => {
const active = isItemActive(item.to, location.pathname)
return (
<Link
key={item.label}
to={item.to}
className={`block px-2 py-1 rounded text-xs transition-colors truncate ${
active
? 'text-indigo-400 bg-indigo-600/10'
: 'text-[#64748b] hover:text-[#94a3b8] hover:bg-[#1e2130]'
}`}
>
{item.label}
</Link>
)
})}
</div>
)}
</div>
)
})}
{/* Empty search state */}
{search.trim() && filteredNav.length === 0 && (
<p className="text-center text-[#4a5568] text-xs py-6">No results</p>
)}
</nav> </nav>
{/* User + logout */} {/* User + logout */}
<div className="border-t border-[#2a2d3e] p-3"> <div className="border-t border-[#2a2d3e] p-3 flex-shrink-0">
{sidebarOpen && ( <div className="px-1 mb-2">
<div className="px-1 mb-2"> <p className="text-white text-xs font-medium truncate">{user?.username}</p>
<p className="text-white text-xs font-medium truncate">{user?.username}</p> <p className="text-[#64748b] text-xs truncate">{user?.role}</p>
<p className="text-[#64748b] text-xs truncate">{user?.role}</p> </div>
</div>
)}
<button <button
onClick={handleLogout} onClick={handleLogout}
className="flex items-center gap-2 px-3 py-2 w-full rounded-lg text-[#94a3b8] hover:bg-[#1e2130] hover:text-red-400 transition-colors text-sm" className="flex items-center gap-2 px-3 py-2 w-full rounded-lg text-[#94a3b8] hover:bg-[#1e2130] hover:text-red-400 transition-colors text-xs"
> >
<LogOut size={16} className="flex-shrink-0" /> <LogOut size={14} className="flex-shrink-0" />
{sidebarOpen && <span>Logout</span>} <span>Logout</span>
</button> </button>
</div> </div>
</aside> </aside>
{/* Main */} {/* ── Main ────────────────────────────────────────────────────────── */}
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden min-w-0">
{/* Topbar */} {/* Topbar */}
<header className="flex items-center gap-4 px-6 py-3 bg-[#1a1d27] border-b border-[#2a2d3e] min-h-[57px]"> <header className="flex items-center gap-4 px-6 py-3 bg-[#1a1d27] border-b border-[#2a2d3e] min-h-[57px] flex-shrink-0">
<button <button
onClick={() => setSidebarOpen(v => !v)} onClick={() => setSidebarOpen(v => !v)}
className="text-[#64748b] hover:text-white transition-colors" className="text-[#64748b] hover:text-white transition-colors"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nixpanel-server", "name": "nixpanel-server",
"version": "0.4.0", "version": "0.4.2",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"server", "server",
+32 -10
View File
@@ -146,6 +146,18 @@ if ! step_done "nodejs"; then
fi fi
success "Node.js $(node --version) ready" success "Node.js $(node --version) ready"
# ── Disable systemd-resolved stub listener (frees port 53 for PowerDNS) ─────
step "Freeing port 53 for PowerDNS"
if ! step_done "resolved_stub"; then
# Ubuntu 24.04 runs systemd-resolved with a stub listener on 127.0.0.53:53.
# PowerDNS's apt post-install restart fails if this is still active.
sed -i 's/#DNSStubListener=yes/DNSStubListener=no/' /etc/systemd/resolved.conf 2>/dev/null || true
sed -i 's/^DNSStubListener=yes/DNSStubListener=no/' /etc/systemd/resolved.conf 2>/dev/null || true
systemctl restart systemd-resolved 2>/dev/null || true
mark_done "resolved_stub"
fi
success "Port 53 freed"
# ── Install all services in one pass ───────────────────────────────────────── # ── Install all services in one pass ─────────────────────────────────────────
step "Installing web stack (Apache, PHP, Exim, Dovecot, PowerDNS, MariaDB)" step "Installing web stack (Apache, PHP, Exim, Dovecot, PowerDNS, MariaDB)"
if ! step_done "packages"; then if ! step_done "packages"; then
@@ -232,7 +244,7 @@ success "Directories created"
step "Configuring Apache" step "Configuring Apache"
if ! step_done "apache"; then if ! step_done "apache"; then
# Enable required modules # Enable required modules
a2enmod rewrite ssl proxy proxy_fcgi headers expires deflate 2>/dev/null || true a2enmod rewrite ssl proxy proxy_http proxy_wstunnel proxy_fcgi headers expires deflate 2>/dev/null || true
# Disable default vhost # Disable default vhost
a2dissite 000-default 2>/dev/null || true a2dissite 000-default 2>/dev/null || true
@@ -344,10 +356,6 @@ setuid=pdns
setgid=pdns setgid=pdns
PDNS PDNS
# Disable systemd-resolved on port 53 to avoid conflict
sed -i 's/#DNSStubListener=yes/DNSStubListener=no/' /etc/systemd/resolved.conf 2>/dev/null || true
systemctl restart systemd-resolved 2>/dev/null || true
systemctl enable pdns systemctl enable pdns
systemctl start pdns systemctl start pdns
@@ -431,10 +439,9 @@ success "Dovecot configured"
# ── Configure SpamAssassin ──────────────────────────────────────────────────── # ── Configure SpamAssassin ────────────────────────────────────────────────────
step "Configuring SpamAssassin" step "Configuring SpamAssassin"
if ! step_done "spamassassin"; then if ! step_done "spamassassin"; then
sed -i 's/ENABLED=0/ENABLED=1/' /etc/default/spamassassin 2>/dev/null || true # Ubuntu 24.04: service unit is 'spamd', not 'spamassassin'
sed -i 's/CRON=0/CRON=1/' /etc/default/spamassassin 2>/dev/null || true systemctl enable spamd
systemctl enable spamassassin systemctl start spamd
systemctl start spamassassin
mark_done "spamassassin" mark_done "spamassassin"
fi fi
success "SpamAssassin configured" success "SpamAssassin configured"
@@ -475,7 +482,12 @@ if [[ -d "$INSTALL_DIR/.git" ]]; then
git -C "$INSTALL_DIR" pull --ff-only git -C "$INSTALL_DIR" pull --ff-only
else else
info "Cloning repository into $INSTALL_DIR..." info "Cloning repository into $INSTALL_DIR..."
git clone https://github.com/NixReaper/nixpanel-server.git "$INSTALL_DIR" # The directory may already exist (created by the mkdir step above).
# Clone to a temp dir then copy contents in so data/ and logs/ are preserved.
TMP_CLONE=$(mktemp -d)
git clone https://github.com/NixReaper/nixpanel-server.git "$TMP_CLONE"
cp -a "$TMP_CLONE/." "$INSTALL_DIR/"
rm -rf "$TMP_CLONE"
fi fi
success "NixPanel source ready at $INSTALL_DIR" success "NixPanel source ready at $INSTALL_DIR"
@@ -565,6 +577,16 @@ cd "$INSTALL_DIR/server"
npm run build --silent npm run build --silent
success "Build complete" success "Build complete"
# ── Set web-readable permissions on frontend assets ───────────────────────────
step "Setting frontend file permissions"
# Apache runs as www-data — ensure it can traverse parents and read dist files
chmod 755 "$INSTALL_DIR" "$INSTALL_DIR/nixserver" "$INSTALL_DIR/nixclient"
find "$INSTALL_DIR/nixserver/dist" -type d -exec chmod 755 {} \;
find "$INSTALL_DIR/nixserver/dist" -type f -exec chmod 644 {} \;
find "$INSTALL_DIR/nixclient/dist" -type d -exec chmod 755 {} \;
find "$INSTALL_DIR/nixclient/dist" -type f -exec chmod 644 {} \;
success "File permissions set"
# ── systemd service ─────────────────────────────────────────────────────────── # ── systemd service ───────────────────────────────────────────────────────────
step "Creating systemd service" step "Creating systemd service"
cat > /etc/systemd/system/nixpanel.service <<UNIT cat > /etc/systemd/system/nixpanel.service <<UNIT
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@nixpanel/server", "name": "@nixpanel/server",
"version": "0.4.0", "version": "0.4.2",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
+1 -1
View File
@@ -73,7 +73,7 @@ export async function buildApp() {
fastify.get('/api/health', async () => ({ fastify.get('/api/health', async () => ({
status: 'ok', status: 'ok',
version: '0.3.2', version: '0.4.2',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
})) }))
+2 -3
View File
@@ -15,9 +15,8 @@ export const config = {
isDev: optional('NODE_ENV', 'development') === 'development', isDev: optional('NODE_ENV', 'development') === 'development',
server: { server: {
host: optional('HOST', '0.0.0.0'), host: optional('HOST', '127.0.0.1'),
nixserverPort: parseInt(optional('NIXSERVER_PORT', '4000'), 10), port: parseInt(optional('PORT', '4000'), 10),
nixclientPort: parseInt(optional('NIXCLIENT_PORT', '4001'), 10),
}, },
db: { db: {
+4 -4
View File
@@ -73,10 +73,10 @@ async function main() {
// Default settings // Default settings
const settings = [ const settings = [
{ key: 'php_default_version', value: '8.3' }, { key: 'php_default_version', value: '8.3' },
{ key: 'mail_server', value: 'postfix' }, { key: 'mail_server', value: 'exim4' },
{ key: 'ftp_server', value: 'vsftpd' }, { key: 'ftp_server', value: 'disabled' },
{ key: 'webserver', value: 'nginx' }, { key: 'webserver', value: 'apache2' },
{ key: 'dns_server', value: 'bind9' }, { key: 'dns_server', value: 'powerdns' },
{ key: 'ssl_provider', value: 'letsencrypt' }, { key: 'ssl_provider', value: 'letsencrypt' },
{ key: 'backup_enabled', value: 'true' }, { key: 'backup_enabled', value: 'true' },
{ key: 'backup_retention_days', value: '7' }, { key: 'backup_retention_days', value: '7' },
+2 -2
View File
@@ -13,8 +13,8 @@ async function start() {
app = await buildApp() app = await buildApp()
await app.listen({ port: config.server.nixserverPort, host: config.server.host }) await app.listen({ port: config.server.port, host: config.server.host })
console.log(`[nixpanel] API running on ${config.server.host}:${config.server.nixserverPort}`) console.log(`[nixpanel] API running on ${config.server.host}:${config.server.port}`)
startScheduler() startScheduler()
+2
View File
@@ -6,6 +6,7 @@ import domainRoutes from './domains.js'
import ftpRoutes from './ftp.js' import ftpRoutes from './ftp.js'
import cronRoutes from './cron.js' import cronRoutes from './cron.js'
import statsRoutes from './stats.js' import statsRoutes from './stats.js'
import sslRoutes from './ssl.js'
export default async function nixclientRoutes(fastify: FastifyInstance) { export default async function nixclientRoutes(fastify: FastifyInstance) {
fastify.register(fileRoutes, { prefix: '/files' }) fastify.register(fileRoutes, { prefix: '/files' })
@@ -15,4 +16,5 @@ export default async function nixclientRoutes(fastify: FastifyInstance) {
fastify.register(ftpRoutes, { prefix: '/ftp' }) fastify.register(ftpRoutes, { prefix: '/ftp' })
fastify.register(cronRoutes, { prefix: '/cron' }) fastify.register(cronRoutes, { prefix: '/cron' })
fastify.register(statsRoutes, { prefix: '/stats' }) fastify.register(statsRoutes, { prefix: '/stats' })
fastify.register(sslRoutes, { prefix: '/ssl' })
} }
+46
View File
@@ -0,0 +1,46 @@
import type { FastifyInstance } from 'fastify'
import prisma from '../../db/client.js'
import { requireAuth } from '../../middleware/auth.js'
import { exec } from '../../core/exec.js'
export default async function sslRoutes(fastify: FastifyInstance) {
const preHandler = [requireAuth]
// GET /api/nixclient/ssl — certs for the authenticated user's account only
fastify.get('/', { preHandler }, async (request, reply) => {
const user = request.user!
const accountId = user.sub
const certs = await prisma.sslCertificate.findMany({
where: { accountId },
orderBy: { createdAt: 'desc' },
})
return reply.send({ success: true, data: certs })
})
// POST /api/nixclient/ssl/:id/renew
fastify.post('/:id/renew', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const user = request.user!
const cert = await prisma.sslCertificate.findFirst({
where: { id: parseInt(id, 10), accountId: user.sub },
})
if (!cert) return reply.code(404).send({ success: false, error: 'Certificate not found' })
const result = await exec('certbot', ['renew', '--cert-name', cert.domain, '--non-interactive'])
if (result.exitCode !== 0) {
return reply.code(500).send({ success: false, error: `Renewal failed: ${result.stderr}` })
}
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 90)
const updated = await prisma.sslCertificate.update({
where: { id: cert.id },
data: { status: 'active', issuedAt: new Date(), expiresAt },
})
return reply.send({ success: true, data: updated })
})
}