15 Commits
Author SHA1 Message Date
NixReaper 501f94b7e8 fix(install): read from /dev/tty for curl|bash, bump v0.6.2 2026-04-12 04:40:04 -05:00
NixReaper 3902935f60 fix(install): read from /dev/tty so curl | bash confirmation prompt works, bump v0.5.2
set -euo pipefail causes the script to exit when read gets EOF from
the curl pipe instead of an interactive terminal. Fix by appending
'< /dev/tty || CONFIRM=Y' so the prompt reads from the actual terminal
regardless of how the script was invoked.
2026-04-12 04:39:14 -05:00
NixReaperandClaude Sonnet 4.6 5c98ac0ee6 docs: add CLAUDE.md development standards, bump v0.6.1
Adds CLAUDE.md which Claude Code reads automatically before every
session. Covers version bumping, changelog format, commit conventions,
file size limits, section/page architecture, and API standards.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-04-11 18:13:05 -05:00
NixReaperandClaude Sonnet 4.6 6dba99d164 chore: bump version to 0.6.0, update changelog
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-04-11 18:11:34 -05:00
NixReaperandClaude Sonnet 4.6 c5498994c3 refactor: split large files into single-responsibility modules, add server-configuration section
Frontend:
- Extract Layout.tsx (801 lines) into navData.ts, Sidebar.tsx, and thin Layout.tsx
- Split Settings.tsx into settings/FormControls, ContactTab, BasicTab, NameserversSection
- Add server-configuration/ section with index card grid and one file per tool (13 tools)
- Add shared SectionCard and ComingSoon components
- Nav category headers with a section index link (optional `to` field on NavCategory)
- /settings redirects to /server-configuration/basic-setup

Backend:
- Split accounts.ts (671 lines) into accounts/list, create, modify, status sub-plugins
- Add GET /api/nixserver/system/network-interfaces for ETHDEV dropdown

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-04-11 18:07:57 -05:00
NixReaper da55367039 fix(install): remove --silent from build steps, clean node_modules before install, bump v0.5.1 2026-04-11 16:15:53 -05:00
NixReaper 55786bfa0e fix: never commit package-lock.json, clean install in update.sh, bump v0.5.0
package-lock.json generated on Windows includes win32 rollup binary but
not linux-x64-gnu, breaking the frontend build on the server.

- Add package-lock.json to .gitignore and remove it from git tracking
- update.sh: rm -rf node_modules + package-lock.json before npm install
  so the server always generates a platform-correct lock file from scratch
- update.sh: remove --silent from build steps so errors are visible
- .gitignore: fix stale whm/cpanel dist paths → nixserver/nixclient
2026-04-11 15:32:29 -05:00
NixReaper 70908745bd fix(update): discard local package-lock.json before git pull, bump v0.4.9
npm install modifies package-lock.json on the server, causing
'git pull --ff-only' to abort with 'your local changes would be
overwritten'. Add 'git checkout -- package-lock.json' before the pull
so the generated lock file never blocks updates.
2026-04-11 15:03:46 -05:00
NixReaper 0362810681 fix(login): fetch version from /api/health instead of hardcoding, bump v0.4.8
Login page had 'NixServer v0.3.2 · Port 2087' hardcoded and subtitle
said 'Web Host Manager'. Now fetches version from GET /api/health on
mount (no auth needed) and shows it dynamically. Changed subtitle to
'Admin Panel'.
2026-04-11 15:02:18 -05:00
NixReaper 9ec39c9120 fix: regenerate package-lock.json after @nixpanel/whm → @nixpanel/nixserver rename, bump v0.4.7
The old package-lock.json still referenced @nixpanel/whm as a workspace.
After the package rename in v0.4.6, npm install failed to resolve the
workspace correctly, causing the nixserver frontend build to fail and
breaking login. Deleted and regenerated package-lock.json cleanly.
2026-04-11 15:01:46 -05:00
NixReaper b7eb76d91a chore: remove WHM trademark references, rename package, bump v0.4.6
WHM is a registered trademark of cPanel, L.L.C. Replace all occurrences:
- Sidebar: 'Basic WebHost Manager® Setup' → 'Basic NixServer Setup'
- Sidebar: 'WHM Marketplace' → 'NixServer Marketplace'
- Sidebar: 'OpenAPI Documentation for WHM' → 'OpenAPI Documentation for NixServer'
- nixserver/package.json: @nixpanel/whm → @nixpanel/nixserver
- server/src/app.ts: whmDist variable → nixserverDist
2026-04-11 14:45:50 -05:00
NixReaper 5af2762634 fix(version): cache-bust refresh + clear stale state on manual check, bump to v0.4.5
The refresh/retry button called fetchVersion() without clearing
versionInfo first, so the old value stayed displayed with no feedback.
On manual refresh it now immediately sets versionInfo to null (shows
spinner) then fetches with ?_t=Date.now() to bypass any browser cache
of the API response.

Bump version to 0.4.5 to include all fixes since 0.4.4 (day/night
toggle, uptime/kill allowlist, auto-retry on version error).
2026-04-11 14:27:19 -05:00
NixReaper ddb51a8b4d feat: day/night mode toggle with theme context
Add ThemeContext (dark/light, persisted to localStorage, applied as
data-theme on <html>). Theme toggle is a sun/moon flip switch at the
right end of the dashboard info bar.

CSS overrides in index.css target every hardcoded Tailwind arbitrary
colour class used across all pages via [data-theme="light"] selectors,
so the entire panel switches without touching individual page files.

Light palette: page #f0f4f8, cards/sidebar #ffffff, hover #f1f5f9,
borders #e2e8f0, text #0f172a. Chart tooltip is made theme-aware via
inline-style interpolation since Tailwind CSS overrides cannot reach
Recharts inline styles.
2026-04-11 14:24:51 -05:00
NixReaper 4de2a7fa8e fix: auto-retry version fetch + add uptime/kill to exec allowlist
Layout.tsx: when versionError is true, automatically retry fetchVersion
after 5 s. This handles the case where the initial fetch runs during
the service-restart window after an upgrade (server not yet up), which
caused the sidebar footer to permanently show 'Version unavailable'
even though the server came back online shortly after.

exec.ts: add 'uptime' and 'kill' to ALLOWED_COMMANDS. 'uptime' was
missing, causing exec('uptime', ['-p']) in GET /api/nixserver/system/info
to throw 'Command not allowed', which rejected the entire Promise.all
and returned 500 — leaving Hostname and OS as '...' on the dashboard.
2026-04-11 14:19:24 -05:00
NixReaper 90dbf94c18 fix(layout): use authenticated api client for version/upgrade calls
Plain axios was called without the Authorization header, so the
requireAdmin middleware rejected every request with 401. The catch
block set versionError but the error state was also not yet deployed,
leaving the spinner stuck on 'Checking version…' forever.

Switch both axios.get('/api/nixserver/system/version') and
axios.post('/api/nixserver/system/upgrade') to the api client from
../api/client, which automatically attaches Bearer token from
localStorage on every request.
2026-04-11 14:15:48 -05:00
47 changed files with 2724 additions and 7492 deletions
+9 -4
View File
@@ -6,15 +6,20 @@ dist/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# package-lock.json is platform-specific (rollup native binaries differ between
# Windows dev machines and Linux servers). Never commit it — the server generates
# its own on each install.
package-lock.json
# Prisma # Prisma
server/prisma/dev.db server/prisma/dev.db
server/prisma/dev.db-journal server/prisma/dev.db-journal
# Build outputs # Build outputs
server/dist/ server/dist/
whm/dist/ nixserver/dist/
cpanel/dist/ nixclient/dist/
# Vite # Vite
whm/.vite/ nixserver/.vite/
cpanel/.vite/ nixclient/.vite/
+91
View File
@@ -6,6 +6,97 @@ Pre-1.0 versions treat MINOR as feature releases and PATCH as bug fixes.
--- ---
## [0.6.2] — 2026-04-12
### Bug Fixes
- **`curl | bash` aborts immediately at confirmation prompt** — `set -euo pipefail` caused the script to exit when `read` received EOF from the pipe instead of a terminal. Fixed by reading from `/dev/tty` directly so the prompt works correctly whether the script is piped or run from a file.
---
## [0.6.1] — 2026-04-11
### Changes
- **`CLAUDE.md` added** — development standards file auto-read by Claude Code before every session. Documents version numbering rules, changelog format, commit message conventions, file size limits, section/page architecture patterns, API conventions, and project structure.
---
## [0.6.0] — 2026-04-11
### Features
- **Basic NixServer Setup page** — full WHM-equivalent settings page at `/server-configuration/basic-setup` with two tabs (Contact Information, Basic Config) and a Nameservers section.
- **Server Configuration section page** — new section index at `/server-configuration` showing all 13 tools as a card grid.
- **`GET /api/nixserver/system/network-interfaces`** — new endpoint returning all non-loopback interfaces, used to populate the Ethernet Device dropdown.
### Refactors
- **File size limit enforced** — `Layout.tsx`, `Settings.tsx`, `accounts.ts` each split into focused sub-files under 300 lines.
- **`NavCategory` gains optional `to` field** — links sidebar category headers to section index pages.
- **Shared components added** — `SectionCard.tsx` and `ComingSoon.tsx`.
- **`/settings` redirected** to `/server-configuration/basic-setup` for backwards compatibility.
---
## [0.5.1] — 2026-04-11
### Bug Fixes
- **`install.sh` build steps used `--silent`** — errors during `npm install` and frontend/server builds were suppressed, making failures invisible. Removed `--silent` from all build commands. Also added `rm -rf node_modules package-lock.json` before `npm install` to ensure a clean platform-correct install, consistent with `update.sh`.
---
## [0.5.0] — 2026-04-11
### Bug Fixes
- **Rollup native binary missing on Linux** — `package-lock.json` was generated on Windows and committed to git. It included the Windows rollup binary (`@rollup/rollup-win32-x64-msvc`) but not the Linux one (`@rollup/rollup-linux-x64-gnu`), causing the frontend build to fail on the server with `Cannot find module @rollup/rollup-linux-x64-gnu`.
- **`package-lock.json` added to `.gitignore`** — lock files are platform-specific and must never be committed. The server now generates its own fresh lock file on every update.
- **`update.sh` now does a clean install** — deletes `node_modules/` and `package-lock.json` before `npm install` on every update run to guarantee platform-correct native binaries. Also drops `--silent` from build commands so errors are visible.
- **`.gitignore` corrected** — removed stale `whm/` and `cpanel/` dist paths, added correct `nixserver/` and `nixclient/` paths.
---
## [0.4.9] — 2026-04-11
### Bug Fixes
- **`update.sh` fails when `package-lock.json` has local changes** — `git pull --ff-only` aborted with "Your local changes would be overwritten by merge" whenever npm had modified the lock file on the server. Fixed by running `git checkout -- package-lock.json` before the pull to discard local changes to the generated lock file.
---
## [0.4.8] — 2026-04-11
### Bug Fixes
- **Login page showing stale hardcoded version** — "NixServer v0.3.2 · Port 2087" was hardcoded. Now fetches version live from `GET /api/health` (no auth required) on mount.
- **Login page subtitle** — "Web Host Manager" replaced with "Admin Panel" to remove any WHM trademark association.
---
## [0.4.7] — 2026-04-11
### Bug Fixes
- **Login broken after v0.4.6 update** — `package-lock.json` still mapped the workspace as `@nixpanel/whm`. After renaming the package to `@nixpanel/nixserver`, npm workspace resolution failed during `npm install`, causing the frontend build to break and leaving the panel unresponsive. Fixed by regenerating `package-lock.json` with the correct package name.
---
## [0.4.6] — 2026-04-11
### Changes
- **Removed all WHM trademark references** — "WHM" is a registered trademark of cPanel, L.L.C. All occurrences replaced with "NixServer":
- Sidebar menu item "Basic WebHost Manager® Setup" → "Basic NixServer Setup"
- Sidebar menu item "WHM Marketplace" → "NixServer Marketplace"
- Sidebar menu item "OpenAPI Documentation for WHM" → "OpenAPI Documentation for NixServer"
- Internal npm package name `@nixpanel/whm` → `@nixpanel/nixserver`
---
## [0.4.5] — 2026-04-11
### Bug Fixes
- **Sidebar version refresh not updating** — clicking the refresh icon now clears the stale version immediately (shows spinner) and adds a `?_t=` cache-bust parameter so the browser never serves a cached response. Same fix applied to the "Retry" button in the error state.
- **`uptime` and `kill` missing from exec allowlist** — caused `GET /api/nixserver/system/info` to throw "Command not allowed", returning 500 and leaving Hostname/OS as `…` on the dashboard.
- **Version fetch auto-retry** — when the version API fails (e.g. during service restart after upgrade), the sidebar now automatically retries every 5 s until it succeeds instead of permanently showing "Version unavailable".
### New Features
- **Day/Night mode toggle** — sun/moon flip switch at the right end of the dashboard info bar. Light palette: page `#f0f4f8`, cards `#ffffff`. Preference persisted to `localStorage`. All pages switch via seven `[data-theme="light"]` CSS overrides in `index.css` without touching individual page files.
---
## [0.4.4] — 2026-04-11 ## [0.4.4] — 2026-04-11
### New Features ### New Features
+165
View File
@@ -0,0 +1,165 @@
# NixPanel Development Standards
This file is read automatically by Claude Code before every session.
All rules below are **mandatory** — follow them without being asked.
---
## 1. Version Numbering
Versioning follows [Semantic Versioning](https://semver.org/). Pre-1.0 rules:
| Change type | Bump | Example |
|---|---|---|
| New feature, new page, new section | **MINOR** | 0.5.1 → 0.6.0 |
| Bug fix, refactor, chore, docs | **PATCH** | 0.5.1 → 0.5.2 |
| Breaking API/schema change | **MINOR** | 0.5.x → 0.6.0 |
**Always** bump the version in `package.json` before committing any user-visible change.
---
## 2. Changelog
File: `CHANGELOG.md` at the repo root.
### Format
```markdown
## [X.Y.Z] — YYYY-MM-DD
### Features ← new functionality
### Changes ← non-breaking behaviour changes / refactors
### Bug Fixes ← defect corrections
### Refactors ← internal restructuring with no behaviour change
```
### Rules
- Add a new entry **at the top** of the file (below the header block) for every commit.
- One entry per version — if multiple changes share a version, group them under the same header.
- Bullet points must be descriptive enough that a developer can understand what changed and why without reading the diff.
- Use bold for the affected component/file: `- **BasicSetup.tsx** — description`.
- Today's date format: `YYYY-MM-DD`.
---
## 3. Commit Messages
Format: `type(scope): short imperative summary`
| Type | When to use |
|---|---|
| `feat` | New feature or page |
| `fix` | Bug fix |
| `refactor` | Code restructuring, no behaviour change |
| `chore` | Version bump, changelog, config, tooling |
| `docs` | Documentation only |
Rules:
- Summary line ≤ 72 characters.
- Always include `Co-Authored-By: Claude Sonnet 4.6 <[email protected]>` trailer.
- Use a multiline HEREDOC — never single-line for complex messages.
### Workflow for every change
1. Make code changes.
2. Bump version in `package.json`.
3. Add entry to `CHANGELOG.md`.
4. Stage all changed files including `package.json` and `CHANGELOG.md`.
5. Commit everything together in **one commit**.
6. Push to `origin main`.
> Do **not** make a separate `chore: bump version` commit — version + changelog belong in the same commit as the code change.
---
## 4. File Size Limit
**No source file should exceed 300 lines.**
When a file approaches this limit, split it by responsibility:
- React pages → extract sub-components into a `pages/<section>/` subfolder.
- Fastify route files → split into sub-plugins under `routes/<section>/` with an `index.ts` registering them.
- Shared UI primitives → `components/` folder.
---
## 5. Page / Section Architecture
### Frontend
Each nav category in the sidebar maps to a **section folder**:
```
pages/<section-name>/
index.tsx ← card-grid landing page (always create this first)
ToolName.tsx ← one file per tool/feature
```
Unimplemented tools use `<ComingSoon title="..." description="..." />` from `components/ComingSoon.tsx`.
Section landing pages use `<SectionCard>` from `components/SectionCard.tsx`.
### Backend
Route files live under `routes/nixserver/<section>/`:
```
routes/nixserver/<section>/
index.ts ← registers sub-plugins, no route logic here
list.ts ← read-only GET routes
create.ts ← POST / creation route
modify.ts ← PUT / PATCH routes
status.ts ← suspend / unsuspend / delete / bulk
```
---
## 6. Project Structure (key paths)
```
nixpanel-server/
package.json ← root version number lives here
CHANGELOG.md ← always update before committing
CLAUDE.md ← this file
nixserver/src/
components/
navData.ts ← NAV array + NavCategory/NavItem types
Sidebar.tsx ← sidebar UI
Layout.tsx ← thin shell (topbar + Outlet)
SectionCard.tsx ← reusable section card
ComingSoon.tsx ← placeholder for unimplemented tools
pages/
server-configuration/ ← example of section folder pattern
index.tsx
BasicSetup.tsx
ChangeRoot.tsx
...
server/src/
routes/nixserver/
accounts/ ← example of split route folder
index.ts
list.ts
create.ts
modify.ts
status.ts
```
---
## 7. API Conventions
- All responses: `{ success: true, data: ... }` or `{ success: false, error: "..." }`.
- All admin routes use `preHandler: [requireAdmin]` or `preHandler: [requireAdminOrReseller]`.
- Settings stored as key-value pairs in the `Setting` model (`key String @id`, `value String`).
- New settings keys use UPPERCASE (matching WHM convention): `CONTACTEMAIL`, `TTL`, etc.
---
## 8. Git Remote
```
origin https://github.com/NixReaper/nixpanel-server.git (fetch/push)
branch main
```
Always push to `origin main` after committing.
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@nixpanel/whm", "name": "@nixpanel/nixserver",
"version": "0.4.3", "version": "0.4.6",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+68 -3
View File
@@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './contexts/AuthContext' import { AuthProvider, useAuth } from './contexts/AuthContext'
import { ThemeProvider } from './contexts/ThemeContext'
import Layout from './components/Layout' import Layout from './components/Layout'
import Login from './pages/Login' import Login from './pages/Login'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
@@ -22,6 +23,22 @@ const System = React.lazy(() => import('./pages/System'))
const WebServer = React.lazy(() => import('./pages/WebServer')) const WebServer = React.lazy(() => import('./pages/WebServer'))
const Settings = React.lazy(() => import('./pages/Settings')) 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 // Accounts sub-pages
const AccountList = React.lazy(() => import('./pages/accounts/List')) const AccountList = React.lazy(() => import('./pages/accounts/List'))
const AccountParkedDomains = React.lazy(() => import('./pages/accounts/ParkedDomains')) const AccountParkedDomains = React.lazy(() => import('./pages/accounts/ParkedDomains'))
@@ -180,6 +197,52 @@ function AppRoutes() {
<React.Suspense fallback={<PageLoader />}><AccountBulkModify /></React.Suspense> <React.Suspense fallback={<PageLoader />}><AccountBulkModify /></React.Suspense>
} /> } />
{/* Server Configuration section */}
<Route path="server-configuration" element={
<React.Suspense fallback={<PageLoader />}><ServerConfiguration /></React.Suspense>
} />
<Route path="server-configuration/basic-setup" element={
<React.Suspense fallback={<PageLoader />}><SCBasicSetup /></React.Suspense>
} />
<Route path="server-configuration/change-root" element={
<React.Suspense fallback={<PageLoader />}><SCChangeRoot /></React.Suspense>
} />
<Route path="server-configuration/analytics" element={
<React.Suspense fallback={<PageLoader />}><SCAnalytics /></React.Suspense>
} />
<Route path="server-configuration/cron-jobs" element={
<React.Suspense fallback={<PageLoader />}><SCCronJobs /></React.Suspense>
} />
<Route path="server-configuration/quota-setup" element={
<React.Suspense fallback={<PageLoader />}><SCQuotaSetup /></React.Suspense>
} />
<Route path="server-configuration/link-nodes" element={
<React.Suspense fallback={<PageLoader />}><SCLinkNodes /></React.Suspense>
} />
<Route path="server-configuration/server-profile" element={
<React.Suspense fallback={<PageLoader />}><SCServerProfile /></React.Suspense>
} />
<Route path="server-configuration/server-time" element={
<React.Suspense fallback={<PageLoader />}><SCServerTime /></React.Suspense>
} />
<Route path="server-configuration/statistics-software" element={
<React.Suspense fallback={<PageLoader />}><SCStatisticsSoftware /></React.Suspense>
} />
<Route path="server-configuration/terminal" element={
<React.Suspense fallback={<PageLoader />}><SCTerminal /></React.Suspense>
} />
<Route path="server-configuration/tweak-settings" element={
<React.Suspense fallback={<PageLoader />}><SCTweakSettings /></React.Suspense>
} />
<Route path="server-configuration/update-preferences" element={
<React.Suspense fallback={<PageLoader />}><SCUpdatePreferences /></React.Suspense>
} />
<Route path="server-configuration/marketplace" element={
<React.Suspense fallback={<PageLoader />}><SCMarketplace /></React.Suspense>
} />
{/* Redirect old /settings to new canonical route */}
<Route path="settings" element={<Navigate to="/server-configuration/basic-setup" replace />} />
{/* Other sections */} {/* Other sections */}
<Route path="packages" element={<Packages />} /> <Route path="packages" element={<Packages />} />
<Route path="resellers" element={ <Route path="resellers" element={
@@ -233,9 +296,11 @@ function PageLoader() {
export default function App() { export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<AuthProvider> <ThemeProvider>
<AppRoutes /> <AuthProvider>
</AuthProvider> <AppRoutes />
</AuthProvider>
</ThemeProvider>
</BrowserRouter> </BrowserRouter>
) )
} }
+26
View File
@@ -0,0 +1,26 @@
import { Wrench } from 'lucide-react'
interface ComingSoonProps {
title: string
description?: string
}
export default function ComingSoon({ title, description }: ComingSoonProps) {
return (
<div className="space-y-4 max-w-2xl">
<div>
<h1 className="text-white text-xl font-semibold">{title}</h1>
{description && <p className="text-[#64748b] text-sm mt-0.5">{description}</p>}
</div>
<div className="flex flex-col items-center justify-center py-20 bg-[#1a1d27] border border-[#2a2d3e] rounded-xl text-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-[#0f1117] border border-[#2a2d3e] flex items-center justify-center">
<Wrench size={24} className="text-[#475569]" />
</div>
<div>
<p className="text-[#94a3b8] font-medium">Coming Soon</p>
<p className="text-[#475569] text-sm mt-1">This feature is under development.</p>
</div>
</div>
</div>
)
}
+6 -758
View File
@@ -1,767 +1,15 @@
import { useState, useMemo, useEffect, useCallback } from 'react' import { useState } from 'react'
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import { import { Menu, X } from 'lucide-react'
LayoutDashboard, Settings, HelpCircle, Network, BookUser, UserCog, import Sidebar from './Sidebar'
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 axios from 'axios'
import { useAuth } from '../contexts/AuthContext'
// ─── 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 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 navigate = useNavigate()
const location = useLocation()
const [sidebarOpen, setSidebarOpen] = useState(true) 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<VersionInfo | null>(null)
const [versionError, setVersionError] = useState(false)
const [upgradeState, setUpgradeState] = useState<'idle' | 'confirm' | 'running' | 'done' | 'error'>('idle')
const fetchVersion = useCallback(async () => {
setVersionError(false)
try {
const { data } = await axios.get('/api/nixserver/system/version', { timeout: 10_000 })
setVersionInfo(data.data)
} catch {
setVersionError(true)
}
}, [])
useEffect(() => { fetchVersion() }, [fetchVersion])
const handleUpgrade = async () => {
if (upgradeState === 'confirm') {
setUpgradeState('running')
try {
await axios.post('/api/nixserver/system/upgrade')
setUpgradeState('done')
} catch {
setUpgradeState('error')
}
} else {
setUpgradeState('confirm')
}
}
// ─────────────────────────────────────────────────────────────────────────
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 () => {
await logout()
navigate('/login')
}
return ( return (
<div className="flex h-screen overflow-hidden bg-[#0f1117]"> <div className="flex h-screen overflow-hidden bg-[#0f1117]">
<Sidebar open={sidebarOpen} onToggle={() => setSidebarOpen(v => !v)} />
{/* ── Sidebar ─────────────────────────────────────────────────────── */}
<aside
className={`flex flex-col transition-all duration-200 bg-[#1a1d27] border-r border-[#2a2d3e] overflow-hidden flex-shrink-0 ${
sidebarOpen ? 'w-72' : 'w-0'
}`}
>
{/* Branding */}
<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">
<span className="text-white text-xs font-bold">N</span>
</div>
<div className="overflow-hidden">
<p className="text-white text-sm font-semibold leading-tight whitespace-nowrap">NixServer</p>
<p className="text-[#64748b] text-xs whitespace-nowrap">Admin Panel</p>
</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>
{/* Nav */}
<nav className="flex-1 overflow-y-auto py-1 scrollbar-thin">
{/* Dashboard — always at the top */}
<Link
to="/"
className={`flex items-center gap-2.5 px-3 py-1.5 mx-2 my-0.5 rounded-md text-xs transition-colors ${
location.pathname === '/'
? '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>
{/* Categories */}
{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>
{/* Version + upgrade */}
<div className="border-t border-[#2a2d3e] px-3 pt-2.5 pb-1 flex-shrink-0">
{versionInfo ? (
<div className="space-y-1.5">
{/* Version row */}
<div className="flex items-center justify-between px-1">
<div className="flex items-center gap-1.5">
{versionInfo.updateAvailable ? (
<AlertCircle size={11} className="text-amber-400 flex-shrink-0" />
) : (
<CheckCircle2 size={11} className="text-emerald-500 flex-shrink-0" />
)}
<span className="text-[#64748b] text-[10px]">
v{versionInfo.currentVersion}
</span>
{versionInfo.updateAvailable && versionInfo.latestVersion && (
<span className="text-amber-400 text-[10px]">
→ v{versionInfo.latestVersion}
</span>
)}
</div>
<button
onClick={fetchVersion}
title="Check for updates"
className="text-[#4a5568] hover:text-[#94a3b8] transition-colors"
>
<RefreshCcw size={10} />
</button>
</div>
{/* Upgrade button */}
{upgradeState === 'done' ? (
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-emerald-600/10 text-emerald-400 text-[10px]">
<CheckCircle2 size={11} />
<span>Upgrade started — panel restarting…</span>
</div>
) : upgradeState === 'error' ? (
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-red-600/10 text-red-400 text-[10px]">
<AlertCircle size={11} />
<span>Upgrade failed — check logs</span>
</div>
) : (
<div className="flex gap-1.5">
<button
onClick={handleUpgrade}
disabled={upgradeState === 'running'}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[10px] font-medium transition-colors ${
upgradeState === 'confirm'
? 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/30'
: versionInfo.updateAvailable
? 'bg-indigo-600/20 text-indigo-300 hover:bg-indigo-600/30'
: 'bg-[#1e2130] text-[#64748b] hover:text-[#94a3b8]'
}`}
>
{upgradeState === 'running' ? (
<><Loader2 size={10} className="animate-spin" /><span>Upgrading…</span></>
) : upgradeState === 'confirm' ? (
<><ArrowUpCircle size={10} /><span>Confirm upgrade</span></>
) : versionInfo.updateAvailable ? (
<><ArrowUpCircle size={10} /><span>Upgrade available</span></>
) : (
<><ArrowUpCircle size={10} /><span>Upgrade panel</span></>
)}
</button>
{upgradeState === 'confirm' && (
<button
onClick={() => setUpgradeState('idle')}
className="px-2 py-1.5 rounded-md bg-[#1e2130] text-[#64748b] hover:text-[#94a3b8] text-[10px] transition-colors"
>
Cancel
</button>
)}
</div>
)}
</div>
) : versionError ? (
<div className="flex items-center justify-between px-1 py-1">
<span className="text-[#4a5568] text-[10px]">Version unavailable</span>
<button onClick={fetchVersion} title="Retry" className="text-[#4a5568] hover:text-[#94a3b8] transition-colors">
<RefreshCcw size={10} />
</button>
</div>
) : (
<div className="flex items-center gap-1.5 px-1 py-1">
<Loader2 size={10} className="animate-spin text-[#4a5568]" />
<span className="text-[#4a5568] text-[10px]">Checking version…</span>
</div>
)}
</div>
{/* User + logout */}
<div className="border-t border-[#2a2d3e] p-3 flex-shrink-0">
<div className="px-1 mb-2">
<p className="text-white text-xs font-medium truncate">{user?.username}</p>
<p className="text-[#64748b] text-xs truncate">{user?.role}</p>
</div>
<button
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-xs"
>
<LogOut size={14} className="flex-shrink-0" />
<span>Logout</span>
</button>
</div>
</aside>
{/* ── Main ────────────────────────────────────────────────────────── */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0"> <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] flex-shrink-0"> <header className="flex items-center gap-4 px-6 py-3 bg-[#1a1d27] border-b border-[#2a2d3e] min-h-[57px] flex-shrink-0">
@@ -777,7 +25,7 @@ export default function Layout() {
</span> </span>
</header> </header>
{/* Content */} {/* Page content */}
<main className="flex-1 overflow-y-auto p-6"> <main className="flex-1 overflow-y-auto p-6">
<Outlet /> <Outlet />
</main> </main>
+46
View File
@@ -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 (
<div className="flex items-start gap-3 p-4 rounded-xl border border-[#2a2d3e] bg-[#0f1117] opacity-50 cursor-not-allowed select-none">
{Icon && (
<div className="w-8 h-8 rounded-lg bg-[#1e2130] flex items-center justify-center flex-shrink-0 mt-0.5">
<Icon size={15} className="text-[#475569]" />
</div>
)}
<div className="min-w-0">
<p className="text-[#64748b] text-sm font-medium truncate">{label}</p>
{description && <p className="text-[#374151] text-xs mt-0.5 line-clamp-2">{description}</p>}
<span className="inline-block mt-1 text-[10px] text-[#374151] border border-[#2a2d3e] rounded px-1.5 py-0.5">Coming soon</span>
</div>
</div>
)
}
return (
<Link
to={to}
className="flex items-start gap-3 p-4 rounded-xl border border-[#2a2d3e] bg-[#0f1117] hover:bg-[#1a1d27] hover:border-indigo-500/40 transition-colors group"
>
{Icon && (
<div className="w-8 h-8 rounded-lg bg-indigo-600/10 flex items-center justify-center flex-shrink-0 mt-0.5 group-hover:bg-indigo-600/20 transition-colors">
<Icon size={15} className="text-indigo-400" />
</div>
)}
<div className="min-w-0">
<p className="text-[#cbd5e1] text-sm font-medium truncate group-hover:text-white transition-colors">{label}</p>
{description && <p className="text-[#475569] text-xs mt-0.5 line-clamp-2">{description}</p>}
</div>
</Link>
)
}
+340
View File
@@ -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<VersionInfo | null>(null)
const [versionError, setVersionError] = useState(false)
const [upgradeState, setUpgradeState] = useState<UpgradeState>('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<Set<string>>(() => {
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 (
<aside
className={`flex flex-col transition-all duration-200 bg-[#1a1d27] border-r border-[#2a2d3e] overflow-hidden flex-shrink-0 ${
open ? 'w-72' : 'w-0'
}`}
>
{/* Branding + toggle */}
<div className="flex items-center gap-2 px-4 py-4 border-b border-[#2a2d3e] min-h-[57px] flex-shrink-0">
<button onClick={onToggle} className="text-[#64748b] hover:text-white transition-colors mr-1">
{open ? <X size={16} /> : <Menu size={16} />}
</button>
<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>
</div>
<div className="overflow-hidden">
<p className="text-white text-sm font-semibold leading-tight whitespace-nowrap">NixServer</p>
<p className="text-[#64748b] text-xs whitespace-nowrap">Admin Panel</p>
</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>
{/* Nav */}
<nav className="flex-1 overflow-y-auto py-1 scrollbar-thin">
<Link
to="/"
className={`flex items-center gap-2.5 px-3 py-1.5 mx-2 my-0.5 rounded-md text-xs transition-colors ${
location.pathname === '/'
? '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>
{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">
<div className="flex items-center gap-1">
<button
onClick={() => !search.trim() && toggleCat(cat.id)}
className={`flex items-center gap-2 flex-1 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>
{cat.to && (
<Link
to={cat.to}
title={`${cat.label} overview`}
className="p-1 text-[#374151] hover:text-indigo-400 transition-colors flex-shrink-0"
>
<ExternalLink size={10} />
</Link>
)}
</div>
{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>
)
})}
{search.trim() && filteredNav.length === 0 && (
<p className="text-center text-[#4a5568] text-xs py-6">No results</p>
)}
</nav>
{/* Version + upgrade */}
<div className="border-t border-[#2a2d3e] px-3 pt-2.5 pb-1 flex-shrink-0">
{versionInfo ? (
<div className="space-y-1.5">
<div className="flex items-center justify-between px-1">
<div className="flex items-center gap-1.5">
{versionInfo.updateAvailable ? (
<AlertCircle size={11} className="text-amber-400 flex-shrink-0" />
) : (
<CheckCircle2 size={11} className="text-emerald-500 flex-shrink-0" />
)}
<span className="text-[#64748b] text-[10px]">v{versionInfo.currentVersion}</span>
{versionInfo.updateAvailable && versionInfo.latestVersion && (
<span className="text-amber-400 text-[10px]">→ v{versionInfo.latestVersion}</span>
)}
</div>
<button
onClick={() => fetchVersion(true)}
title="Check for updates"
className="text-[#4a5568] hover:text-[#94a3b8] transition-colors"
>
<RefreshCcw size={10} />
</button>
</div>
{upgradeState === 'done' ? (
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-emerald-600/10 text-emerald-400 text-[10px]">
<CheckCircle2 size={11} />
<span>Upgrade started — panel restarting…</span>
</div>
) : upgradeState === 'error' ? (
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-red-600/10 text-red-400 text-[10px]">
<AlertCircle size={11} />
<span>Upgrade failed — check logs</span>
</div>
) : (
<div className="flex gap-1.5">
<button
onClick={handleUpgrade}
disabled={upgradeState === 'running'}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[10px] font-medium transition-colors ${
upgradeState === 'confirm'
? 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/30'
: versionInfo.updateAvailable
? 'bg-indigo-600/20 text-indigo-300 hover:bg-indigo-600/30'
: 'bg-[#1e2130] text-[#64748b] hover:text-[#94a3b8]'
}`}
>
{upgradeState === 'running' ? (
<><Loader2 size={10} className="animate-spin" /><span>Upgrading…</span></>
) : upgradeState === 'confirm' ? (
<><ArrowUpCircle size={10} /><span>Confirm upgrade</span></>
) : versionInfo.updateAvailable ? (
<><ArrowUpCircle size={10} /><span>Upgrade available</span></>
) : (
<><ArrowUpCircle size={10} /><span>Upgrade panel</span></>
)}
</button>
{upgradeState === 'confirm' && (
<button
onClick={() => setUpgradeState('idle')}
className="px-2 py-1.5 rounded-md bg-[#1e2130] text-[#64748b] hover:text-[#94a3b8] text-[10px] transition-colors"
>
Cancel
</button>
)}
</div>
)}
</div>
) : versionError ? (
<div className="flex items-center justify-between px-1 py-1">
<span className="text-[#4a5568] text-[10px]">Version unavailable</span>
<button onClick={() => fetchVersion(true)} title="Retry" className="text-[#4a5568] hover:text-[#94a3b8] transition-colors">
<RefreshCcw size={10} />
</button>
</div>
) : (
<div className="flex items-center gap-1.5 px-1 py-1">
<Loader2 size={10} className="animate-spin text-[#4a5568]" />
<span className="text-[#4a5568] text-[10px]">Checking version…</span>
</div>
)}
</div>
{/* User + logout */}
<div className="border-t border-[#2a2d3e] p-3 flex-shrink-0">
<div className="px-1 mb-2">
<p className="text-white text-xs font-medium truncate">{user?.username}</p>
<p className="text-[#64748b] text-xs truncate">{user?.role}</p>
</div>
<button
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-xs"
>
<LogOut size={14} className="flex-shrink-0" />
<span>Logout</span>
</button>
</div>
</aside>
)
}
+443
View File
@@ -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' },
],
},
]
+35
View File
@@ -0,0 +1,35 @@
import { createContext, useContext, useEffect, useState } from 'react'
type Theme = 'dark' | 'light'
interface ThemeContextValue {
theme: Theme
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() =>
(localStorage.getItem('nixpanel-theme') as Theme) ?? 'dark'
)
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('nixpanel-theme', theme)
}, [theme])
const toggleTheme = () => setTheme(t => (t === 'dark' ? 'light' : 'dark'))
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used inside ThemeProvider')
return ctx
}
+42
View File
@@ -45,3 +45,45 @@ body {
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #3a3d4e; background: #3a3d4e;
} }
/* ── Light theme ─────────────────────────────────────────────────────────── */
[data-theme="light"] {
--bg-primary: #f0f4f8;
--bg-secondary: #ffffff;
--bg-card: #f1f5f9;
--border: #e2e8f0;
--text-primary: #0f172a;
--text-muted: #64748b;
}
[data-theme="light"] body {
background-color: #f0f4f8;
color: #0f172a;
}
/* Scrollbar */
[data-theme="light"] ::-webkit-scrollbar-track { background: #f1f5f9; }
[data-theme="light"] ::-webkit-scrollbar-thumb { background: #cbd5e1; }
[data-theme="light"] ::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
/* ── Background overrides ──────────────────────────────────────────────── */
/* Page background (darkest) */
[data-theme="light"] .bg-\[\#0f1117\] { background-color: #f0f4f8 !important; }
/* Cards / sidebar */
[data-theme="light"] .bg-\[\#1a1d27\] { background-color: #ffffff !important; }
/* Hover / alternate rows */
[data-theme="light"] .bg-\[\#1e2130\] { background-color: #f1f5f9 !important; }
/* ── Border overrides ──────────────────────────────────────────────────── */
[data-theme="light"] .border-\[\#2a2d3e\] { border-color: #e2e8f0 !important; }
/* ── Text overrides ────────────────────────────────────────────────────── */
[data-theme="light"] .text-white { color: #0f172a !important; }
[data-theme="light"] .text-\[\#94a3b8\] { color: #475569 !important; }
[data-theme="light"] .text-\[\#64748b\] { color: #64748b !important; }
[data-theme="light"] .text-\[\#4a5568\] { color: #94a3b8 !important; }
/* ── Input fields ──────────────────────────────────────────────────────── */
[data-theme="light"] input { color: #0f172a !important; }
[data-theme="light"] input::placeholder { color: #94a3b8 !important; }
+41 -8
View File
@@ -1,8 +1,9 @@
import React, { useEffect, useState, useRef } from 'react' import React, { useEffect, useState, useRef } from 'react'
import { Users, Server, HardDrive, Activity, AlertTriangle, CheckCircle2 } from 'lucide-react' import { Users, Server, HardDrive, Activity, AlertTriangle, CheckCircle2, Sun, Moon } from 'lucide-react'
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts' import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'
import { api } from '../api/client' import { api } from '../api/client'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useTheme } from '../contexts/ThemeContext'
interface SystemStats { interface SystemStats {
cpu: number cpu: number
@@ -71,6 +72,7 @@ function Divider() {
export default function Dashboard() { export default function Dashboard() {
const { user } = useAuth() const { user } = useAuth()
const { theme, toggleTheme } = useTheme()
const [stats, setStats] = useState<SystemStats | null>(null) const [stats, setStats] = useState<SystemStats | null>(null)
const [accountStats, setAccountStats] = useState<AccountStats | null>(null) const [accountStats, setAccountStats] = useState<AccountStats | null>(null)
const [cpuHistory, setCpuHistory] = useState<{ t: string; cpu: number; mem: number }[]>([]) const [cpuHistory, setCpuHistory] = useState<{ t: string; cpu: number; mem: number }[]>([])
@@ -164,17 +166,42 @@ export default function Dashboard() {
{/* ── cPanel-style server info bar ─────────────────────────────────── */} {/* ── cPanel-style server info bar ─────────────────────────────────── */}
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl px-5 py-3.5 flex flex-wrap items-center gap-x-5 gap-y-3"> <div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl px-5 py-3.5 flex flex-wrap items-center gap-x-5 gap-y-3">
<InfoCell label="Username" value={user?.username ?? '…'} /> <InfoCell label="Username" value={user?.username ?? '…'} />
<Divider /> <Divider />
<InfoCell label="Hostname" value={systemInfo?.hostname ?? '…'} /> <InfoCell label="Hostname" value={systemInfo?.hostname ?? '…'} />
<Divider /> <Divider />
<InfoCell label="OS" value={systemInfo?.os ?? '…'} /> <InfoCell label="OS" value={systemInfo?.os ?? '…'} />
<Divider /> <Divider />
<InfoCell label="NixPanel Version" value={version ? `v${version}` : '…'} /> <InfoCell label="NixPanel Version" value={version ? `v${version}` : '…'} />
<Divider /> <Divider />
<InfoCell label="Load Averages" value={loadStr} /> <InfoCell label="Load Averages" value={loadStr} />
<Divider /> <Divider />
<InfoCell label="Server Monitoring" value={monitoringNode} /> <InfoCell label="Server Monitoring" value={monitoringNode} />
{/* ── Day / Night toggle — far right ── */}
<div className="ml-auto flex items-center gap-2 flex-shrink-0">
<Moon
size={13}
className={theme === 'dark' ? 'text-indigo-400' : 'text-[#94a3b8]'}
/>
<button
onClick={toggleTheme}
title={theme === 'dark' ? 'Switch to day mode' : 'Switch to night mode'}
className={`relative flex items-center w-10 h-5 rounded-full transition-colors cursor-pointer flex-shrink-0 ${
theme === 'light' ? 'bg-amber-400' : 'bg-[#2a2d3e]'
}`}
>
<span
className={`absolute h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
theme === 'light' ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
<Sun
size={13}
className={theme === 'light' ? 'text-amber-500' : 'text-[#4a5568]'}
/>
</div>
</div> </div>
{/* ── Stat cards ───────────────────────────────────────────────────── */} {/* ── Stat cards ───────────────────────────────────────────────────── */}
@@ -210,8 +237,14 @@ export default function Dashboard() {
<XAxis dataKey="t" tick={{ fill: '#64748b', fontSize: 10 }} tickLine={false} axisLine={false} /> <XAxis dataKey="t" tick={{ fill: '#64748b', fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis domain={[0, 100]} tick={{ fill: '#64748b', fontSize: 10 }} tickLine={false} axisLine={false} /> <YAxis domain={[0, 100]} tick={{ fill: '#64748b', fontSize: 10 }} tickLine={false} axisLine={false} />
<Tooltip <Tooltip
contentStyle={{ background: '#1e2130', border: '1px solid #2a2d3e', borderRadius: 8, fontSize: 12 }} contentStyle={{
labelStyle={{ color: '#94a3b8' }} background: theme === 'light' ? '#ffffff' : '#1e2130',
border: `1px solid ${theme === 'light' ? '#e2e8f0' : '#2a2d3e'}`,
borderRadius: 8,
fontSize: 12,
color: theme === 'light' ? '#0f172a' : '#e2e8f0',
}}
labelStyle={{ color: theme === 'light' ? '#64748b' : '#94a3b8' }}
/> />
<Area type="monotone" dataKey="cpu" stroke="#6366f1" fill="url(#cpu)" strokeWidth={2} name="CPU %" /> <Area type="monotone" dataKey="cpu" stroke="#6366f1" fill="url(#cpu)" strokeWidth={2} name="CPU %" />
<Area type="monotone" dataKey="mem" stroke="#10b981" fill="url(#mem)" strokeWidth={2} name="Mem %" /> <Area type="monotone" dataKey="mem" stroke="#10b981" fill="url(#mem)" strokeWidth={2} name="Mem %" />
+11 -3
View File
@@ -1,7 +1,8 @@
import React, { useState } from 'react' import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { Shield } from 'lucide-react' import { Shield } from 'lucide-react'
import axios from 'axios'
export default function Login() { export default function Login() {
const { login } = useAuth() const { login } = useAuth()
@@ -10,6 +11,13 @@ export default function Login() {
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [version, setVersion] = useState<string | null>(null)
useEffect(() => {
axios.get('/api/health', { timeout: 5_000 })
.then(r => setVersion(r.data.version ?? null))
.catch(() => {})
}, [])
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -35,7 +43,7 @@ export default function Login() {
<Shield size={24} className="text-white" /> <Shield size={24} className="text-white" />
</div> </div>
<h1 className="text-white text-xl font-bold">NixServer</h1> <h1 className="text-white text-xl font-bold">NixServer</h1>
<p className="text-[#64748b] text-sm mt-1">Web Host Manager</p> <p className="text-[#64748b] text-sm mt-1">Admin Panel</p>
</div> </div>
{/* Form */} {/* Form */}
@@ -81,7 +89,7 @@ export default function Login() {
</form> </form>
<p className="text-center text-[#64748b] text-xs mt-4"> <p className="text-center text-[#64748b] text-xs mt-4">
NixServer v0.3.2 · Port 2087 NixServer{version ? ` v${version}` : ''} · Port 2087
</p> </p>
</div> </div>
</div> </div>
+101 -25
View File
@@ -1,49 +1,125 @@
import React, { useEffect, useState } from 'react' 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 { 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() { export default function Settings() {
const [settings, setSettings] = useState<Record<string, string>>({}) const [settings, setSettings] = useState<Record<string, string>>({})
const [interfaces, setInterfaces] = useState<string[]>([])
const [tab, setTab] = useState<Tab>('contact')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [error, setError] = useState('')
const fetch = async () => { const load = async () => {
setLoading(true) setLoading(true)
try { const r = await api.get('/nixserver/system/settings'); setSettings(r.data.data) } setError('')
finally { setLoading(false) } 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) => { const save = async (e: React.FormEvent) => {
e.preventDefault(); setSaving(true) e.preventDefault()
try { await api.put('/nixserver/system/settings', settings); setSaved(true); setTimeout(() => setSaved(false), 2000) } setSaving(true)
finally { setSaving(false) } 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 ( return (
<div className="space-y-4 max-w-2xl"> <div className="space-y-6 max-w-4xl">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div><h1 className="text-white text-xl font-semibold">Settings</h1><p className="text-[#64748b] text-sm">System-wide configuration</p></div> <div>
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button> <h1 className="text-white text-xl font-semibold">Basic NixServer Setup</h1>
<p className="text-[#64748b] text-sm mt-0.5">Configure server-wide defaults</p>
</div>
<button
type="button"
onClick={load}
className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors"
>
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
</button>
</div> </div>
<form onSubmit={save} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-6 space-y-3"> {error && (
{Object.entries(settings).map(([key, value]) => ( <div className="flex items-center gap-2 bg-red-900/20 border border-red-700/40 rounded-lg px-4 py-3 text-red-400 text-sm">
<div key={key} className="flex items-center gap-4"> <AlertTriangle size={14} className="flex-shrink-0" />
<label className="text-[#94a3b8] text-xs font-mono w-56 flex-shrink-0">{key}</label> {error}
<input </div>
value={value} )}
onChange={e => 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" <form onSubmit={save}>
/> {/* Tab bar */}
</div> <div className="flex border-b border-[#2a2d3e]">
))} {TABS.map(t => (
<div className="pt-4"> <button
<button type="submit" disabled={saving} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm px-4 py-2 rounded-lg"> key={t.id}
<Save size={14} /> {saving ? 'Saving...' : saved ? 'Saved!' : 'Save Settings'} type="button"
onClick={() => 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}
</button>
))}
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] border-t-0 rounded-b-xl px-6 py-2">
{tab === 'contact'
? <ContactTab settings={settings} onChange={handleChange} />
: <BasicTab settings={settings} onChange={handleChange} interfaces={interfaces} />
}
</div>
<NameserversSection settings={settings} onChange={handleChange} />
<div className="flex items-center gap-4 pt-4">
<button
type="submit"
disabled={saving || loading}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-medium px-5 py-2.5 rounded-lg transition-colors"
>
<Save size={14} />
{saving ? 'Saving…' : saved ? 'Saved!' : 'Save Changes'}
</button> </button>
{saved && <span className="text-green-400 text-sm">Settings saved successfully.</span>}
</div> </div>
</form> </form>
</div> </div>
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function Analytics() {
return (
<ComingSoon
title="Configure Analytics"
description="Enable and configure server-side analytics and statistics collection."
/>
)
}
@@ -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'
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function ChangeRoot() {
return (
<ComingSoon
title="Change Root Password"
description="Update the root account password for this server."
/>
)
}
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function CronJobs() {
return (
<ComingSoon
title="Configure Cron Jobs"
description="Schedule recurring system-level tasks and maintenance scripts."
/>
)
}
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function LinkNodes() {
return (
<ComingSoon
title="Link Server Nodes"
description="Connect additional server nodes for clustering or load distribution."
/>
)
}
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function Marketplace() {
return (
<ComingSoon
title="NixServer Marketplace"
description="Browse and install official extensions, plugins, and integrations."
/>
)
}
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function QuotaSetup() {
return (
<ComingSoon
title="Initial Quota Setup"
description="Configure disk quota enforcement and default account limits."
/>
)
}
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function ServerProfile() {
return (
<ComingSoon
title="Server Profile"
description="Set server-wide identity, locale, and branding preferences."
/>
)
}
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function ServerTime() {
return (
<ComingSoon
title="Server Time"
description="Configure the system clock, timezone, and NTP synchronization."
/>
)
}
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function StatisticsSoftware() {
return (
<ComingSoon
title="Statistics Software"
description="Install and configure AWStats, Webalizer, or other log analysis tools."
/>
)
}
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function Terminal() {
return (
<ComingSoon
title="Terminal"
description="Open a browser-based terminal session with server access."
/>
)
}
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function TweakSettings() {
return (
<ComingSoon
title="Tweak Settings"
description="Fine-tune advanced server behaviours and feature toggles."
/>
)
}
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function UpdatePreferences() {
return (
<ComingSoon
title="Update Preferences"
description="Control automatic update behaviour, channels, and scheduling."
/>
)
}
@@ -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 (
<div className="space-y-6">
<div>
<h1 className="text-white text-xl font-semibold">Server Configuration</h1>
<p className="text-[#64748b] text-sm mt-0.5">Server-wide settings and system configuration tools</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
{TOOLS.map(tool => (
<SectionCard key={tool.to} {...tool} />
))}
</div>
</div>
)
}
+130
View File
@@ -0,0 +1,130 @@
import { AlertTriangle } from 'lucide-react'
import { Field, TextInput, RadioGroup, get } from './FormControls'
interface Props {
settings: Record<string, string>
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 (
<div>
<Field label="Shared IPv4 Address" hint="ADDR — Main server IPv4 address">
<TextInput
value={get(settings, 'ADDR')}
onChange={v => onChange('ADDR', v)}
placeholder="192.168.1.100"
/>
</Field>
<Field label="Shared IPv6 Address" hint="ADDR6 — Leave blank if not using IPv6">
<TextInput
value={get(settings, 'ADDR6')}
onChange={v => onChange('ADDR6', v)}
placeholder="2001:db8::1"
/>
</Field>
<Field label="Ethernet Device" hint="ETHDEV — Network interface for IP configuration">
<div className="flex gap-2">
<select
value={isOther ? '__other__' : ethdev}
onChange={e => {
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"
>
<option value="">— select —</option>
{interfaces.map(iface => (
<option key={iface} value={iface}>{iface}</option>
))}
<option value="__other__">Other…</option>
</select>
{isOther && (
<TextInput
value={ethdev}
onChange={v => onChange('ETHDEV', v)}
placeholder="eth0"
/>
)}
</div>
</Field>
<Field label="Home Directory" hint="HOMEDIR — Default: /home">
<TextInput
value={get(settings, 'HOMEDIR')}
onChange={v => onChange('HOMEDIR', v)}
placeholder="/home"
/>
</Field>
<Field
label="Home Directory Prefix Match"
hint="HOMEMATCH — Must be present in home path. Default: home"
>
<TextInput
value={get(settings, 'HOMEMATCH')}
onChange={v => onChange('HOMEMATCH', v)}
placeholder="home"
/>
</Field>
<Field
label="Auto-Create CGI-bin Alias"
hint="SCRIPTALIAS — Automatically create a cgi-bin for new accounts"
>
<RadioGroup
value={get(settings, 'SCRIPTALIAS')}
onChange={v => onChange('SCRIPTALIAS', v)}
options={[
{ value: 'yes', label: 'Yes' },
{ value: 'no', label: 'No' },
]}
/>
</Field>
<Field label="DNS TTL" hint="TTL — Time-to-live for DNS records (seconds). Default: 14400">
<TextInput
value={get(settings, 'TTL')}
onChange={v => onChange('TTL', v)}
placeholder="14400"
/>
</Field>
<Field label="Nameserver TTL" hint="NSTTL — Time-to-live for NS records (seconds). Default: 86400">
<TextInput
value={get(settings, 'NSTTL')}
onChange={v => onChange('NSTTL', v)}
placeholder="86400"
/>
</Field>
<Field label="Apache Log Style" hint="LOGSTYLE — Log format used in Apache vhost configs">
<div className="space-y-2">
<RadioGroup
value={get(settings, 'LOGSTYLE')}
onChange={v => onChange('LOGSTYLE', v)}
options={[
{ value: 'combined', label: 'Combined' },
{ value: 'common', label: 'Common' },
]}
/>
{get(settings, 'LOGSTYLE') === 'common' && (
<div className="flex items-start gap-2 bg-yellow-900/20 border border-yellow-700/40 rounded-lg px-3 py-2 text-yellow-400 text-xs">
<AlertTriangle size={14} className="mt-0.5 flex-shrink-0" />
<span>
<strong>Warning:</strong> "Common" log format omits the referrer and user-agent
fields. "Combined" is recommended for most setups.
</span>
</div>
)}
</div>
</Field>
</div>
)
}
+107
View File
@@ -0,0 +1,107 @@
import { Field, TextInput, get } from './FormControls'
interface Props {
settings: Record<string, string>
onChange: (key: string, value: string) => void
}
export default function ContactTab({ settings, onChange }: Props) {
return (
<div>
<Field
label="Contact Email Address(es)"
hint="Separate multiple addresses with spaces. Used for system notifications."
>
<TextInput
value={get(settings, 'CONTACTEMAIL')}
onChange={v => onChange('CONTACTEMAIL', v)}
placeholder="[email protected]"
type="email"
/>
<p className="text-[#475569] text-xs mt-1">
Enter the email address(es) to which you would like server notification messages to be
sent. Separate multiple addresses with spaces.
</p>
</Field>
<Field label="Email Sender Name" hint="EMAILFROMNAME — Name shown in From: header">
<TextInput
value={get(settings, 'EMAILFROMNAME')}
onChange={v => onChange('EMAILFROMNAME', v)}
placeholder="NixPanel"
/>
</Field>
<Field label="Reply-To Email Address" hint="EMAILREPLYTO — Optional reply-to header">
<TextInput
value={get(settings, 'EMAILREPLYTO')}
onChange={v => onChange('EMAILREPLYTO', v)}
placeholder="[email protected]"
type="email"
/>
</Field>
<Field label="Pager / Cellular Email" hint="CONTACTPAGER — For critical alerts (e.g. SMS gateway)">
<TextInput
value={get(settings, 'CONTACTPAGER')}
onChange={v => onChange('CONTACTPAGER', v)}
placeholder="[email protected]"
/>
</Field>
<Field label="Pushbullet API Token" hint="CONTACTPUSHBULLET — Leave blank to disable">
<TextInput
value={get(settings, 'CONTACTPUSHBULLET')}
onChange={v => onChange('CONTACTPUSHBULLET', v)}
placeholder="o.xxxxxxxxxxxxxxxxxxxxxx"
type="password"
/>
</Field>
<Field label="Slack Webhook URL(s)" hint="CONTACTSLACK — One URL per line">
<textarea
value={get(settings, 'CONTACTSLACK')}
onChange={e => 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"
/>
</Field>
<Field label="POST Notification URL(s)" hint="CONTACTPOSTURL — One URL per line">
<textarea
value={get(settings, 'CONTACTPOSTURL')}
onChange={e => 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"
/>
</Field>
<Field label="ICQ User ID" hint="ICQUSER">
<TextInput
value={get(settings, 'ICQUSER')}
onChange={v => onChange('ICQUSER', v)}
placeholder="123456789"
/>
</Field>
<Field label="ICQ Password" hint="ICQPASS">
<TextInput
value={get(settings, 'ICQPASS')}
onChange={v => onChange('ICQPASS', v)}
placeholder="••••••••"
type="password"
/>
</Field>
<Field label="ICQ Contact UIDs" hint="CONTACTUIN — UIDs to notify (space-separated)">
<TextInput
value={get(settings, 'CONTACTUIN')}
onChange={v => onChange('CONTACTUIN', v)}
placeholder="123456789 987654321"
/>
</Field>
</div>
)
}
@@ -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 (
<div className="grid grid-cols-[280px_1fr] gap-4 items-start py-3 border-b border-[#1e2130] last:border-0">
<div>
<div className="text-[#cbd5e1] text-sm font-medium">{label}</div>
{hint && <div className="text-[#475569] text-xs mt-0.5">{hint}</div>}
</div>
<div>{children}</div>
</div>
)
}
export function TextInput({
value,
onChange,
placeholder,
type = 'text',
}: {
value: string
onChange: (v: string) => void
placeholder?: string
type?: string
}) {
return (
<input
type={type}
value={value}
onChange={e => 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 (
<div className="flex gap-6">
{options.map(opt => (
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
value={opt.value}
checked={value === opt.value}
onChange={() => onChange(opt.value)}
className="accent-indigo-500"
/>
<span className="text-[#cbd5e1] text-sm">{opt.label}</span>
</label>
))}
</div>
)
}
export const FIELD_DEFAULTS: Record<string, string> = {
HOMEDIR: '/home',
HOMEMATCH: 'home',
TTL: '14400',
NSTTL: '86400',
SCRIPTALIAS: 'yes',
LOGSTYLE: 'combined',
}
export function get(settings: Record<string, string>, key: string): string {
return settings[key] ?? FIELD_DEFAULTS[key] ?? ''
}
@@ -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<string, string> = {
NS: 'Nameserver 1',
NS2: 'Nameserver 2',
NS3: 'Nameserver 3 (optional)',
NS4: 'Nameserver 4 (optional)',
}
const NS_PLACEHOLDERS: Record<string, string> = {
NS: 'ns1.example.com',
NS2: 'ns2.example.com',
NS3: '',
NS4: '',
}
interface Props {
settings: Record<string, string>
onChange: (key: string, value: string) => void
}
export default function NameserversSection({ settings, onChange }: Props) {
return (
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden mt-6">
<div className="flex items-center gap-3 px-6 py-4 border-b border-[#2a2d3e]">
<Server size={16} className="text-indigo-400" />
<h2 className="text-white font-semibold">Nameservers</h2>
</div>
<div className="px-6 py-4">
{NS_KEYS.map(key => (
<div
key={key}
className="grid grid-cols-[280px_1fr_auto] gap-4 items-center py-3 border-b border-[#1e2130] last:border-0"
>
<div className="text-[#cbd5e1] text-sm font-medium">{NS_LABELS[key]}</div>
<TextInput
value={get(settings, key)}
onChange={v => onChange(key, v)}
placeholder={NS_PLACEHOLDERS[key]}
/>
<button
type="button"
onClick={() => {
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
</button>
</div>
))}
</div>
</div>
)
}
-6004
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nixpanel-server", "name": "nixpanel-server",
"version": "0.4.4", "version": "0.6.2",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"server", "server",
+8 -5
View File
@@ -82,6 +82,8 @@ if [[ -z "$ADMIN_EMAIL" ]]; then
ADMIN_EMAIL="admin@$(hostname -f 2>/dev/null || echo localhost)" ADMIN_EMAIL="admin@$(hostname -f 2>/dev/null || echo localhost)"
fi fi
# Redirect all interactive reads to /dev/tty so curl | bash works correctly
MARIADB_ROOT_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) MARIADB_ROOT_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
JWT_SECRET=$(gen_secret) JWT_SECRET=$(gen_secret)
JWT_REFRESH_SECRET=$(gen_secret) JWT_REFRESH_SECRET=$(gen_secret)
@@ -93,7 +95,7 @@ echo " Admin username : $ADMIN_USER"
echo " Admin email : $ADMIN_EMAIL" echo " Admin email : $ADMIN_EMAIL"
echo " Admin password : $([ "$GENERATED_PASS" = "true" ] && echo "<auto-generated>" || echo "$ADMIN_PASS")" echo " Admin password : $([ "$GENERATED_PASS" = "true" ] && echo "<auto-generated>" || echo "$ADMIN_PASS")"
echo "" echo ""
read -r -p "Continue? [Y/n] " CONFIRM read -r -p "Continue? [Y/n] " CONFIRM < /dev/tty || CONFIRM="Y"
CONFIRM="${CONFIRM:-Y}" CONFIRM="${CONFIRM:-Y}"
[[ "$CONFIRM" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; } [[ "$CONFIRM" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
@@ -547,7 +549,8 @@ success ".env written"
# ── npm install ─────────────────────────────────────────────────────────────── # ── npm install ───────────────────────────────────────────────────────────────
step "Installing Node.js dependencies" step "Installing Node.js dependencies"
cd "$INSTALL_DIR" cd "$INSTALL_DIR"
npm install --silent rm -rf node_modules package-lock.json
npm install
success "npm dependencies installed" success "npm dependencies installed"
# ── Prisma generate + db push ───────────────────────────────────────────────── # ── Prisma generate + db push ─────────────────────────────────────────────────
@@ -571,10 +574,10 @@ success "Admin account and default packages seeded"
# ── Build ───────────────────────────────────────────────────────────────────── # ── Build ─────────────────────────────────────────────────────────────────────
step "Building frontends and server" step "Building frontends and server"
cd "$INSTALL_DIR" cd "$INSTALL_DIR"
npm run build --workspace=nixserver --silent npm run build --workspace=nixserver
npm run build --workspace=nixclient --silent npm run build --workspace=nixclient
cd "$INSTALL_DIR/server" cd "$INSTALL_DIR/server"
npm run build --silent npm run build
success "Build complete" success "Build complete"
# ── Set web-readable permissions on frontend assets ─────────────────────────── # ── Set web-readable permissions on frontend assets ───────────────────────────
+11 -4
View File
@@ -14,12 +14,19 @@ INSTALL_DIR="${1:-/opt/nixpanel}"
[[ -d "$INSTALL_DIR/.git" ]] || error "NixPanel not found at $INSTALL_DIR" [[ -d "$INSTALL_DIR/.git" ]] || error "NixPanel not found at $INSTALL_DIR"
step "Pulling latest code" step "Pulling latest code"
# package-lock.json is not tracked (platform-specific), but discard any other
# locally modified tracked files that could block a fast-forward pull
git -C "$INSTALL_DIR" checkout -- . 2>/dev/null || true
git -C "$INSTALL_DIR" pull --ff-only git -C "$INSTALL_DIR" pull --ff-only
success "Code updated" success "Code updated"
step "Installing dependencies" step "Installing dependencies"
cd "$INSTALL_DIR" cd "$INSTALL_DIR"
npm install --workspaces --include-workspace-root --silent # Delete node_modules and any stale lock file so npm regenerates them fresh
# for this platform (avoids the rollup native binary mismatch between
# Windows dev machines and Linux servers)
rm -rf node_modules package-lock.json
npm install --workspaces --include-workspace-root
success "Dependencies ready" success "Dependencies ready"
step "Running migrations" step "Running migrations"
@@ -30,8 +37,8 @@ success "Migrations applied"
step "Rebuilding frontends" step "Rebuilding frontends"
cd "$INSTALL_DIR" cd "$INSTALL_DIR"
npm run build --workspace=nixserver --silent npm run build --workspace=nixserver
npm run build --workspace=nixclient --silent npm run build --workspace=nixclient
# Ensure Apache (www-data) can read the rebuilt assets # Ensure Apache (www-data) can read the rebuilt assets
chmod 755 "$INSTALL_DIR/nixserver" "$INSTALL_DIR/nixclient" chmod 755 "$INSTALL_DIR/nixserver" "$INSTALL_DIR/nixclient"
find "$INSTALL_DIR/nixserver/dist" -type d -exec chmod 755 {} \; find "$INSTALL_DIR/nixserver/dist" -type d -exec chmod 755 {} \;
@@ -42,7 +49,7 @@ success "Frontends rebuilt"
step "Rebuilding server" step "Rebuilding server"
cd "$INSTALL_DIR/server" cd "$INSTALL_DIR/server"
npm run build --silent 2>/dev/null || npx tsc 2>/dev/null || true npm run build 2>/dev/null || npx tsc 2>/dev/null || true
success "Server rebuilt" success "Server rebuilt"
step "Restarting service" step "Restarting service"
+2 -2
View File
@@ -60,7 +60,7 @@ export async function buildApp() {
// ─── Static files ──────────────────────────────────────────────────────────── // ─── Static files ────────────────────────────────────────────────────────────
// NixServer frontend // NixServer frontend
const whmDist = path.resolve(__dirname, '../../nixserver/dist') const nixserverDist = path.resolve(__dirname, '../../nixserver/dist')
const cpanelDist = path.resolve(__dirname, '../../nixclient/dist') const cpanelDist = path.resolve(__dirname, '../../nixclient/dist')
// ─── API Routes ────────────────────────────────────────────────────────────── // ─── API Routes ──────────────────────────────────────────────────────────────
@@ -73,7 +73,7 @@ export async function buildApp() {
fastify.get('/api/health', async () => ({ fastify.get('/api/health', async () => ({
status: 'ok', status: 'ok',
version: '0.4.4', version: '0.6.2',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
})) }))
+1 -1
View File
@@ -24,7 +24,7 @@ const ALLOWED_COMMANDS = new Set([
// Network // Network
'ss', 'ip', 'hostname', 'ss', 'ip', 'hostname',
// System // System
'uname', 'free', 'ps', 'uname', 'uptime', 'free', 'ps', 'kill',
// PHP // PHP
'php8.2', 'php8.3', 'php8.2', 'php8.3',
// Fail2ban // Fail2ban
-671
View File
@@ -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<string, string>
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<string, unknown> = {}
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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = {}
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<string, string>) ?? {}
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<string, boolean>) ?? {}
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 })
})
}
@@ -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 })
})
}
@@ -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)
}
@@ -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<string, string>
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<string, unknown> = {}
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<string, unknown> = { 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 })
})
}
@@ -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<string, unknown> = { 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<string, unknown> = {}
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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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 } })
})
}
@@ -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<string, string>) ?? {}
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<string, boolean>) ?? {}
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 })
})
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from 'fastify' import type { FastifyInstance } from 'fastify'
import accountRoutes from './accounts.js' import accountRoutes from './accounts/index.js'
import packageRoutes from './packages.js' import packageRoutes from './packages.js'
import resellerRoutes from './resellers.js' import resellerRoutes from './resellers.js'
import dnsRoutes from './dns.js' import dnsRoutes from './dns.js'
+14
View File
@@ -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 // GET /api/nixserver/system/settings
fastify.get('/settings', { preHandler: [requireAdmin] }, async (request, reply) => { fastify.get('/settings', { preHandler: [requireAdmin] }, async (request, reply) => {
const settings = await prisma.setting.findMany({ orderBy: { key: 'asc' } }) const settings = await prisma.setting.findMany({ orderBy: { key: 'asc' } })