Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b369d707d7 | ||
|
|
ba9e00d65b | ||
|
|
aa9998cbd8 | ||
|
|
f9352617b0 | ||
|
|
17b0becc82 | ||
|
|
501f94b7e8 | ||
|
|
3902935f60 | ||
|
|
5c98ac0ee6 | ||
|
|
6dba99d164 | ||
|
|
c5498994c3 | ||
|
|
da55367039 | ||
|
|
55786bfa0e | ||
|
|
70908745bd | ||
|
|
0362810681 | ||
|
|
9ec39c9120 | ||
|
|
b7eb76d91a | ||
|
|
5af2762634 | ||
|
|
ddb51a8b4d | ||
|
|
4de2a7fa8e | ||
|
|
90dbf94c18 | ||
|
|
146c17abd8 | ||
|
|
47ce924a90 | ||
|
|
82b9ac30e9 | ||
|
|
c6f13f2e07 | ||
|
|
945da97c97 | ||
|
|
a0e3fc7b1e | ||
|
|
da576bdbdc | ||
|
|
489755136c | ||
|
|
2911cab755 | ||
|
|
8d6fe60168 | ||
|
|
6e59552e4f | ||
|
|
eebcce027b | ||
|
|
87edf902a1 | ||
|
|
d235d27fe3 |
+9
-4
@@ -6,15 +6,20 @@ dist/
|
||||
.DS_Store
|
||||
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
|
||||
server/prisma/dev.db
|
||||
server/prisma/dev.db-journal
|
||||
|
||||
# Build outputs
|
||||
server/dist/
|
||||
whm/dist/
|
||||
cpanel/dist/
|
||||
nixserver/dist/
|
||||
nixclient/dist/
|
||||
|
||||
# Vite
|
||||
whm/.vite/
|
||||
cpanel/.vite/
|
||||
nixserver/.vite/
|
||||
nixclient/.vite/
|
||||
|
||||
+155
@@ -6,6 +6,161 @@ Pre-1.0 versions treat MINOR as feature releases and PATCH as bug fixes.
|
||||
|
||||
---
|
||||
|
||||
## [0.6.6] — 2026-04-12
|
||||
|
||||
### Bug Fixes
|
||||
- **`seed.ts` skips admin on re-run** — `findUnique` + `create` meant re-running the seed after a failed install left the admin password in `.env` out of sync with the hashed password in the database. Changed to `upsert` so the seed always writes the current `.env` password, making re-runs safe.
|
||||
|
||||
---
|
||||
|
||||
## [0.6.5] — 2026-04-12
|
||||
|
||||
### Bug Fixes
|
||||
- **`install.sh` regenerates secrets on re-run** — `JWT_SECRET`, `JWT_REFRESH_SECRET`, and `MARIADB_ROOT_PASS` were always generated fresh, so re-running the installer after a failed build overwrote `.env` with secrets that no longer matched the already-configured MariaDB password. The installer now reads existing values from `.env` (if present) and only generates new secrets when none exist.
|
||||
|
||||
---
|
||||
|
||||
## [0.6.4] — 2026-04-12
|
||||
|
||||
### Bug Fixes
|
||||
- **`SectionCard.tsx` TypeScript build error** — `icon` prop typed as `ComponentType<{ size?: number }>` but Lucide icons declare `size?: string | number`. Widened the prop type to `size?: number | string` to match Lucide's `LucideProps`.
|
||||
|
||||
---
|
||||
|
||||
## [0.6.3] — 2026-04-12
|
||||
|
||||
### Bug Fixes
|
||||
- **`install.sh` aborts immediately when piped via `curl | bash`** — Interactive confirmation prompt (`read`) received EOF from the pipe and exited non-zero, causing `set -euo pipefail` to kill the script before installation began. Replaced the prompt entirely with a 5-second countdown (`sleep 5`) so the installer works correctly whether piped or run from a file.
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
### New Features
|
||||
- **Server info bar on Dashboard** — cPanel/WHM-style info strip at the top of the NixPanel home page. Shows Username, Hostname, OS, NixPanel Version, Load Averages (live from WebSocket), and Server Monitoring alert count. Alerts are colour-coded: green "All Clear" when all managed services are running; amber "N Alerts" with a count of stopped/failed services.
|
||||
|
||||
---
|
||||
|
||||
## [0.4.3] — 2026-04-11
|
||||
|
||||
### New Features
|
||||
- **Version display in sidebar** — the sidebar footer shows the currently installed version (e.g. `v0.4.3`), fetched live from the API. A green checkmark indicates up to date; an amber alert indicates an update is available.
|
||||
- **One-click panel upgrade** — "Upgrade panel" button in the sidebar footer. When a newer version is available it highlights amber and shows the target version. Clicking requires a confirmation step before running. Calls `POST /api/nixserver/system/upgrade` which fires `scripts/update.sh` in the background (git pull → npm install → prisma migrate → build frontends + server → restart service). The panel comes back online automatically after ~60 s.
|
||||
- **Check for updates button** — small refresh icon next to the version badge re-fetches version info on demand.
|
||||
- **Backend: `GET /api/nixserver/system/version`** — returns `currentVersion` (read from `/opt/nixpanel/package.json`), `latestVersion` (fetched from GitHub raw content with a 5 s timeout), and `updateAvailable` boolean.
|
||||
- **Backend: `POST /api/nixserver/system/upgrade`** — admin-only; responds immediately then spawns `scripts/update.sh` detached so the HTTP response is flushed before the service restarts.
|
||||
|
||||
---
|
||||
|
||||
## [0.4.2] — 2026-04-11
|
||||
|
||||
### Changes
|
||||
- **NixServer sidebar redesigned** — replaced the flat 14-item nav with a full WHM-style collapsible category sidebar (29 categories, ~210 menu items). Categories fold/unfold on click; the active route's category auto-expands on navigation. A live search box at the top of the sidebar filters items across all categories and auto-expands matching results. Sidebar can be hidden entirely via the topbar toggle. All existing routes are wired to their correct menu items; future feature items route to the closest related hub page.
|
||||
|
||||
---
|
||||
|
||||
## [0.4.1] — 2026-04-11
|
||||
|
||||
### Bug Fixes (Installer)
|
||||
- **SpamAssassin service name**: On Ubuntu 24.04 the service unit is `spamd`, not `spamassassin`. The old `systemctl enable spamassassin` caused the install to abort due to `set -euo pipefail`.
|
||||
- **PowerDNS port 53 conflict**: `systemd-resolved` stub listener now disabled in a dedicated step that runs *before* package installation. Previously it ran after apt had already tried (and failed) to restart `pdns` during post-install because port 53 was still held by `systemd-resolved`.
|
||||
- **Git clone into pre-existing directory**: The directory structure step (`mkdir -p`) creates `$INSTALL_DIR` before the clone step, causing `git clone` to fail with "destination path already exists". Fixed by cloning to a temp directory and copying contents into place (preserving any existing `data/` and `logs/` subdirectories).
|
||||
- **Apache 403 on frontend assets**: Frontend dist files were created with root-only permissions. Added a post-build step that sets 755/644 on all dist directories and files so Apache (`www-data`) can read them.
|
||||
- **Apache 500 on all API requests**: `mod_proxy_http` was not enabled. Without it, `ProxyPass http://...` directives silently fail and Apache returns 500 for every API call. Also enabled `mod_proxy_wstunnel` (required for the `ws://` WebSocket reverse proxy).
|
||||
|
||||
---
|
||||
|
||||
## [0.4.0] — 2026-04-11
|
||||
|
||||
### New Features
|
||||
|
||||
@@ -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.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nixpanel/cpanel",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -10,14 +10,14 @@ export default function SSL() {
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try { const r = await api.get('/nixserver/ssl'); setCerts(r.data.data) }
|
||||
try { const r = await api.get('/nixclient/ssl'); setCerts(r.data.data) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const renew = async (id: number) => {
|
||||
try { await api.post(`/nixserver/ssl/${id}/renew`); fetch() }
|
||||
try { await api.post(`/nixclient/ssl/${id}/renew`); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Renewal failed') }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nixpanel/whm",
|
||||
"version": "0.4.0",
|
||||
"name": "@nixpanel/nixserver",
|
||||
"version": "0.4.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+68
-3
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import Layout from './components/Layout'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
@@ -22,6 +23,22 @@ const System = React.lazy(() => import('./pages/System'))
|
||||
const WebServer = React.lazy(() => import('./pages/WebServer'))
|
||||
const Settings = React.lazy(() => import('./pages/Settings'))
|
||||
|
||||
// Server Configuration section
|
||||
const ServerConfiguration = React.lazy(() => import('./pages/server-configuration/index'))
|
||||
const SCBasicSetup = React.lazy(() => import('./pages/server-configuration/BasicSetup'))
|
||||
const SCChangeRoot = React.lazy(() => import('./pages/server-configuration/ChangeRoot'))
|
||||
const SCAnalytics = React.lazy(() => import('./pages/server-configuration/Analytics'))
|
||||
const SCCronJobs = React.lazy(() => import('./pages/server-configuration/CronJobs'))
|
||||
const SCQuotaSetup = React.lazy(() => import('./pages/server-configuration/QuotaSetup'))
|
||||
const SCLinkNodes = React.lazy(() => import('./pages/server-configuration/LinkNodes'))
|
||||
const SCServerProfile = React.lazy(() => import('./pages/server-configuration/ServerProfile'))
|
||||
const SCServerTime = React.lazy(() => import('./pages/server-configuration/ServerTime'))
|
||||
const SCStatisticsSoftware = React.lazy(() => import('./pages/server-configuration/StatisticsSoftware'))
|
||||
const SCTerminal = React.lazy(() => import('./pages/server-configuration/Terminal'))
|
||||
const SCTweakSettings = React.lazy(() => import('./pages/server-configuration/TweakSettings'))
|
||||
const SCUpdatePreferences = React.lazy(() => import('./pages/server-configuration/UpdatePreferences'))
|
||||
const SCMarketplace = React.lazy(() => import('./pages/server-configuration/Marketplace'))
|
||||
|
||||
// Accounts sub-pages
|
||||
const AccountList = React.lazy(() => import('./pages/accounts/List'))
|
||||
const AccountParkedDomains = React.lazy(() => import('./pages/accounts/ParkedDomains'))
|
||||
@@ -180,6 +197,52 @@ function AppRoutes() {
|
||||
<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 */}
|
||||
<Route path="packages" element={<Packages />} />
|
||||
<Route path="resellers" element={
|
||||
@@ -233,9 +296,11 @@ function PageLoader() {
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,103 +1,18 @@
|
||||
import { useState } from 'react'
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard, Users, Package, UserCog, Globe, Mail, Shield,
|
||||
Server, HardDrive, Settings, LogOut, Menu, X,
|
||||
Activity, Lock, BarChart3, Cpu
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const nav = [
|
||||
{ label: 'Dashboard', to: '/', icon: LayoutDashboard },
|
||||
{ label: 'Accounts', to: '/accounts', icon: Users },
|
||||
{ label: 'Packages', to: '/packages', icon: Package },
|
||||
{ label: 'Resellers', to: '/resellers', icon: UserCog },
|
||||
{ label: 'DNS', to: '/dns', icon: Globe },
|
||||
{ label: 'Email', to: '/email', icon: Mail },
|
||||
{ label: 'SSL', to: '/ssl', icon: Lock },
|
||||
{ label: 'PHP', to: '/php', icon: Cpu },
|
||||
{ label: 'Web Server', to: '/webserver', icon: Server },
|
||||
{ label: 'Services', to: '/services', icon: Activity },
|
||||
{ label: 'Backup', to: '/backup', icon: HardDrive },
|
||||
{ label: 'Security', to: '/security', icon: Shield },
|
||||
{ label: 'System', to: '/system', icon: BarChart3 },
|
||||
{ label: 'Settings', to: '/settings', icon: Settings },
|
||||
]
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Menu, X } from 'lucide-react'
|
||||
import Sidebar from './Sidebar'
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-[#0f1117]">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`flex flex-col transition-all duration-200 bg-[#1a1d27] border-r border-[#2a2d3e] ${
|
||||
sidebarOpen ? 'w-56' : 'w-14'
|
||||
}`}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2 px-4 py-4 border-b border-[#2a2d3e] min-h-[57px]">
|
||||
<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>
|
||||
{sidebarOpen && (
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-white text-sm font-semibold leading-tight">NixServer</p>
|
||||
<p className="text-[#64748b] text-xs">Admin Panel</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Sidebar open={sidebarOpen} onToggle={() => setSidebarOpen(v => !v)} />
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 overflow-y-auto py-2">
|
||||
{nav.map(({ label, to, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-2 mx-2 my-0.5 rounded-lg text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-indigo-600/20 text-indigo-400'
|
||||
: 'text-[#94a3b8] hover:bg-[#1e2130] hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon size={16} className="flex-shrink-0" />
|
||||
{sidebarOpen && <span className="truncate">{label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User + logout */}
|
||||
<div className="border-t border-[#2a2d3e] p-3">
|
||||
{sidebarOpen && (
|
||||
<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-sm"
|
||||
>
|
||||
<LogOut size={16} className="flex-shrink-0" />
|
||||
{sidebarOpen && <span>Logout</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
{/* Topbar */}
|
||||
<header className="flex items-center gap-4 px-6 py-3 bg-[#1a1d27] border-b border-[#2a2d3e] min-h-[57px]">
|
||||
<header className="flex items-center gap-4 px-6 py-3 bg-[#1a1d27] border-b border-[#2a2d3e] min-h-[57px] flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(v => !v)}
|
||||
className="text-[#64748b] hover:text-white transition-colors"
|
||||
@@ -110,7 +25,7 @@ export default function Layout() {
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
@@ -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 | string; 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -45,3 +45,45 @@ body {
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
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; }
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import { Users, Server, HardDrive, Activity } 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 { api } from '../api/client'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
|
||||
interface SystemStats {
|
||||
cpu: number
|
||||
@@ -18,6 +20,17 @@ interface AccountStats {
|
||||
terminated: number
|
||||
}
|
||||
|
||||
interface SystemInfo {
|
||||
hostname: string
|
||||
os: string
|
||||
}
|
||||
|
||||
interface ServiceStatus {
|
||||
name: string
|
||||
status: 'running' | 'stopped' | 'failed' | 'unknown'
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface StatCard {
|
||||
label: string
|
||||
value: string | number
|
||||
@@ -31,15 +44,52 @@ const formatBytes = (bytes: number) => {
|
||||
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / 1024 / 1024).toFixed(0)} MB`
|
||||
}
|
||||
|
||||
// ─── Server Info Bar ─────────────────────────────────────────────────────────
|
||||
|
||||
interface InfoCellProps {
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
}
|
||||
|
||||
function InfoCell({ label, value }: InfoCellProps) {
|
||||
return (
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-[#4a5568] text-[10px] font-medium uppercase tracking-wide leading-none mb-0.5">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[#94a3b8] text-xs font-medium leading-none truncate">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div className="w-px h-8 bg-[#2a2d3e] flex-shrink-0" />
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user } = useAuth()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const [stats, setStats] = useState<SystemStats | null>(null)
|
||||
const [accountStats, setAccountStats] = useState<AccountStats | null>(null)
|
||||
const [cpuHistory, setCpuHistory] = useState<{ t: string; cpu: number; mem: number }[]>([])
|
||||
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
|
||||
const [version, setVersion] = useState<string | null>(null)
|
||||
const [alertCount, setAlertCount] = useState<number | null>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/nixserver/accounts/stats').then(r => setAccountStats(r.data.data)).catch(() => {})
|
||||
api.get('/nixserver/system/stats').then(r => setStats(r.data.data)).catch(() => {})
|
||||
api.get('/nixserver/system/info').then(r => setSystemInfo(r.data.data)).catch(() => {})
|
||||
api.get('/nixserver/system/version').then(r => setVersion(r.data.data?.currentVersion ?? null)).catch(() => {})
|
||||
api.get('/nixserver/services').then(r => {
|
||||
const services: ServiceStatus[] = r.data.data ?? []
|
||||
setAlertCount(services.filter(s => s.status !== 'running').length)
|
||||
}).catch(() => { setAlertCount(0) })
|
||||
|
||||
// WebSocket live stats
|
||||
const token = localStorage.getItem('access_token')
|
||||
@@ -92,14 +142,69 @@ export default function Dashboard() {
|
||||
},
|
||||
]
|
||||
|
||||
const loadStr = stats
|
||||
? `${stats.load[0].toFixed(2)}\u00a0\u00a0${stats.load[1].toFixed(2)}\u00a0\u00a0${stats.load[2].toFixed(2)}`
|
||||
: '…'
|
||||
|
||||
const monitoringNode =
|
||||
alertCount === null ? (
|
||||
<span className="text-[#4a5568]">…</span>
|
||||
) : alertCount === 0 ? (
|
||||
<span className="flex items-center gap-1 text-emerald-400">
|
||||
<CheckCircle2 size={11} />
|
||||
All Clear
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-amber-400">
|
||||
<AlertTriangle size={11} />
|
||||
{alertCount} Alert{alertCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Dashboard</h1>
|
||||
<p className="text-[#64748b] text-sm mt-0.5">Server overview and real-time metrics</p>
|
||||
|
||||
{/* ── 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">
|
||||
<InfoCell label="Username" value={user?.username ?? '…'} />
|
||||
<Divider />
|
||||
<InfoCell label="Hostname" value={systemInfo?.hostname ?? '…'} />
|
||||
<Divider />
|
||||
<InfoCell label="OS" value={systemInfo?.os ?? '…'} />
|
||||
<Divider />
|
||||
<InfoCell label="NixPanel Version" value={version ? `v${version}` : '…'} />
|
||||
<Divider />
|
||||
<InfoCell label="Load Averages" value={loadStr} />
|
||||
<Divider />
|
||||
<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>
|
||||
|
||||
{/* Stat cards */}
|
||||
{/* ── Stat cards ───────────────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{cards.map(card => (
|
||||
<div key={card.label} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
|
||||
@@ -113,7 +218,7 @@ export default function Dashboard() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CPU / Memory chart */}
|
||||
{/* ── CPU / Memory chart ───────────────────────────────────────────── */}
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
|
||||
<h2 className="text-white text-sm font-medium mb-4">CPU & Memory — Live</h2>
|
||||
{cpuHistory.length > 0 ? (
|
||||
@@ -132,8 +237,14 @@ export default function Dashboard() {
|
||||
<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} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#1e2130', border: '1px solid #2a2d3e', borderRadius: 8, fontSize: 12 }}
|
||||
labelStyle={{ color: '#94a3b8' }}
|
||||
contentStyle={{
|
||||
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="mem" stroke="#10b981" fill="url(#mem)" strokeWidth={2} name="Mem %" />
|
||||
@@ -146,15 +257,15 @@ export default function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Account status breakdown */}
|
||||
{/* ── Account status breakdown ─────────────────────────────────────── */}
|
||||
{accountStats && (
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
|
||||
<h2 className="text-white text-sm font-medium mb-4">Account Status</h2>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: 'Active', value: accountStats.active, color: 'text-emerald-400' },
|
||||
{ label: 'Suspended', value: accountStats.suspended, color: 'text-amber-400' },
|
||||
{ label: 'Terminated', value: accountStats.terminated, color: 'text-red-400' },
|
||||
{ label: 'Active', value: accountStats.active, color: 'text-emerald-400' },
|
||||
{ label: 'Suspended', value: accountStats.suspended, color: 'text-amber-400' },
|
||||
{ label: 'Terminated', value: accountStats.terminated, color: 'text-red-400' },
|
||||
].map(s => (
|
||||
<div key={s.label} className="text-center">
|
||||
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { Shield } from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth()
|
||||
@@ -10,6 +11,13 @@ export default function Login() {
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
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) => {
|
||||
e.preventDefault()
|
||||
@@ -35,7 +43,7 @@ export default function Login() {
|
||||
<Shield size={24} className="text-white" />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Form */}
|
||||
@@ -81,7 +89,7 @@ export default function Login() {
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[#64748b] text-xs mt-4">
|
||||
NixServer v0.3.2 · Port 2087
|
||||
NixServer{version ? ` v${version}` : ''} · Port 2087
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,49 +1,125 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Save, RefreshCw } from 'lucide-react'
|
||||
import { Save, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
import ContactTab from './settings/ContactTab'
|
||||
import BasicTab from './settings/BasicTab'
|
||||
import NameserversSection from './settings/NameserversSection'
|
||||
|
||||
type Tab = 'contact' | 'basic'
|
||||
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: 'contact', label: 'Contact Information' },
|
||||
{ id: 'basic', label: 'Basic Config' },
|
||||
]
|
||||
|
||||
export default function Settings() {
|
||||
const [settings, setSettings] = useState<Record<string, string>>({})
|
||||
const [interfaces, setInterfaces] = useState<string[]>([])
|
||||
const [tab, setTab] = useState<Tab>('contact')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const fetch = async () => {
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try { const r = await api.get('/nixserver/system/settings'); setSettings(r.data.data) }
|
||||
finally { setLoading(false) }
|
||||
setError('')
|
||||
try {
|
||||
const [settingsRes, ifaceRes] = await Promise.allSettled([
|
||||
api.get('/nixserver/system/settings'),
|
||||
api.get('/nixserver/system/network-interfaces'),
|
||||
])
|
||||
if (settingsRes.status === 'fulfilled') setSettings(settingsRes.value.data.data)
|
||||
if (ifaceRes.status === 'fulfilled') setInterfaces(ifaceRes.value.data.data ?? [])
|
||||
} catch {
|
||||
setError('Failed to load settings')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const handleChange = (key: string, value: string) =>
|
||||
setSettings(s => ({ ...s, [key]: value }))
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true)
|
||||
try { await api.put('/nixserver/system/settings', settings); setSaved(true); setTimeout(() => setSaved(false), 2000) }
|
||||
finally { setSaving(false) }
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.put('/nixserver/system/settings', settings)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2500)
|
||||
} catch {
|
||||
setError('Failed to save settings')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<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>
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<form onSubmit={save} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-6 space-y-3">
|
||||
{Object.entries(settings).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-4">
|
||||
<label className="text-[#94a3b8] text-xs font-mono w-56 flex-shrink-0">{key}</label>
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-4">
|
||||
<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">
|
||||
<Save size={14} /> {saving ? 'Saving...' : saved ? 'Saved!' : 'Save Settings'}
|
||||
{error && (
|
||||
<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">
|
||||
<AlertTriangle size={14} className="flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={save}>
|
||||
{/* Tab bar */}
|
||||
<div className="flex border-b border-[#2a2d3e]">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
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>
|
||||
{saved && <span className="text-green-400 text-sm">Settings saved successfully.</span>}
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Generated
-6004
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nixpanel-server",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.6",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"server",
|
||||
|
||||
+54
-20
@@ -82,9 +82,21 @@ if [[ -z "$ADMIN_EMAIL" ]]; then
|
||||
ADMIN_EMAIL="admin@$(hostname -f 2>/dev/null || echo localhost)"
|
||||
fi
|
||||
|
||||
MARIADB_ROOT_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
|
||||
JWT_SECRET=$(gen_secret)
|
||||
JWT_REFRESH_SECRET=$(gen_secret)
|
||||
# ── Preserve secrets across re-runs ──────────────────────────────────────────
|
||||
# If .env already exists, reuse the same secrets so a failed-and-retried
|
||||
# install doesn't leave MariaDB with a password that doesn't match .env.
|
||||
_EXISTING_ENV="${INSTALL_DIR}/server/.env"
|
||||
if [[ -f "$_EXISTING_ENV" ]]; then
|
||||
_read_env() { grep -m1 "^$1=" "$_EXISTING_ENV" 2>/dev/null | cut -d= -f2- | tr -d '"'; }
|
||||
MARIADB_ROOT_PASS=$(_read_env MARIADB_PASSWORD)
|
||||
JWT_SECRET=$(_read_env JWT_SECRET)
|
||||
JWT_REFRESH_SECRET=$(_read_env JWT_REFRESH_SECRET)
|
||||
fi
|
||||
|
||||
# Fill in any values that weren't in the existing .env (or first install)
|
||||
MARIADB_ROOT_PASS=${MARIADB_ROOT_PASS:-$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)}
|
||||
JWT_SECRET=${JWT_SECRET:-$(gen_secret)}
|
||||
JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET:-$(gen_secret)}
|
||||
|
||||
# ── Confirm ───────────────────────────────────────────────────────────────────
|
||||
echo -e "${BOLD}Installation summary:${RESET}"
|
||||
@@ -93,9 +105,8 @@ echo " Admin username : $ADMIN_USER"
|
||||
echo " Admin email : $ADMIN_EMAIL"
|
||||
echo " Admin password : $([ "$GENERATED_PASS" = "true" ] && echo "<auto-generated>" || echo "$ADMIN_PASS")"
|
||||
echo ""
|
||||
read -r -p "Continue? [Y/n] " CONFIRM
|
||||
CONFIRM="${CONFIRM:-Y}"
|
||||
[[ "$CONFIRM" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
|
||||
echo " Starting in 5 seconds — press Ctrl+C to abort."
|
||||
sleep 5
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────────────
|
||||
mkdir -p /var/log/nixpanel
|
||||
@@ -146,6 +157,18 @@ if ! step_done "nodejs"; then
|
||||
fi
|
||||
success "Node.js $(node --version) ready"
|
||||
|
||||
# ── Disable systemd-resolved stub listener (frees port 53 for PowerDNS) ─────
|
||||
step "Freeing port 53 for PowerDNS"
|
||||
if ! step_done "resolved_stub"; then
|
||||
# Ubuntu 24.04 runs systemd-resolved with a stub listener on 127.0.0.53:53.
|
||||
# PowerDNS's apt post-install restart fails if this is still active.
|
||||
sed -i 's/#DNSStubListener=yes/DNSStubListener=no/' /etc/systemd/resolved.conf 2>/dev/null || true
|
||||
sed -i 's/^DNSStubListener=yes/DNSStubListener=no/' /etc/systemd/resolved.conf 2>/dev/null || true
|
||||
systemctl restart systemd-resolved 2>/dev/null || true
|
||||
mark_done "resolved_stub"
|
||||
fi
|
||||
success "Port 53 freed"
|
||||
|
||||
# ── Install all services in one pass ─────────────────────────────────────────
|
||||
step "Installing web stack (Apache, PHP, Exim, Dovecot, PowerDNS, MariaDB)"
|
||||
if ! step_done "packages"; then
|
||||
@@ -232,7 +255,7 @@ success "Directories created"
|
||||
step "Configuring Apache"
|
||||
if ! step_done "apache"; then
|
||||
# Enable required modules
|
||||
a2enmod rewrite ssl proxy proxy_fcgi headers expires deflate 2>/dev/null || true
|
||||
a2enmod rewrite ssl proxy proxy_http proxy_wstunnel proxy_fcgi headers expires deflate 2>/dev/null || true
|
||||
|
||||
# Disable default vhost
|
||||
a2dissite 000-default 2>/dev/null || true
|
||||
@@ -344,10 +367,6 @@ setuid=pdns
|
||||
setgid=pdns
|
||||
PDNS
|
||||
|
||||
# Disable systemd-resolved on port 53 to avoid conflict
|
||||
sed -i 's/#DNSStubListener=yes/DNSStubListener=no/' /etc/systemd/resolved.conf 2>/dev/null || true
|
||||
systemctl restart systemd-resolved 2>/dev/null || true
|
||||
|
||||
systemctl enable pdns
|
||||
systemctl start pdns
|
||||
|
||||
@@ -431,10 +450,9 @@ success "Dovecot configured"
|
||||
# ── Configure SpamAssassin ────────────────────────────────────────────────────
|
||||
step "Configuring SpamAssassin"
|
||||
if ! step_done "spamassassin"; then
|
||||
sed -i 's/ENABLED=0/ENABLED=1/' /etc/default/spamassassin 2>/dev/null || true
|
||||
sed -i 's/CRON=0/CRON=1/' /etc/default/spamassassin 2>/dev/null || true
|
||||
systemctl enable spamassassin
|
||||
systemctl start spamassassin
|
||||
# Ubuntu 24.04: service unit is 'spamd', not 'spamassassin'
|
||||
systemctl enable spamd
|
||||
systemctl start spamd
|
||||
mark_done "spamassassin"
|
||||
fi
|
||||
success "SpamAssassin configured"
|
||||
@@ -475,7 +493,12 @@ if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||
git -C "$INSTALL_DIR" pull --ff-only
|
||||
else
|
||||
info "Cloning repository into $INSTALL_DIR..."
|
||||
git clone https://github.com/NixReaper/nixpanel-server.git "$INSTALL_DIR"
|
||||
# The directory may already exist (created by the mkdir step above).
|
||||
# Clone to a temp dir then copy contents in so data/ and logs/ are preserved.
|
||||
TMP_CLONE=$(mktemp -d)
|
||||
git clone https://github.com/NixReaper/nixpanel-server.git "$TMP_CLONE"
|
||||
cp -a "$TMP_CLONE/." "$INSTALL_DIR/"
|
||||
rm -rf "$TMP_CLONE"
|
||||
fi
|
||||
success "NixPanel source ready at $INSTALL_DIR"
|
||||
|
||||
@@ -535,7 +558,8 @@ success ".env written"
|
||||
# ── npm install ───────────────────────────────────────────────────────────────
|
||||
step "Installing Node.js dependencies"
|
||||
cd "$INSTALL_DIR"
|
||||
npm install --silent
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
success "npm dependencies installed"
|
||||
|
||||
# ── Prisma generate + db push ─────────────────────────────────────────────────
|
||||
@@ -559,12 +583,22 @@ success "Admin account and default packages seeded"
|
||||
# ── Build ─────────────────────────────────────────────────────────────────────
|
||||
step "Building frontends and server"
|
||||
cd "$INSTALL_DIR"
|
||||
npm run build --workspace=nixserver --silent
|
||||
npm run build --workspace=nixclient --silent
|
||||
npm run build --workspace=nixserver
|
||||
npm run build --workspace=nixclient
|
||||
cd "$INSTALL_DIR/server"
|
||||
npm run build --silent
|
||||
npm run build
|
||||
success "Build complete"
|
||||
|
||||
# ── Set web-readable permissions on frontend assets ───────────────────────────
|
||||
step "Setting frontend file permissions"
|
||||
# Apache runs as www-data — ensure it can traverse parents and read dist files
|
||||
chmod 755 "$INSTALL_DIR" "$INSTALL_DIR/nixserver" "$INSTALL_DIR/nixclient"
|
||||
find "$INSTALL_DIR/nixserver/dist" -type d -exec chmod 755 {} \;
|
||||
find "$INSTALL_DIR/nixserver/dist" -type f -exec chmod 644 {} \;
|
||||
find "$INSTALL_DIR/nixclient/dist" -type d -exec chmod 755 {} \;
|
||||
find "$INSTALL_DIR/nixclient/dist" -type f -exec chmod 644 {} \;
|
||||
success "File permissions set"
|
||||
|
||||
# ── systemd service ───────────────────────────────────────────────────────────
|
||||
step "Creating systemd service"
|
||||
cat > /etc/systemd/system/nixpanel.service <<UNIT
|
||||
|
||||
+19
-6
@@ -14,29 +14,42 @@ INSTALL_DIR="${1:-/opt/nixpanel}"
|
||||
[[ -d "$INSTALL_DIR/.git" ]] || error "NixPanel not found at $INSTALL_DIR"
|
||||
|
||||
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
|
||||
success "Code updated"
|
||||
|
||||
step "Installing dependencies"
|
||||
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"
|
||||
|
||||
step "Running migrations"
|
||||
cd "$INSTALL_DIR/server"
|
||||
npx prisma generate --silent
|
||||
npx prisma migrate deploy
|
||||
npx prisma generate
|
||||
npx prisma db push
|
||||
success "Migrations applied"
|
||||
|
||||
step "Rebuilding frontends"
|
||||
cd "$INSTALL_DIR"
|
||||
npm run build --workspace=nixserver --silent
|
||||
npm run build --workspace=nixclient --silent
|
||||
npm run build --workspace=nixserver
|
||||
npm run build --workspace=nixclient
|
||||
# Ensure Apache (www-data) can read the rebuilt assets
|
||||
chmod 755 "$INSTALL_DIR/nixserver" "$INSTALL_DIR/nixclient"
|
||||
find "$INSTALL_DIR/nixserver/dist" -type d -exec chmod 755 {} \;
|
||||
find "$INSTALL_DIR/nixserver/dist" -type f -exec chmod 644 {} \;
|
||||
find "$INSTALL_DIR/nixclient/dist" -type d -exec chmod 755 {} \;
|
||||
find "$INSTALL_DIR/nixclient/dist" -type f -exec chmod 644 {} \;
|
||||
success "Frontends rebuilt"
|
||||
|
||||
step "Rebuilding 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"
|
||||
|
||||
step "Restarting service"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nixpanel/server",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -427,3 +427,23 @@ model Setting {
|
||||
|
||||
@@map("settings")
|
||||
}
|
||||
|
||||
// ─── Nav Modules ─────────────────────────────────────────────────────────────
|
||||
|
||||
model NavModule {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
label String
|
||||
sectionId String
|
||||
sectionLabel String
|
||||
sectionIcon String @default("Settings")
|
||||
sectionTo String?
|
||||
sectionWeight Int @default(100)
|
||||
weight Int @default(100)
|
||||
route String
|
||||
enabled Boolean @default(true)
|
||||
builtIn Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("nav_modules")
|
||||
}
|
||||
|
||||
+2
-2
@@ -60,7 +60,7 @@ export async function buildApp() {
|
||||
// ─── Static files ────────────────────────────────────────────────────────────
|
||||
|
||||
// NixServer frontend
|
||||
const whmDist = path.resolve(__dirname, '../../nixserver/dist')
|
||||
const nixserverDist = path.resolve(__dirname, '../../nixserver/dist')
|
||||
const cpanelDist = path.resolve(__dirname, '../../nixclient/dist')
|
||||
|
||||
// ─── API Routes ──────────────────────────────────────────────────────────────
|
||||
@@ -73,7 +73,7 @@ export async function buildApp() {
|
||||
|
||||
fastify.get('/api/health', async () => ({
|
||||
status: 'ok',
|
||||
version: '0.3.2',
|
||||
version: '0.6.6',
|
||||
timestamp: new Date().toISOString(),
|
||||
}))
|
||||
|
||||
|
||||
@@ -15,9 +15,8 @@ export const config = {
|
||||
isDev: optional('NODE_ENV', 'development') === 'development',
|
||||
|
||||
server: {
|
||||
host: optional('HOST', '0.0.0.0'),
|
||||
nixserverPort: parseInt(optional('NIXSERVER_PORT', '4000'), 10),
|
||||
nixclientPort: parseInt(optional('NIXCLIENT_PORT', '4001'), 10),
|
||||
host: optional('HOST', '127.0.0.1'),
|
||||
port: parseInt(optional('PORT', '4000'), 10),
|
||||
},
|
||||
|
||||
db: {
|
||||
|
||||
@@ -24,7 +24,7 @@ const ALLOWED_COMMANDS = new Set([
|
||||
// Network
|
||||
'ss', 'ip', 'hostname',
|
||||
// System
|
||||
'uname', 'free', 'ps',
|
||||
'uname', 'uptime', 'free', 'ps', 'kill',
|
||||
// PHP
|
||||
'php8.2', 'php8.3',
|
||||
// Fail2ban
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
type Seed = {
|
||||
name: string; label: string; route: string
|
||||
sectionId: string; sectionLabel: string; sectionIcon: string
|
||||
sectionTo?: string; sectionWeight: number; weight: number
|
||||
}
|
||||
|
||||
const s = (sId: string, sLbl: string, icon: string, sw: number, sTo?: string) =>
|
||||
(nm: string, lbl: string, rt: string, w: number): Seed =>
|
||||
({ name: nm, label: lbl, route: rt, sectionId: sId, sectionLabel: sLbl, sectionIcon: icon, sectionTo: sTo, sectionWeight: sw, weight: w })
|
||||
|
||||
const sc = s('server-config', 'Server Configuration', 'Settings', 10, '/server-configuration')
|
||||
const su = s('support', 'Support', 'HelpCircle', 20)
|
||||
const nw = s('networking', 'Networking Setup', 'Network', 30)
|
||||
const co = s('contacts', 'Server Contacts', 'BookUser', 40)
|
||||
const rs = s('resellers', 'Resellers', 'UserCog', 50)
|
||||
const sv = s('service-config', 'Service Configuration', 'Wrench', 60)
|
||||
const lc = s('locales', 'Locales', 'Languages', 70)
|
||||
const bk = s('backup', 'Backup', 'HardDrive', 80)
|
||||
const cl = s('clusters', 'Clusters', 'GitFork', 90)
|
||||
const rb = s('reboot', 'System Reboot', 'RotateCcw', 100)
|
||||
const st = s('server-status', 'Server Status', 'Activity', 110)
|
||||
const ai = s('account-info', 'Account Information', 'Users', 120)
|
||||
const af = s('account-functions', 'Account Functions', 'UserCheck', 130)
|
||||
const ma = s('multi-account', 'Multi Account Functions', 'Users', 140)
|
||||
const tr = s('transfers', 'Transfers', 'ArrowRightLeft',150)
|
||||
const th = s('themes', 'Themes', 'Paintbrush', 160)
|
||||
const pk = s('packages', 'Packages', 'Package', 170)
|
||||
const dn = s('dns', 'DNS Functions', 'Globe', 180)
|
||||
const db = s('databases', 'Database Services', 'Database', 190)
|
||||
const ip = s('ip', 'IP Functions', 'Wifi', 200)
|
||||
const sw2= s('software', 'Software', 'Box', 210)
|
||||
const em = s('email', 'Email', 'Mail', 220)
|
||||
const sh = s('system-health', 'System Health', 'HeartPulse', 230)
|
||||
const np = s('nixpanel', 'NixPanel', 'LayoutDashboard',240)
|
||||
const sl = s('ssl', 'SSL/TLS', 'Lock', 250)
|
||||
const mk = s('market', 'Market', 'ShoppingBag', 260)
|
||||
const re = s('restart-services', 'Restart Services', 'RefreshCw', 270)
|
||||
const dv = s('development', 'Development', 'Code2', 280)
|
||||
const pl = s('plugins', 'Plugins', 'Puzzle', 290)
|
||||
|
||||
export const NAV_SEED: Seed[] = [
|
||||
sc('basic-nixserver-setup', 'Basic NixServer Setup', '/server-configuration/basic-setup', 10),
|
||||
sc('change-root', 'Change Root Password', '/server-configuration/change-root', 20),
|
||||
sc('configure-analytics', 'Configure Analytics', '/server-configuration/analytics', 30),
|
||||
sc('configure-cron-jobs', 'Configure Cron Jobs', '/server-configuration/cron-jobs', 40),
|
||||
sc('initial-quota-setup', 'Initial Quota Setup', '/server-configuration/quota-setup', 50),
|
||||
sc('link-server-nodes', 'Link Server Nodes', '/server-configuration/link-nodes', 60),
|
||||
sc('server-profile', 'Server Profile', '/server-configuration/server-profile', 70),
|
||||
sc('server-time', 'Server Time', '/server-configuration/server-time', 80),
|
||||
sc('statistics-software', 'Statistics Software', '/server-configuration/statistics-software', 90),
|
||||
sc('terminal', 'Terminal', '/server-configuration/terminal', 100),
|
||||
sc('tweak-settings', 'Tweak Settings', '/server-configuration/tweak-settings', 110),
|
||||
sc('update-preferences', 'Update Preferences', '/server-configuration/update-preferences', 120),
|
||||
sc('nixserver-marketplace', 'NixServer Marketplace', '/server-configuration/marketplace', 130),
|
||||
su('grant-support-access', 'Grant NixPanel Support Access', '/security', 10),
|
||||
su('support-center', 'Support Center', '/settings', 20),
|
||||
nw('change-hostname', 'Change Hostname', '/security', 10),
|
||||
nw('resolver-config', 'Resolver Configuration', '/security', 20),
|
||||
nw('security-center', 'Security Center', '/security', 30),
|
||||
nw('apache-mod-userdir', 'Apache mod_userdir Tweak', '/webserver', 40),
|
||||
nw('smtp-restrictions', 'SMTP Restrictions', '/email', 50),
|
||||
nw('compiler-access', 'Compiler Access', '/security', 60),
|
||||
nw('security-policies', 'Configure Security Policies', '/security', 70),
|
||||
nw('mxstop-brute-force', 'mXSTOP Brute Force Protection', '/security', 80),
|
||||
nw('host-access-control', 'Host Access Control', '/security', 90),
|
||||
nw('external-auth', 'Manage External Authentications', '/security', 100),
|
||||
nw('root-ssh-keys', "Manage root's SSH Keys", '/security', 110),
|
||||
nw('wheel-users', 'Manage Wheel Group Users', '/security', 120),
|
||||
nw('modsecurity-config', 'ModSecurity Configuration', '/security', 130),
|
||||
nw('modsecurity-tools', 'ModSecurity Tools', '/security', 140),
|
||||
nw('modsecurity-vendors', 'ModSecurity Vendors', '/security', 150),
|
||||
nw('password-strength', 'Password Strength Configuration', '/security', 160),
|
||||
nw('security-advisor', 'Security Advisor', '/security', 170),
|
||||
nw('security-questions', 'Security Questions', '/security', 180),
|
||||
nw('shell-fork-bomb', 'Shell Fork Bomb Protection', '/security', 190),
|
||||
nw('ssh-password-auth', 'SSH Password Authorization Tweak', '/security', 200),
|
||||
nw('traceroute', 'Traceroute Enable/Disable', '/security', 210),
|
||||
nw('two-factor-auth', 'Two-Factor Authentication', '/security', 220),
|
||||
co('contact-manager', 'Contact Manager', '/settings', 10),
|
||||
co('system-mail-prefs', 'Edit System Mail Preferences', '/email', 20),
|
||||
rs('change-account-owner', 'Change Ownership of an Account', '/resellers', 10),
|
||||
rs('change-multi-owner', 'Change Ownership of Multiple Accounts', '/resellers', 20),
|
||||
rs('edit-reseller-ns', 'Edit Reseller Nameservers and Privileges', '/resellers', 30),
|
||||
rs('email-all-resellers', 'Email All Resellers', '/resellers', 40),
|
||||
rs('reseller-ip-delegation', "Manage Reseller's IP Delegation", '/resellers', 50),
|
||||
rs('reseller-shared-ip', "Manage Reseller's Shared IP", '/resellers', 60),
|
||||
rs('reseller-center', 'Reseller Center', '/resellers', 70),
|
||||
rs('reset-resellers', 'Reset Resellers', '/resellers', 80),
|
||||
rs('show-reseller-accounts', 'Show Reseller Accounts', '/resellers', 90),
|
||||
rs('view-reseller-usage', 'View Reseller Usage', '/resellers', 100),
|
||||
sv('apache-configuration', 'Apache Configuration', '/webserver', 10),
|
||||
sv('log-rotation', 'Log Rotation Configuration', '/settings', 20),
|
||||
sv('web-disk-config', 'Web Disk Configuration', '/settings', 30),
|
||||
sv('web-services-config', 'Web Services Configuration', '/settings', 40),
|
||||
sv('exim-config', 'Exim Configuration Manager', '/email', 50),
|
||||
sv('ftp-server-selection', 'FTP Server Selection', '/settings', 60),
|
||||
sv('mailserver-config', 'Mailserver Configuration', '/email', 70),
|
||||
sv('manage-service-ssl', 'Manage Service SSL Certificates', '/ssl', 80),
|
||||
sv('nameserver-selection', 'Nameserver Selection', '/dns', 90),
|
||||
sv('service-manager', 'Service Manager', '/services', 100),
|
||||
lc('configure-locales', 'Configure Application Locales', '/settings', 10),
|
||||
lc('copy-locale', 'Copy a Locale', '/settings', 20),
|
||||
lc('delete-locale', 'Delete a Locale', '/settings', 30),
|
||||
lc('edit-locale', 'Edit a Locale', '/settings', 40),
|
||||
lc('locale-xml-download', 'Locale XML Download', '/settings', 50),
|
||||
lc('locale-xml-upload', 'Locale XML Upload', '/settings', 60),
|
||||
lc('view-locales', 'View Available Locales', '/settings', 70),
|
||||
bk('backup-configuration', 'Backup Configuration', '/backup', 10),
|
||||
bk('backup-restoration', 'Backup Restoration', '/backup', 20),
|
||||
bk('backup-user-selection', 'Backup User Selection', '/backup', 30),
|
||||
bk('file-dir-restoration', 'File and Directory Restoration', '/backup', 40),
|
||||
cl('config-cluster', 'Configuration Cluster', '/settings', 10),
|
||||
cl('dns-cluster', 'DNS Cluster', '/dns', 20),
|
||||
cl('remote-access-key', 'Remote Access Key', '/security', 30),
|
||||
rb('force-reboot', 'Forceful Server Reboot', '/system', 10),
|
||||
rb('graceful-reboot', 'Graceful Server Reboot', '/system', 20),
|
||||
st('apache-status', 'Apache Status', '/webserver', 10),
|
||||
st('daily-process-log', 'Daily Process Log', '/system', 20),
|
||||
st('server-information', 'Server Information', '/system', 30),
|
||||
st('service-status', 'Service Status', '/services', 40),
|
||||
st('task-queue-monitor', 'Task Queue Monitor', '/system', 50),
|
||||
ai('list-accounts', 'List Accounts', '/accounts/list', 10),
|
||||
ai('list-parked-domains', 'List Parked Domains', '/accounts/parked-domains', 20),
|
||||
ai('list-subdomains', 'List Subdomains', '/accounts/subdomains', 30),
|
||||
ai('list-suspended', 'List Suspended Accounts', '/accounts/suspended', 40),
|
||||
ai('show-over-quota', 'Show Accounts Over Quota', '/accounts/over-quota', 50),
|
||||
ai('view-bandwidth', 'View Bandwidth Usage', '/accounts/bandwidth', 60),
|
||||
af('change-site-ip', "Change Site's IP Address", '/accounts/change-ip', 10),
|
||||
af('create-account', 'Create a New Account', '/accounts/create', 20),
|
||||
af('email-all-users', 'Email All Users', '/accounts/email-users', 30),
|
||||
af('force-password-change', 'Force Password Change', '/accounts/force-password', 40),
|
||||
af('limit-bandwidth', 'Limit Bandwidth Usage', '/accounts/limit-bandwidth',50),
|
||||
af('manage-suspension', 'Manage Account Suspension', '/accounts/suspension', 60),
|
||||
af('manage-demo-mode', 'Manage Demo Mode', '/accounts/demo-mode', 70),
|
||||
af('manage-shell-access', 'Manage Shell Access', '/accounts/shell-access', 80),
|
||||
af('modify-account', 'Modify an Account', '/accounts/modify', 90),
|
||||
af('password-modification', 'Password Modification', '/accounts/password', 100),
|
||||
af('quota-modification', 'Quota Modification', '/accounts/quota', 110),
|
||||
af('raw-apache-log', 'Raw Apache Log Download', '/accounts/apache-logs', 120),
|
||||
af('raw-nginx-log', 'Raw NGINX Log Download', '/accounts/nginx-logs', 130),
|
||||
af('rearrange-account', 'Rearrange an Account', '/accounts/rearrange', 140),
|
||||
af('reset-acct-bandwidth', 'Reset Account Bandwidth', '/accounts/reset-bandwidth',150),
|
||||
af('terminate-accounts', 'Terminate Accounts', '/accounts/terminate', 160),
|
||||
af('unsuspend-bw', 'Unsuspend Bandwidth Exceeders', '/accounts/unsuspend-bandwidth',170),
|
||||
af('upgrade-downgrade', 'Upgrade/Downgrade an Account', '/accounts/upgrade', 180),
|
||||
af('web-template-editor', 'Web Template Editor', '/accounts/web-template', 190),
|
||||
ma('bulk-ip', "Change Multiple Sites' IP Addresses",'/accounts/bulk-ip', 10),
|
||||
ma('bulk-modify', 'Modify/Upgrade Multiple Accounts', '/accounts/bulk-modify', 20),
|
||||
tr('addon-to-account', 'Convert Addon Domain to Account', '/accounts', 10),
|
||||
tr('review-transfers', 'Review Transfers and Restores', '/accounts', 20),
|
||||
tr('transfer-cpanel', 'Transfer or Restore a cPanel Account','/accounts',30),
|
||||
tr('transfer-tool', 'Transfer Tool', '/accounts', 40),
|
||||
th('theme-manager', 'Theme Manager', '/settings', 10),
|
||||
pk('add-package', 'Add a Package', '/packages', 10),
|
||||
pk('delete-package', 'Delete a Package', '/packages', 20),
|
||||
pk('edit-package', 'Edit a Package', '/packages', 30),
|
||||
pk('feature-manager', 'Feature Manager', '/packages', 40),
|
||||
dn('add-dns-zone', 'Add a DNS Zone', '/dns', 10),
|
||||
dn('add-a-entry', 'Add an A Entry for Hostname', '/dns', 20),
|
||||
dn('delete-dns-zone', 'Delete a DNS Zone', '/dns', 30),
|
||||
dn('dns-zone-manager', 'DNS Zone Manager', '/dns', 40),
|
||||
dn('edit-zone-templates', 'Edit Zone Templates', '/dns', 50),
|
||||
dn('email-dns-records', 'Email DNS Record Manager', '/dns', 60),
|
||||
dn('email-routing-config', 'Email Routing Configuration', '/email', 70),
|
||||
dn('enable-dkim-spf', 'Enable DKIM/SPF Globally', '/email', 80),
|
||||
dn('ns-record-report', 'Nameserver Record Report', '/dns', 90),
|
||||
dn('park-domain', 'Park a Domain', '/dns', 100),
|
||||
dn('dns-cleanup', 'Perform a DNS Cleanup', '/dns', 110),
|
||||
dn('set-zone-ttl', 'Set Zone TTL', '/dns', 120),
|
||||
dn('domain-forwarding', 'Setup/Edit Domain Forwarding', '/dns', 130),
|
||||
dn('sync-dns-records', 'Synchronize DNS Records', '/dns', 140),
|
||||
db('change-db-root-pass', 'Change Database Root Password', '/settings', 10),
|
||||
db('change-db-user-pass', 'Change Database User Password', '/settings', 20),
|
||||
db('db-map-tool', 'Database Map Tool', '/settings', 30),
|
||||
db('edit-db-config', 'Edit Database Configuration', '/settings', 40),
|
||||
db('manage-db-hosts', 'Manage Database Access Hosts', '/settings', 50),
|
||||
db('manage-db-profiles', 'Manage Database Profiles', '/settings', 60),
|
||||
db('manage-db-users', 'Manage Database Users', '/settings', 70),
|
||||
db('manage-databases', 'Manage Databases', '/settings', 80),
|
||||
db('phpmyadmin', 'phpMyAdmin', '/settings', 90),
|
||||
db('repair-databases', 'Repair Databases', '/settings', 100),
|
||||
db('show-db-processes', 'Show Database Processes', '/settings', 110),
|
||||
db('upgrade-db-version', 'Upgrade Database Version', '/settings', 120),
|
||||
ip('ipv6-ranges', 'IPv6 Ranges', '/security', 10),
|
||||
ip('add-ip', 'Add a New IP Address', '/security', 20),
|
||||
ip('assign-ipv6', 'Assign IPv6 Address', '/security', 30),
|
||||
ip('configure-remote-ips', 'Configure Remote Service IPs', '/security', 40),
|
||||
ip('ip-migration', 'IP Migration Wizard', '/security', 50),
|
||||
ip('rebuild-ip-pool', 'Rebuild the IP Address Pool', '/security', 60),
|
||||
ip('show-ip-usage', 'Show IP Address Usage', '/security', 70),
|
||||
ip('show-delete-ips', 'Show or Delete Current IPs', '/security', 80),
|
||||
ip('reserved-ips', 'Show/Edit Reserved IPs', '/security', 90),
|
||||
sw2('install-perl-module', 'Install a Perl Module', '/system', 10),
|
||||
sw2('install-distro-pkgs', 'Install Distro Packages', '/system', 20),
|
||||
sw2('module-installers', 'Module Installers', '/system', 30),
|
||||
sw2('multiphp-ini-editor', 'MultiPHP INI Editor', '/php', 40),
|
||||
sw2('multiphp-manager', 'MultiPHP Manager', '/php', 50),
|
||||
sw2('nginx-manager', 'NGINX Manager', '/webserver', 60),
|
||||
sw2('rebuild-rpm-db', 'Rebuild RPM Database', '/system', 70),
|
||||
sw2('system-update', 'System Update', '/system', 80),
|
||||
sw2('update-server-software','Update Server Software', '/system', 90),
|
||||
sw2('easyapache4', 'EasyApache 4', '/webserver', 100),
|
||||
em('email-deliverability', 'Email Deliverability', '/email', 10),
|
||||
em('filter-by-country', 'Filter Incoming Emails by Country', '/email', 20),
|
||||
em('filter-by-domain', 'Filter Incoming Emails by Domain', '/email', 30),
|
||||
em('greylisting', 'Greylisting', '/email', 40),
|
||||
em('mail-delivery-reports', 'Mail Delivery Reports', '/email', 50),
|
||||
em('mail-queue-manager', 'Mail Queue Manager', '/email', 60),
|
||||
em('mail-troubleshooter', 'Mail Troubleshooter', '/email', 70),
|
||||
em('mailbox-conversion', 'Mailbox Conversion', '/email', 80),
|
||||
em('repair-mailbox-perms', 'Repair Mailbox Permissions', '/email', 90),
|
||||
em('spamd-startup', 'Spamd Startup Configuration', '/email', 100),
|
||||
em('view-mail-stats', 'View Mail Statistics Summary', '/email', 110),
|
||||
em('view-relayers', 'View Relayers', '/email', 120),
|
||||
em('view-sent-summary', 'View Sent Summary', '/email', 130),
|
||||
sh('bg-process-killer', 'Background Process Killer', '/system', 10),
|
||||
sh('process-manager', 'Process Manager', '/system', 20),
|
||||
sh('show-disk-usage', 'Show Current Disk Usage', '/system', 30),
|
||||
sh('show-processes', 'Show Current Running Processes', '/system', 40),
|
||||
np('changelog', 'Change Log', '/settings', 10),
|
||||
np('customization', 'Customization', '/settings', 20),
|
||||
np('manage-plugins', 'Manage Plugins', '/modules', 30),
|
||||
np('modify-news', 'Modify NixPanel News', '/settings', 40),
|
||||
np('reset-mailman-pass', 'Reset a Mailman Password', '/email', 50),
|
||||
np('upgrade-latest', 'Upgrade to Latest Version', '/settings', 60),
|
||||
sl('generate-ssl', 'Generate an SSL Certificate and Signing Request', '/ssl', 10),
|
||||
sl('install-ssl', 'Install an SSL Certificate on a Domain', '/ssl', 20),
|
||||
sl('manage-autossl', 'Manage AutoSSL', '/ssl', 30),
|
||||
sl('manage-ssl-hosts', 'Manage SSL Hosts', '/ssl', 40),
|
||||
sl('purchase-ssl', 'Purchase and Install an SSL Certificate', '/ssl', 50),
|
||||
sl('ssl-storage', 'SSL Storage Manager', '/ssl', 60),
|
||||
sl('ssl-config', 'SSL/TLS Configuration', '/ssl', 70),
|
||||
mk('market-provider', 'Market Provider Manager', '/settings', 10),
|
||||
re('restart-db', 'Database Server', '/services', 10),
|
||||
re('restart-dns', 'DNS Server', '/services', 20),
|
||||
re('restart-apache', 'HTTP Server (Apache)', '/services', 30),
|
||||
re('restart-imap', 'IMAP Server', '/services', 40),
|
||||
re('restart-exim', 'Mail Server (Exim)', '/services', 50),
|
||||
re('restart-mailman', 'Mailing List Manager (Mailman)', '/services', 60),
|
||||
re('restart-php-fpm', 'PHP-FPM service for Apache', '/services', 70),
|
||||
re('restart-ssh', 'SSH Server (OpenSSH)', '/services', 80),
|
||||
dv('appconfig-apps', 'Apps Managed by AppConfig', '/settings', 10),
|
||||
dv('dev-forum', 'NixPanel Development Forum', '/settings', 20),
|
||||
dv('plugin-file-gen', 'NixPanel Plugin File Generator', '/settings', 30),
|
||||
dv('developer-docs', 'Developer Documentation', '/settings', 40),
|
||||
dv('manage-api-tokens', 'Manage API Tokens', '/settings', 50),
|
||||
dv('manage-hooks', 'Manage Hooks', '/settings', 60),
|
||||
dv('openapi-nixpanel', 'OpenAPI Documentation for NixPanel', '/settings', 70),
|
||||
dv('openapi-nixserver', 'OpenAPI Documentation for NixServer','/settings', 80),
|
||||
pl('backuply', 'Backuply by Softaculous', '/backup', 10),
|
||||
pl('imunifyav', 'ImunifyAV', '/security', 20),
|
||||
pl('server-monitoring', 'Server Monitoring', '/system', 30),
|
||||
pl('sitepad', 'SitePad Website Builder', '/settings', 40),
|
||||
pl('softaculous', 'Softaculous - Instant Installs', '/settings', 50),
|
||||
pl('wp-toolkit', 'WP Toolkit', '/settings', 60),
|
||||
]
|
||||
+28
-17
@@ -7,19 +7,14 @@ const prisma = new PrismaClient()
|
||||
async function main() {
|
||||
console.log('Seeding database...')
|
||||
|
||||
// Admin user
|
||||
const existing = await prisma.adminUser.findUnique({ where: { username: config.admin.username } })
|
||||
if (!existing) {
|
||||
const hash = await bcrypt.hash(config.admin.password, 12)
|
||||
await prisma.adminUser.create({
|
||||
data: {
|
||||
username: config.admin.username,
|
||||
passwordHash: hash,
|
||||
email: config.admin.email,
|
||||
},
|
||||
})
|
||||
console.log(`Created admin user: ${config.admin.username}`)
|
||||
}
|
||||
// Admin user — upsert so re-runs always sync the password from .env
|
||||
const hash = await bcrypt.hash(config.admin.password, 12)
|
||||
await prisma.adminUser.upsert({
|
||||
where: { username: config.admin.username },
|
||||
update: { passwordHash: hash, email: config.admin.email },
|
||||
create: { username: config.admin.username, passwordHash: hash, email: config.admin.email },
|
||||
})
|
||||
console.log(`Admin user ready: ${config.admin.username}`)
|
||||
|
||||
// Default packages
|
||||
const packages = [
|
||||
@@ -73,10 +68,10 @@ async function main() {
|
||||
// Default settings
|
||||
const settings = [
|
||||
{ key: 'php_default_version', value: '8.3' },
|
||||
{ key: 'mail_server', value: 'postfix' },
|
||||
{ key: 'ftp_server', value: 'vsftpd' },
|
||||
{ key: 'webserver', value: 'nginx' },
|
||||
{ key: 'dns_server', value: 'bind9' },
|
||||
{ key: 'mail_server', value: 'exim4' },
|
||||
{ key: 'ftp_server', value: 'disabled' },
|
||||
{ key: 'webserver', value: 'apache2' },
|
||||
{ key: 'dns_server', value: 'powerdns' },
|
||||
{ key: 'ssl_provider', value: 'letsencrypt' },
|
||||
{ key: 'backup_enabled', value: 'true' },
|
||||
{ key: 'backup_retention_days', value: '7' },
|
||||
@@ -95,6 +90,22 @@ async function main() {
|
||||
}
|
||||
console.log('Created default settings')
|
||||
|
||||
// Nav modules
|
||||
const { NAV_SEED } = await import('./navSeed.js')
|
||||
for (const m of NAV_SEED) {
|
||||
await prisma.navModule.upsert({
|
||||
where: { name: m.name },
|
||||
update: {
|
||||
label: m.label, route: m.route,
|
||||
sectionId: m.sectionId, sectionLabel: m.sectionLabel,
|
||||
sectionIcon: m.sectionIcon, sectionTo: m.sectionTo ?? null,
|
||||
sectionWeight: m.sectionWeight, weight: m.weight,
|
||||
},
|
||||
create: { ...m, sectionTo: m.sectionTo ?? null, enabled: true, builtIn: true },
|
||||
})
|
||||
}
|
||||
console.log(`Seeded ${NAV_SEED.length} nav modules`)
|
||||
|
||||
console.log('Seed complete.')
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@ async function start() {
|
||||
|
||||
app = await buildApp()
|
||||
|
||||
await app.listen({ port: config.server.nixserverPort, host: config.server.host })
|
||||
console.log(`[nixpanel] API running on ${config.server.host}:${config.server.nixserverPort}`)
|
||||
await app.listen({ port: config.server.port, host: config.server.host })
|
||||
console.log(`[nixpanel] API running on ${config.server.host}:${config.server.port}`)
|
||||
|
||||
startScheduler()
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import domainRoutes from './domains.js'
|
||||
import ftpRoutes from './ftp.js'
|
||||
import cronRoutes from './cron.js'
|
||||
import statsRoutes from './stats.js'
|
||||
import sslRoutes from './ssl.js'
|
||||
|
||||
export default async function nixclientRoutes(fastify: FastifyInstance) {
|
||||
fastify.register(fileRoutes, { prefix: '/files' })
|
||||
@@ -15,4 +16,5 @@ export default async function nixclientRoutes(fastify: FastifyInstance) {
|
||||
fastify.register(ftpRoutes, { prefix: '/ftp' })
|
||||
fastify.register(cronRoutes, { prefix: '/cron' })
|
||||
fastify.register(statsRoutes, { prefix: '/stats' })
|
||||
fastify.register(sslRoutes, { prefix: '/ssl' })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
|
||||
export default async function sslRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
// GET /api/nixclient/ssl — certs for the authenticated user's account only
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.sub
|
||||
|
||||
const certs = await prisma.sslCertificate.findMany({
|
||||
where: { accountId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
return reply.send({ success: true, data: certs })
|
||||
})
|
||||
|
||||
// POST /api/nixclient/ssl/:id/renew
|
||||
fastify.post('/:id/renew', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const cert = await prisma.sslCertificate.findFirst({
|
||||
where: { id: parseInt(id, 10), accountId: user.sub },
|
||||
})
|
||||
if (!cert) return reply.code(404).send({ success: false, error: 'Certificate not found' })
|
||||
|
||||
const result = await exec('certbot', ['renew', '--cert-name', cert.domain, '--non-interactive'])
|
||||
if (result.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: `Renewal failed: ${result.stderr}` })
|
||||
}
|
||||
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 90)
|
||||
|
||||
const updated = await prisma.sslCertificate.update({
|
||||
where: { id: cert.id },
|
||||
data: { status: 'active', issuedAt: new Date(), expiresAt },
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: updated })
|
||||
})
|
||||
}
|
||||
@@ -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,5 +1,5 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import accountRoutes from './accounts.js'
|
||||
import accountRoutes from './accounts/index.js'
|
||||
import packageRoutes from './packages.js'
|
||||
import resellerRoutes from './resellers.js'
|
||||
import dnsRoutes from './dns.js'
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../../db/client.js'
|
||||
import { requireAdmin } from '../../../middleware/auth.js'
|
||||
|
||||
const ModuleBody = z.object({
|
||||
name: z.string().min(1).max(64).regex(/^[a-z0-9-]+$/),
|
||||
label: z.string().min(1).max(128),
|
||||
sectionId: z.string().min(1).max(64),
|
||||
sectionLabel: z.string().min(1).max(128),
|
||||
sectionIcon: z.string().min(1).max(64).default('Settings'),
|
||||
sectionTo: z.string().optional(),
|
||||
sectionWeight: z.number().int().default(100),
|
||||
weight: z.number().int().default(100),
|
||||
route: z.string().min(1).max(256),
|
||||
})
|
||||
|
||||
const UpdateBody = z.object({
|
||||
label: z.string().min(1).max(128).optional(),
|
||||
sectionId: z.string().min(1).max(64).optional(),
|
||||
sectionLabel: z.string().min(1).max(128).optional(),
|
||||
sectionIcon: z.string().min(1).max(64).optional(),
|
||||
sectionTo: z.string().nullable().optional(),
|
||||
sectionWeight: z.number().int().optional(),
|
||||
weight: z.number().int().optional(),
|
||||
route: z.string().min(1).max(256).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export default async function moduleRoutes(fastify: FastifyInstance) {
|
||||
// ── GET /nav — grouped nav for sidebar (enabled modules only) ──────────────
|
||||
fastify.get('/nav', { preHandler: [requireAdmin] }, async (_req, reply) => {
|
||||
const modules = await prisma.navModule.findMany({
|
||||
where: { enabled: true },
|
||||
orderBy: [{ sectionWeight: 'asc' }, { weight: 'asc' }],
|
||||
})
|
||||
|
||||
// Group by sectionId
|
||||
const sectionMap = new Map<string, {
|
||||
id: string; label: string; icon: string; to?: string | null; items: typeof modules
|
||||
}>()
|
||||
|
||||
for (const m of modules) {
|
||||
if (!sectionMap.has(m.sectionId)) {
|
||||
sectionMap.set(m.sectionId, {
|
||||
id: m.sectionId, label: m.sectionLabel,
|
||||
icon: m.sectionIcon, to: m.sectionTo,
|
||||
items: [],
|
||||
})
|
||||
}
|
||||
sectionMap.get(m.sectionId)!.items.push(m)
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: Array.from(sectionMap.values()) })
|
||||
})
|
||||
|
||||
// ── GET / — all modules for Module Manager ─────────────────────────────────
|
||||
fastify.get('/', { preHandler: [requireAdmin] }, async (_req, reply) => {
|
||||
const modules = await prisma.navModule.findMany({
|
||||
orderBy: [{ sectionWeight: 'asc' }, { weight: 'asc' }],
|
||||
})
|
||||
return reply.send({ success: true, data: modules })
|
||||
})
|
||||
|
||||
// ── POST / — install a new module ──────────────────────────────────────────
|
||||
fastify.post('/', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const body = ModuleBody.safeParse(request.body)
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0].message })
|
||||
|
||||
const existing = await prisma.navModule.findUnique({ where: { name: body.data.name } })
|
||||
if (existing) return reply.code(409).send({ success: false, error: 'Module name already exists' })
|
||||
|
||||
const module = await prisma.navModule.create({
|
||||
data: { ...body.data, sectionTo: body.data.sectionTo ?? null, enabled: true, builtIn: false },
|
||||
})
|
||||
return reply.code(201).send({ success: true, data: module })
|
||||
})
|
||||
|
||||
// ── PUT /:id — update module ───────────────────────────────────────────────
|
||||
fastify.put('/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const id = parseInt((request.params as { id: string }).id)
|
||||
if (isNaN(id)) return reply.code(400).send({ success: false, error: 'Invalid id' })
|
||||
|
||||
const body = UpdateBody.safeParse(request.body)
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0].message })
|
||||
|
||||
const module = await prisma.navModule.findUnique({ where: { id } })
|
||||
if (!module) return reply.code(404).send({ success: false, error: 'Module not found' })
|
||||
|
||||
const updated = await prisma.navModule.update({ where: { id }, data: body.data })
|
||||
return reply.send({ success: true, data: updated })
|
||||
})
|
||||
|
||||
// ── DELETE /:id — remove a non-built-in module ─────────────────────────────
|
||||
fastify.delete('/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const id = parseInt((request.params as { id: string }).id)
|
||||
if (isNaN(id)) return reply.code(400).send({ success: false, error: 'Invalid id' })
|
||||
|
||||
const module = await prisma.navModule.findUnique({ where: { id } })
|
||||
if (!module) return reply.code(404).send({ success: false, error: 'Module not found' })
|
||||
if (module.builtIn) return reply.code(403).send({ success: false, error: 'Cannot delete a built-in module' })
|
||||
|
||||
await prisma.navModule.delete({ where: { id } })
|
||||
return reply.send({ success: true, data: null })
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { spawn } from 'child_process'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdmin } from '../../middleware/auth.js'
|
||||
import { getSystemStats } from '../../core/system.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
import { readFile } from 'fs/promises'
|
||||
|
||||
const INSTALL_DIR = process.env.INSTALL_DIR ?? '/opt/nixpanel'
|
||||
|
||||
export default async function systemRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/nixserver/system/stats
|
||||
fastify.get('/stats', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
@@ -96,6 +99,20 @@ export default async function systemRoutes(fastify: FastifyInstance) {
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/nixserver/system/network-interfaces
|
||||
fastify.get('/network-interfaces', { preHandler: [requireAdmin] }, async (_request, reply) => {
|
||||
try {
|
||||
const result = await exec('ls', ['/sys/class/net'])
|
||||
const ifaces = result.stdout
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s && s !== 'lo') // exclude loopback
|
||||
return reply.send({ success: true, data: ifaces })
|
||||
} catch {
|
||||
return reply.send({ success: true, data: [] })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/nixserver/system/settings
|
||||
fastify.get('/settings', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const settings = await prisma.setting.findMany({ orderBy: { key: 'asc' } })
|
||||
@@ -121,4 +138,56 @@ export default async function systemRoutes(fastify: FastifyInstance) {
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Settings updated' } })
|
||||
})
|
||||
|
||||
// GET /api/nixserver/system/version
|
||||
// Returns the installed version and checks GitHub for the latest available version.
|
||||
fastify.get('/version', { preHandler: [requireAdmin] }, async (_request, reply) => {
|
||||
// Read installed version from package.json
|
||||
let currentVersion = '0.0.0'
|
||||
try {
|
||||
const pkg = JSON.parse(await readFile(`${INSTALL_DIR}/package.json`, 'utf8'))
|
||||
currentVersion = pkg.version ?? '0.0.0'
|
||||
} catch { /* running in dev or non-standard path */ }
|
||||
|
||||
// Check GitHub for latest version (5 s timeout, non-fatal)
|
||||
let latestVersion: string | null = null
|
||||
try {
|
||||
const resp = await fetch(
|
||||
'https://raw.githubusercontent.com/NixReaper/nixpanel-server/main/package.json',
|
||||
{ signal: AbortSignal.timeout(5_000) }
|
||||
)
|
||||
if (resp.ok) {
|
||||
const json = await resp.json() as { version?: string }
|
||||
latestVersion = json.version ?? null
|
||||
}
|
||||
} catch { /* offline or rate-limited */ }
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updateAvailable: latestVersion !== null && latestVersion !== currentVersion,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// POST /api/nixserver/system/upgrade
|
||||
// Fires the update script in the background and returns immediately.
|
||||
// The service will restart ~60 s later once the build finishes.
|
||||
fastify.post('/upgrade', { preHandler: [requireAdmin] }, async (_request, reply) => {
|
||||
reply.send({
|
||||
success: true,
|
||||
data: { message: 'Upgrade started. The panel will be back online in about 60 seconds.' },
|
||||
})
|
||||
|
||||
// Delay slightly to ensure the HTTP response is flushed before the service restarts
|
||||
setTimeout(() => {
|
||||
const child = spawn('bash', [`${INSTALL_DIR}/scripts/update.sh`], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
})
|
||||
child.unref()
|
||||
}, 800)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user