9 Commits
Author SHA1 Message Date
NixReaper b369d707d7 Add nav seed data and nixserver route modules, update schema/seed 2026-08-29 21:59:01 -05:00
NixReaper ba9e00d65b fix(seed): upsert admin so re-runs sync password from .env
Using findUnique+create skipped admin creation on re-runs, leaving
the password in .env out of sync with the DB hash after a failed
install. Upsert ensures the DB password always matches .env.
2026-04-12 05:05:04 -05:00
NixReaper aa9998cbd8 fix(installer): preserve secrets across re-runs
Re-running the installer after a failed build regenerated JWT_SECRET
and MARIADB_ROOT_PASS, overwriting .env with values that didn't match
the already-set MariaDB password. Now reads existing .env values and
only generates new ones on a fresh install.
2026-04-12 04:56:27 -05:00
NixReaper f9352617b0 fix(nixserver): widen SectionCard icon prop to accept Lucide components
Lucide icons type size as string | number but SectionCardProps declared
size as number only, causing TS2322 and failing the nixserver build.
2026-04-12 04:51:10 -05:00
NixReaper 17b0becc82 fix(installer): replace confirmation prompt with 5-second countdown
curl | bash pipes stdin so read gets EOF and returns non-zero, which
set -euo pipefail treats as an error and aborts immediately. Replaced
the interactive prompt with a sleep 5 countdown — works whether the
script is piped or run directly.

Bumps version to 0.6.3.
2026-04-12 04:44:07 -05:00
NixReaper 501f94b7e8 fix(install): read from /dev/tty for curl|bash, bump v0.6.2 2026-04-12 04:40:04 -05:00
NixReaperandClaude Sonnet 4.6 5c98ac0ee6 docs: add CLAUDE.md development standards, bump v0.6.1
Adds CLAUDE.md which Claude Code reads automatically before every
session. Covers version bumping, changelog format, commit conventions,
file size limits, section/page architecture, and API standards.

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

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

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-04-11 18:07:57 -05:00
42 changed files with 2939 additions and 1492 deletions
+51 -1
View File
@@ -6,13 +6,63 @@ Pre-1.0 versions treat MINOR as feature releases and PATCH as bug fixes.
---
## [0.5.2] — 2026-04-11
## [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
+165
View File
@@ -0,0 +1,165 @@
# NixPanel Development Standards
This file is read automatically by Claude Code before every session.
All rules below are **mandatory** — follow them without being asked.
---
## 1. Version Numbering
Versioning follows [Semantic Versioning](https://semver.org/). Pre-1.0 rules:
| Change type | Bump | Example |
|---|---|---|
| New feature, new page, new section | **MINOR** | 0.5.1 → 0.6.0 |
| Bug fix, refactor, chore, docs | **PATCH** | 0.5.1 → 0.5.2 |
| Breaking API/schema change | **MINOR** | 0.5.x → 0.6.0 |
**Always** bump the version in `package.json` before committing any user-visible change.
---
## 2. Changelog
File: `CHANGELOG.md` at the repo root.
### Format
```markdown
## [X.Y.Z] — YYYY-MM-DD
### Features ← new functionality
### Changes ← non-breaking behaviour changes / refactors
### Bug Fixes ← defect corrections
### Refactors ← internal restructuring with no behaviour change
```
### Rules
- Add a new entry **at the top** of the file (below the header block) for every commit.
- One entry per version — if multiple changes share a version, group them under the same header.
- Bullet points must be descriptive enough that a developer can understand what changed and why without reading the diff.
- Use bold for the affected component/file: `- **BasicSetup.tsx** — description`.
- Today's date format: `YYYY-MM-DD`.
---
## 3. Commit Messages
Format: `type(scope): short imperative summary`
| Type | When to use |
|---|---|
| `feat` | New feature or page |
| `fix` | Bug fix |
| `refactor` | Code restructuring, no behaviour change |
| `chore` | Version bump, changelog, config, tooling |
| `docs` | Documentation only |
Rules:
- Summary line ≤ 72 characters.
- Always include `Co-Authored-By: Claude Sonnet 4.6 <[email protected]>` trailer.
- Use a multiline HEREDOC — never single-line for complex messages.
### Workflow for every change
1. Make code changes.
2. Bump version in `package.json`.
3. Add entry to `CHANGELOG.md`.
4. Stage all changed files including `package.json` and `CHANGELOG.md`.
5. Commit everything together in **one commit**.
6. Push to `origin main`.
> Do **not** make a separate `chore: bump version` commit — version + changelog belong in the same commit as the code change.
---
## 4. File Size Limit
**No source file should exceed 300 lines.**
When a file approaches this limit, split it by responsibility:
- React pages → extract sub-components into a `pages/<section>/` subfolder.
- Fastify route files → split into sub-plugins under `routes/<section>/` with an `index.ts` registering them.
- Shared UI primitives → `components/` folder.
---
## 5. Page / Section Architecture
### Frontend
Each nav category in the sidebar maps to a **section folder**:
```
pages/<section-name>/
index.tsx ← card-grid landing page (always create this first)
ToolName.tsx ← one file per tool/feature
```
Unimplemented tools use `<ComingSoon title="..." description="..." />` from `components/ComingSoon.tsx`.
Section landing pages use `<SectionCard>` from `components/SectionCard.tsx`.
### Backend
Route files live under `routes/nixserver/<section>/`:
```
routes/nixserver/<section>/
index.ts ← registers sub-plugins, no route logic here
list.ts ← read-only GET routes
create.ts ← POST / creation route
modify.ts ← PUT / PATCH routes
status.ts ← suspend / unsuspend / delete / bulk
```
---
## 6. Project Structure (key paths)
```
nixpanel-server/
package.json ← root version number lives here
CHANGELOG.md ← always update before committing
CLAUDE.md ← this file
nixserver/src/
components/
navData.ts ← NAV array + NavCategory/NavItem types
Sidebar.tsx ← sidebar UI
Layout.tsx ← thin shell (topbar + Outlet)
SectionCard.tsx ← reusable section card
ComingSoon.tsx ← placeholder for unimplemented tools
pages/
server-configuration/ ← example of section folder pattern
index.tsx
BasicSetup.tsx
ChangeRoot.tsx
...
server/src/
routes/nixserver/
accounts/ ← example of split route folder
index.ts
list.ts
create.ts
modify.ts
status.ts
```
---
## 7. API Conventions
- All responses: `{ success: true, data: ... }` or `{ success: false, error: "..." }`.
- All admin routes use `preHandler: [requireAdmin]` or `preHandler: [requireAdminOrReseller]`.
- Settings stored as key-value pairs in the `Setting` model (`key String @id`, `value String`).
- New settings keys use UPPERCASE (matching WHM convention): `CONTACTEMAIL`, `TTL`, etc.
---
## 8. Git Remote
```
origin https://github.com/NixReaper/nixpanel-server.git (fetch/push)
branch main
```
Always push to `origin main` after committing.
+62
View File
@@ -23,6 +23,22 @@ const System = React.lazy(() => import('./pages/System'))
const WebServer = React.lazy(() => import('./pages/WebServer'))
const Settings = React.lazy(() => import('./pages/Settings'))
// Server Configuration section
const ServerConfiguration = React.lazy(() => import('./pages/server-configuration/index'))
const SCBasicSetup = React.lazy(() => import('./pages/server-configuration/BasicSetup'))
const SCChangeRoot = React.lazy(() => import('./pages/server-configuration/ChangeRoot'))
const SCAnalytics = React.lazy(() => import('./pages/server-configuration/Analytics'))
const SCCronJobs = React.lazy(() => import('./pages/server-configuration/CronJobs'))
const SCQuotaSetup = React.lazy(() => import('./pages/server-configuration/QuotaSetup'))
const SCLinkNodes = React.lazy(() => import('./pages/server-configuration/LinkNodes'))
const SCServerProfile = React.lazy(() => import('./pages/server-configuration/ServerProfile'))
const SCServerTime = React.lazy(() => import('./pages/server-configuration/ServerTime'))
const SCStatisticsSoftware = React.lazy(() => import('./pages/server-configuration/StatisticsSoftware'))
const SCTerminal = React.lazy(() => import('./pages/server-configuration/Terminal'))
const SCTweakSettings = React.lazy(() => import('./pages/server-configuration/TweakSettings'))
const SCUpdatePreferences = React.lazy(() => import('./pages/server-configuration/UpdatePreferences'))
const SCMarketplace = React.lazy(() => import('./pages/server-configuration/Marketplace'))
// Accounts sub-pages
const AccountList = React.lazy(() => import('./pages/accounts/List'))
const AccountParkedDomains = React.lazy(() => import('./pages/accounts/ParkedDomains'))
@@ -181,6 +197,52 @@ function AppRoutes() {
<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={
+26
View File
@@ -0,0 +1,26 @@
import { Wrench } from 'lucide-react'
interface ComingSoonProps {
title: string
description?: string
}
export default function ComingSoon({ title, description }: ComingSoonProps) {
return (
<div className="space-y-4 max-w-2xl">
<div>
<h1 className="text-white text-xl font-semibold">{title}</h1>
{description && <p className="text-[#64748b] text-sm mt-0.5">{description}</p>}
</div>
<div className="flex flex-col items-center justify-center py-20 bg-[#1a1d27] border border-[#2a2d3e] rounded-xl text-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-[#0f1117] border border-[#2a2d3e] flex items-center justify-center">
<Wrench size={24} className="text-[#475569]" />
</div>
<div>
<p className="text-[#94a3b8] font-medium">Coming Soon</p>
<p className="text-[#475569] text-sm mt-1">This feature is under development.</p>
</div>
</div>
</div>
)
}
+6 -772
View File
@@ -1,781 +1,15 @@
import { useState, useMemo, useEffect, useCallback } from 'react'
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
import {
LayoutDashboard, Settings, HelpCircle, Network, BookUser, UserCog,
Wrench, Languages, HardDrive, GitFork, RefreshCw, Activity,
Users, UserCheck, ArrowRightLeft, Paintbrush, Package,
Globe, Database, Wifi, Box, Mail, HeartPulse, Lock,
ShoppingBag, Code2, Puzzle, LogOut, Menu, X,
ChevronDown, ChevronRight, Search, RotateCcw, RefreshCcw,
ArrowUpCircle, CheckCircle2, AlertCircle, Loader2
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { api } from '../api/client'
// ─── Nav data ────────────────────────────────────────────────────────────────
type NavItem = { label: string; to: string }
type NavCategory = {
id: string
label: string
icon: React.ComponentType<{ size?: number | string; className?: string }>
items: NavItem[]
}
const NAV: NavCategory[] = [
{
id: 'server-config',
label: 'Server Configuration',
icon: Settings,
items: [
{ label: 'Basic NixServer Setup', to: '/settings' },
{ label: 'Change Root Password', to: '/security' },
{ label: 'Configure Analytics', to: '/settings' },
{ label: 'Configure Cron Jobs', to: '/settings' },
{ label: 'Initial Quota Setup', to: '/settings' },
{ label: 'Link Server Nodes', to: '/settings' },
{ label: 'Server Profile', to: '/settings' },
{ label: 'Server Time', to: '/system' },
{ label: 'Statistics Software', to: '/settings' },
{ label: 'Terminal', to: '/system' },
{ label: 'Tweak Settings', to: '/settings' },
{ label: 'Update Preferences', to: '/settings' },
{ label: 'NixServer Marketplace', to: '/settings' },
],
},
{
id: 'support',
label: 'Support',
icon: HelpCircle,
items: [
{ label: 'Grant NixPanel Support Access', to: '/security' },
{ label: 'Support Center', to: '/settings' },
],
},
{
id: 'networking',
label: 'Networking Setup',
icon: Network,
items: [
{ label: 'Change Hostname', to: '/security' },
{ label: 'Resolver Configuration', to: '/security' },
{ label: 'Security Center', to: '/security' },
{ label: 'Apache mod_userdir Tweak', to: '/webserver' },
{ label: 'SMTP Restrictions', to: '/email' },
{ label: 'Compiler Access', to: '/security' },
{ label: 'Configure Security Policies', to: '/security' },
{ label: 'mXSTOP Brute Force Protection', to: '/security' },
{ label: 'Host Access Control', to: '/security' },
{ label: 'Manage External Authentications', to: '/security' },
{ label: "Manage root's SSH Keys", to: '/security' },
{ label: 'Manage Wheel Group Users', to: '/security' },
{ label: 'ModSecurity™ Configuration', to: '/security' },
{ label: 'ModSecurity™ Tools', to: '/security' },
{ label: 'ModSecurity™ Vendors', to: '/security' },
{ label: 'Password Strength Configuration', to: '/security' },
{ label: 'Security Advisor', to: '/security' },
{ label: 'Security Questions', to: '/security' },
{ label: 'Shell Fork Bomb Protection', to: '/security' },
{ label: 'SSH Password Authorization Tweak', to: '/security' },
{ label: 'Traceroute Enable/Disable', to: '/security' },
{ label: 'Two-Factor Authentication', to: '/security' },
],
},
{
id: 'contacts',
label: 'Server Contacts',
icon: BookUser,
items: [
{ label: 'Contact Manager', to: '/settings' },
{ label: 'Edit System Mail Preferences', to: '/email' },
],
},
{
id: 'resellers',
label: 'Resellers',
icon: UserCog,
items: [
{ label: 'Change Ownership of an Account', to: '/resellers' },
{ label: 'Change Ownership of Multiple Accounts', to: '/resellers' },
{ label: 'Edit Reseller Nameservers and Privileges', to: '/resellers' },
{ label: 'Email All Resellers', to: '/resellers' },
{ label: "Manage Reseller's IP Delegation", to: '/resellers' },
{ label: "Manage Reseller's Shared IP", to: '/resellers' },
{ label: 'Reseller Center', to: '/resellers' },
{ label: 'Reset Resellers', to: '/resellers' },
{ label: 'Show Reseller Accounts', to: '/resellers' },
{ label: 'View Reseller Usage', to: '/resellers' },
],
},
{
id: 'service-config',
label: 'Service Configuration',
icon: Wrench,
items: [
{ label: 'Apache Configuration', to: '/webserver' },
{ label: 'Log Rotation Configuration', to: '/settings' },
{ label: 'Web Disk Configuration', to: '/settings' },
{ label: 'Web Services Configuration', to: '/settings' },
{ label: 'Exim Configuration Manager', to: '/email' },
{ label: 'FTP Server Selection', to: '/settings' },
{ label: 'Mailserver Configuration', to: '/email' },
{ label: 'Manage Service SSL Certificates', to: '/ssl' },
{ label: 'Nameserver Selection', to: '/dns' },
{ label: 'Service Manager', to: '/services' },
],
},
{
id: 'locales',
label: 'Locales',
icon: Languages,
items: [
{ label: 'Configure Application Locales', to: '/settings' },
{ label: 'Copy a Locale', to: '/settings' },
{ label: 'Delete a Locale', to: '/settings' },
{ label: 'Edit a Locale', to: '/settings' },
{ label: 'Locale XML Download', to: '/settings' },
{ label: 'Locale XML Upload', to: '/settings' },
{ label: 'View Available Locales', to: '/settings' },
],
},
{
id: 'backup',
label: 'Backup',
icon: HardDrive,
items: [
{ label: 'Backup Configuration', to: '/backup' },
{ label: 'Backup Restoration', to: '/backup' },
{ label: 'Backup User Selection', to: '/backup' },
{ label: 'File and Directory Restoration', to: '/backup' },
],
},
{
id: 'clusters',
label: 'Clusters',
icon: GitFork,
items: [
{ label: 'Configuration Cluster', to: '/settings' },
{ label: 'DNS Cluster', to: '/dns' },
{ label: 'Remote Access Key', to: '/security' },
],
},
{
id: 'reboot',
label: 'System Reboot',
icon: RotateCcw,
items: [
{ label: 'Forceful Server Reboot', to: '/system' },
{ label: 'Graceful Server Reboot', to: '/system' },
],
},
{
id: 'server-status',
label: 'Server Status',
icon: Activity,
items: [
{ label: 'Apache Status', to: '/webserver' },
{ label: 'Daily Process Log', to: '/system' },
{ label: 'Server Information',to: '/system' },
{ label: 'Service Status', to: '/services' },
{ label: 'Task Queue Monitor',to: '/system' },
],
},
{
id: 'account-info',
label: 'Account Information',
icon: Users,
items: [
{ label: 'List Accounts', to: '/accounts/list' },
{ label: 'List Parked Domains', to: '/accounts/parked-domains'},
{ label: 'List Subdomains', to: '/accounts/subdomains' },
{ label: 'List Suspended Accounts', to: '/accounts/suspended' },
{ label: 'Show Accounts Over Quota', to: '/accounts/over-quota' },
{ label: 'View Bandwidth Usage', to: '/accounts/bandwidth' },
],
},
{
id: 'account-functions',
label: 'Account Functions',
icon: UserCheck,
items: [
{ label: "Change Site's IP Address", to: '/accounts/change-ip' },
{ label: 'Create a New Account', to: '/accounts/create' },
{ label: 'Email All Users', to: '/accounts/email-users' },
{ label: 'Force Password Change', to: '/accounts/force-password' },
{ label: 'Limit Bandwidth Usage', to: '/accounts/limit-bandwidth' },
{ label: 'Manage Account Suspension', to: '/accounts/suspension' },
{ label: 'Manage Demo Mode', to: '/accounts/demo-mode' },
{ label: 'Manage Shell Access', to: '/accounts/shell-access' },
{ label: 'Modify an Account', to: '/accounts/modify' },
{ label: 'Password Modification', to: '/accounts/password' },
{ label: 'Quota Modification', to: '/accounts/quota' },
{ label: 'Raw Apache Log Download', to: '/accounts/apache-logs' },
{ label: 'Raw NGINX® Log Download', to: '/accounts/nginx-logs' },
{ label: 'Rearrange an Account', to: '/accounts/rearrange' },
{ label: 'Reset Account Bandwidth', to: '/accounts/reset-bandwidth' },
{ label: 'Terminate Accounts', to: '/accounts/terminate' },
{ label: 'Unsuspend Bandwidth Exceeders', to: '/accounts/unsuspend-bandwidth' },
{ label: 'Upgrade/Downgrade an Account', to: '/accounts/upgrade' },
{ label: 'Web Template Editor', to: '/accounts/web-template' },
],
},
{
id: 'multi-account',
label: 'Multi Account Functions',
icon: Users,
items: [
{ label: "Change Multiple Sites' IP Addresses", to: '/accounts/bulk-ip' },
{ label: 'Modify/Upgrade Multiple Accounts', to: '/accounts/bulk-modify' },
],
},
{
id: 'transfers',
label: 'Transfers',
icon: ArrowRightLeft,
items: [
{ label: 'Convert Addon Domain to Account', to: '/accounts' },
{ label: 'Review Transfers and Restores', to: '/accounts' },
{ label: 'Transfer or Restore a cPanel Account', to: '/accounts' },
{ label: 'Transfer Tool', to: '/accounts' },
],
},
{
id: 'themes',
label: 'Themes',
icon: Paintbrush,
items: [
{ label: 'Theme Manager', to: '/settings' },
],
},
{
id: 'packages',
label: 'Packages',
icon: Package,
items: [
{ label: 'Add a Package', to: '/packages' },
{ label: 'Delete a Package', to: '/packages' },
{ label: 'Edit a Package', to: '/packages' },
{ label: 'Feature Manager', to: '/packages' },
],
},
{
id: 'dns',
label: 'DNS Functions',
icon: Globe,
items: [
{ label: 'Add a DNS Zone', to: '/dns' },
{ label: 'Add an A Entry for Hostname', to: '/dns' },
{ label: 'Delete a DNS Zone', to: '/dns' },
{ label: 'DNS Zone Manager', to: '/dns' },
{ label: 'Edit Zone Templates', to: '/dns' },
{ label: 'Email DNS Record Manager', to: '/dns' },
{ label: 'Email Routing Configuration', to: '/email' },
{ label: 'Enable DKIM/SPF Globally', to: '/email' },
{ label: 'Nameserver Record Report', to: '/dns' },
{ label: 'Park a Domain', to: '/dns' },
{ label: 'Perform a DNS Cleanup', to: '/dns' },
{ label: 'Set Zone TTL', to: '/dns' },
{ label: 'Setup/Edit Domain Forwarding',to: '/dns' },
{ label: 'Synchronize DNS Records', to: '/dns' },
],
},
{
id: 'databases',
label: 'Database Services',
icon: Database,
items: [
{ label: 'Change Database Root Password', to: '/settings' },
{ label: 'Change Database User Password', to: '/settings' },
{ label: 'Database Map Tool', to: '/settings' },
{ label: 'Edit Database Configuration', to: '/settings' },
{ label: 'Manage Database Access Hosts', to: '/settings' },
{ label: 'Manage Database Profiles', to: '/settings' },
{ label: 'Manage Database Users', to: '/settings' },
{ label: 'Manage Databases', to: '/settings' },
{ label: 'phpMyAdmin', to: '/settings' },
{ label: 'Repair Databases', to: '/settings' },
{ label: 'Show Database Processes', to: '/settings' },
{ label: 'Upgrade Database Version', to: '/settings' },
],
},
{
id: 'ip',
label: 'IP Functions',
icon: Wifi,
items: [
{ label: 'IPv6 Ranges', to: '/security' },
{ label: 'Add a New IP Address', to: '/security' },
{ label: 'Assign IPv6 Address', to: '/security' },
{ label: 'Configure Remote Service IPs', to: '/security' },
{ label: 'IP Migration Wizard', to: '/security' },
{ label: 'Rebuild the IP Address Pool', to: '/security' },
{ label: 'Show IP Address Usage', to: '/security' },
{ label: 'Show or Delete Current IPs', to: '/security' },
{ label: 'Show/Edit Reserved IPs', to: '/security' },
],
},
{
id: 'software',
label: 'Software',
icon: Box,
items: [
{ label: 'Install a Perl Module', to: '/system' },
{ label: 'Install Distro Packages', to: '/system' },
{ label: 'Module Installers', to: '/system' },
{ label: 'MultiPHP INI Editor', to: '/php' },
{ label: 'MultiPHP Manager', to: '/php' },
{ label: 'NGINX® Manager', to: '/webserver' },
{ label: 'Rebuild RPM Database', to: '/system' },
{ label: 'System Update', to: '/system' },
{ label: 'Update Server Software', to: '/system' },
{ label: 'EasyApache 4', to: '/webserver' },
],
},
{
id: 'email',
label: 'Email',
icon: Mail,
items: [
{ label: 'Email Deliverability', to: '/email' },
{ label: 'Filter Incoming Emails by Country', to: '/email' },
{ label: 'Filter Incoming Emails by Domain', to: '/email' },
{ label: 'Greylisting', to: '/email' },
{ label: 'Mail Delivery Reports', to: '/email' },
{ label: 'Mail Queue Manager', to: '/email' },
{ label: 'Mail Troubleshooter', to: '/email' },
{ label: 'Mailbox Conversion', to: '/email' },
{ label: 'Repair Mailbox Permissions', to: '/email' },
{ label: 'Spamd Startup Configuration', to: '/email' },
{ label: 'View Mail Statistics Summary', to: '/email' },
{ label: 'View Relayers', to: '/email' },
{ label: 'View Sent Summary', to: '/email' },
],
},
{
id: 'system-health',
label: 'System Health',
icon: HeartPulse,
items: [
{ label: 'Background Process Killer', to: '/system' },
{ label: 'Process Manager', to: '/system' },
{ label: 'Show Current Disk Usage', to: '/system' },
{ label: 'Show Current Running Processes', to: '/system' },
],
},
{
id: 'nixpanel',
label: 'NixPanel',
icon: LayoutDashboard,
items: [
{ label: 'Change Log', to: '/settings' },
{ label: 'Customization', to: '/settings' },
{ label: 'Manage Plugins', to: '/settings' },
{ label: 'Modify NixPanel News', to: '/settings' },
{ label: 'Reset a Mailman Password', to: '/email' },
{ label: 'Upgrade to Latest Version', to: '/settings' },
],
},
{
id: 'ssl',
label: 'SSL/TLS',
icon: Lock,
items: [
{ label: 'Generate an SSL Certificate and Signing Request', to: '/ssl' },
{ label: 'Install an SSL Certificate on a Domain', to: '/ssl' },
{ label: 'Manage AutoSSL', to: '/ssl' },
{ label: 'Manage SSL Hosts', to: '/ssl' },
{ label: 'Purchase and Install an SSL Certificate', to: '/ssl' },
{ label: 'SSL Storage Manager', to: '/ssl' },
{ label: 'SSL/TLS Configuration', to: '/ssl' },
],
},
{
id: 'market',
label: 'Market',
icon: ShoppingBag,
items: [
{ label: 'Market Provider Manager', to: '/settings' },
],
},
{
id: 'restart-services',
label: 'Restart Services',
icon: RefreshCw,
items: [
{ label: 'Database Server', to: '/services' },
{ label: 'DNS Server', to: '/services' },
{ label: 'HTTP Server (Apache)', to: '/services' },
{ label: 'IMAP Server', to: '/services' },
{ label: 'Mail Server (Exim)', to: '/services' },
{ label: 'Mailing List Manager (Mailman)', to: '/services' },
{ label: 'PHP-FPM service for Apache', to: '/services' },
{ label: 'SSH Server (OpenSSH)', to: '/services' },
],
},
{
id: 'development',
label: 'Development',
icon: Code2,
items: [
{ label: 'Apps Managed by AppConfig', to: '/settings' },
{ label: 'NixPanel Development Forum', to: '/settings' },
{ label: 'NixPanel Plugin File Generator', to: '/settings' },
{ label: 'Developer Documentation', to: '/settings' },
{ label: 'Manage API Tokens', to: '/settings' },
{ label: 'Manage Hooks', to: '/settings' },
{ label: 'OpenAPI Documentation for NixPanel', to: '/settings' },
{ label: 'OpenAPI Documentation for NixServer', to: '/settings' },
],
},
{
id: 'plugins',
label: 'Plugins',
icon: Puzzle,
items: [
{ label: 'Backuply by Softaculous', to: '/backup' },
{ label: 'ImunifyAV', to: '/security' },
{ label: 'Server Monitoring', to: '/system' },
{ label: 'SitePad Website Builder', to: '/settings' },
{ label: 'Softaculous - Instant Installs', to: '/settings' },
{ label: 'WP Toolkit', to: '/settings' },
],
},
]
// ─── Helpers ─────────────────────────────────────────────────────────────────
function isItemActive(to: string, pathname: string) {
if (to === '/') return pathname === '/'
return pathname === to || pathname.startsWith(to + '/')
}
// ─── Component ───────────────────────────────────────────────────────────────
import { useState } from 'react'
import { Outlet } from 'react-router-dom'
import { Menu, X } from 'lucide-react'
import Sidebar from './Sidebar'
export default function Layout() {
const { user, logout } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [sidebarOpen, setSidebarOpen] = useState(true)
const [search, setSearch] = useState('')
// ── Version / upgrade state ──────────────────────────────────────────────
type VersionInfo = { currentVersion: string; latestVersion: string | null; updateAvailable: boolean }
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null)
const [versionError, setVersionError] = useState(false)
const [upgradeState, setUpgradeState] = useState<'idle' | 'confirm' | 'running' | 'done' | 'error'>('idle')
const fetchVersion = useCallback(async (manual = false) => {
setVersionError(false)
// On a manual refresh, clear the stale value so the spinner shows
if (manual) setVersionInfo(null)
try {
const { data } = await api.get('/nixserver/system/version', {
timeout: 10_000,
// Cache-bust so the browser never serves a stale version response
params: { _t: Date.now() },
})
setVersionInfo(data.data)
} catch {
setVersionError(true)
}
}, [])
// Initial fetch
useEffect(() => { fetchVersion() }, [fetchVersion])
// Auto-retry every 5 s while in error state (handles the service-restart window after an upgrade)
useEffect(() => {
if (!versionError) return
const t = setTimeout(fetchVersion, 5_000)
return () => clearTimeout(t)
}, [versionError, fetchVersion])
const handleUpgrade = async () => {
if (upgradeState === 'confirm') {
setUpgradeState('running')
try {
await api.post('/nixserver/system/upgrade')
setUpgradeState('done')
} catch {
setUpgradeState('error')
}
} else {
setUpgradeState('confirm')
}
}
// ─────────────────────────────────────────────────────────────────────────
const [openCats, setOpenCats] = useState<Set<string>>(() => {
// Auto-open the category that contains the active route on first render
const active = NAV.find(cat =>
cat.items.some(item => isItemActive(item.to, location.pathname))
)
return active ? new Set([active.id]) : new Set()
})
// When the route changes (e.g. user navigates), auto-open the matching category
useEffect(() => {
const active = NAV.find(cat =>
cat.items.some(item => isItemActive(item.to, location.pathname))
)
if (active) {
setOpenCats(prev => prev.has(active.id) ? prev : new Set([...prev, active.id]))
}
}, [location.pathname])
const toggleCat = (id: string) => {
setOpenCats(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
// Filter nav based on search query
const filteredNav = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return NAV
return NAV.map(cat => ({
...cat,
items: cat.items.filter(item => item.label.toLowerCase().includes(q)),
})).filter(cat =>
cat.items.length > 0 || cat.label.toLowerCase().includes(q)
)
}, [search])
// When searching, all matching categories are forced open
const effectiveOpen = (id: string) =>
search.trim() ? filteredNav.some(c => c.id === id) : openCats.has(id)
const handleLogout = async () => {
await logout()
navigate('/login')
}
return (
<div className="flex h-screen overflow-hidden bg-[#0f1117]">
<Sidebar open={sidebarOpen} onToggle={() => setSidebarOpen(v => !v)} />
{/* ── Sidebar ─────────────────────────────────────────────────────── */}
<aside
className={`flex flex-col transition-all duration-200 bg-[#1a1d27] border-r border-[#2a2d3e] overflow-hidden flex-shrink-0 ${
sidebarOpen ? 'w-72' : 'w-0'
}`}
>
{/* Branding */}
<div className="flex items-center gap-2 px-4 py-4 border-b border-[#2a2d3e] min-h-[57px] flex-shrink-0">
<div className="w-7 h-7 rounded-lg bg-indigo-600 flex items-center justify-center flex-shrink-0">
<span className="text-white text-xs font-bold">N</span>
</div>
<div className="overflow-hidden">
<p className="text-white text-sm font-semibold leading-tight whitespace-nowrap">NixServer</p>
<p className="text-[#64748b] text-xs whitespace-nowrap">Admin Panel</p>
</div>
</div>
{/* Search */}
<div className="px-3 py-2 border-b border-[#2a2d3e] flex-shrink-0">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#4a5568] pointer-events-none" />
<input
type="text"
placeholder="Search..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full bg-[#0f1117] border border-[#2a2d3e] rounded-md text-xs text-[#94a3b8] placeholder-[#4a5568] pl-7 pr-3 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[#4a5568] hover:text-[#94a3b8]"
>
<X size={11} />
</button>
)}
</div>
</div>
{/* Nav */}
<nav className="flex-1 overflow-y-auto py-1 scrollbar-thin">
{/* Dashboard — always at the top */}
<Link
to="/"
className={`flex items-center gap-2.5 px-3 py-1.5 mx-2 my-0.5 rounded-md text-xs transition-colors ${
location.pathname === '/'
? 'bg-indigo-600/20 text-indigo-400'
: 'text-[#94a3b8] hover:bg-[#1e2130] hover:text-white'
}`}
>
<LayoutDashboard size={13} className="flex-shrink-0" />
<span className="truncate">Dashboard</span>
</Link>
{/* Categories */}
{filteredNav.map(cat => {
const isOpen = effectiveOpen(cat.id)
const Icon = cat.icon
const hasActive = cat.items.some(item => isItemActive(item.to, location.pathname))
return (
<div key={cat.id} className="mx-2 my-0.5">
{/* Category header */}
<button
onClick={() => !search.trim() && toggleCat(cat.id)}
className={`flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-xs font-medium transition-colors ${
hasActive && !search.trim()
? 'text-indigo-300'
: 'text-[#64748b] hover:text-[#94a3b8]'
} ${search.trim() ? 'cursor-default' : ''}`}
>
<Icon size={13} className="flex-shrink-0" />
<span className="flex-1 text-left truncate">{cat.label}</span>
{!search.trim() && (
isOpen
? <ChevronDown size={11} className="flex-shrink-0 opacity-60" />
: <ChevronRight size={11} className="flex-shrink-0 opacity-60" />
)}
</button>
{/* Items */}
{isOpen && (
<div className="ml-3 pl-2 border-l border-[#2a2d3e] mt-0.5 mb-1">
{cat.items.map(item => {
const active = isItemActive(item.to, location.pathname)
return (
<Link
key={item.label}
to={item.to}
className={`block px-2 py-1 rounded text-xs transition-colors truncate ${
active
? 'text-indigo-400 bg-indigo-600/10'
: 'text-[#64748b] hover:text-[#94a3b8] hover:bg-[#1e2130]'
}`}
>
{item.label}
</Link>
)
})}
</div>
)}
</div>
)
})}
{/* Empty search state */}
{search.trim() && filteredNav.length === 0 && (
<p className="text-center text-[#4a5568] text-xs py-6">No results</p>
)}
</nav>
{/* Version + upgrade */}
<div className="border-t border-[#2a2d3e] px-3 pt-2.5 pb-1 flex-shrink-0">
{versionInfo ? (
<div className="space-y-1.5">
{/* Version row */}
<div className="flex items-center justify-between px-1">
<div className="flex items-center gap-1.5">
{versionInfo.updateAvailable ? (
<AlertCircle size={11} className="text-amber-400 flex-shrink-0" />
) : (
<CheckCircle2 size={11} className="text-emerald-500 flex-shrink-0" />
)}
<span className="text-[#64748b] text-[10px]">
v{versionInfo.currentVersion}
</span>
{versionInfo.updateAvailable && versionInfo.latestVersion && (
<span className="text-amber-400 text-[10px]">
→ v{versionInfo.latestVersion}
</span>
)}
</div>
<button
onClick={() => fetchVersion(true)}
title="Check for updates"
className="text-[#4a5568] hover:text-[#94a3b8] transition-colors"
>
<RefreshCcw size={10} />
</button>
</div>
{/* Upgrade button */}
{upgradeState === 'done' ? (
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-emerald-600/10 text-emerald-400 text-[10px]">
<CheckCircle2 size={11} />
<span>Upgrade started — panel restarting…</span>
</div>
) : upgradeState === 'error' ? (
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-red-600/10 text-red-400 text-[10px]">
<AlertCircle size={11} />
<span>Upgrade failed — check logs</span>
</div>
) : (
<div className="flex gap-1.5">
<button
onClick={handleUpgrade}
disabled={upgradeState === 'running'}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[10px] font-medium transition-colors ${
upgradeState === 'confirm'
? 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/30'
: versionInfo.updateAvailable
? 'bg-indigo-600/20 text-indigo-300 hover:bg-indigo-600/30'
: 'bg-[#1e2130] text-[#64748b] hover:text-[#94a3b8]'
}`}
>
{upgradeState === 'running' ? (
<><Loader2 size={10} className="animate-spin" /><span>Upgrading…</span></>
) : upgradeState === 'confirm' ? (
<><ArrowUpCircle size={10} /><span>Confirm upgrade</span></>
) : versionInfo.updateAvailable ? (
<><ArrowUpCircle size={10} /><span>Upgrade available</span></>
) : (
<><ArrowUpCircle size={10} /><span>Upgrade panel</span></>
)}
</button>
{upgradeState === 'confirm' && (
<button
onClick={() => setUpgradeState('idle')}
className="px-2 py-1.5 rounded-md bg-[#1e2130] text-[#64748b] hover:text-[#94a3b8] text-[10px] transition-colors"
>
Cancel
</button>
)}
</div>
)}
</div>
) : versionError ? (
<div className="flex items-center justify-between px-1 py-1">
<span className="text-[#4a5568] text-[10px]">Version unavailable</span>
<button onClick={() => fetchVersion(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>
{/* ── Main ────────────────────────────────────────────────────────── */}
<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] flex-shrink-0">
@@ -791,7 +25,7 @@ export default function Layout() {
</span>
</header>
{/* Content */}
{/* Page content */}
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
+46
View File
@@ -0,0 +1,46 @@
import { Link } from 'react-router-dom'
import type { ComponentType } from 'react'
interface SectionCardProps {
label: string
description?: string
to: string
icon?: ComponentType<{ size?: number | 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>
)
}
+340
View File
@@ -0,0 +1,340 @@
import { useState, useMemo, useEffect, useCallback } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import {
LayoutDashboard, LogOut, Menu, X,
ChevronDown, ChevronRight, Search, RefreshCcw,
ArrowUpCircle, CheckCircle2, AlertCircle, Loader2, ExternalLink,
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { api } from '../api/client'
import { NAV, isItemActive } from './navData'
type VersionInfo = {
currentVersion: string
latestVersion: string | null
updateAvailable: boolean
}
type UpgradeState = 'idle' | 'confirm' | 'running' | 'done' | 'error'
interface SidebarProps {
open: boolean
onToggle: () => void
}
export default function Sidebar({ open, onToggle }: SidebarProps) {
const { user, logout } = useAuth()
const location = useLocation()
const navigate = useNavigate()
const [search, setSearch] = useState('')
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null)
const [versionError, setVersionError] = useState(false)
const [upgradeState, setUpgradeState] = useState<UpgradeState>('idle')
// ── Version checking ───────────────────────────────────────────────────────
const fetchVersion = useCallback(async (manual = false) => {
setVersionError(false)
if (manual) setVersionInfo(null)
try {
const { data } = await api.get('/nixserver/system/version', {
timeout: 10_000,
params: { _t: Date.now() },
})
setVersionInfo(data.data)
} catch {
setVersionError(true)
}
}, [])
useEffect(() => { fetchVersion() }, [fetchVersion])
useEffect(() => {
if (!versionError) return
const t = setTimeout(fetchVersion, 5_000)
return () => clearTimeout(t)
}, [versionError, fetchVersion])
const handleUpgrade = async () => {
if (upgradeState === 'confirm') {
setUpgradeState('running')
try {
await api.post('/nixserver/system/upgrade')
setUpgradeState('done')
} catch {
setUpgradeState('error')
}
} else {
setUpgradeState('confirm')
}
}
// ── Category open/close ────────────────────────────────────────────────────
const [openCats, setOpenCats] = useState<Set<string>>(() => {
const active = NAV.find(cat =>
cat.items.some(item => isItemActive(item.to, location.pathname))
)
return active ? new Set([active.id]) : new Set()
})
useEffect(() => {
const active = NAV.find(cat =>
cat.items.some(item => isItemActive(item.to, location.pathname))
)
if (active) {
setOpenCats(prev => prev.has(active.id) ? prev : new Set([...prev, active.id]))
}
}, [location.pathname])
const toggleCat = (id: string) => {
setOpenCats(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
// ── Search filter ──────────────────────────────────────────────────────────
const filteredNav = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return NAV
return NAV.map(cat => ({
...cat,
items: cat.items.filter(item => item.label.toLowerCase().includes(q)),
})).filter(cat => cat.items.length > 0 || cat.label.toLowerCase().includes(q))
}, [search])
const effectiveOpen = (id: string) =>
search.trim() ? filteredNav.some(c => c.id === id) : openCats.has(id)
const handleLogout = async () => {
await logout()
navigate('/login')
}
// ── Render ─────────────────────────────────────────────────────────────────
return (
<aside
className={`flex flex-col transition-all duration-200 bg-[#1a1d27] border-r border-[#2a2d3e] overflow-hidden flex-shrink-0 ${
open ? 'w-72' : 'w-0'
}`}
>
{/* Branding + toggle */}
<div className="flex items-center gap-2 px-4 py-4 border-b border-[#2a2d3e] min-h-[57px] flex-shrink-0">
<button onClick={onToggle} className="text-[#64748b] hover:text-white transition-colors mr-1">
{open ? <X size={16} /> : <Menu size={16} />}
</button>
<div className="w-7 h-7 rounded-lg bg-indigo-600 flex items-center justify-center flex-shrink-0">
<span className="text-white text-xs font-bold">N</span>
</div>
<div className="overflow-hidden">
<p className="text-white text-sm font-semibold leading-tight whitespace-nowrap">NixServer</p>
<p className="text-[#64748b] text-xs whitespace-nowrap">Admin Panel</p>
</div>
</div>
{/* Search */}
<div className="px-3 py-2 border-b border-[#2a2d3e] flex-shrink-0">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#4a5568] pointer-events-none" />
<input
type="text"
placeholder="Search..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full bg-[#0f1117] border border-[#2a2d3e] rounded-md text-xs text-[#94a3b8] placeholder-[#4a5568] pl-7 pr-3 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[#4a5568] hover:text-[#94a3b8]"
>
<X size={11} />
</button>
)}
</div>
</div>
{/* Nav */}
<nav className="flex-1 overflow-y-auto py-1 scrollbar-thin">
<Link
to="/"
className={`flex items-center gap-2.5 px-3 py-1.5 mx-2 my-0.5 rounded-md text-xs transition-colors ${
location.pathname === '/'
? 'bg-indigo-600/20 text-indigo-400'
: 'text-[#94a3b8] hover:bg-[#1e2130] hover:text-white'
}`}
>
<LayoutDashboard size={13} className="flex-shrink-0" />
<span className="truncate">Dashboard</span>
</Link>
{filteredNav.map(cat => {
const isOpen = effectiveOpen(cat.id)
const Icon = cat.icon
const hasActive = cat.items.some(item => isItemActive(item.to, location.pathname))
return (
<div key={cat.id} className="mx-2 my-0.5">
<div className="flex items-center gap-1">
<button
onClick={() => !search.trim() && toggleCat(cat.id)}
className={`flex items-center gap-2 flex-1 px-2 py-1.5 rounded-md text-xs font-medium transition-colors ${
hasActive && !search.trim()
? 'text-indigo-300'
: 'text-[#64748b] hover:text-[#94a3b8]'
} ${search.trim() ? 'cursor-default' : ''}`}
>
<Icon size={13} className="flex-shrink-0" />
<span className="flex-1 text-left truncate">{cat.label}</span>
{!search.trim() && (
isOpen
? <ChevronDown size={11} className="flex-shrink-0 opacity-60" />
: <ChevronRight size={11} className="flex-shrink-0 opacity-60" />
)}
</button>
{cat.to && (
<Link
to={cat.to}
title={`${cat.label} overview`}
className="p-1 text-[#374151] hover:text-indigo-400 transition-colors flex-shrink-0"
>
<ExternalLink size={10} />
</Link>
)}
</div>
{isOpen && (
<div className="ml-3 pl-2 border-l border-[#2a2d3e] mt-0.5 mb-1">
{cat.items.map(item => {
const active = isItemActive(item.to, location.pathname)
return (
<Link
key={item.label}
to={item.to}
className={`block px-2 py-1 rounded text-xs transition-colors truncate ${
active
? 'text-indigo-400 bg-indigo-600/10'
: 'text-[#64748b] hover:text-[#94a3b8] hover:bg-[#1e2130]'
}`}
>
{item.label}
</Link>
)
})}
</div>
)}
</div>
)
})}
{search.trim() && filteredNav.length === 0 && (
<p className="text-center text-[#4a5568] text-xs py-6">No results</p>
)}
</nav>
{/* Version + upgrade */}
<div className="border-t border-[#2a2d3e] px-3 pt-2.5 pb-1 flex-shrink-0">
{versionInfo ? (
<div className="space-y-1.5">
<div className="flex items-center justify-between px-1">
<div className="flex items-center gap-1.5">
{versionInfo.updateAvailable ? (
<AlertCircle size={11} className="text-amber-400 flex-shrink-0" />
) : (
<CheckCircle2 size={11} className="text-emerald-500 flex-shrink-0" />
)}
<span className="text-[#64748b] text-[10px]">v{versionInfo.currentVersion}</span>
{versionInfo.updateAvailable && versionInfo.latestVersion && (
<span className="text-amber-400 text-[10px]">→ v{versionInfo.latestVersion}</span>
)}
</div>
<button
onClick={() => fetchVersion(true)}
title="Check for updates"
className="text-[#4a5568] hover:text-[#94a3b8] transition-colors"
>
<RefreshCcw size={10} />
</button>
</div>
{upgradeState === 'done' ? (
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-emerald-600/10 text-emerald-400 text-[10px]">
<CheckCircle2 size={11} />
<span>Upgrade started — panel restarting…</span>
</div>
) : upgradeState === 'error' ? (
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-red-600/10 text-red-400 text-[10px]">
<AlertCircle size={11} />
<span>Upgrade failed — check logs</span>
</div>
) : (
<div className="flex gap-1.5">
<button
onClick={handleUpgrade}
disabled={upgradeState === 'running'}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[10px] font-medium transition-colors ${
upgradeState === 'confirm'
? 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/30'
: versionInfo.updateAvailable
? 'bg-indigo-600/20 text-indigo-300 hover:bg-indigo-600/30'
: 'bg-[#1e2130] text-[#64748b] hover:text-[#94a3b8]'
}`}
>
{upgradeState === 'running' ? (
<><Loader2 size={10} className="animate-spin" /><span>Upgrading…</span></>
) : upgradeState === 'confirm' ? (
<><ArrowUpCircle size={10} /><span>Confirm upgrade</span></>
) : versionInfo.updateAvailable ? (
<><ArrowUpCircle size={10} /><span>Upgrade available</span></>
) : (
<><ArrowUpCircle size={10} /><span>Upgrade panel</span></>
)}
</button>
{upgradeState === 'confirm' && (
<button
onClick={() => setUpgradeState('idle')}
className="px-2 py-1.5 rounded-md bg-[#1e2130] text-[#64748b] hover:text-[#94a3b8] text-[10px] transition-colors"
>
Cancel
</button>
)}
</div>
)}
</div>
) : versionError ? (
<div className="flex items-center justify-between px-1 py-1">
<span className="text-[#4a5568] text-[10px]">Version unavailable</span>
<button onClick={() => fetchVersion(true)} title="Retry" className="text-[#4a5568] hover:text-[#94a3b8] transition-colors">
<RefreshCcw size={10} />
</button>
</div>
) : (
<div className="flex items-center gap-1.5 px-1 py-1">
<Loader2 size={10} className="animate-spin text-[#4a5568]" />
<span className="text-[#4a5568] text-[10px]">Checking version…</span>
</div>
)}
</div>
{/* User + logout */}
<div className="border-t border-[#2a2d3e] p-3 flex-shrink-0">
<div className="px-1 mb-2">
<p className="text-white text-xs font-medium truncate">{user?.username}</p>
<p className="text-[#64748b] text-xs truncate">{user?.role}</p>
</div>
<button
onClick={handleLogout}
className="flex items-center gap-2 px-3 py-2 w-full rounded-lg text-[#94a3b8] hover:bg-[#1e2130] hover:text-red-400 transition-colors text-xs"
>
<LogOut size={14} className="flex-shrink-0" />
<span>Logout</span>
</button>
</div>
</aside>
)
}
+443
View File
@@ -0,0 +1,443 @@
import type { ComponentType } from 'react'
import {
LayoutDashboard, Settings, HelpCircle, Network, BookUser, UserCog,
Wrench, Languages, HardDrive, GitFork, RefreshCw, Activity,
Users, UserCheck, ArrowRightLeft, Paintbrush, Package,
Globe, Database, Wifi, Box, Mail, HeartPulse, Lock,
ShoppingBag, Code2, Puzzle, RotateCcw,
} from 'lucide-react'
export type NavItem = { label: string; to: string }
export type NavCategory = {
id: string
label: string
icon: ComponentType<{ size?: number | string; className?: string }>
to?: string // optional link to a section index page
items: NavItem[]
}
export function isItemActive(to: string, pathname: string): boolean {
if (to === '/') return pathname === '/'
return pathname === to || pathname.startsWith(to + '/')
}
export const NAV: NavCategory[] = [
{
id: 'server-config',
label: 'Server Configuration',
icon: Settings,
to: '/server-configuration',
items: [
{ label: 'Basic NixServer Setup', to: '/server-configuration/basic-setup' },
{ label: 'Change Root Password', to: '/server-configuration/change-root' },
{ label: 'Configure Analytics', to: '/server-configuration/analytics' },
{ label: 'Configure Cron Jobs', to: '/server-configuration/cron-jobs' },
{ label: 'Initial Quota Setup', to: '/server-configuration/quota-setup' },
{ label: 'Link Server Nodes', to: '/server-configuration/link-nodes' },
{ label: 'Server Profile', to: '/server-configuration/server-profile' },
{ label: 'Server Time', to: '/server-configuration/server-time' },
{ label: 'Statistics Software', to: '/server-configuration/statistics-software' },
{ label: 'Terminal', to: '/server-configuration/terminal' },
{ label: 'Tweak Settings', to: '/server-configuration/tweak-settings' },
{ label: 'Update Preferences', to: '/server-configuration/update-preferences' },
{ label: 'NixServer Marketplace', to: '/server-configuration/marketplace' },
],
},
{
id: 'support',
label: 'Support',
icon: HelpCircle,
items: [
{ label: 'Grant NixPanel Support Access', to: '/security' },
{ label: 'Support Center', to: '/settings' },
],
},
{
id: 'networking',
label: 'Networking Setup',
icon: Network,
items: [
{ label: 'Change Hostname', to: '/security' },
{ label: 'Resolver Configuration', to: '/security' },
{ label: 'Security Center', to: '/security' },
{ label: 'Apache mod_userdir Tweak', to: '/webserver' },
{ label: 'SMTP Restrictions', to: '/email' },
{ label: 'Compiler Access', to: '/security' },
{ label: 'Configure Security Policies', to: '/security' },
{ label: 'mXSTOP Brute Force Protection', to: '/security' },
{ label: 'Host Access Control', to: '/security' },
{ label: 'Manage External Authentications', to: '/security' },
{ label: "Manage root's SSH Keys", to: '/security' },
{ label: 'Manage Wheel Group Users', to: '/security' },
{ label: 'ModSecurity™ Configuration', to: '/security' },
{ label: 'ModSecurity™ Tools', to: '/security' },
{ label: 'ModSecurity™ Vendors', to: '/security' },
{ label: 'Password Strength Configuration', to: '/security' },
{ label: 'Security Advisor', to: '/security' },
{ label: 'Security Questions', to: '/security' },
{ label: 'Shell Fork Bomb Protection', to: '/security' },
{ label: 'SSH Password Authorization Tweak', to: '/security' },
{ label: 'Traceroute Enable/Disable', to: '/security' },
{ label: 'Two-Factor Authentication', to: '/security' },
],
},
{
id: 'contacts',
label: 'Server Contacts',
icon: BookUser,
items: [
{ label: 'Contact Manager', to: '/settings' },
{ label: 'Edit System Mail Preferences', to: '/email' },
],
},
{
id: 'resellers',
label: 'Resellers',
icon: UserCog,
items: [
{ label: 'Change Ownership of an Account', to: '/resellers' },
{ label: 'Change Ownership of Multiple Accounts', to: '/resellers' },
{ label: 'Edit Reseller Nameservers and Privileges', to: '/resellers' },
{ label: 'Email All Resellers', to: '/resellers' },
{ label: "Manage Reseller's IP Delegation", to: '/resellers' },
{ label: "Manage Reseller's Shared IP", to: '/resellers' },
{ label: 'Reseller Center', to: '/resellers' },
{ label: 'Reset Resellers', to: '/resellers' },
{ label: 'Show Reseller Accounts', to: '/resellers' },
{ label: 'View Reseller Usage', to: '/resellers' },
],
},
{
id: 'service-config',
label: 'Service Configuration',
icon: Wrench,
items: [
{ label: 'Apache Configuration', to: '/webserver' },
{ label: 'Log Rotation Configuration', to: '/settings' },
{ label: 'Web Disk Configuration', to: '/settings' },
{ label: 'Web Services Configuration', to: '/settings' },
{ label: 'Exim Configuration Manager', to: '/email' },
{ label: 'FTP Server Selection', to: '/settings' },
{ label: 'Mailserver Configuration', to: '/email' },
{ label: 'Manage Service SSL Certificates', to: '/ssl' },
{ label: 'Nameserver Selection', to: '/dns' },
{ label: 'Service Manager', to: '/services' },
],
},
{
id: 'locales',
label: 'Locales',
icon: Languages,
items: [
{ label: 'Configure Application Locales', to: '/settings' },
{ label: 'Copy a Locale', to: '/settings' },
{ label: 'Delete a Locale', to: '/settings' },
{ label: 'Edit a Locale', to: '/settings' },
{ label: 'Locale XML Download', to: '/settings' },
{ label: 'Locale XML Upload', to: '/settings' },
{ label: 'View Available Locales', to: '/settings' },
],
},
{
id: 'backup',
label: 'Backup',
icon: HardDrive,
items: [
{ label: 'Backup Configuration', to: '/backup' },
{ label: 'Backup Restoration', to: '/backup' },
{ label: 'Backup User Selection', to: '/backup' },
{ label: 'File and Directory Restoration', to: '/backup' },
],
},
{
id: 'clusters',
label: 'Clusters',
icon: GitFork,
items: [
{ label: 'Configuration Cluster', to: '/settings' },
{ label: 'DNS Cluster', to: '/dns' },
{ label: 'Remote Access Key', to: '/security' },
],
},
{
id: 'reboot',
label: 'System Reboot',
icon: RotateCcw,
items: [
{ label: 'Forceful Server Reboot', to: '/system' },
{ label: 'Graceful Server Reboot', to: '/system' },
],
},
{
id: 'server-status',
label: 'Server Status',
icon: Activity,
items: [
{ label: 'Apache Status', to: '/webserver' },
{ label: 'Daily Process Log', to: '/system' },
{ label: 'Server Information', to: '/system' },
{ label: 'Service Status', to: '/services' },
{ label: 'Task Queue Monitor', to: '/system' },
],
},
{
id: 'account-info',
label: 'Account Information',
icon: Users,
items: [
{ label: 'List Accounts', to: '/accounts/list' },
{ label: 'List Parked Domains', to: '/accounts/parked-domains' },
{ label: 'List Subdomains', to: '/accounts/subdomains' },
{ label: 'List Suspended Accounts', to: '/accounts/suspended' },
{ label: 'Show Accounts Over Quota', to: '/accounts/over-quota' },
{ label: 'View Bandwidth Usage', to: '/accounts/bandwidth' },
],
},
{
id: 'account-functions',
label: 'Account Functions',
icon: UserCheck,
items: [
{ label: "Change Site's IP Address", to: '/accounts/change-ip' },
{ label: 'Create a New Account', to: '/accounts/create' },
{ label: 'Email All Users', to: '/accounts/email-users' },
{ label: 'Force Password Change', to: '/accounts/force-password' },
{ label: 'Limit Bandwidth Usage', to: '/accounts/limit-bandwidth' },
{ label: 'Manage Account Suspension', to: '/accounts/suspension' },
{ label: 'Manage Demo Mode', to: '/accounts/demo-mode' },
{ label: 'Manage Shell Access', to: '/accounts/shell-access' },
{ label: 'Modify an Account', to: '/accounts/modify' },
{ label: 'Password Modification', to: '/accounts/password' },
{ label: 'Quota Modification', to: '/accounts/quota' },
{ label: 'Raw Apache Log Download', to: '/accounts/apache-logs' },
{ label: 'Raw NGINX® Log Download', to: '/accounts/nginx-logs' },
{ label: 'Rearrange an Account', to: '/accounts/rearrange' },
{ label: 'Reset Account Bandwidth', to: '/accounts/reset-bandwidth' },
{ label: 'Terminate Accounts', to: '/accounts/terminate' },
{ label: 'Unsuspend Bandwidth Exceeders',to: '/accounts/unsuspend-bandwidth'},
{ label: 'Upgrade/Downgrade an Account', to: '/accounts/upgrade' },
{ label: 'Web Template Editor', to: '/accounts/web-template' },
],
},
{
id: 'multi-account',
label: 'Multi Account Functions',
icon: Users,
items: [
{ label: "Change Multiple Sites' IP Addresses", to: '/accounts/bulk-ip' },
{ label: 'Modify/Upgrade Multiple Accounts', to: '/accounts/bulk-modify' },
],
},
{
id: 'transfers',
label: 'Transfers',
icon: ArrowRightLeft,
items: [
{ label: 'Convert Addon Domain to Account', to: '/accounts' },
{ label: 'Review Transfers and Restores', to: '/accounts' },
{ label: 'Transfer or Restore a cPanel Account', to: '/accounts' },
{ label: 'Transfer Tool', to: '/accounts' },
],
},
{
id: 'themes',
label: 'Themes',
icon: Paintbrush,
items: [
{ label: 'Theme Manager', to: '/settings' },
],
},
{
id: 'packages',
label: 'Packages',
icon: Package,
items: [
{ label: 'Add a Package', to: '/packages' },
{ label: 'Delete a Package', to: '/packages' },
{ label: 'Edit a Package', to: '/packages' },
{ label: 'Feature Manager', to: '/packages' },
],
},
{
id: 'dns',
label: 'DNS Functions',
icon: Globe,
items: [
{ label: 'Add a DNS Zone', to: '/dns' },
{ label: 'Add an A Entry for Hostname', to: '/dns' },
{ label: 'Delete a DNS Zone', to: '/dns' },
{ label: 'DNS Zone Manager', to: '/dns' },
{ label: 'Edit Zone Templates', to: '/dns' },
{ label: 'Email DNS Record Manager', to: '/dns' },
{ label: 'Email Routing Configuration', to: '/email' },
{ label: 'Enable DKIM/SPF Globally', to: '/email' },
{ label: 'Nameserver Record Report', to: '/dns' },
{ label: 'Park a Domain', to: '/dns' },
{ label: 'Perform a DNS Cleanup', to: '/dns' },
{ label: 'Set Zone TTL', to: '/dns' },
{ label: 'Setup/Edit Domain Forwarding', to: '/dns' },
{ label: 'Synchronize DNS Records', to: '/dns' },
],
},
{
id: 'databases',
label: 'Database Services',
icon: Database,
items: [
{ label: 'Change Database Root Password', to: '/settings' },
{ label: 'Change Database User Password', to: '/settings' },
{ label: 'Database Map Tool', to: '/settings' },
{ label: 'Edit Database Configuration', to: '/settings' },
{ label: 'Manage Database Access Hosts', to: '/settings' },
{ label: 'Manage Database Profiles', to: '/settings' },
{ label: 'Manage Database Users', to: '/settings' },
{ label: 'Manage Databases', to: '/settings' },
{ label: 'phpMyAdmin', to: '/settings' },
{ label: 'Repair Databases', to: '/settings' },
{ label: 'Show Database Processes', to: '/settings' },
{ label: 'Upgrade Database Version', to: '/settings' },
],
},
{
id: 'ip',
label: 'IP Functions',
icon: Wifi,
items: [
{ label: 'IPv6 Ranges', to: '/security' },
{ label: 'Add a New IP Address', to: '/security' },
{ label: 'Assign IPv6 Address', to: '/security' },
{ label: 'Configure Remote Service IPs', to: '/security' },
{ label: 'IP Migration Wizard', to: '/security' },
{ label: 'Rebuild the IP Address Pool', to: '/security' },
{ label: 'Show IP Address Usage', to: '/security' },
{ label: 'Show or Delete Current IPs', to: '/security' },
{ label: 'Show/Edit Reserved IPs', to: '/security' },
],
},
{
id: 'software',
label: 'Software',
icon: Box,
items: [
{ label: 'Install a Perl Module', to: '/system' },
{ label: 'Install Distro Packages', to: '/system' },
{ label: 'Module Installers', to: '/system' },
{ label: 'MultiPHP INI Editor', to: '/php' },
{ label: 'MultiPHP Manager', to: '/php' },
{ label: 'NGINX® Manager', to: '/webserver' },
{ label: 'Rebuild RPM Database', to: '/system' },
{ label: 'System Update', to: '/system' },
{ label: 'Update Server Software', to: '/system' },
{ label: 'EasyApache 4', to: '/webserver' },
],
},
{
id: 'email',
label: 'Email',
icon: Mail,
items: [
{ label: 'Email Deliverability', to: '/email' },
{ label: 'Filter Incoming Emails by Country', to: '/email' },
{ label: 'Filter Incoming Emails by Domain', to: '/email' },
{ label: 'Greylisting', to: '/email' },
{ label: 'Mail Delivery Reports', to: '/email' },
{ label: 'Mail Queue Manager', to: '/email' },
{ label: 'Mail Troubleshooter', to: '/email' },
{ label: 'Mailbox Conversion', to: '/email' },
{ label: 'Repair Mailbox Permissions', to: '/email' },
{ label: 'Spamd Startup Configuration', to: '/email' },
{ label: 'View Mail Statistics Summary', to: '/email' },
{ label: 'View Relayers', to: '/email' },
{ label: 'View Sent Summary', to: '/email' },
],
},
{
id: 'system-health',
label: 'System Health',
icon: HeartPulse,
items: [
{ label: 'Background Process Killer', to: '/system' },
{ label: 'Process Manager', to: '/system' },
{ label: 'Show Current Disk Usage', to: '/system' },
{ label: 'Show Current Running Processes', to: '/system' },
],
},
{
id: 'nixpanel',
label: 'NixPanel',
icon: LayoutDashboard,
items: [
{ label: 'Change Log', to: '/settings' },
{ label: 'Customization', to: '/settings' },
{ label: 'Manage Plugins', to: '/settings' },
{ label: 'Modify NixPanel News', to: '/settings' },
{ label: 'Reset a Mailman Password', to: '/email' },
{ label: 'Upgrade to Latest Version',to: '/settings' },
],
},
{
id: 'ssl',
label: 'SSL/TLS',
icon: Lock,
items: [
{ label: 'Generate an SSL Certificate and Signing Request', to: '/ssl' },
{ label: 'Install an SSL Certificate on a Domain', to: '/ssl' },
{ label: 'Manage AutoSSL', to: '/ssl' },
{ label: 'Manage SSL Hosts', to: '/ssl' },
{ label: 'Purchase and Install an SSL Certificate', to: '/ssl' },
{ label: 'SSL Storage Manager', to: '/ssl' },
{ label: 'SSL/TLS Configuration', to: '/ssl' },
],
},
{
id: 'market',
label: 'Market',
icon: ShoppingBag,
items: [
{ label: 'Market Provider Manager', to: '/settings' },
],
},
{
id: 'restart-services',
label: 'Restart Services',
icon: RefreshCw,
items: [
{ label: 'Database Server', to: '/services' },
{ label: 'DNS Server', to: '/services' },
{ label: 'HTTP Server (Apache)', to: '/services' },
{ label: 'IMAP Server', to: '/services' },
{ label: 'Mail Server (Exim)', to: '/services' },
{ label: 'Mailing List Manager (Mailman)', to: '/services' },
{ label: 'PHP-FPM service for Apache', to: '/services' },
{ label: 'SSH Server (OpenSSH)', to: '/services' },
],
},
{
id: 'development',
label: 'Development',
icon: Code2,
items: [
{ label: 'Apps Managed by AppConfig', to: '/settings' },
{ label: 'NixPanel Development Forum', to: '/settings' },
{ label: 'NixPanel Plugin File Generator', to: '/settings' },
{ label: 'Developer Documentation', to: '/settings' },
{ label: 'Manage API Tokens', to: '/settings' },
{ label: 'Manage Hooks', to: '/settings' },
{ label: 'OpenAPI Documentation for NixPanel', to: '/settings' },
{ label: 'OpenAPI Documentation for NixServer', to: '/settings' },
],
},
{
id: 'plugins',
label: 'Plugins',
icon: Puzzle,
items: [
{ label: 'Backuply by Softaculous', to: '/backup' },
{ label: 'ImunifyAV', to: '/security' },
{ label: 'Server Monitoring', to: '/system' },
{ label: 'SitePad Website Builder', to: '/settings' },
{ label: 'Softaculous - Instant Installs', to: '/settings' },
{ label: 'WP Toolkit', to: '/settings' },
],
},
]
+101 -25
View File
@@ -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>
)
}
+130
View File
@@ -0,0 +1,130 @@
import { AlertTriangle } from 'lucide-react'
import { Field, TextInput, RadioGroup, get } from './FormControls'
interface Props {
settings: Record<string, string>
onChange: (key: string, value: string) => void
interfaces: string[]
}
export default function BasicTab({ settings, onChange, interfaces }: Props) {
const ethdev = get(settings, 'ETHDEV')
const isOther = ethdev !== '' && !interfaces.includes(ethdev)
return (
<div>
<Field label="Shared IPv4 Address" hint="ADDR — Main server IPv4 address">
<TextInput
value={get(settings, 'ADDR')}
onChange={v => onChange('ADDR', v)}
placeholder="192.168.1.100"
/>
</Field>
<Field label="Shared IPv6 Address" hint="ADDR6 — Leave blank if not using IPv6">
<TextInput
value={get(settings, 'ADDR6')}
onChange={v => onChange('ADDR6', v)}
placeholder="2001:db8::1"
/>
</Field>
<Field label="Ethernet Device" hint="ETHDEV — Network interface for IP configuration">
<div className="flex gap-2">
<select
value={isOther ? '__other__' : ethdev}
onChange={e => {
if (e.target.value !== '__other__') onChange('ETHDEV', e.target.value)
else onChange('ETHDEV', '')
}}
className="bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
>
<option value="">— select —</option>
{interfaces.map(iface => (
<option key={iface} value={iface}>{iface}</option>
))}
<option value="__other__">Other…</option>
</select>
{isOther && (
<TextInput
value={ethdev}
onChange={v => onChange('ETHDEV', v)}
placeholder="eth0"
/>
)}
</div>
</Field>
<Field label="Home Directory" hint="HOMEDIR — Default: /home">
<TextInput
value={get(settings, 'HOMEDIR')}
onChange={v => onChange('HOMEDIR', v)}
placeholder="/home"
/>
</Field>
<Field
label="Home Directory Prefix Match"
hint="HOMEMATCH — Must be present in home path. Default: home"
>
<TextInput
value={get(settings, 'HOMEMATCH')}
onChange={v => onChange('HOMEMATCH', v)}
placeholder="home"
/>
</Field>
<Field
label="Auto-Create CGI-bin Alias"
hint="SCRIPTALIAS — Automatically create a cgi-bin for new accounts"
>
<RadioGroup
value={get(settings, 'SCRIPTALIAS')}
onChange={v => onChange('SCRIPTALIAS', v)}
options={[
{ value: 'yes', label: 'Yes' },
{ value: 'no', label: 'No' },
]}
/>
</Field>
<Field label="DNS TTL" hint="TTL — Time-to-live for DNS records (seconds). Default: 14400">
<TextInput
value={get(settings, 'TTL')}
onChange={v => onChange('TTL', v)}
placeholder="14400"
/>
</Field>
<Field label="Nameserver TTL" hint="NSTTL — Time-to-live for NS records (seconds). Default: 86400">
<TextInput
value={get(settings, 'NSTTL')}
onChange={v => onChange('NSTTL', v)}
placeholder="86400"
/>
</Field>
<Field label="Apache Log Style" hint="LOGSTYLE — Log format used in Apache vhost configs">
<div className="space-y-2">
<RadioGroup
value={get(settings, 'LOGSTYLE')}
onChange={v => onChange('LOGSTYLE', v)}
options={[
{ value: 'combined', label: 'Combined' },
{ value: 'common', label: 'Common' },
]}
/>
{get(settings, 'LOGSTYLE') === 'common' && (
<div className="flex items-start gap-2 bg-yellow-900/20 border border-yellow-700/40 rounded-lg px-3 py-2 text-yellow-400 text-xs">
<AlertTriangle size={14} className="mt-0.5 flex-shrink-0" />
<span>
<strong>Warning:</strong> "Common" log format omits the referrer and user-agent
fields. "Combined" is recommended for most setups.
</span>
</div>
)}
</div>
</Field>
</div>
)
}
+107
View File
@@ -0,0 +1,107 @@
import { Field, TextInput, get } from './FormControls'
interface Props {
settings: Record<string, string>
onChange: (key: string, value: string) => void
}
export default function ContactTab({ settings, onChange }: Props) {
return (
<div>
<Field
label="Contact Email Address(es)"
hint="Separate multiple addresses with spaces. Used for system notifications."
>
<TextInput
value={get(settings, 'CONTACTEMAIL')}
onChange={v => onChange('CONTACTEMAIL', v)}
placeholder="[email protected]"
type="email"
/>
<p className="text-[#475569] text-xs mt-1">
Enter the email address(es) to which you would like server notification messages to be
sent. Separate multiple addresses with spaces.
</p>
</Field>
<Field label="Email Sender Name" hint="EMAILFROMNAME — Name shown in From: header">
<TextInput
value={get(settings, 'EMAILFROMNAME')}
onChange={v => onChange('EMAILFROMNAME', v)}
placeholder="NixPanel"
/>
</Field>
<Field label="Reply-To Email Address" hint="EMAILREPLYTO — Optional reply-to header">
<TextInput
value={get(settings, 'EMAILREPLYTO')}
onChange={v => onChange('EMAILREPLYTO', v)}
placeholder="[email protected]"
type="email"
/>
</Field>
<Field label="Pager / Cellular Email" hint="CONTACTPAGER — For critical alerts (e.g. SMS gateway)">
<TextInput
value={get(settings, 'CONTACTPAGER')}
onChange={v => onChange('CONTACTPAGER', v)}
placeholder="[email protected]"
/>
</Field>
<Field label="Pushbullet API Token" hint="CONTACTPUSHBULLET — Leave blank to disable">
<TextInput
value={get(settings, 'CONTACTPUSHBULLET')}
onChange={v => onChange('CONTACTPUSHBULLET', v)}
placeholder="o.xxxxxxxxxxxxxxxxxxxxxx"
type="password"
/>
</Field>
<Field label="Slack Webhook URL(s)" hint="CONTACTSLACK — One URL per line">
<textarea
value={get(settings, 'CONTACTSLACK')}
onChange={e => onChange('CONTACTSLACK', e.target.value)}
placeholder="https://hooks.slack.com/services/..."
rows={3}
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500 placeholder-[#374151] resize-y"
/>
</Field>
<Field label="POST Notification URL(s)" hint="CONTACTPOSTURL — One URL per line">
<textarea
value={get(settings, 'CONTACTPOSTURL')}
onChange={e => onChange('CONTACTPOSTURL', e.target.value)}
placeholder="https://example.com/webhook"
rows={3}
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500 placeholder-[#374151] resize-y"
/>
</Field>
<Field label="ICQ User ID" hint="ICQUSER">
<TextInput
value={get(settings, 'ICQUSER')}
onChange={v => onChange('ICQUSER', v)}
placeholder="123456789"
/>
</Field>
<Field label="ICQ Password" hint="ICQPASS">
<TextInput
value={get(settings, 'ICQPASS')}
onChange={v => onChange('ICQPASS', v)}
placeholder="••••••••"
type="password"
/>
</Field>
<Field label="ICQ Contact UIDs" hint="CONTACTUIN — UIDs to notify (space-separated)">
<TextInput
value={get(settings, 'CONTACTUIN')}
onChange={v => onChange('CONTACTUIN', v)}
placeholder="123456789 987654321"
/>
</Field>
</div>
)
}
@@ -0,0 +1,83 @@
// Shared form primitives used across Settings tabs
export function Field({
label,
hint,
children,
}: {
label: string
hint?: string
children: React.ReactNode
}) {
return (
<div className="grid grid-cols-[280px_1fr] gap-4 items-start py-3 border-b border-[#1e2130] last:border-0">
<div>
<div className="text-[#cbd5e1] text-sm font-medium">{label}</div>
{hint && <div className="text-[#475569] text-xs mt-0.5">{hint}</div>}
</div>
<div>{children}</div>
</div>
)
}
export function TextInput({
value,
onChange,
placeholder,
type = 'text',
}: {
value: string
onChange: (v: string) => void
placeholder?: string
type?: string
}) {
return (
<input
type={type}
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500 placeholder-[#374151]"
/>
)
}
export function RadioGroup({
value,
onChange,
options,
}: {
value: string
onChange: (v: string) => void
options: { value: string; label: string }[]
}) {
return (
<div className="flex gap-6">
{options.map(opt => (
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
value={opt.value}
checked={value === opt.value}
onChange={() => onChange(opt.value)}
className="accent-indigo-500"
/>
<span className="text-[#cbd5e1] text-sm">{opt.label}</span>
</label>
))}
</div>
)
}
export const FIELD_DEFAULTS: Record<string, string> = {
HOMEDIR: '/home',
HOMEMATCH: 'home',
TTL: '14400',
NSTTL: '86400',
SCRIPTALIAS: 'yes',
LOGSTYLE: 'combined',
}
export function get(settings: Record<string, string>, key: string): string {
return settings[key] ?? FIELD_DEFAULTS[key] ?? ''
}
@@ -0,0 +1,57 @@
import { Server } from 'lucide-react'
import { TextInput, get } from './FormControls'
const NS_KEYS = ['NS', 'NS2', 'NS3', 'NS4'] as const
const NS_LABELS: Record<string, string> = {
NS: 'Nameserver 1',
NS2: 'Nameserver 2',
NS3: 'Nameserver 3 (optional)',
NS4: 'Nameserver 4 (optional)',
}
const NS_PLACEHOLDERS: Record<string, string> = {
NS: 'ns1.example.com',
NS2: 'ns2.example.com',
NS3: '',
NS4: '',
}
interface Props {
settings: Record<string, string>
onChange: (key: string, value: string) => void
}
export default function NameserversSection({ settings, onChange }: Props) {
return (
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden mt-6">
<div className="flex items-center gap-3 px-6 py-4 border-b border-[#2a2d3e]">
<Server size={16} className="text-indigo-400" />
<h2 className="text-white font-semibold">Nameservers</h2>
</div>
<div className="px-6 py-4">
{NS_KEYS.map(key => (
<div
key={key}
className="grid grid-cols-[280px_1fr_auto] gap-4 items-center py-3 border-b border-[#1e2130] last:border-0"
>
<div className="text-[#cbd5e1] text-sm font-medium">{NS_LABELS[key]}</div>
<TextInput
value={get(settings, key)}
onChange={v => onChange(key, v)}
placeholder={NS_PLACEHOLDERS[key]}
/>
<button
type="button"
onClick={() => {
const ns = get(settings, key)
if (ns) alert(`Configure A record for ${ns} — DNS management coming soon.`)
}}
className="text-xs text-indigo-400 hover:text-indigo-300 border border-indigo-500/30 hover:border-indigo-400 rounded px-3 py-1.5 whitespace-nowrap transition-colors"
>
Configure Address Records
</button>
</div>
))}
</div>
</div>
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nixpanel-server",
"version": "0.5.2",
"version": "0.6.6",
"private": true,
"workspaces": [
"server",
+16 -7
View File
@@ -82,11 +82,21 @@ if [[ -z "$ADMIN_EMAIL" ]]; then
ADMIN_EMAIL="admin@$(hostname -f 2>/dev/null || echo localhost)"
fi
# Redirect all interactive reads to /dev/tty so curl | bash works correctly
# ── 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
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)
# 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}"
@@ -95,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 < /dev/tty || CONFIRM="Y"
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
+20
View File
@@ -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")
}
+1 -1
View File
@@ -73,7 +73,7 @@ export async function buildApp() {
fastify.get('/api/health', async () => ({
status: 'ok',
version: '0.5.2',
version: '0.6.6',
timestamp: new Date().toISOString(),
}))
+256
View File
@@ -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),
]
+24 -13
View File
@@ -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 = [
@@ -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.')
}
-671
View File
@@ -1,671 +0,0 @@
import type { FastifyInstance } from 'fastify'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import prisma from '../../db/client.js'
import { requireAdminOrReseller } from '../../middleware/auth.js'
import { exec, validateUsername, validateDomain } from '../../core/exec.js'
import { enableVhost, disableVhost, suspendVhost, enablePhpFpmPool, disablePhpFpmPool } from '../../core/apache.js'
import { createZone, removeZone } from '../../core/dns.js'
import { config } from '../../config.js'
const CreateAccountSchema = z.object({
username: z.string().min(1).max(32),
password: z.string().min(8),
email: z.string().email(),
domain: z.string().min(4),
packageId: z.number().int().positive().optional(),
resellerId: z.number().int().positive().optional(),
ipId: z.number().int().positive().optional(),
})
const UpdateAccountSchema = z.object({
email: z.string().email().optional(),
packageId: z.number().int().positive().optional(),
password: z.string().min(8).optional(),
notes: z.string().max(500).optional(),
})
export default async function accountRoutes(fastify: FastifyInstance) {
const preHandler = [requireAdminOrReseller]
// GET /api/nixserver/accounts
fastify.get('/', { preHandler }, async (request, reply) => {
const q = request.query as Record<string, string>
const page = Math.max(1, parseInt(q.page ?? '1', 10))
const limit = Math.min(100, Math.max(1, parseInt(q.limit ?? '25', 10)))
const skip = (page - 1) * limit
const where: Record<string, unknown> = {}
if (q.search) {
where.OR = [
{ username: { contains: q.search } },
{ domain: { contains: q.search } },
{ email: { contains: q.search } },
]
}
if (q.status) where.status = q.status
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
const [items, total] = await Promise.all([
prisma.account.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true, username: true, domain: true, email: true, status: true,
diskUsedMb: true, bandwidthUsedMb: true, ipAddress: true,
createdAt: true, suspendedAt: true, suspendReason: true,
package: { select: { name: true, diskMb: true, bandwidthMb: true } },
reseller: { select: { username: true } },
},
}),
prisma.account.count({ where }),
])
return reply.send({
success: true,
data: { items, total, page, limit, totalPages: Math.ceil(total / limit) },
})
})
// GET /api/nixserver/accounts/stats
fastify.get('/stats', { preHandler }, async (request, reply) => {
const where = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
const [total, active, suspended, terminated] = await Promise.all([
prisma.account.count({ where }),
prisma.account.count({ where: { ...where, status: 'active' } }),
prisma.account.count({ where: { ...where, status: 'suspended' } }),
prisma.account.count({ where: { ...where, status: 'terminated' } }),
])
return reply.send({ success: true, data: { total, active, suspended, terminated } })
})
// GET /api/nixserver/accounts/parked-domains
fastify.get('/parked-domains', { preHandler }, async (request, reply) => {
const where = request.user!.role === 'reseller'
? { account: { resellerId: request.user!.sub } }
: {}
const items = await prisma.parkedDomain.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
account: { select: { username: true, domain: true } },
},
})
return reply.send({ success: true, data: items })
})
// GET /api/nixserver/accounts/subdomains
fastify.get('/subdomains', { preHandler }, async (request, reply) => {
const where = request.user!.role === 'reseller'
? { account: { resellerId: request.user!.sub } }
: {}
const items = await prisma.subdomain.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
account: { select: { username: true } },
},
})
return reply.send({ success: true, data: items })
})
// GET /api/nixserver/accounts/over-quota
fastify.get('/over-quota', { preHandler }, async (request, reply) => {
const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
const accounts = await prisma.account.findMany({
where: {
...resellerWhere,
package: { isNot: null },
},
select: {
id: true, username: true, domain: true, status: true,
diskUsedMb: true,
package: { select: { name: true, diskMb: true, bandwidthMb: true } },
},
})
const overQuota = accounts.filter((a: typeof accounts[number]) => a.package && a.diskUsedMb > a.package.diskMb)
return reply.send({ success: true, data: overQuota })
})
// GET /api/nixserver/accounts/bandwidth
fastify.get('/bandwidth', { preHandler }, async (request, reply) => {
const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
const items = await prisma.account.findMany({
where: resellerWhere,
orderBy: { bandwidthUsedMb: 'desc' },
select: {
id: true, username: true, domain: true, status: true,
bandwidthUsedMb: true, suspendReason: true,
package: { select: { name: true, bandwidthMb: true } },
},
})
return reply.send({ success: true, data: items })
})
// POST /api/nixserver/accounts/:id/change-ip
fastify.post('/:id/change-ip', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const body = z.object({ ipAddress: z.string().min(1) }).safeParse(request.body)
if (!body.success) {
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
}
const where: Record<string, unknown> = { id: parseInt(id, 10) }
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
const account = await prisma.account.findFirst({ where })
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
const updated = await prisma.account.update({
where: { id: account.id },
data: { ipAddress: body.data.ipAddress },
})
await prisma.auditLog.create({
data: {
action: 'account.change-ip',
target: account.username,
details: `IP changed to ${body.data.ipAddress}`,
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
const { passwordHash: _, ...safe } = updated
return reply.send({ success: true, data: safe })
})
// POST /api/nixserver/accounts/:id/reset-bandwidth
fastify.post('/:id/reset-bandwidth', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const where: Record<string, unknown> = { id: parseInt(id, 10) }
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
const account = await prisma.account.findFirst({ where })
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
await prisma.account.update({
where: { id: account.id },
data: { bandwidthUsedMb: 0 },
})
await prisma.auditLog.create({
data: {
action: 'account.reset-bandwidth',
target: account.username,
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
return reply.send({ success: true, data: { message: `Bandwidth reset for ${account.username}` } })
})
// PUT /api/nixserver/accounts/:id/quota
fastify.put('/:id/quota', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const body = z.object({
diskMb: z.number().int().positive(),
bandwidthMb: z.number().int().positive(),
}).safeParse(request.body)
if (!body.success) {
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
}
const where: Record<string, unknown> = { id: parseInt(id, 10) }
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
const account = await prisma.account.findFirst({
where,
include: { package: true },
})
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
if (account.packageId) {
await prisma.package.update({
where: { id: account.packageId },
data: { diskMb: body.data.diskMb, bandwidthMb: body.data.bandwidthMb },
})
}
await prisma.auditLog.create({
data: {
action: 'account.quota-modification',
target: account.username,
details: `diskMb=${body.data.diskMb}, bandwidthMb=${body.data.bandwidthMb}`,
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
return reply.send({ success: true, data: { message: 'Quota updated', ...body.data } })
})
// POST /api/nixserver/accounts/unsuspend-bandwidth-exceeded
fastify.post('/unsuspend-bandwidth-exceeded', { preHandler }, async (request, reply) => {
const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
const accounts = await prisma.account.findMany({
where: {
...resellerWhere,
status: 'suspended',
suspendReason: { contains: 'bandwidth' },
},
})
const results: { id: number; username: string; success: boolean; error?: string }[] = []
for (const account of accounts) {
try {
await exec('usermod', ['-U', account.username])
await prisma.account.update({
where: { id: account.id },
data: { status: 'active', suspendedAt: null, suspendReason: null },
})
results.push({ id: account.id, username: account.username, success: true })
} catch (e) {
results.push({ id: account.id, username: account.username, success: false, error: String(e) })
}
}
await prisma.auditLog.create({
data: {
action: 'account.unsuspend-bandwidth-exceeded',
target: `${results.length} accounts`,
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
return reply.send({ success: true, data: results })
})
// GET /api/nixserver/accounts/:id
fastify.get('/:id', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const where: Record<string, unknown> = { id: parseInt(id, 10) }
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
const account = await prisma.account.findFirst({
where,
include: {
package: true,
reseller: { select: { username: true, company: true } },
ip: true,
dnsZones: { select: { id: true, domain: true, status: true } },
_count: {
select: {
emailAccounts: true, databases: true, ftpAccounts: true,
subdomains: true, addonDomains: true, sslCertificates: true,
},
},
},
})
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
const { passwordHash, ...safe } = account
return reply.send({ success: true, data: safe })
})
// POST /api/nixserver/accounts
fastify.post('/', { preHandler }, async (request, reply) => {
const body = CreateAccountSchema.safeParse(request.body)
if (!body.success) {
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
}
const { username, password, email, domain, packageId, resellerId, ipId } = body.data
if (!validateUsername(username)) {
return reply.code(400).send({ success: false, error: 'Invalid username format' })
}
if (!validateDomain(domain)) {
return reply.code(400).send({ success: false, error: 'Invalid domain format' })
}
const exists = await prisma.account.findFirst({
where: { OR: [{ username }, { domain }] },
})
if (exists) {
return reply.code(409).send({ success: false, error: 'Username or domain already in use' })
}
const passwordHash = await bcrypt.hash(password, 12)
const homedir = `${config.paths.homeDir}/${username}`
// Create system user
const userAdd = await exec('useradd', [
'-m', '-d', homedir, '-s', '/usr/sbin/nologin', username,
])
if (userAdd.exitCode !== 0 && !config.isDev) {
return reply.code(500).send({ success: false, error: `Failed to create system user: ${userAdd.stderr}` })
}
// Create directory structure
const dirs = [
`${homedir}/public_html`,
`${homedir}/logs`,
`${homedir}/tmp`,
`${homedir}/mail`,
`${homedir}/.ssh`,
]
for (const dir of dirs) {
await exec('mkdir', ['-p', dir])
}
await exec('chown', ['-R', `${username}:${username}`, homedir])
await exec('chmod', ['750', homedir])
await exec('chmod', ['755', `${homedir}/public_html`])
// Resolve IP for vhost
let ipAddress: string | undefined
if (ipId) {
const ip = await prisma.ipAddress.findUnique({ where: { id: ipId } })
ipAddress = ip?.address
}
// Create account in DB
const account = await prisma.account.create({
data: {
username,
passwordHash,
email,
domain,
homedir,
status: 'active',
packageId: packageId ?? null,
resellerId: resellerId ?? (request.user!.role === 'reseller' ? request.user!.sub : null),
ipId: ipId ?? null,
ipAddress: ipAddress ?? null,
},
})
// Create DNS zone
if (ipAddress) {
const serverHostname = (await exec('hostname', ['-f'])).stdout || 'ns1.server.com'
try {
const zoneFile = await createZone({
domain,
ip: ipAddress,
ns1: `ns1.${domain}`,
ns2: `ns2.${domain}`,
})
await prisma.dnsZone.create({
data: { domain, zoneFile, accountId: account.id },
})
} catch (e) {
// Non-fatal — log and continue
console.warn(`DNS zone creation failed for ${domain}:`, e)
}
}
// Create Apache vhost + PHP-FPM pool
const phpVersion = (await prisma.package.findUnique({ where: { id: packageId ?? 0 } }))?.phpVersion ?? '8.3'
try {
await enablePhpFpmPool(username, phpVersion)
await enableVhost({ domain, username, documentRoot: `${homedir}/public_html`, phpVersion })
} catch (e) {
console.warn(`Apache/PHP-FPM setup failed for ${domain}:`, e)
}
// Audit log
await prisma.auditLog.create({
data: {
action: 'account.create',
target: username,
details: `Created hosting account for domain ${domain}`,
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
const { passwordHash: _, ...safe } = account
return reply.code(201).send({ success: true, data: safe })
})
// PUT /api/nixserver/accounts/:id
fastify.put('/:id', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const body = UpdateAccountSchema.safeParse(request.body)
if (!body.success) {
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
}
const where: Record<string, unknown> = { id: parseInt(id, 10) }
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
const existing = await prisma.account.findFirst({ where })
if (!existing) return reply.code(404).send({ success: false, error: 'Account not found' })
const updateData: Record<string, unknown> = {}
if (body.data.email) updateData.email = body.data.email
if (body.data.packageId) updateData.packageId = body.data.packageId
if (body.data.notes !== undefined) updateData.notes = body.data.notes
if (body.data.password) {
updateData.passwordHash = await bcrypt.hash(body.data.password, 12)
// Update system user password
await exec('passwd', ['--stdin', existing.username])
}
const updated = await prisma.account.update({
where: { id: parseInt(id, 10) },
data: updateData,
})
const { passwordHash: _, ...safe } = updated
return reply.send({ success: true, data: safe })
})
// POST /api/nixserver/accounts/:id/suspend
fastify.post('/:id/suspend', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const { reason } = (request.body as Record<string, string>) ?? {}
const account = await prisma.account.findFirst({
where: { id: parseInt(id, 10), status: 'active' },
})
if (!account) return reply.code(404).send({ success: false, error: 'Active account not found' })
await exec('usermod', ['-L', account.username])
try { await suspendVhost(account.domain, account.username) } catch (e) {
console.warn(`suspendVhost failed for ${account.domain}:`, e)
}
await prisma.account.update({
where: { id: account.id },
data: { status: 'suspended', suspendedAt: new Date(), suspendReason: reason ?? null },
})
await prisma.auditLog.create({
data: {
action: 'account.suspend',
target: account.username,
details: reason ?? 'No reason given',
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
return reply.send({ success: true, data: { message: `Account ${account.username} suspended` } })
})
// POST /api/nixserver/accounts/:id/unsuspend
fastify.post('/:id/unsuspend', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const account = await prisma.account.findFirst({
where: { id: parseInt(id, 10), status: 'suspended' },
})
if (!account) return reply.code(404).send({ success: false, error: 'Suspended account not found' })
await exec('usermod', ['-U', account.username])
try {
await enableVhost({ domain: account.domain, username: account.username, documentRoot: `${account.homedir}/public_html` })
} catch (e) {
console.warn(`enableVhost failed on unsuspend for ${account.domain}:`, e)
}
await prisma.account.update({
where: { id: account.id },
data: { status: 'active', suspendedAt: null, suspendReason: null },
})
await prisma.auditLog.create({
data: {
action: 'account.unsuspend',
target: account.username,
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
return reply.send({ success: true, data: { message: `Account ${account.username} unsuspended` } })
})
// DELETE /api/nixserver/accounts/:id
fastify.delete('/:id', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const { removeFiles } = (request.body as Record<string, boolean>) ?? {}
const account = await prisma.account.findUnique({ where: { id: parseInt(id, 10) } })
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
// Remove Apache vhost + PHP-FPM pool
try {
await disableVhost(account.domain)
} catch (e) {
console.warn(`Failed to remove vhost for ${account.domain}:`, e)
}
try {
const phpVersion = account.packageId
? (await prisma.package.findUnique({ where: { id: account.packageId } }))?.phpVersion ?? '8.3'
: '8.3'
await disablePhpFpmPool(account.username, phpVersion)
} catch (e) {
console.warn(`Failed to remove PHP-FPM pool for ${account.username}:`, e)
}
// Remove DNS zone
try {
await removeZone(account.domain)
} catch (e) {
console.warn(`Failed to remove DNS zone for ${account.domain}:`, e)
}
// Remove system user
const delArgs = removeFiles ? ['-r', account.username] : [account.username]
await exec('userdel', delArgs)
// Mark as terminated in DB (cascade deletes related records)
await prisma.account.update({
where: { id: account.id },
data: { status: 'terminated', terminatedAt: new Date() },
})
await prisma.auditLog.create({
data: {
action: 'account.terminate',
target: account.username,
details: removeFiles ? 'Files removed' : 'Files retained',
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
return reply.send({ success: true, data: { message: `Account ${account.username} terminated` } })
})
// PUT /api/nixserver/accounts/:id/shell — enable or disable shell access
fastify.put('/:id/shell', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const { enabled } = (request.body ?? {}) as { enabled?: boolean }
if (typeof enabled !== 'boolean') {
return reply.code(400).send({ success: false, error: '"enabled" boolean required' })
}
const account = await prisma.account.findUnique({ where: { id: parseInt(id, 10) } })
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
const shell = enabled ? '/bin/bash' : '/usr/sbin/nologin'
await exec('usermod', ['-s', shell, account.username])
await prisma.account.update({ where: { id: account.id }, data: { shell } })
await prisma.auditLog.create({
data: {
action: enabled ? 'account.shell.enable' : 'account.shell.disable',
target: account.username,
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
return reply.send({ success: true, data: { shell } })
})
// POST /api/nixserver/accounts/bulk
fastify.post('/bulk', { preHandler }, async (request, reply) => {
const body = z.object({
action: z.enum(['suspend', 'unsuspend', 'terminate']),
ids: z.array(z.number().int().positive()).min(1).max(100),
reason: z.string().optional(),
}).safeParse(request.body)
if (!body.success) {
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
}
const { action, ids, reason } = body.data
const results: { id: number; success: boolean; error?: string }[] = []
for (const id of ids) {
try {
const account = await prisma.account.findUnique({ where: { id } })
if (!account) {
results.push({ id, success: false, error: 'Not found' })
continue
}
if (action === 'suspend') {
await exec('usermod', ['-L', account.username])
await prisma.account.update({
where: { id },
data: { status: 'suspended', suspendedAt: new Date(), suspendReason: reason ?? null },
})
} else if (action === 'unsuspend') {
await exec('usermod', ['-U', account.username])
await prisma.account.update({
where: { id },
data: { status: 'active', suspendedAt: null, suspendReason: null },
})
} else if (action === 'terminate') {
await exec('userdel', [account.username])
await prisma.account.update({
where: { id },
data: { status: 'terminated', terminatedAt: new Date() },
})
}
results.push({ id, success: true })
} catch (e) {
results.push({ id, success: false, error: String(e) })
}
}
return reply.send({ success: true, data: results })
})
}
@@ -0,0 +1,110 @@
import type { FastifyInstance } from 'fastify'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import prisma from '../../../db/client.js'
import { requireAdminOrReseller } from '../../../middleware/auth.js'
import { exec, validateUsername, validateDomain } from '../../../core/exec.js'
import { enableVhost, enablePhpFpmPool } from '../../../core/apache.js'
import { createZone } from '../../../core/dns.js'
import { config } from '../../../config.js'
const CreateAccountSchema = z.object({
username: z.string().min(1).max(32),
password: z.string().min(8),
email: z.string().email(),
domain: z.string().min(4),
packageId: z.number().int().positive().optional(),
resellerId: z.number().int().positive().optional(),
ipId: z.number().int().positive().optional(),
})
export default async function createRoute(fastify: FastifyInstance) {
// POST /api/nixserver/accounts
fastify.post('/', { preHandler: [requireAdminOrReseller] }, async (request, reply) => {
const body = CreateAccountSchema.safeParse(request.body)
if (!body.success) {
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
}
const { username, password, email, domain, packageId, resellerId, ipId } = body.data
if (!validateUsername(username)) {
return reply.code(400).send({ success: false, error: 'Invalid username format' })
}
if (!validateDomain(domain)) {
return reply.code(400).send({ success: false, error: 'Invalid domain format' })
}
const exists = await prisma.account.findFirst({ where: { OR: [{ username }, { domain }] } })
if (exists) {
return reply.code(409).send({ success: false, error: 'Username or domain already in use' })
}
const passwordHash = await bcrypt.hash(password, 12)
const homedir = `${config.paths.homeDir}/${username}`
const userAdd = await exec('useradd', ['-m', '-d', homedir, '-s', '/usr/sbin/nologin', username])
if (userAdd.exitCode !== 0 && !config.isDev) {
return reply.code(500).send({ success: false, error: `Failed to create system user: ${userAdd.stderr}` })
}
const dirs = [
`${homedir}/public_html`,
`${homedir}/logs`,
`${homedir}/tmp`,
`${homedir}/mail`,
`${homedir}/.ssh`,
]
for (const dir of dirs) await exec('mkdir', ['-p', dir])
await exec('chown', ['-R', `${username}:${username}`, homedir])
await exec('chmod', ['750', homedir])
await exec('chmod', ['755', `${homedir}/public_html`])
let ipAddress: string | undefined
if (ipId) {
const ip = await prisma.ipAddress.findUnique({ where: { id: ipId } })
ipAddress = ip?.address
}
const account = await prisma.account.create({
data: {
username, passwordHash, email, domain, homedir,
status: 'active',
packageId: packageId ?? null,
resellerId: resellerId ?? (request.user!.role === 'reseller' ? request.user!.sub : null),
ipId: ipId ?? null,
ipAddress: ipAddress ?? null,
},
})
if (ipAddress) {
try {
const zoneFile = await createZone({ domain, ip: ipAddress, ns1: `ns1.${domain}`, ns2: `ns2.${domain}` })
await prisma.dnsZone.create({ data: { domain, zoneFile, accountId: account.id } })
} catch (e) {
console.warn(`DNS zone creation failed for ${domain}:`, e)
}
}
const phpVersion = (await prisma.package.findUnique({ where: { id: packageId ?? 0 } }))?.phpVersion ?? '8.3'
try {
await enablePhpFpmPool(username, phpVersion)
await enableVhost({ domain, username, documentRoot: `${homedir}/public_html`, phpVersion })
} catch (e) {
console.warn(`Apache/PHP-FPM setup failed for ${domain}:`, e)
}
await prisma.auditLog.create({
data: {
action: 'account.create',
target: username,
details: `Created hosting account for domain ${domain}`,
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
const { passwordHash: _, ...safe } = account
return reply.code(201).send({ success: true, data: safe })
})
}
@@ -0,0 +1,12 @@
import type { FastifyInstance } from 'fastify'
import listRoutes from './list.js'
import createRoute from './create.js'
import modifyRoutes from './modify.js'
import statusRoutes from './status.js'
export default async function accountRoutes(fastify: FastifyInstance) {
fastify.register(listRoutes)
fastify.register(createRoute)
fastify.register(modifyRoutes)
fastify.register(statusRoutes)
}
@@ -0,0 +1,147 @@
import type { FastifyInstance } from 'fastify'
import prisma from '../../../db/client.js'
import { requireAdminOrReseller } from '../../../middleware/auth.js'
const preHandler = [requireAdminOrReseller]
export default async function listRoutes(fastify: FastifyInstance) {
// GET /api/nixserver/accounts
fastify.get('/', { preHandler }, async (request, reply) => {
const q = request.query as Record<string, string>
const page = Math.max(1, parseInt(q.page ?? '1', 10))
const limit = Math.min(100, Math.max(1, parseInt(q.limit ?? '25', 10)))
const skip = (page - 1) * limit
const where: Record<string, unknown> = {}
if (q.search) {
where.OR = [
{ username: { contains: q.search } },
{ domain: { contains: q.search } },
{ email: { contains: q.search } },
]
}
if (q.status) where.status = q.status
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
const [items, total] = await Promise.all([
prisma.account.findMany({
where, skip, take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true, username: true, domain: true, email: true, status: true,
diskUsedMb: true, bandwidthUsedMb: true, ipAddress: true,
createdAt: true, suspendedAt: true, suspendReason: true,
package: { select: { name: true, diskMb: true, bandwidthMb: true } },
reseller: { select: { username: true } },
},
}),
prisma.account.count({ where }),
])
return reply.send({
success: true,
data: { items, total, page, limit, totalPages: Math.ceil(total / limit) },
})
})
// GET /api/nixserver/accounts/stats
fastify.get('/stats', { preHandler }, async (request, reply) => {
const where = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
const [total, active, suspended, terminated] = await Promise.all([
prisma.account.count({ where }),
prisma.account.count({ where: { ...where, status: 'active' } }),
prisma.account.count({ where: { ...where, status: 'suspended' } }),
prisma.account.count({ where: { ...where, status: 'terminated' } }),
])
return reply.send({ success: true, data: { total, active, suspended, terminated } })
})
// GET /api/nixserver/accounts/parked-domains
fastify.get('/parked-domains', { preHandler }, async (request, reply) => {
const where = request.user!.role === 'reseller'
? { account: { resellerId: request.user!.sub } } : {}
const items = await prisma.parkedDomain.findMany({
where,
orderBy: { createdAt: 'desc' },
include: { account: { select: { username: true, domain: true } } },
})
return reply.send({ success: true, data: items })
})
// GET /api/nixserver/accounts/subdomains
fastify.get('/subdomains', { preHandler }, async (request, reply) => {
const where = request.user!.role === 'reseller'
? { account: { resellerId: request.user!.sub } } : {}
const items = await prisma.subdomain.findMany({
where,
orderBy: { createdAt: 'desc' },
include: { account: { select: { username: true } } },
})
return reply.send({ success: true, data: items })
})
// GET /api/nixserver/accounts/over-quota
fastify.get('/over-quota', { preHandler }, async (request, reply) => {
const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
const accounts = await prisma.account.findMany({
where: { ...resellerWhere, package: { isNot: null } },
select: {
id: true, username: true, domain: true, status: true, diskUsedMb: true,
package: { select: { name: true, diskMb: true, bandwidthMb: true } },
},
})
const overQuota = accounts.filter(
(a: typeof accounts[number]) => a.package && a.diskUsedMb > a.package.diskMb
)
return reply.send({ success: true, data: overQuota })
})
// GET /api/nixserver/accounts/bandwidth
fastify.get('/bandwidth', { preHandler }, async (request, reply) => {
const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
const items = await prisma.account.findMany({
where: resellerWhere,
orderBy: { bandwidthUsedMb: 'desc' },
select: {
id: true, username: true, domain: true, status: true,
bandwidthUsedMb: true, suspendReason: true,
package: { select: { name: true, bandwidthMb: true } },
},
})
return reply.send({ success: true, data: items })
})
// GET /api/nixserver/accounts/:id
fastify.get('/:id', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const where: Record<string, unknown> = { id: parseInt(id, 10) }
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
const account = await prisma.account.findFirst({
where,
include: {
package: true,
reseller: { select: { username: true, company: true } },
ip: true,
dnsZones: { select: { id: true, domain: true, status: true } },
_count: {
select: {
emailAccounts: true, databases: true, ftpAccounts: true,
subdomains: true, addonDomains: true, sslCertificates: true,
},
},
},
})
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
const { passwordHash, ...safe } = account
return reply.send({ success: true, data: safe })
})
}
@@ -0,0 +1,171 @@
import type { FastifyInstance } from 'fastify'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import prisma from '../../../db/client.js'
import { requireAdminOrReseller } from '../../../middleware/auth.js'
import { exec } from '../../../core/exec.js'
const UpdateAccountSchema = z.object({
email: z.string().email().optional(),
packageId: z.number().int().positive().optional(),
password: z.string().min(8).optional(),
notes: z.string().max(500).optional(),
})
const preHandler = [requireAdminOrReseller]
export default async function modifyRoutes(fastify: FastifyInstance) {
// PUT /api/nixserver/accounts/:id
fastify.put('/:id', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const body = UpdateAccountSchema.safeParse(request.body)
if (!body.success) {
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
}
const where: Record<string, unknown> = { id: parseInt(id, 10) }
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
const existing = await prisma.account.findFirst({ where })
if (!existing) return reply.code(404).send({ success: false, error: 'Account not found' })
const updateData: Record<string, unknown> = {}
if (body.data.email) updateData.email = body.data.email
if (body.data.packageId) updateData.packageId = body.data.packageId
if (body.data.notes !== undefined) updateData.notes = body.data.notes
if (body.data.password) {
updateData.passwordHash = await bcrypt.hash(body.data.password, 12)
await exec('passwd', ['--stdin', existing.username])
}
const updated = await prisma.account.update({
where: { id: parseInt(id, 10) },
data: updateData,
})
const { passwordHash: _, ...safe } = updated
return reply.send({ success: true, data: safe })
})
// PUT /api/nixserver/accounts/:id/quota
fastify.put('/:id/quota', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const body = z.object({
diskMb: z.number().int().positive(),
bandwidthMb: z.number().int().positive(),
}).safeParse(request.body)
if (!body.success) {
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
}
const where: Record<string, unknown> = { id: parseInt(id, 10) }
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
const account = await prisma.account.findFirst({ where, include: { package: true } })
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
if (account.packageId) {
await prisma.package.update({
where: { id: account.packageId },
data: { diskMb: body.data.diskMb, bandwidthMb: body.data.bandwidthMb },
})
}
await prisma.auditLog.create({
data: {
action: 'account.quota-modification',
target: account.username,
details: `diskMb=${body.data.diskMb}, bandwidthMb=${body.data.bandwidthMb}`,
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
return reply.send({ success: true, data: { message: 'Quota updated', ...body.data } })
})
// POST /api/nixserver/accounts/:id/change-ip
fastify.post('/:id/change-ip', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const body = z.object({ ipAddress: z.string().min(1) }).safeParse(request.body)
if (!body.success) {
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
}
const where: Record<string, unknown> = { id: parseInt(id, 10) }
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
const account = await prisma.account.findFirst({ where })
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
const updated = await prisma.account.update({
where: { id: account.id },
data: { ipAddress: body.data.ipAddress },
})
await prisma.auditLog.create({
data: {
action: 'account.change-ip',
target: account.username,
details: `IP changed to ${body.data.ipAddress}`,
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
const { passwordHash: _, ...safe } = updated
return reply.send({ success: true, data: safe })
})
// POST /api/nixserver/accounts/:id/reset-bandwidth
fastify.post('/:id/reset-bandwidth', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const where: Record<string, unknown> = { id: parseInt(id, 10) }
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
const account = await prisma.account.findFirst({ where })
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
await prisma.account.update({ where: { id: account.id }, data: { bandwidthUsedMb: 0 } })
await prisma.auditLog.create({
data: {
action: 'account.reset-bandwidth',
target: account.username,
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
return reply.send({ success: true, data: { message: `Bandwidth reset for ${account.username}` } })
})
// PUT /api/nixserver/accounts/:id/shell
fastify.put('/:id/shell', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const { enabled } = (request.body ?? {}) as { enabled?: boolean }
if (typeof enabled !== 'boolean') {
return reply.code(400).send({ success: false, error: '"enabled" boolean required' })
}
const account = await prisma.account.findUnique({ where: { id: parseInt(id, 10) } })
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
const shell = enabled ? '/bin/bash' : '/usr/sbin/nologin'
await exec('usermod', ['-s', shell, account.username])
await prisma.account.update({ where: { id: account.id }, data: { shell } })
await prisma.auditLog.create({
data: {
action: enabled ? 'account.shell.enable' : 'account.shell.disable',
target: account.username,
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
return reply.send({ success: true, data: { shell } })
})
}
@@ -0,0 +1,204 @@
import type { FastifyInstance } from 'fastify'
import { z } from 'zod'
import prisma from '../../../db/client.js'
import { requireAdminOrReseller } from '../../../middleware/auth.js'
import { exec } from '../../../core/exec.js'
import { enableVhost, disableVhost, suspendVhost, disablePhpFpmPool } from '../../../core/apache.js'
import { removeZone } from '../../../core/dns.js'
const preHandler = [requireAdminOrReseller]
export default async function statusRoutes(fastify: FastifyInstance) {
// POST /api/nixserver/accounts/:id/suspend
fastify.post('/:id/suspend', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const { reason } = (request.body as Record<string, string>) ?? {}
const account = await prisma.account.findFirst({
where: { id: parseInt(id, 10), status: 'active' },
})
if (!account) return reply.code(404).send({ success: false, error: 'Active account not found' })
await exec('usermod', ['-L', account.username])
try { await suspendVhost(account.domain, account.username) } catch (e) {
console.warn(`suspendVhost failed for ${account.domain}:`, e)
}
await prisma.account.update({
where: { id: account.id },
data: { status: 'suspended', suspendedAt: new Date(), suspendReason: reason ?? null },
})
await prisma.auditLog.create({
data: {
action: 'account.suspend',
target: account.username,
details: reason ?? 'No reason given',
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
return reply.send({ success: true, data: { message: `Account ${account.username} suspended` } })
})
// POST /api/nixserver/accounts/:id/unsuspend
fastify.post('/:id/unsuspend', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const account = await prisma.account.findFirst({
where: { id: parseInt(id, 10), status: 'suspended' },
})
if (!account) return reply.code(404).send({ success: false, error: 'Suspended account not found' })
await exec('usermod', ['-U', account.username])
try {
await enableVhost({ domain: account.domain, username: account.username, documentRoot: `${account.homedir}/public_html` })
} catch (e) {
console.warn(`enableVhost failed on unsuspend for ${account.domain}:`, e)
}
await prisma.account.update({
where: { id: account.id },
data: { status: 'active', suspendedAt: null, suspendReason: null },
})
await prisma.auditLog.create({
data: {
action: 'account.unsuspend',
target: account.username,
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
return reply.send({ success: true, data: { message: `Account ${account.username} unsuspended` } })
})
// POST /api/nixserver/accounts/unsuspend-bandwidth-exceeded
fastify.post('/unsuspend-bandwidth-exceeded', { preHandler }, async (request, reply) => {
const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
const accounts = await prisma.account.findMany({
where: { ...resellerWhere, status: 'suspended', suspendReason: { contains: 'bandwidth' } },
})
const results: { id: number; username: string; success: boolean; error?: string }[] = []
for (const account of accounts) {
try {
await exec('usermod', ['-U', account.username])
await prisma.account.update({
where: { id: account.id },
data: { status: 'active', suspendedAt: null, suspendReason: null },
})
results.push({ id: account.id, username: account.username, success: true })
} catch (e) {
results.push({ id: account.id, username: account.username, success: false, error: String(e) })
}
}
await prisma.auditLog.create({
data: {
action: 'account.unsuspend-bandwidth-exceeded',
target: `${results.length} accounts`,
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
return reply.send({ success: true, data: results })
})
// DELETE /api/nixserver/accounts/:id
fastify.delete('/:id', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const { removeFiles } = (request.body as Record<string, boolean>) ?? {}
const account = await prisma.account.findUnique({ where: { id: parseInt(id, 10) } })
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
try { await disableVhost(account.domain) } catch (e) {
console.warn(`Failed to remove vhost for ${account.domain}:`, e)
}
try {
const phpVersion = account.packageId
? (await prisma.package.findUnique({ where: { id: account.packageId } }))?.phpVersion ?? '8.3'
: '8.3'
await disablePhpFpmPool(account.username, phpVersion)
} catch (e) {
console.warn(`Failed to remove PHP-FPM pool for ${account.username}:`, e)
}
try { await removeZone(account.domain) } catch (e) {
console.warn(`Failed to remove DNS zone for ${account.domain}:`, e)
}
await exec('userdel', removeFiles ? ['-r', account.username] : [account.username])
await prisma.account.update({
where: { id: account.id },
data: { status: 'terminated', terminatedAt: new Date() },
})
await prisma.auditLog.create({
data: {
action: 'account.terminate',
target: account.username,
details: removeFiles ? 'Files removed' : 'Files retained',
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
return reply.send({ success: true, data: { message: `Account ${account.username} terminated` } })
})
// POST /api/nixserver/accounts/bulk
fastify.post('/bulk', { preHandler }, async (request, reply) => {
const body = z.object({
action: z.enum(['suspend', 'unsuspend', 'terminate']),
ids: z.array(z.number().int().positive()).min(1).max(100),
reason: z.string().optional(),
}).safeParse(request.body)
if (!body.success) {
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
}
const { action, ids, reason } = body.data
const results: { id: number; success: boolean; error?: string }[] = []
for (const id of ids) {
try {
const account = await prisma.account.findUnique({ where: { id } })
if (!account) { results.push({ id, success: false, error: 'Not found' }); continue }
if (action === 'suspend') {
await exec('usermod', ['-L', account.username])
await prisma.account.update({
where: { id },
data: { status: 'suspended', suspendedAt: new Date(), suspendReason: reason ?? null },
})
} else if (action === 'unsuspend') {
await exec('usermod', ['-U', account.username])
await prisma.account.update({
where: { id },
data: { status: 'active', suspendedAt: null, suspendReason: null },
})
} else if (action === 'terminate') {
await exec('userdel', [account.username])
await prisma.account.update({
where: { id },
data: { status: 'terminated', terminatedAt: new Date() },
})
}
results.push({ id, success: true })
} catch (e) {
results.push({ id, success: false, error: String(e) })
}
}
return reply.send({ success: true, data: results })
})
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from 'fastify'
import 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 })
})
}
+14
View File
@@ -99,6 +99,20 @@ export default async function systemRoutes(fastify: FastifyInstance) {
})
})
// GET /api/nixserver/system/network-interfaces
fastify.get('/network-interfaces', { preHandler: [requireAdmin] }, async (_request, reply) => {
try {
const result = await exec('ls', ['/sys/class/net'])
const ifaces = result.stdout
.split('\n')
.map(s => s.trim())
.filter(s => s && s !== 'lo') // exclude loopback
return reply.send({ success: true, data: ifaces })
} catch {
return reply.send({ success: true, data: [] })
}
})
// GET /api/nixserver/system/settings
fastify.get('/settings', { preHandler: [requireAdmin] }, async (request, reply) => {
const settings = await prisma.setting.findMany({ orderBy: { key: 'asc' } })