Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
501f94b7e8 | ||
|
|
3902935f60 | ||
|
|
5c98ac0ee6 | ||
|
|
6dba99d164 | ||
|
|
c5498994c3 | ||
|
|
da55367039 | ||
|
|
55786bfa0e | ||
|
|
70908745bd | ||
|
|
0362810681 |
+9
-4
@@ -6,15 +6,20 @@ dist/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# package-lock.json is platform-specific (rollup native binaries differ between
|
||||||
|
# Windows dev machines and Linux servers). Never commit it — the server generates
|
||||||
|
# its own on each install.
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
# Prisma
|
# Prisma
|
||||||
server/prisma/dev.db
|
server/prisma/dev.db
|
||||||
server/prisma/dev.db-journal
|
server/prisma/dev.db-journal
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
server/dist/
|
server/dist/
|
||||||
whm/dist/
|
nixserver/dist/
|
||||||
cpanel/dist/
|
nixclient/dist/
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
whm/.vite/
|
nixserver/.vite/
|
||||||
cpanel/.vite/
|
nixclient/.vite/
|
||||||
|
|||||||
@@ -6,6 +6,67 @@ Pre-1.0 versions treat MINOR as feature releases and PATCH as bug fixes.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [0.6.2] — 2026-04-12
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- **`curl | bash` aborts immediately at confirmation prompt** — `set -euo pipefail` caused the script to exit when `read` received EOF from the pipe instead of a terminal. Fixed by reading from `/dev/tty` directly so the prompt works correctly whether the script is piped or run from a file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.6.1] — 2026-04-11
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- **`CLAUDE.md` added** — development standards file auto-read by Claude Code before every session. Documents version numbering rules, changelog format, commit message conventions, file size limits, section/page architecture patterns, API conventions, and project structure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.6.0] — 2026-04-11
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Basic NixServer Setup page** — full WHM-equivalent settings page at `/server-configuration/basic-setup` with two tabs (Contact Information, Basic Config) and a Nameservers section.
|
||||||
|
- **Server Configuration section page** — new section index at `/server-configuration` showing all 13 tools as a card grid.
|
||||||
|
- **`GET /api/nixserver/system/network-interfaces`** — new endpoint returning all non-loopback interfaces, used to populate the Ethernet Device dropdown.
|
||||||
|
|
||||||
|
### Refactors
|
||||||
|
- **File size limit enforced** — `Layout.tsx`, `Settings.tsx`, `accounts.ts` each split into focused sub-files under 300 lines.
|
||||||
|
- **`NavCategory` gains optional `to` field** — links sidebar category headers to section index pages.
|
||||||
|
- **Shared components added** — `SectionCard.tsx` and `ComingSoon.tsx`.
|
||||||
|
- **`/settings` redirected** to `/server-configuration/basic-setup` for backwards compatibility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.5.1] — 2026-04-11
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- **`install.sh` build steps used `--silent`** — errors during `npm install` and frontend/server builds were suppressed, making failures invisible. Removed `--silent` from all build commands. Also added `rm -rf node_modules package-lock.json` before `npm install` to ensure a clean platform-correct install, consistent with `update.sh`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.5.0] — 2026-04-11
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- **Rollup native binary missing on Linux** — `package-lock.json` was generated on Windows and committed to git. It included the Windows rollup binary (`@rollup/rollup-win32-x64-msvc`) but not the Linux one (`@rollup/rollup-linux-x64-gnu`), causing the frontend build to fail on the server with `Cannot find module @rollup/rollup-linux-x64-gnu`.
|
||||||
|
- **`package-lock.json` added to `.gitignore`** — lock files are platform-specific and must never be committed. The server now generates its own fresh lock file on every update.
|
||||||
|
- **`update.sh` now does a clean install** — deletes `node_modules/` and `package-lock.json` before `npm install` on every update run to guarantee platform-correct native binaries. Also drops `--silent` from build commands so errors are visible.
|
||||||
|
- **`.gitignore` corrected** — removed stale `whm/` and `cpanel/` dist paths, added correct `nixserver/` and `nixclient/` paths.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.4.9] — 2026-04-11
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- **`update.sh` fails when `package-lock.json` has local changes** — `git pull --ff-only` aborted with "Your local changes would be overwritten by merge" whenever npm had modified the lock file on the server. Fixed by running `git checkout -- package-lock.json` before the pull to discard local changes to the generated lock file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.4.8] — 2026-04-11
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- **Login page showing stale hardcoded version** — "NixServer v0.3.2 · Port 2087" was hardcoded. Now fetches version live from `GET /api/health` (no auth required) on mount.
|
||||||
|
- **Login page subtitle** — "Web Host Manager" replaced with "Admin Panel" to remove any WHM trademark association.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.4.7] — 2026-04-11
|
## [0.4.7] — 2026-04-11
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -23,6 +23,22 @@ const System = React.lazy(() => import('./pages/System'))
|
|||||||
const WebServer = React.lazy(() => import('./pages/WebServer'))
|
const WebServer = React.lazy(() => import('./pages/WebServer'))
|
||||||
const Settings = React.lazy(() => import('./pages/Settings'))
|
const Settings = React.lazy(() => import('./pages/Settings'))
|
||||||
|
|
||||||
|
// Server Configuration section
|
||||||
|
const ServerConfiguration = React.lazy(() => import('./pages/server-configuration/index'))
|
||||||
|
const SCBasicSetup = React.lazy(() => import('./pages/server-configuration/BasicSetup'))
|
||||||
|
const SCChangeRoot = React.lazy(() => import('./pages/server-configuration/ChangeRoot'))
|
||||||
|
const SCAnalytics = React.lazy(() => import('./pages/server-configuration/Analytics'))
|
||||||
|
const SCCronJobs = React.lazy(() => import('./pages/server-configuration/CronJobs'))
|
||||||
|
const SCQuotaSetup = React.lazy(() => import('./pages/server-configuration/QuotaSetup'))
|
||||||
|
const SCLinkNodes = React.lazy(() => import('./pages/server-configuration/LinkNodes'))
|
||||||
|
const SCServerProfile = React.lazy(() => import('./pages/server-configuration/ServerProfile'))
|
||||||
|
const SCServerTime = React.lazy(() => import('./pages/server-configuration/ServerTime'))
|
||||||
|
const SCStatisticsSoftware = React.lazy(() => import('./pages/server-configuration/StatisticsSoftware'))
|
||||||
|
const SCTerminal = React.lazy(() => import('./pages/server-configuration/Terminal'))
|
||||||
|
const SCTweakSettings = React.lazy(() => import('./pages/server-configuration/TweakSettings'))
|
||||||
|
const SCUpdatePreferences = React.lazy(() => import('./pages/server-configuration/UpdatePreferences'))
|
||||||
|
const SCMarketplace = React.lazy(() => import('./pages/server-configuration/Marketplace'))
|
||||||
|
|
||||||
// Accounts sub-pages
|
// Accounts sub-pages
|
||||||
const AccountList = React.lazy(() => import('./pages/accounts/List'))
|
const AccountList = React.lazy(() => import('./pages/accounts/List'))
|
||||||
const AccountParkedDomains = React.lazy(() => import('./pages/accounts/ParkedDomains'))
|
const AccountParkedDomains = React.lazy(() => import('./pages/accounts/ParkedDomains'))
|
||||||
@@ -181,6 +197,52 @@ function AppRoutes() {
|
|||||||
<React.Suspense fallback={<PageLoader />}><AccountBulkModify /></React.Suspense>
|
<React.Suspense fallback={<PageLoader />}><AccountBulkModify /></React.Suspense>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
{/* Server Configuration section */}
|
||||||
|
<Route path="server-configuration" element={
|
||||||
|
<React.Suspense fallback={<PageLoader />}><ServerConfiguration /></React.Suspense>
|
||||||
|
} />
|
||||||
|
<Route path="server-configuration/basic-setup" element={
|
||||||
|
<React.Suspense fallback={<PageLoader />}><SCBasicSetup /></React.Suspense>
|
||||||
|
} />
|
||||||
|
<Route path="server-configuration/change-root" element={
|
||||||
|
<React.Suspense fallback={<PageLoader />}><SCChangeRoot /></React.Suspense>
|
||||||
|
} />
|
||||||
|
<Route path="server-configuration/analytics" element={
|
||||||
|
<React.Suspense fallback={<PageLoader />}><SCAnalytics /></React.Suspense>
|
||||||
|
} />
|
||||||
|
<Route path="server-configuration/cron-jobs" element={
|
||||||
|
<React.Suspense fallback={<PageLoader />}><SCCronJobs /></React.Suspense>
|
||||||
|
} />
|
||||||
|
<Route path="server-configuration/quota-setup" element={
|
||||||
|
<React.Suspense fallback={<PageLoader />}><SCQuotaSetup /></React.Suspense>
|
||||||
|
} />
|
||||||
|
<Route path="server-configuration/link-nodes" element={
|
||||||
|
<React.Suspense fallback={<PageLoader />}><SCLinkNodes /></React.Suspense>
|
||||||
|
} />
|
||||||
|
<Route path="server-configuration/server-profile" element={
|
||||||
|
<React.Suspense fallback={<PageLoader />}><SCServerProfile /></React.Suspense>
|
||||||
|
} />
|
||||||
|
<Route path="server-configuration/server-time" element={
|
||||||
|
<React.Suspense fallback={<PageLoader />}><SCServerTime /></React.Suspense>
|
||||||
|
} />
|
||||||
|
<Route path="server-configuration/statistics-software" element={
|
||||||
|
<React.Suspense fallback={<PageLoader />}><SCStatisticsSoftware /></React.Suspense>
|
||||||
|
} />
|
||||||
|
<Route path="server-configuration/terminal" element={
|
||||||
|
<React.Suspense fallback={<PageLoader />}><SCTerminal /></React.Suspense>
|
||||||
|
} />
|
||||||
|
<Route path="server-configuration/tweak-settings" element={
|
||||||
|
<React.Suspense fallback={<PageLoader />}><SCTweakSettings /></React.Suspense>
|
||||||
|
} />
|
||||||
|
<Route path="server-configuration/update-preferences" element={
|
||||||
|
<React.Suspense fallback={<PageLoader />}><SCUpdatePreferences /></React.Suspense>
|
||||||
|
} />
|
||||||
|
<Route path="server-configuration/marketplace" element={
|
||||||
|
<React.Suspense fallback={<PageLoader />}><SCMarketplace /></React.Suspense>
|
||||||
|
} />
|
||||||
|
{/* Redirect old /settings to new canonical route */}
|
||||||
|
<Route path="settings" element={<Navigate to="/server-configuration/basic-setup" replace />} />
|
||||||
|
|
||||||
{/* Other sections */}
|
{/* Other sections */}
|
||||||
<Route path="packages" element={<Packages />} />
|
<Route path="packages" element={<Packages />} />
|
||||||
<Route path="resellers" element={
|
<Route path="resellers" element={
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Wrench } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ComingSoonProps {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ComingSoon({ title, description }: ComingSoonProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-white text-xl font-semibold">{title}</h1>
|
||||||
|
{description && <p className="text-[#64748b] text-sm mt-0.5">{description}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 bg-[#1a1d27] border border-[#2a2d3e] rounded-xl text-center gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#0f1117] border border-[#2a2d3e] flex items-center justify-center">
|
||||||
|
<Wrench size={24} className="text-[#475569]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[#94a3b8] font-medium">Coming Soon</p>
|
||||||
|
<p className="text-[#475569] text-sm mt-1">This feature is under development.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,781 +1,15 @@
|
|||||||
import { useState, useMemo, useEffect, useCallback } from 'react'
|
import { useState } from 'react'
|
||||||
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
|
import { Outlet } from 'react-router-dom'
|
||||||
import {
|
import { Menu, X } from 'lucide-react'
|
||||||
LayoutDashboard, Settings, HelpCircle, Network, BookUser, UserCog,
|
import Sidebar from './Sidebar'
|
||||||
Wrench, Languages, HardDrive, GitFork, RefreshCw, Activity,
|
|
||||||
Users, UserCheck, ArrowRightLeft, Paintbrush, Package,
|
|
||||||
Globe, Database, Wifi, Box, Mail, HeartPulse, Lock,
|
|
||||||
ShoppingBag, Code2, Puzzle, LogOut, Menu, X,
|
|
||||||
ChevronDown, ChevronRight, Search, RotateCcw, RefreshCcw,
|
|
||||||
ArrowUpCircle, CheckCircle2, AlertCircle, Loader2
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { 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 ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const { user, logout } = useAuth()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const location = useLocation()
|
|
||||||
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
|
|
||||||
// ── Version / upgrade state ──────────────────────────────────────────────
|
|
||||||
type VersionInfo = { currentVersion: string; latestVersion: string | null; updateAvailable: boolean }
|
|
||||||
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null)
|
|
||||||
const [versionError, setVersionError] = useState(false)
|
|
||||||
const [upgradeState, setUpgradeState] = useState<'idle' | 'confirm' | 'running' | 'done' | 'error'>('idle')
|
|
||||||
|
|
||||||
const fetchVersion = useCallback(async (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 (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-[#0f1117]">
|
<div className="flex h-screen overflow-hidden bg-[#0f1117]">
|
||||||
|
<Sidebar open={sidebarOpen} onToggle={() => setSidebarOpen(v => !v)} />
|
||||||
|
|
||||||
{/* ── Sidebar ─────────────────────────────────────────────────────── */}
|
|
||||||
<aside
|
|
||||||
className={`flex flex-col transition-all duration-200 bg-[#1a1d27] border-r border-[#2a2d3e] overflow-hidden flex-shrink-0 ${
|
|
||||||
sidebarOpen ? 'w-72' : 'w-0'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Branding */}
|
|
||||||
<div className="flex items-center gap-2 px-4 py-4 border-b border-[#2a2d3e] min-h-[57px] flex-shrink-0">
|
|
||||||
<div className="w-7 h-7 rounded-lg bg-indigo-600 flex items-center justify-center flex-shrink-0">
|
|
||||||
<span className="text-white text-xs font-bold">N</span>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-hidden">
|
|
||||||
<p className="text-white text-sm font-semibold leading-tight whitespace-nowrap">NixServer</p>
|
|
||||||
<p className="text-[#64748b] text-xs whitespace-nowrap">Admin Panel</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search */}
|
|
||||||
<div className="px-3 py-2 border-b border-[#2a2d3e] flex-shrink-0">
|
|
||||||
<div className="relative">
|
|
||||||
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#4a5568] pointer-events-none" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search..."
|
|
||||||
value={search}
|
|
||||||
onChange={e => setSearch(e.target.value)}
|
|
||||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] rounded-md text-xs text-[#94a3b8] placeholder-[#4a5568] pl-7 pr-3 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors"
|
|
||||||
/>
|
|
||||||
{search && (
|
|
||||||
<button
|
|
||||||
onClick={() => setSearch('')}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-[#4a5568] hover:text-[#94a3b8]"
|
|
||||||
>
|
|
||||||
<X size={11} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Nav */}
|
|
||||||
<nav className="flex-1 overflow-y-auto py-1 scrollbar-thin">
|
|
||||||
|
|
||||||
{/* Dashboard — always at the top */}
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className={`flex items-center gap-2.5 px-3 py-1.5 mx-2 my-0.5 rounded-md text-xs transition-colors ${
|
|
||||||
location.pathname === '/'
|
|
||||||
? 'bg-indigo-600/20 text-indigo-400'
|
|
||||||
: 'text-[#94a3b8] hover:bg-[#1e2130] hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<LayoutDashboard size={13} className="flex-shrink-0" />
|
|
||||||
<span className="truncate">Dashboard</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Categories */}
|
|
||||||
{filteredNav.map(cat => {
|
|
||||||
const isOpen = effectiveOpen(cat.id)
|
|
||||||
const Icon = cat.icon
|
|
||||||
const hasActive = cat.items.some(item => isItemActive(item.to, location.pathname))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={cat.id} className="mx-2 my-0.5">
|
|
||||||
{/* Category header */}
|
|
||||||
<button
|
|
||||||
onClick={() => !search.trim() && toggleCat(cat.id)}
|
|
||||||
className={`flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
|
||||||
hasActive && !search.trim()
|
|
||||||
? 'text-indigo-300'
|
|
||||||
: 'text-[#64748b] hover:text-[#94a3b8]'
|
|
||||||
} ${search.trim() ? 'cursor-default' : ''}`}
|
|
||||||
>
|
|
||||||
<Icon size={13} className="flex-shrink-0" />
|
|
||||||
<span className="flex-1 text-left truncate">{cat.label}</span>
|
|
||||||
{!search.trim() && (
|
|
||||||
isOpen
|
|
||||||
? <ChevronDown size={11} className="flex-shrink-0 opacity-60" />
|
|
||||||
: <ChevronRight size={11} className="flex-shrink-0 opacity-60" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Items */}
|
|
||||||
{isOpen && (
|
|
||||||
<div className="ml-3 pl-2 border-l border-[#2a2d3e] mt-0.5 mb-1">
|
|
||||||
{cat.items.map(item => {
|
|
||||||
const active = isItemActive(item.to, location.pathname)
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.label}
|
|
||||||
to={item.to}
|
|
||||||
className={`block px-2 py-1 rounded text-xs transition-colors truncate ${
|
|
||||||
active
|
|
||||||
? 'text-indigo-400 bg-indigo-600/10'
|
|
||||||
: 'text-[#64748b] hover:text-[#94a3b8] hover:bg-[#1e2130]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Empty search state */}
|
|
||||||
{search.trim() && filteredNav.length === 0 && (
|
|
||||||
<p className="text-center text-[#4a5568] text-xs py-6">No results</p>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Version + upgrade */}
|
|
||||||
<div className="border-t border-[#2a2d3e] px-3 pt-2.5 pb-1 flex-shrink-0">
|
|
||||||
{versionInfo ? (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{/* Version row */}
|
|
||||||
<div className="flex items-center justify-between px-1">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{versionInfo.updateAvailable ? (
|
|
||||||
<AlertCircle size={11} className="text-amber-400 flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<CheckCircle2 size={11} className="text-emerald-500 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="text-[#64748b] text-[10px]">
|
|
||||||
v{versionInfo.currentVersion}
|
|
||||||
</span>
|
|
||||||
{versionInfo.updateAvailable && versionInfo.latestVersion && (
|
|
||||||
<span className="text-amber-400 text-[10px]">
|
|
||||||
→ v{versionInfo.latestVersion}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => fetchVersion(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">
|
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||||
{/* Topbar */}
|
{/* Topbar */}
|
||||||
<header className="flex items-center gap-4 px-6 py-3 bg-[#1a1d27] border-b border-[#2a2d3e] min-h-[57px] flex-shrink-0">
|
<header className="flex items-center gap-4 px-6 py-3 bg-[#1a1d27] border-b border-[#2a2d3e] min-h-[57px] flex-shrink-0">
|
||||||
@@ -791,7 +25,7 @@ export default function Layout() {
|
|||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Page content */}
|
||||||
<main className="flex-1 overflow-y-auto p-6">
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import type { ComponentType } from 'react'
|
||||||
|
|
||||||
|
interface SectionCardProps {
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
to: string
|
||||||
|
icon?: ComponentType<{ size?: number; className?: string }>
|
||||||
|
comingSoon?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SectionCard({ label, description, to, icon: Icon, comingSoon }: SectionCardProps) {
|
||||||
|
if (comingSoon) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 p-4 rounded-xl border border-[#2a2d3e] bg-[#0f1117] opacity-50 cursor-not-allowed select-none">
|
||||||
|
{Icon && (
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-[#1e2130] flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<Icon size={15} className="text-[#475569]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[#64748b] text-sm font-medium truncate">{label}</p>
|
||||||
|
{description && <p className="text-[#374151] text-xs mt-0.5 line-clamp-2">{description}</p>}
|
||||||
|
<span className="inline-block mt-1 text-[10px] text-[#374151] border border-[#2a2d3e] rounded px-1.5 py-0.5">Coming soon</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className="flex items-start gap-3 p-4 rounded-xl border border-[#2a2d3e] bg-[#0f1117] hover:bg-[#1a1d27] hover:border-indigo-500/40 transition-colors group"
|
||||||
|
>
|
||||||
|
{Icon && (
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-indigo-600/10 flex items-center justify-center flex-shrink-0 mt-0.5 group-hover:bg-indigo-600/20 transition-colors">
|
||||||
|
<Icon size={15} className="text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[#cbd5e1] text-sm font-medium truncate group-hover:text-white transition-colors">{label}</p>
|
||||||
|
{description && <p className="text-[#475569] text-xs mt-0.5 line-clamp-2">{description}</p>}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import { useState, useMemo, useEffect, useCallback } from 'react'
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
LayoutDashboard, LogOut, Menu, X,
|
||||||
|
ChevronDown, ChevronRight, Search, RefreshCcw,
|
||||||
|
ArrowUpCircle, CheckCircle2, AlertCircle, Loader2, ExternalLink,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
import { NAV, isItemActive } from './navData'
|
||||||
|
|
||||||
|
type VersionInfo = {
|
||||||
|
currentVersion: string
|
||||||
|
latestVersion: string | null
|
||||||
|
updateAvailable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpgradeState = 'idle' | 'confirm' | 'running' | 'done' | 'error'
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
open: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sidebar({ open, onToggle }: SidebarProps) {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null)
|
||||||
|
const [versionError, setVersionError] = useState(false)
|
||||||
|
const [upgradeState, setUpgradeState] = useState<UpgradeState>('idle')
|
||||||
|
|
||||||
|
// ── Version checking ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const fetchVersion = useCallback(async (manual = false) => {
|
||||||
|
setVersionError(false)
|
||||||
|
if (manual) setVersionInfo(null)
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/nixserver/system/version', {
|
||||||
|
timeout: 10_000,
|
||||||
|
params: { _t: Date.now() },
|
||||||
|
})
|
||||||
|
setVersionInfo(data.data)
|
||||||
|
} catch {
|
||||||
|
setVersionError(true)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { fetchVersion() }, [fetchVersion])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!versionError) return
|
||||||
|
const t = setTimeout(fetchVersion, 5_000)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [versionError, fetchVersion])
|
||||||
|
|
||||||
|
const handleUpgrade = async () => {
|
||||||
|
if (upgradeState === 'confirm') {
|
||||||
|
setUpgradeState('running')
|
||||||
|
try {
|
||||||
|
await api.post('/nixserver/system/upgrade')
|
||||||
|
setUpgradeState('done')
|
||||||
|
} catch {
|
||||||
|
setUpgradeState('error')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setUpgradeState('confirm')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Category open/close ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const [openCats, setOpenCats] = useState<Set<string>>(() => {
|
||||||
|
const active = NAV.find(cat =>
|
||||||
|
cat.items.some(item => isItemActive(item.to, location.pathname))
|
||||||
|
)
|
||||||
|
return active ? new Set([active.id]) : new Set()
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const active = NAV.find(cat =>
|
||||||
|
cat.items.some(item => isItemActive(item.to, location.pathname))
|
||||||
|
)
|
||||||
|
if (active) {
|
||||||
|
setOpenCats(prev => prev.has(active.id) ? prev : new Set([...prev, active.id]))
|
||||||
|
}
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
|
const toggleCat = (id: string) => {
|
||||||
|
setOpenCats(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Search filter ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const filteredNav = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase()
|
||||||
|
if (!q) return NAV
|
||||||
|
return NAV.map(cat => ({
|
||||||
|
...cat,
|
||||||
|
items: cat.items.filter(item => item.label.toLowerCase().includes(q)),
|
||||||
|
})).filter(cat => cat.items.length > 0 || cat.label.toLowerCase().includes(q))
|
||||||
|
}, [search])
|
||||||
|
|
||||||
|
const effectiveOpen = (id: string) =>
|
||||||
|
search.trim() ? filteredNav.some(c => c.id === id) : openCats.has(id)
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout()
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={`flex flex-col transition-all duration-200 bg-[#1a1d27] border-r border-[#2a2d3e] overflow-hidden flex-shrink-0 ${
|
||||||
|
open ? 'w-72' : 'w-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Branding + toggle */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-4 border-b border-[#2a2d3e] min-h-[57px] flex-shrink-0">
|
||||||
|
<button onClick={onToggle} className="text-[#64748b] hover:text-white transition-colors mr-1">
|
||||||
|
{open ? <X size={16} /> : <Menu size={16} />}
|
||||||
|
</button>
|
||||||
|
<div className="w-7 h-7 rounded-lg bg-indigo-600 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-white text-xs font-bold">N</span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<p className="text-white text-sm font-semibold leading-tight whitespace-nowrap">NixServer</p>
|
||||||
|
<p className="text-[#64748b] text-xs whitespace-nowrap">Admin Panel</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="px-3 py-2 border-b border-[#2a2d3e] flex-shrink-0">
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#4a5568] pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="w-full bg-[#0f1117] border border-[#2a2d3e] rounded-md text-xs text-[#94a3b8] placeholder-[#4a5568] pl-7 pr-3 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-[#4a5568] hover:text-[#94a3b8]"
|
||||||
|
>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav */}
|
||||||
|
<nav className="flex-1 overflow-y-auto py-1 scrollbar-thin">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className={`flex items-center gap-2.5 px-3 py-1.5 mx-2 my-0.5 rounded-md text-xs transition-colors ${
|
||||||
|
location.pathname === '/'
|
||||||
|
? 'bg-indigo-600/20 text-indigo-400'
|
||||||
|
: 'text-[#94a3b8] hover:bg-[#1e2130] hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LayoutDashboard size={13} className="flex-shrink-0" />
|
||||||
|
<span className="truncate">Dashboard</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{filteredNav.map(cat => {
|
||||||
|
const isOpen = effectiveOpen(cat.id)
|
||||||
|
const Icon = cat.icon
|
||||||
|
const hasActive = cat.items.some(item => isItemActive(item.to, location.pathname))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={cat.id} className="mx-2 my-0.5">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => !search.trim() && toggleCat(cat.id)}
|
||||||
|
className={`flex items-center gap-2 flex-1 px-2 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||||
|
hasActive && !search.trim()
|
||||||
|
? 'text-indigo-300'
|
||||||
|
: 'text-[#64748b] hover:text-[#94a3b8]'
|
||||||
|
} ${search.trim() ? 'cursor-default' : ''}`}
|
||||||
|
>
|
||||||
|
<Icon size={13} className="flex-shrink-0" />
|
||||||
|
<span className="flex-1 text-left truncate">{cat.label}</span>
|
||||||
|
{!search.trim() && (
|
||||||
|
isOpen
|
||||||
|
? <ChevronDown size={11} className="flex-shrink-0 opacity-60" />
|
||||||
|
: <ChevronRight size={11} className="flex-shrink-0 opacity-60" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{cat.to && (
|
||||||
|
<Link
|
||||||
|
to={cat.to}
|
||||||
|
title={`${cat.label} overview`}
|
||||||
|
className="p-1 text-[#374151] hover:text-indigo-400 transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
<ExternalLink size={10} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="ml-3 pl-2 border-l border-[#2a2d3e] mt-0.5 mb-1">
|
||||||
|
{cat.items.map(item => {
|
||||||
|
const active = isItemActive(item.to, location.pathname)
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
to={item.to}
|
||||||
|
className={`block px-2 py-1 rounded text-xs transition-colors truncate ${
|
||||||
|
active
|
||||||
|
? 'text-indigo-400 bg-indigo-600/10'
|
||||||
|
: 'text-[#64748b] hover:text-[#94a3b8] hover:bg-[#1e2130]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{search.trim() && filteredNav.length === 0 && (
|
||||||
|
<p className="text-center text-[#4a5568] text-xs py-6">No results</p>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Version + upgrade */}
|
||||||
|
<div className="border-t border-[#2a2d3e] px-3 pt-2.5 pb-1 flex-shrink-0">
|
||||||
|
{versionInfo ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{versionInfo.updateAvailable ? (
|
||||||
|
<AlertCircle size={11} className="text-amber-400 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 size={11} className="text-emerald-500 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="text-[#64748b] text-[10px]">v{versionInfo.currentVersion}</span>
|
||||||
|
{versionInfo.updateAvailable && versionInfo.latestVersion && (
|
||||||
|
<span className="text-amber-400 text-[10px]">→ v{versionInfo.latestVersion}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => fetchVersion(true)}
|
||||||
|
title="Check for updates"
|
||||||
|
className="text-[#4a5568] hover:text-[#94a3b8] transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCcw size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{upgradeState === 'done' ? (
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-emerald-600/10 text-emerald-400 text-[10px]">
|
||||||
|
<CheckCircle2 size={11} />
|
||||||
|
<span>Upgrade started — panel restarting…</span>
|
||||||
|
</div>
|
||||||
|
) : upgradeState === 'error' ? (
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-red-600/10 text-red-400 text-[10px]">
|
||||||
|
<AlertCircle size={11} />
|
||||||
|
<span>Upgrade failed — check logs</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={handleUpgrade}
|
||||||
|
disabled={upgradeState === 'running'}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[10px] font-medium transition-colors ${
|
||||||
|
upgradeState === 'confirm'
|
||||||
|
? 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/30'
|
||||||
|
: versionInfo.updateAvailable
|
||||||
|
? 'bg-indigo-600/20 text-indigo-300 hover:bg-indigo-600/30'
|
||||||
|
: 'bg-[#1e2130] text-[#64748b] hover:text-[#94a3b8]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{upgradeState === 'running' ? (
|
||||||
|
<><Loader2 size={10} className="animate-spin" /><span>Upgrading…</span></>
|
||||||
|
) : upgradeState === 'confirm' ? (
|
||||||
|
<><ArrowUpCircle size={10} /><span>Confirm upgrade</span></>
|
||||||
|
) : versionInfo.updateAvailable ? (
|
||||||
|
<><ArrowUpCircle size={10} /><span>Upgrade available</span></>
|
||||||
|
) : (
|
||||||
|
<><ArrowUpCircle size={10} /><span>Upgrade panel</span></>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{upgradeState === 'confirm' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setUpgradeState('idle')}
|
||||||
|
className="px-2 py-1.5 rounded-md bg-[#1e2130] text-[#64748b] hover:text-[#94a3b8] text-[10px] transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : versionError ? (
|
||||||
|
<div className="flex items-center justify-between px-1 py-1">
|
||||||
|
<span className="text-[#4a5568] text-[10px]">Version unavailable</span>
|
||||||
|
<button onClick={() => fetchVersion(true)} title="Retry" className="text-[#4a5568] hover:text-[#94a3b8] transition-colors">
|
||||||
|
<RefreshCcw size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5 px-1 py-1">
|
||||||
|
<Loader2 size={10} className="animate-spin text-[#4a5568]" />
|
||||||
|
<span className="text-[#4a5568] text-[10px]">Checking version…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User + logout */}
|
||||||
|
<div className="border-t border-[#2a2d3e] p-3 flex-shrink-0">
|
||||||
|
<div className="px-1 mb-2">
|
||||||
|
<p className="text-white text-xs font-medium truncate">{user?.username}</p>
|
||||||
|
<p className="text-[#64748b] text-xs truncate">{user?.role}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 w-full rounded-lg text-[#94a3b8] hover:bg-[#1e2130] hover:text-red-400 transition-colors text-xs"
|
||||||
|
>
|
||||||
|
<LogOut size={14} className="flex-shrink-0" />
|
||||||
|
<span>Logout</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
import type { ComponentType } from 'react'
|
||||||
|
import {
|
||||||
|
LayoutDashboard, Settings, HelpCircle, Network, BookUser, UserCog,
|
||||||
|
Wrench, Languages, HardDrive, GitFork, RefreshCw, Activity,
|
||||||
|
Users, UserCheck, ArrowRightLeft, Paintbrush, Package,
|
||||||
|
Globe, Database, Wifi, Box, Mail, HeartPulse, Lock,
|
||||||
|
ShoppingBag, Code2, Puzzle, RotateCcw,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
export type NavItem = { label: string; to: string }
|
||||||
|
export type NavCategory = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon: ComponentType<{ size?: number | string; className?: string }>
|
||||||
|
to?: string // optional link to a section index page
|
||||||
|
items: NavItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isItemActive(to: string, pathname: string): boolean {
|
||||||
|
if (to === '/') return pathname === '/'
|
||||||
|
return pathname === to || pathname.startsWith(to + '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NAV: NavCategory[] = [
|
||||||
|
{
|
||||||
|
id: 'server-config',
|
||||||
|
label: 'Server Configuration',
|
||||||
|
icon: Settings,
|
||||||
|
to: '/server-configuration',
|
||||||
|
items: [
|
||||||
|
{ label: 'Basic NixServer Setup', to: '/server-configuration/basic-setup' },
|
||||||
|
{ label: 'Change Root Password', to: '/server-configuration/change-root' },
|
||||||
|
{ label: 'Configure Analytics', to: '/server-configuration/analytics' },
|
||||||
|
{ label: 'Configure Cron Jobs', to: '/server-configuration/cron-jobs' },
|
||||||
|
{ label: 'Initial Quota Setup', to: '/server-configuration/quota-setup' },
|
||||||
|
{ label: 'Link Server Nodes', to: '/server-configuration/link-nodes' },
|
||||||
|
{ label: 'Server Profile', to: '/server-configuration/server-profile' },
|
||||||
|
{ label: 'Server Time', to: '/server-configuration/server-time' },
|
||||||
|
{ label: 'Statistics Software', to: '/server-configuration/statistics-software' },
|
||||||
|
{ label: 'Terminal', to: '/server-configuration/terminal' },
|
||||||
|
{ label: 'Tweak Settings', to: '/server-configuration/tweak-settings' },
|
||||||
|
{ label: 'Update Preferences', to: '/server-configuration/update-preferences' },
|
||||||
|
{ label: 'NixServer Marketplace', to: '/server-configuration/marketplace' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'support',
|
||||||
|
label: 'Support',
|
||||||
|
icon: HelpCircle,
|
||||||
|
items: [
|
||||||
|
{ label: 'Grant NixPanel Support Access', to: '/security' },
|
||||||
|
{ label: 'Support Center', to: '/settings' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'networking',
|
||||||
|
label: 'Networking Setup',
|
||||||
|
icon: Network,
|
||||||
|
items: [
|
||||||
|
{ label: 'Change Hostname', to: '/security' },
|
||||||
|
{ label: 'Resolver Configuration', to: '/security' },
|
||||||
|
{ label: 'Security Center', to: '/security' },
|
||||||
|
{ label: 'Apache mod_userdir Tweak', to: '/webserver' },
|
||||||
|
{ label: 'SMTP Restrictions', to: '/email' },
|
||||||
|
{ label: 'Compiler Access', to: '/security' },
|
||||||
|
{ label: 'Configure Security Policies', to: '/security' },
|
||||||
|
{ label: 'mXSTOP Brute Force Protection', to: '/security' },
|
||||||
|
{ label: 'Host Access Control', to: '/security' },
|
||||||
|
{ label: 'Manage External Authentications', to: '/security' },
|
||||||
|
{ label: "Manage root's SSH Keys", to: '/security' },
|
||||||
|
{ label: 'Manage Wheel Group Users', to: '/security' },
|
||||||
|
{ label: 'ModSecurity™ Configuration', to: '/security' },
|
||||||
|
{ label: 'ModSecurity™ Tools', to: '/security' },
|
||||||
|
{ label: 'ModSecurity™ Vendors', to: '/security' },
|
||||||
|
{ label: 'Password Strength Configuration', to: '/security' },
|
||||||
|
{ label: 'Security Advisor', to: '/security' },
|
||||||
|
{ label: 'Security Questions', to: '/security' },
|
||||||
|
{ label: 'Shell Fork Bomb Protection', to: '/security' },
|
||||||
|
{ label: 'SSH Password Authorization Tweak', to: '/security' },
|
||||||
|
{ label: 'Traceroute Enable/Disable', to: '/security' },
|
||||||
|
{ label: 'Two-Factor Authentication', to: '/security' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'contacts',
|
||||||
|
label: 'Server Contacts',
|
||||||
|
icon: BookUser,
|
||||||
|
items: [
|
||||||
|
{ label: 'Contact Manager', to: '/settings' },
|
||||||
|
{ label: 'Edit System Mail Preferences', to: '/email' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'resellers',
|
||||||
|
label: 'Resellers',
|
||||||
|
icon: UserCog,
|
||||||
|
items: [
|
||||||
|
{ label: 'Change Ownership of an Account', to: '/resellers' },
|
||||||
|
{ label: 'Change Ownership of Multiple Accounts', to: '/resellers' },
|
||||||
|
{ label: 'Edit Reseller Nameservers and Privileges', to: '/resellers' },
|
||||||
|
{ label: 'Email All Resellers', to: '/resellers' },
|
||||||
|
{ label: "Manage Reseller's IP Delegation", to: '/resellers' },
|
||||||
|
{ label: "Manage Reseller's Shared IP", to: '/resellers' },
|
||||||
|
{ label: 'Reseller Center', to: '/resellers' },
|
||||||
|
{ label: 'Reset Resellers', to: '/resellers' },
|
||||||
|
{ label: 'Show Reseller Accounts', to: '/resellers' },
|
||||||
|
{ label: 'View Reseller Usage', to: '/resellers' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'service-config',
|
||||||
|
label: 'Service Configuration',
|
||||||
|
icon: Wrench,
|
||||||
|
items: [
|
||||||
|
{ label: 'Apache Configuration', to: '/webserver' },
|
||||||
|
{ label: 'Log Rotation Configuration', to: '/settings' },
|
||||||
|
{ label: 'Web Disk Configuration', to: '/settings' },
|
||||||
|
{ label: 'Web Services Configuration', to: '/settings' },
|
||||||
|
{ label: 'Exim Configuration Manager', to: '/email' },
|
||||||
|
{ label: 'FTP Server Selection', to: '/settings' },
|
||||||
|
{ label: 'Mailserver Configuration', to: '/email' },
|
||||||
|
{ label: 'Manage Service SSL Certificates', to: '/ssl' },
|
||||||
|
{ label: 'Nameserver Selection', to: '/dns' },
|
||||||
|
{ label: 'Service Manager', to: '/services' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'locales',
|
||||||
|
label: 'Locales',
|
||||||
|
icon: Languages,
|
||||||
|
items: [
|
||||||
|
{ label: 'Configure Application Locales', to: '/settings' },
|
||||||
|
{ label: 'Copy a Locale', to: '/settings' },
|
||||||
|
{ label: 'Delete a Locale', to: '/settings' },
|
||||||
|
{ label: 'Edit a Locale', to: '/settings' },
|
||||||
|
{ label: 'Locale XML Download', to: '/settings' },
|
||||||
|
{ label: 'Locale XML Upload', to: '/settings' },
|
||||||
|
{ label: 'View Available Locales', to: '/settings' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'backup',
|
||||||
|
label: 'Backup',
|
||||||
|
icon: HardDrive,
|
||||||
|
items: [
|
||||||
|
{ label: 'Backup Configuration', to: '/backup' },
|
||||||
|
{ label: 'Backup Restoration', to: '/backup' },
|
||||||
|
{ label: 'Backup User Selection', to: '/backup' },
|
||||||
|
{ label: 'File and Directory Restoration', to: '/backup' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'clusters',
|
||||||
|
label: 'Clusters',
|
||||||
|
icon: GitFork,
|
||||||
|
items: [
|
||||||
|
{ label: 'Configuration Cluster', to: '/settings' },
|
||||||
|
{ label: 'DNS Cluster', to: '/dns' },
|
||||||
|
{ label: 'Remote Access Key', to: '/security' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reboot',
|
||||||
|
label: 'System Reboot',
|
||||||
|
icon: RotateCcw,
|
||||||
|
items: [
|
||||||
|
{ label: 'Forceful Server Reboot', to: '/system' },
|
||||||
|
{ label: 'Graceful Server Reboot', to: '/system' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'server-status',
|
||||||
|
label: 'Server Status',
|
||||||
|
icon: Activity,
|
||||||
|
items: [
|
||||||
|
{ label: 'Apache Status', to: '/webserver' },
|
||||||
|
{ label: 'Daily Process Log', to: '/system' },
|
||||||
|
{ label: 'Server Information', to: '/system' },
|
||||||
|
{ label: 'Service Status', to: '/services' },
|
||||||
|
{ label: 'Task Queue Monitor', to: '/system' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'account-info',
|
||||||
|
label: 'Account Information',
|
||||||
|
icon: Users,
|
||||||
|
items: [
|
||||||
|
{ label: 'List Accounts', to: '/accounts/list' },
|
||||||
|
{ label: 'List Parked Domains', to: '/accounts/parked-domains' },
|
||||||
|
{ label: 'List Subdomains', to: '/accounts/subdomains' },
|
||||||
|
{ label: 'List Suspended Accounts', to: '/accounts/suspended' },
|
||||||
|
{ label: 'Show Accounts Over Quota', to: '/accounts/over-quota' },
|
||||||
|
{ label: 'View Bandwidth Usage', to: '/accounts/bandwidth' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'account-functions',
|
||||||
|
label: 'Account Functions',
|
||||||
|
icon: UserCheck,
|
||||||
|
items: [
|
||||||
|
{ label: "Change Site's IP Address", to: '/accounts/change-ip' },
|
||||||
|
{ label: 'Create a New Account', to: '/accounts/create' },
|
||||||
|
{ label: 'Email All Users', to: '/accounts/email-users' },
|
||||||
|
{ label: 'Force Password Change', to: '/accounts/force-password' },
|
||||||
|
{ label: 'Limit Bandwidth Usage', to: '/accounts/limit-bandwidth' },
|
||||||
|
{ label: 'Manage Account Suspension', to: '/accounts/suspension' },
|
||||||
|
{ label: 'Manage Demo Mode', to: '/accounts/demo-mode' },
|
||||||
|
{ label: 'Manage Shell Access', to: '/accounts/shell-access' },
|
||||||
|
{ label: 'Modify an Account', to: '/accounts/modify' },
|
||||||
|
{ label: 'Password Modification', to: '/accounts/password' },
|
||||||
|
{ label: 'Quota Modification', to: '/accounts/quota' },
|
||||||
|
{ label: 'Raw Apache Log Download', to: '/accounts/apache-logs' },
|
||||||
|
{ label: 'Raw NGINX® Log Download', to: '/accounts/nginx-logs' },
|
||||||
|
{ label: 'Rearrange an Account', to: '/accounts/rearrange' },
|
||||||
|
{ label: 'Reset Account Bandwidth', to: '/accounts/reset-bandwidth' },
|
||||||
|
{ label: 'Terminate Accounts', to: '/accounts/terminate' },
|
||||||
|
{ label: 'Unsuspend Bandwidth Exceeders',to: '/accounts/unsuspend-bandwidth'},
|
||||||
|
{ label: 'Upgrade/Downgrade an Account', to: '/accounts/upgrade' },
|
||||||
|
{ label: 'Web Template Editor', to: '/accounts/web-template' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'multi-account',
|
||||||
|
label: 'Multi Account Functions',
|
||||||
|
icon: Users,
|
||||||
|
items: [
|
||||||
|
{ label: "Change Multiple Sites' IP Addresses", to: '/accounts/bulk-ip' },
|
||||||
|
{ label: 'Modify/Upgrade Multiple Accounts', to: '/accounts/bulk-modify' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'transfers',
|
||||||
|
label: 'Transfers',
|
||||||
|
icon: ArrowRightLeft,
|
||||||
|
items: [
|
||||||
|
{ label: 'Convert Addon Domain to Account', to: '/accounts' },
|
||||||
|
{ label: 'Review Transfers and Restores', to: '/accounts' },
|
||||||
|
{ label: 'Transfer or Restore a cPanel Account', to: '/accounts' },
|
||||||
|
{ label: 'Transfer Tool', to: '/accounts' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'themes',
|
||||||
|
label: 'Themes',
|
||||||
|
icon: Paintbrush,
|
||||||
|
items: [
|
||||||
|
{ label: 'Theme Manager', to: '/settings' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'packages',
|
||||||
|
label: 'Packages',
|
||||||
|
icon: Package,
|
||||||
|
items: [
|
||||||
|
{ label: 'Add a Package', to: '/packages' },
|
||||||
|
{ label: 'Delete a Package', to: '/packages' },
|
||||||
|
{ label: 'Edit a Package', to: '/packages' },
|
||||||
|
{ label: 'Feature Manager', to: '/packages' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dns',
|
||||||
|
label: 'DNS Functions',
|
||||||
|
icon: Globe,
|
||||||
|
items: [
|
||||||
|
{ label: 'Add a DNS Zone', to: '/dns' },
|
||||||
|
{ label: 'Add an A Entry for Hostname', to: '/dns' },
|
||||||
|
{ label: 'Delete a DNS Zone', to: '/dns' },
|
||||||
|
{ label: 'DNS Zone Manager', to: '/dns' },
|
||||||
|
{ label: 'Edit Zone Templates', to: '/dns' },
|
||||||
|
{ label: 'Email DNS Record Manager', to: '/dns' },
|
||||||
|
{ label: 'Email Routing Configuration', to: '/email' },
|
||||||
|
{ label: 'Enable DKIM/SPF Globally', to: '/email' },
|
||||||
|
{ label: 'Nameserver Record Report', to: '/dns' },
|
||||||
|
{ label: 'Park a Domain', to: '/dns' },
|
||||||
|
{ label: 'Perform a DNS Cleanup', to: '/dns' },
|
||||||
|
{ label: 'Set Zone TTL', to: '/dns' },
|
||||||
|
{ label: 'Setup/Edit Domain Forwarding', to: '/dns' },
|
||||||
|
{ label: 'Synchronize DNS Records', to: '/dns' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'databases',
|
||||||
|
label: 'Database Services',
|
||||||
|
icon: Database,
|
||||||
|
items: [
|
||||||
|
{ label: 'Change Database Root Password', to: '/settings' },
|
||||||
|
{ label: 'Change Database User Password', to: '/settings' },
|
||||||
|
{ label: 'Database Map Tool', to: '/settings' },
|
||||||
|
{ label: 'Edit Database Configuration', to: '/settings' },
|
||||||
|
{ label: 'Manage Database Access Hosts', to: '/settings' },
|
||||||
|
{ label: 'Manage Database Profiles', to: '/settings' },
|
||||||
|
{ label: 'Manage Database Users', to: '/settings' },
|
||||||
|
{ label: 'Manage Databases', to: '/settings' },
|
||||||
|
{ label: 'phpMyAdmin', to: '/settings' },
|
||||||
|
{ label: 'Repair Databases', to: '/settings' },
|
||||||
|
{ label: 'Show Database Processes', to: '/settings' },
|
||||||
|
{ label: 'Upgrade Database Version', to: '/settings' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ip',
|
||||||
|
label: 'IP Functions',
|
||||||
|
icon: Wifi,
|
||||||
|
items: [
|
||||||
|
{ label: 'IPv6 Ranges', to: '/security' },
|
||||||
|
{ label: 'Add a New IP Address', to: '/security' },
|
||||||
|
{ label: 'Assign IPv6 Address', to: '/security' },
|
||||||
|
{ label: 'Configure Remote Service IPs', to: '/security' },
|
||||||
|
{ label: 'IP Migration Wizard', to: '/security' },
|
||||||
|
{ label: 'Rebuild the IP Address Pool', to: '/security' },
|
||||||
|
{ label: 'Show IP Address Usage', to: '/security' },
|
||||||
|
{ label: 'Show or Delete Current IPs', to: '/security' },
|
||||||
|
{ label: 'Show/Edit Reserved IPs', to: '/security' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'software',
|
||||||
|
label: 'Software',
|
||||||
|
icon: Box,
|
||||||
|
items: [
|
||||||
|
{ label: 'Install a Perl Module', to: '/system' },
|
||||||
|
{ label: 'Install Distro Packages', to: '/system' },
|
||||||
|
{ label: 'Module Installers', to: '/system' },
|
||||||
|
{ label: 'MultiPHP INI Editor', to: '/php' },
|
||||||
|
{ label: 'MultiPHP Manager', to: '/php' },
|
||||||
|
{ label: 'NGINX® Manager', to: '/webserver' },
|
||||||
|
{ label: 'Rebuild RPM Database', to: '/system' },
|
||||||
|
{ label: 'System Update', to: '/system' },
|
||||||
|
{ label: 'Update Server Software', to: '/system' },
|
||||||
|
{ label: 'EasyApache 4', to: '/webserver' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'email',
|
||||||
|
label: 'Email',
|
||||||
|
icon: Mail,
|
||||||
|
items: [
|
||||||
|
{ label: 'Email Deliverability', to: '/email' },
|
||||||
|
{ label: 'Filter Incoming Emails by Country', to: '/email' },
|
||||||
|
{ label: 'Filter Incoming Emails by Domain', to: '/email' },
|
||||||
|
{ label: 'Greylisting', to: '/email' },
|
||||||
|
{ label: 'Mail Delivery Reports', to: '/email' },
|
||||||
|
{ label: 'Mail Queue Manager', to: '/email' },
|
||||||
|
{ label: 'Mail Troubleshooter', to: '/email' },
|
||||||
|
{ label: 'Mailbox Conversion', to: '/email' },
|
||||||
|
{ label: 'Repair Mailbox Permissions', to: '/email' },
|
||||||
|
{ label: 'Spamd Startup Configuration', to: '/email' },
|
||||||
|
{ label: 'View Mail Statistics Summary', to: '/email' },
|
||||||
|
{ label: 'View Relayers', to: '/email' },
|
||||||
|
{ label: 'View Sent Summary', to: '/email' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system-health',
|
||||||
|
label: 'System Health',
|
||||||
|
icon: HeartPulse,
|
||||||
|
items: [
|
||||||
|
{ label: 'Background Process Killer', to: '/system' },
|
||||||
|
{ label: 'Process Manager', to: '/system' },
|
||||||
|
{ label: 'Show Current Disk Usage', to: '/system' },
|
||||||
|
{ label: 'Show Current Running Processes', to: '/system' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nixpanel',
|
||||||
|
label: 'NixPanel',
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
items: [
|
||||||
|
{ label: 'Change Log', to: '/settings' },
|
||||||
|
{ label: 'Customization', to: '/settings' },
|
||||||
|
{ label: 'Manage Plugins', to: '/settings' },
|
||||||
|
{ label: 'Modify NixPanel News', to: '/settings' },
|
||||||
|
{ label: 'Reset a Mailman Password', to: '/email' },
|
||||||
|
{ label: 'Upgrade to Latest Version',to: '/settings' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssl',
|
||||||
|
label: 'SSL/TLS',
|
||||||
|
icon: Lock,
|
||||||
|
items: [
|
||||||
|
{ label: 'Generate an SSL Certificate and Signing Request', to: '/ssl' },
|
||||||
|
{ label: 'Install an SSL Certificate on a Domain', to: '/ssl' },
|
||||||
|
{ label: 'Manage AutoSSL', to: '/ssl' },
|
||||||
|
{ label: 'Manage SSL Hosts', to: '/ssl' },
|
||||||
|
{ label: 'Purchase and Install an SSL Certificate', to: '/ssl' },
|
||||||
|
{ label: 'SSL Storage Manager', to: '/ssl' },
|
||||||
|
{ label: 'SSL/TLS Configuration', to: '/ssl' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'market',
|
||||||
|
label: 'Market',
|
||||||
|
icon: ShoppingBag,
|
||||||
|
items: [
|
||||||
|
{ label: 'Market Provider Manager', to: '/settings' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'restart-services',
|
||||||
|
label: 'Restart Services',
|
||||||
|
icon: RefreshCw,
|
||||||
|
items: [
|
||||||
|
{ label: 'Database Server', to: '/services' },
|
||||||
|
{ label: 'DNS Server', to: '/services' },
|
||||||
|
{ label: 'HTTP Server (Apache)', to: '/services' },
|
||||||
|
{ label: 'IMAP Server', to: '/services' },
|
||||||
|
{ label: 'Mail Server (Exim)', to: '/services' },
|
||||||
|
{ label: 'Mailing List Manager (Mailman)', to: '/services' },
|
||||||
|
{ label: 'PHP-FPM service for Apache', to: '/services' },
|
||||||
|
{ label: 'SSH Server (OpenSSH)', to: '/services' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'development',
|
||||||
|
label: 'Development',
|
||||||
|
icon: Code2,
|
||||||
|
items: [
|
||||||
|
{ label: 'Apps Managed by AppConfig', to: '/settings' },
|
||||||
|
{ label: 'NixPanel Development Forum', to: '/settings' },
|
||||||
|
{ label: 'NixPanel Plugin File Generator', to: '/settings' },
|
||||||
|
{ label: 'Developer Documentation', to: '/settings' },
|
||||||
|
{ label: 'Manage API Tokens', to: '/settings' },
|
||||||
|
{ label: 'Manage Hooks', to: '/settings' },
|
||||||
|
{ label: 'OpenAPI Documentation for NixPanel', to: '/settings' },
|
||||||
|
{ label: 'OpenAPI Documentation for NixServer', to: '/settings' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'plugins',
|
||||||
|
label: 'Plugins',
|
||||||
|
icon: Puzzle,
|
||||||
|
items: [
|
||||||
|
{ label: 'Backuply by Softaculous', to: '/backup' },
|
||||||
|
{ label: 'ImunifyAV', to: '/security' },
|
||||||
|
{ label: 'Server Monitoring', to: '/system' },
|
||||||
|
{ label: 'SitePad Website Builder', to: '/settings' },
|
||||||
|
{ label: 'Softaculous - Instant Installs', to: '/settings' },
|
||||||
|
{ label: 'WP Toolkit', to: '/settings' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { Shield } from 'lucide-react'
|
import { Shield } from 'lucide-react'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
@@ -10,6 +11,13 @@ export default function Login() {
|
|||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [version, setVersion] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
axios.get('/api/health', { timeout: 5_000 })
|
||||||
|
.then(r => setVersion(r.data.version ?? null))
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -35,7 +43,7 @@ export default function Login() {
|
|||||||
<Shield size={24} className="text-white" />
|
<Shield size={24} className="text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-white text-xl font-bold">NixServer</h1>
|
<h1 className="text-white text-xl font-bold">NixServer</h1>
|
||||||
<p className="text-[#64748b] text-sm mt-1">Web Host Manager</p>
|
<p className="text-[#64748b] text-sm mt-1">Admin Panel</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
@@ -81,7 +89,7 @@ export default function Login() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-[#64748b] text-xs mt-4">
|
<p className="text-center text-[#64748b] text-xs mt-4">
|
||||||
NixServer v0.3.2 · Port 2087
|
NixServer{version ? ` v${version}` : ''} · Port 2087
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,49 +1,125 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { Save, RefreshCw } from 'lucide-react'
|
import { Save, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
|
import ContactTab from './settings/ContactTab'
|
||||||
|
import BasicTab from './settings/BasicTab'
|
||||||
|
import NameserversSection from './settings/NameserversSection'
|
||||||
|
|
||||||
|
type Tab = 'contact' | 'basic'
|
||||||
|
|
||||||
|
const TABS: { id: Tab; label: string }[] = [
|
||||||
|
{ id: 'contact', label: 'Contact Information' },
|
||||||
|
{ id: 'basic', label: 'Basic Config' },
|
||||||
|
]
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const [settings, setSettings] = useState<Record<string, string>>({})
|
const [settings, setSettings] = useState<Record<string, string>>({})
|
||||||
|
const [interfaces, setInterfaces] = useState<string[]>([])
|
||||||
|
const [tab, setTab] = useState<Tab>('contact')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const fetch = async () => {
|
const load = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try { const r = await api.get('/nixserver/system/settings'); setSettings(r.data.data) }
|
setError('')
|
||||||
finally { setLoading(false) }
|
try {
|
||||||
|
const [settingsRes, ifaceRes] = await Promise.allSettled([
|
||||||
|
api.get('/nixserver/system/settings'),
|
||||||
|
api.get('/nixserver/system/network-interfaces'),
|
||||||
|
])
|
||||||
|
if (settingsRes.status === 'fulfilled') setSettings(settingsRes.value.data.data)
|
||||||
|
if (ifaceRes.status === 'fulfilled') setInterfaces(ifaceRes.value.data.data ?? [])
|
||||||
|
} catch {
|
||||||
|
setError('Failed to load settings')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { fetch() }, [])
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
|
const handleChange = (key: string, value: string) =>
|
||||||
|
setSettings(s => ({ ...s, [key]: value }))
|
||||||
|
|
||||||
const save = async (e: React.FormEvent) => {
|
const save = async (e: React.FormEvent) => {
|
||||||
e.preventDefault(); setSaving(true)
|
e.preventDefault()
|
||||||
try { await api.put('/nixserver/system/settings', settings); setSaved(true); setTimeout(() => setSaved(false), 2000) }
|
setSaving(true)
|
||||||
finally { setSaving(false) }
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.put('/nixserver/system/settings', settings)
|
||||||
|
setSaved(true)
|
||||||
|
setTimeout(() => setSaved(false), 2500)
|
||||||
|
} catch {
|
||||||
|
setError('Failed to save settings')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div><h1 className="text-white text-xl font-semibold">Settings</h1><p className="text-[#64748b] text-sm">System-wide configuration</p></div>
|
<div>
|
||||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
<h1 className="text-white text-xl font-semibold">Basic NixServer Setup</h1>
|
||||||
|
<p className="text-[#64748b] text-sm mt-0.5">Configure server-wide defaults</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={load}
|
||||||
|
className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={save} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-6 space-y-3">
|
{error && (
|
||||||
{Object.entries(settings).map(([key, value]) => (
|
<div className="flex items-center gap-2 bg-red-900/20 border border-red-700/40 rounded-lg px-4 py-3 text-red-400 text-sm">
|
||||||
<div key={key} className="flex items-center gap-4">
|
<AlertTriangle size={14} className="flex-shrink-0" />
|
||||||
<label className="text-[#94a3b8] text-xs font-mono w-56 flex-shrink-0">{key}</label>
|
{error}
|
||||||
<input
|
</div>
|
||||||
value={value}
|
)}
|
||||||
onChange={e => setSettings(s => ({ ...s, [key]: e.target.value }))}
|
|
||||||
className="flex-1 bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-indigo-500"
|
<form onSubmit={save}>
|
||||||
/>
|
{/* Tab bar */}
|
||||||
</div>
|
<div className="flex border-b border-[#2a2d3e]">
|
||||||
))}
|
{TABS.map(t => (
|
||||||
<div className="pt-4">
|
<button
|
||||||
<button type="submit" disabled={saving} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm px-4 py-2 rounded-lg">
|
key={t.id}
|
||||||
<Save size={14} /> {saving ? 'Saving...' : saved ? 'Saved!' : 'Save Settings'}
|
type="button"
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
className={[
|
||||||
|
'px-5 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||||
|
tab === t.id
|
||||||
|
? 'border-indigo-500 text-white'
|
||||||
|
: 'border-transparent text-[#64748b] hover:text-[#94a3b8]',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#1a1d27] border border-[#2a2d3e] border-t-0 rounded-b-xl px-6 py-2">
|
||||||
|
{tab === 'contact'
|
||||||
|
? <ContactTab settings={settings} onChange={handleChange} />
|
||||||
|
: <BasicTab settings={settings} onChange={handleChange} interfaces={interfaces} />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NameserversSection settings={settings} onChange={handleChange} />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving || loading}
|
||||||
|
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-medium px-5 py-2.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Save size={14} />
|
||||||
|
{saving ? 'Saving…' : saved ? 'Saved!' : 'Save Changes'}
|
||||||
</button>
|
</button>
|
||||||
|
{saved && <span className="text-green-400 text-sm">Settings saved successfully.</span>}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import ComingSoon from '../../components/ComingSoon'
|
||||||
|
|
||||||
|
export default function Analytics() {
|
||||||
|
return (
|
||||||
|
<ComingSoon
|
||||||
|
title="Configure Analytics"
|
||||||
|
description="Enable and configure server-side analytics and statistics collection."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Basic NixServer Setup — full implementation lives in the settings/ sub-components.
|
||||||
|
// This file is the route entry point at /server-configuration/basic-setup.
|
||||||
|
export { default } from '../Settings'
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import ComingSoon from '../../components/ComingSoon'
|
||||||
|
|
||||||
|
export default function ChangeRoot() {
|
||||||
|
return (
|
||||||
|
<ComingSoon
|
||||||
|
title="Change Root Password"
|
||||||
|
description="Update the root account password for this server."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import ComingSoon from '../../components/ComingSoon'
|
||||||
|
|
||||||
|
export default function CronJobs() {
|
||||||
|
return (
|
||||||
|
<ComingSoon
|
||||||
|
title="Configure Cron Jobs"
|
||||||
|
description="Schedule recurring system-level tasks and maintenance scripts."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import ComingSoon from '../../components/ComingSoon'
|
||||||
|
|
||||||
|
export default function LinkNodes() {
|
||||||
|
return (
|
||||||
|
<ComingSoon
|
||||||
|
title="Link Server Nodes"
|
||||||
|
description="Connect additional server nodes for clustering or load distribution."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import ComingSoon from '../../components/ComingSoon'
|
||||||
|
|
||||||
|
export default function Marketplace() {
|
||||||
|
return (
|
||||||
|
<ComingSoon
|
||||||
|
title="NixServer Marketplace"
|
||||||
|
description="Browse and install official extensions, plugins, and integrations."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import ComingSoon from '../../components/ComingSoon'
|
||||||
|
|
||||||
|
export default function QuotaSetup() {
|
||||||
|
return (
|
||||||
|
<ComingSoon
|
||||||
|
title="Initial Quota Setup"
|
||||||
|
description="Configure disk quota enforcement and default account limits."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import ComingSoon from '../../components/ComingSoon'
|
||||||
|
|
||||||
|
export default function ServerProfile() {
|
||||||
|
return (
|
||||||
|
<ComingSoon
|
||||||
|
title="Server Profile"
|
||||||
|
description="Set server-wide identity, locale, and branding preferences."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import ComingSoon from '../../components/ComingSoon'
|
||||||
|
|
||||||
|
export default function ServerTime() {
|
||||||
|
return (
|
||||||
|
<ComingSoon
|
||||||
|
title="Server Time"
|
||||||
|
description="Configure the system clock, timezone, and NTP synchronization."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import ComingSoon from '../../components/ComingSoon'
|
||||||
|
|
||||||
|
export default function StatisticsSoftware() {
|
||||||
|
return (
|
||||||
|
<ComingSoon
|
||||||
|
title="Statistics Software"
|
||||||
|
description="Install and configure AWStats, Webalizer, or other log analysis tools."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import ComingSoon from '../../components/ComingSoon'
|
||||||
|
|
||||||
|
export default function Terminal() {
|
||||||
|
return (
|
||||||
|
<ComingSoon
|
||||||
|
title="Terminal"
|
||||||
|
description="Open a browser-based terminal session with server access."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import ComingSoon from '../../components/ComingSoon'
|
||||||
|
|
||||||
|
export default function TweakSettings() {
|
||||||
|
return (
|
||||||
|
<ComingSoon
|
||||||
|
title="Tweak Settings"
|
||||||
|
description="Fine-tune advanced server behaviours and feature toggles."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import ComingSoon from '../../components/ComingSoon'
|
||||||
|
|
||||||
|
export default function UpdatePreferences() {
|
||||||
|
return (
|
||||||
|
<ComingSoon
|
||||||
|
title="Update Preferences"
|
||||||
|
description="Control automatic update behaviour, channels, and scheduling."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import {
|
||||||
|
Settings, Lock, BarChart2, Clock, HardDrive, Link2,
|
||||||
|
User, Timer, PieChart, Terminal, Sliders, RefreshCw, ShoppingBag,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import SectionCard from '../../components/SectionCard'
|
||||||
|
|
||||||
|
const TOOLS = [
|
||||||
|
{
|
||||||
|
label: 'Basic NixServer Setup',
|
||||||
|
description: 'Configure contact info, shared IP, nameservers, DNS TTL, home directory, and Apache log style.',
|
||||||
|
to: '/server-configuration/basic-setup',
|
||||||
|
icon: Settings,
|
||||||
|
comingSoon: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Change Root Password',
|
||||||
|
description: 'Update the root account password for this server.',
|
||||||
|
to: '/server-configuration/change-root',
|
||||||
|
icon: Lock,
|
||||||
|
comingSoon: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Configure Analytics',
|
||||||
|
description: 'Enable and configure server-side analytics and statistics collection.',
|
||||||
|
to: '/server-configuration/analytics',
|
||||||
|
icon: BarChart2,
|
||||||
|
comingSoon: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Configure Cron Jobs',
|
||||||
|
description: 'Schedule recurring system-level tasks and maintenance scripts.',
|
||||||
|
to: '/server-configuration/cron-jobs',
|
||||||
|
icon: Clock,
|
||||||
|
comingSoon: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Initial Quota Setup',
|
||||||
|
description: 'Configure disk quota enforcement and default account limits.',
|
||||||
|
to: '/server-configuration/quota-setup',
|
||||||
|
icon: HardDrive,
|
||||||
|
comingSoon: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Link Server Nodes',
|
||||||
|
description: 'Connect additional server nodes for clustering or load distribution.',
|
||||||
|
to: '/server-configuration/link-nodes',
|
||||||
|
icon: Link2,
|
||||||
|
comingSoon: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Server Profile',
|
||||||
|
description: 'Set server-wide identity, locale, and branding preferences.',
|
||||||
|
to: '/server-configuration/server-profile',
|
||||||
|
icon: User,
|
||||||
|
comingSoon: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Server Time',
|
||||||
|
description: 'Configure the system clock, timezone, and NTP synchronization.',
|
||||||
|
to: '/server-configuration/server-time',
|
||||||
|
icon: Timer,
|
||||||
|
comingSoon: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Statistics Software',
|
||||||
|
description: 'Install and configure AWStats, Webalizer, or other log analysis tools.',
|
||||||
|
to: '/server-configuration/statistics-software',
|
||||||
|
icon: PieChart,
|
||||||
|
comingSoon: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Terminal',
|
||||||
|
description: 'Open a browser-based terminal session with server access.',
|
||||||
|
to: '/server-configuration/terminal',
|
||||||
|
icon: Terminal,
|
||||||
|
comingSoon: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Tweak Settings',
|
||||||
|
description: 'Fine-tune advanced server behaviours and feature toggles.',
|
||||||
|
to: '/server-configuration/tweak-settings',
|
||||||
|
icon: Sliders,
|
||||||
|
comingSoon: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Update Preferences',
|
||||||
|
description: 'Control automatic update behaviour, channels, and scheduling.',
|
||||||
|
to: '/server-configuration/update-preferences',
|
||||||
|
icon: RefreshCw,
|
||||||
|
comingSoon: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'NixServer Marketplace',
|
||||||
|
description: 'Browse and install official extensions, plugins, and integrations.',
|
||||||
|
to: '/server-configuration/marketplace',
|
||||||
|
icon: ShoppingBag,
|
||||||
|
comingSoon: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function ServerConfiguration() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-white text-xl font-semibold">Server Configuration</h1>
|
||||||
|
<p className="text-[#64748b] text-sm mt-0.5">Server-wide settings and system configuration tools</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||||
|
{TOOLS.map(tool => (
|
||||||
|
<SectionCard key={tool.to} {...tool} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
import { Field, TextInput, RadioGroup, get } from './FormControls'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Record<string, string>
|
||||||
|
onChange: (key: string, value: string) => void
|
||||||
|
interfaces: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BasicTab({ settings, onChange, interfaces }: Props) {
|
||||||
|
const ethdev = get(settings, 'ETHDEV')
|
||||||
|
const isOther = ethdev !== '' && !interfaces.includes(ethdev)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Field label="Shared IPv4 Address" hint="ADDR — Main server IPv4 address">
|
||||||
|
<TextInput
|
||||||
|
value={get(settings, 'ADDR')}
|
||||||
|
onChange={v => onChange('ADDR', v)}
|
||||||
|
placeholder="192.168.1.100"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Shared IPv6 Address" hint="ADDR6 — Leave blank if not using IPv6">
|
||||||
|
<TextInput
|
||||||
|
value={get(settings, 'ADDR6')}
|
||||||
|
onChange={v => onChange('ADDR6', v)}
|
||||||
|
placeholder="2001:db8::1"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Ethernet Device" hint="ETHDEV — Network interface for IP configuration">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={isOther ? '__other__' : ethdev}
|
||||||
|
onChange={e => {
|
||||||
|
if (e.target.value !== '__other__') onChange('ETHDEV', e.target.value)
|
||||||
|
else onChange('ETHDEV', '')
|
||||||
|
}}
|
||||||
|
className="bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="">— select —</option>
|
||||||
|
{interfaces.map(iface => (
|
||||||
|
<option key={iface} value={iface}>{iface}</option>
|
||||||
|
))}
|
||||||
|
<option value="__other__">Other…</option>
|
||||||
|
</select>
|
||||||
|
{isOther && (
|
||||||
|
<TextInput
|
||||||
|
value={ethdev}
|
||||||
|
onChange={v => onChange('ETHDEV', v)}
|
||||||
|
placeholder="eth0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Home Directory" hint="HOMEDIR — Default: /home">
|
||||||
|
<TextInput
|
||||||
|
value={get(settings, 'HOMEDIR')}
|
||||||
|
onChange={v => onChange('HOMEDIR', v)}
|
||||||
|
placeholder="/home"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Home Directory Prefix Match"
|
||||||
|
hint="HOMEMATCH — Must be present in home path. Default: home"
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
value={get(settings, 'HOMEMATCH')}
|
||||||
|
onChange={v => onChange('HOMEMATCH', v)}
|
||||||
|
placeholder="home"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Auto-Create CGI-bin Alias"
|
||||||
|
hint="SCRIPTALIAS — Automatically create a cgi-bin for new accounts"
|
||||||
|
>
|
||||||
|
<RadioGroup
|
||||||
|
value={get(settings, 'SCRIPTALIAS')}
|
||||||
|
onChange={v => onChange('SCRIPTALIAS', v)}
|
||||||
|
options={[
|
||||||
|
{ value: 'yes', label: 'Yes' },
|
||||||
|
{ value: 'no', label: 'No' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="DNS TTL" hint="TTL — Time-to-live for DNS records (seconds). Default: 14400">
|
||||||
|
<TextInput
|
||||||
|
value={get(settings, 'TTL')}
|
||||||
|
onChange={v => onChange('TTL', v)}
|
||||||
|
placeholder="14400"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Nameserver TTL" hint="NSTTL — Time-to-live for NS records (seconds). Default: 86400">
|
||||||
|
<TextInput
|
||||||
|
value={get(settings, 'NSTTL')}
|
||||||
|
onChange={v => onChange('NSTTL', v)}
|
||||||
|
placeholder="86400"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Apache Log Style" hint="LOGSTYLE — Log format used in Apache vhost configs">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<RadioGroup
|
||||||
|
value={get(settings, 'LOGSTYLE')}
|
||||||
|
onChange={v => onChange('LOGSTYLE', v)}
|
||||||
|
options={[
|
||||||
|
{ value: 'combined', label: 'Combined' },
|
||||||
|
{ value: 'common', label: 'Common' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{get(settings, 'LOGSTYLE') === 'common' && (
|
||||||
|
<div className="flex items-start gap-2 bg-yellow-900/20 border border-yellow-700/40 rounded-lg px-3 py-2 text-yellow-400 text-xs">
|
||||||
|
<AlertTriangle size={14} className="mt-0.5 flex-shrink-0" />
|
||||||
|
<span>
|
||||||
|
<strong>Warning:</strong> "Common" log format omits the referrer and user-agent
|
||||||
|
fields. "Combined" is recommended for most setups.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { Field, TextInput, get } from './FormControls'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Record<string, string>
|
||||||
|
onChange: (key: string, value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContactTab({ settings, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Field
|
||||||
|
label="Contact Email Address(es)"
|
||||||
|
hint="Separate multiple addresses with spaces. Used for system notifications."
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
value={get(settings, 'CONTACTEMAIL')}
|
||||||
|
onChange={v => onChange('CONTACTEMAIL', v)}
|
||||||
|
placeholder="[email protected]"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
<p className="text-[#475569] text-xs mt-1">
|
||||||
|
Enter the email address(es) to which you would like server notification messages to be
|
||||||
|
sent. Separate multiple addresses with spaces.
|
||||||
|
</p>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Email Sender Name" hint="EMAILFROMNAME — Name shown in From: header">
|
||||||
|
<TextInput
|
||||||
|
value={get(settings, 'EMAILFROMNAME')}
|
||||||
|
onChange={v => onChange('EMAILFROMNAME', v)}
|
||||||
|
placeholder="NixPanel"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Reply-To Email Address" hint="EMAILREPLYTO — Optional reply-to header">
|
||||||
|
<TextInput
|
||||||
|
value={get(settings, 'EMAILREPLYTO')}
|
||||||
|
onChange={v => onChange('EMAILREPLYTO', v)}
|
||||||
|
placeholder="[email protected]"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Pager / Cellular Email" hint="CONTACTPAGER — For critical alerts (e.g. SMS gateway)">
|
||||||
|
<TextInput
|
||||||
|
value={get(settings, 'CONTACTPAGER')}
|
||||||
|
onChange={v => onChange('CONTACTPAGER', v)}
|
||||||
|
placeholder="[email protected]"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Pushbullet API Token" hint="CONTACTPUSHBULLET — Leave blank to disable">
|
||||||
|
<TextInput
|
||||||
|
value={get(settings, 'CONTACTPUSHBULLET')}
|
||||||
|
onChange={v => onChange('CONTACTPUSHBULLET', v)}
|
||||||
|
placeholder="o.xxxxxxxxxxxxxxxxxxxxxx"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Slack Webhook URL(s)" hint="CONTACTSLACK — One URL per line">
|
||||||
|
<textarea
|
||||||
|
value={get(settings, 'CONTACTSLACK')}
|
||||||
|
onChange={e => onChange('CONTACTSLACK', e.target.value)}
|
||||||
|
placeholder="https://hooks.slack.com/services/..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500 placeholder-[#374151] resize-y"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="POST Notification URL(s)" hint="CONTACTPOSTURL — One URL per line">
|
||||||
|
<textarea
|
||||||
|
value={get(settings, 'CONTACTPOSTURL')}
|
||||||
|
onChange={e => onChange('CONTACTPOSTURL', e.target.value)}
|
||||||
|
placeholder="https://example.com/webhook"
|
||||||
|
rows={3}
|
||||||
|
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500 placeholder-[#374151] resize-y"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="ICQ User ID" hint="ICQUSER">
|
||||||
|
<TextInput
|
||||||
|
value={get(settings, 'ICQUSER')}
|
||||||
|
onChange={v => onChange('ICQUSER', v)}
|
||||||
|
placeholder="123456789"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="ICQ Password" hint="ICQPASS">
|
||||||
|
<TextInput
|
||||||
|
value={get(settings, 'ICQPASS')}
|
||||||
|
onChange={v => onChange('ICQPASS', v)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="ICQ Contact UIDs" hint="CONTACTUIN — UIDs to notify (space-separated)">
|
||||||
|
<TextInput
|
||||||
|
value={get(settings, 'CONTACTUIN')}
|
||||||
|
onChange={v => onChange('CONTACTUIN', v)}
|
||||||
|
placeholder="123456789 987654321"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Shared form primitives used across Settings tabs
|
||||||
|
|
||||||
|
export function Field({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
hint?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[280px_1fr] gap-4 items-start py-3 border-b border-[#1e2130] last:border-0">
|
||||||
|
<div>
|
||||||
|
<div className="text-[#cbd5e1] text-sm font-medium">{label}</div>
|
||||||
|
{hint && <div className="text-[#475569] text-xs mt-0.5">{hint}</div>}
|
||||||
|
</div>
|
||||||
|
<div>{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
type = 'text',
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
type?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500 placeholder-[#374151]"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RadioGroup({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
options: { value: string; label: string }[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{options.map(opt => (
|
||||||
|
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value={opt.value}
|
||||||
|
checked={value === opt.value}
|
||||||
|
onChange={() => onChange(opt.value)}
|
||||||
|
className="accent-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="text-[#cbd5e1] text-sm">{opt.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FIELD_DEFAULTS: Record<string, string> = {
|
||||||
|
HOMEDIR: '/home',
|
||||||
|
HOMEMATCH: 'home',
|
||||||
|
TTL: '14400',
|
||||||
|
NSTTL: '86400',
|
||||||
|
SCRIPTALIAS: 'yes',
|
||||||
|
LOGSTYLE: 'combined',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get(settings: Record<string, string>, key: string): string {
|
||||||
|
return settings[key] ?? FIELD_DEFAULTS[key] ?? ''
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Server } from 'lucide-react'
|
||||||
|
import { TextInput, get } from './FormControls'
|
||||||
|
|
||||||
|
const NS_KEYS = ['NS', 'NS2', 'NS3', 'NS4'] as const
|
||||||
|
const NS_LABELS: Record<string, string> = {
|
||||||
|
NS: 'Nameserver 1',
|
||||||
|
NS2: 'Nameserver 2',
|
||||||
|
NS3: 'Nameserver 3 (optional)',
|
||||||
|
NS4: 'Nameserver 4 (optional)',
|
||||||
|
}
|
||||||
|
const NS_PLACEHOLDERS: Record<string, string> = {
|
||||||
|
NS: 'ns1.example.com',
|
||||||
|
NS2: 'ns2.example.com',
|
||||||
|
NS3: '',
|
||||||
|
NS4: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Record<string, string>
|
||||||
|
onChange: (key: string, value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NameserversSection({ settings, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden mt-6">
|
||||||
|
<div className="flex items-center gap-3 px-6 py-4 border-b border-[#2a2d3e]">
|
||||||
|
<Server size={16} className="text-indigo-400" />
|
||||||
|
<h2 className="text-white font-semibold">Nameservers</h2>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
{NS_KEYS.map(key => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="grid grid-cols-[280px_1fr_auto] gap-4 items-center py-3 border-b border-[#1e2130] last:border-0"
|
||||||
|
>
|
||||||
|
<div className="text-[#cbd5e1] text-sm font-medium">{NS_LABELS[key]}</div>
|
||||||
|
<TextInput
|
||||||
|
value={get(settings, key)}
|
||||||
|
onChange={v => onChange(key, v)}
|
||||||
|
placeholder={NS_PLACEHOLDERS[key]}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const ns = get(settings, key)
|
||||||
|
if (ns) alert(`Configure A record for ${ns} — DNS management coming soon.`)
|
||||||
|
}}
|
||||||
|
className="text-xs text-indigo-400 hover:text-indigo-300 border border-indigo-500/30 hover:border-indigo-400 rounded px-3 py-1.5 whitespace-nowrap transition-colors"
|
||||||
|
>
|
||||||
|
Configure Address Records
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Generated
-4022
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nixpanel-server",
|
"name": "nixpanel-server",
|
||||||
"version": "0.4.7",
|
"version": "0.6.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"server",
|
"server",
|
||||||
|
|||||||
+8
-5
@@ -82,6 +82,8 @@ if [[ -z "$ADMIN_EMAIL" ]]; then
|
|||||||
ADMIN_EMAIL="admin@$(hostname -f 2>/dev/null || echo localhost)"
|
ADMIN_EMAIL="admin@$(hostname -f 2>/dev/null || echo localhost)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Redirect all interactive reads to /dev/tty so curl | bash works correctly
|
||||||
|
|
||||||
MARIADB_ROOT_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
|
MARIADB_ROOT_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
|
||||||
JWT_SECRET=$(gen_secret)
|
JWT_SECRET=$(gen_secret)
|
||||||
JWT_REFRESH_SECRET=$(gen_secret)
|
JWT_REFRESH_SECRET=$(gen_secret)
|
||||||
@@ -93,7 +95,7 @@ echo " Admin username : $ADMIN_USER"
|
|||||||
echo " Admin email : $ADMIN_EMAIL"
|
echo " Admin email : $ADMIN_EMAIL"
|
||||||
echo " Admin password : $([ "$GENERATED_PASS" = "true" ] && echo "<auto-generated>" || echo "$ADMIN_PASS")"
|
echo " Admin password : $([ "$GENERATED_PASS" = "true" ] && echo "<auto-generated>" || echo "$ADMIN_PASS")"
|
||||||
echo ""
|
echo ""
|
||||||
read -r -p "Continue? [Y/n] " CONFIRM
|
read -r -p "Continue? [Y/n] " CONFIRM < /dev/tty || CONFIRM="Y"
|
||||||
CONFIRM="${CONFIRM:-Y}"
|
CONFIRM="${CONFIRM:-Y}"
|
||||||
[[ "$CONFIRM" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
|
[[ "$CONFIRM" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
|
||||||
|
|
||||||
@@ -547,7 +549,8 @@ success ".env written"
|
|||||||
# ── npm install ───────────────────────────────────────────────────────────────
|
# ── npm install ───────────────────────────────────────────────────────────────
|
||||||
step "Installing Node.js dependencies"
|
step "Installing Node.js dependencies"
|
||||||
cd "$INSTALL_DIR"
|
cd "$INSTALL_DIR"
|
||||||
npm install --silent
|
rm -rf node_modules package-lock.json
|
||||||
|
npm install
|
||||||
success "npm dependencies installed"
|
success "npm dependencies installed"
|
||||||
|
|
||||||
# ── Prisma generate + db push ─────────────────────────────────────────────────
|
# ── Prisma generate + db push ─────────────────────────────────────────────────
|
||||||
@@ -571,10 +574,10 @@ success "Admin account and default packages seeded"
|
|||||||
# ── Build ─────────────────────────────────────────────────────────────────────
|
# ── Build ─────────────────────────────────────────────────────────────────────
|
||||||
step "Building frontends and server"
|
step "Building frontends and server"
|
||||||
cd "$INSTALL_DIR"
|
cd "$INSTALL_DIR"
|
||||||
npm run build --workspace=nixserver --silent
|
npm run build --workspace=nixserver
|
||||||
npm run build --workspace=nixclient --silent
|
npm run build --workspace=nixclient
|
||||||
cd "$INSTALL_DIR/server"
|
cd "$INSTALL_DIR/server"
|
||||||
npm run build --silent
|
npm run build
|
||||||
success "Build complete"
|
success "Build complete"
|
||||||
|
|
||||||
# ── Set web-readable permissions on frontend assets ───────────────────────────
|
# ── Set web-readable permissions on frontend assets ───────────────────────────
|
||||||
|
|||||||
+11
-4
@@ -14,12 +14,19 @@ INSTALL_DIR="${1:-/opt/nixpanel}"
|
|||||||
[[ -d "$INSTALL_DIR/.git" ]] || error "NixPanel not found at $INSTALL_DIR"
|
[[ -d "$INSTALL_DIR/.git" ]] || error "NixPanel not found at $INSTALL_DIR"
|
||||||
|
|
||||||
step "Pulling latest code"
|
step "Pulling latest code"
|
||||||
|
# package-lock.json is not tracked (platform-specific), but discard any other
|
||||||
|
# locally modified tracked files that could block a fast-forward pull
|
||||||
|
git -C "$INSTALL_DIR" checkout -- . 2>/dev/null || true
|
||||||
git -C "$INSTALL_DIR" pull --ff-only
|
git -C "$INSTALL_DIR" pull --ff-only
|
||||||
success "Code updated"
|
success "Code updated"
|
||||||
|
|
||||||
step "Installing dependencies"
|
step "Installing dependencies"
|
||||||
cd "$INSTALL_DIR"
|
cd "$INSTALL_DIR"
|
||||||
npm install --workspaces --include-workspace-root --silent
|
# Delete node_modules and any stale lock file so npm regenerates them fresh
|
||||||
|
# for this platform (avoids the rollup native binary mismatch between
|
||||||
|
# Windows dev machines and Linux servers)
|
||||||
|
rm -rf node_modules package-lock.json
|
||||||
|
npm install --workspaces --include-workspace-root
|
||||||
success "Dependencies ready"
|
success "Dependencies ready"
|
||||||
|
|
||||||
step "Running migrations"
|
step "Running migrations"
|
||||||
@@ -30,8 +37,8 @@ success "Migrations applied"
|
|||||||
|
|
||||||
step "Rebuilding frontends"
|
step "Rebuilding frontends"
|
||||||
cd "$INSTALL_DIR"
|
cd "$INSTALL_DIR"
|
||||||
npm run build --workspace=nixserver --silent
|
npm run build --workspace=nixserver
|
||||||
npm run build --workspace=nixclient --silent
|
npm run build --workspace=nixclient
|
||||||
# Ensure Apache (www-data) can read the rebuilt assets
|
# Ensure Apache (www-data) can read the rebuilt assets
|
||||||
chmod 755 "$INSTALL_DIR/nixserver" "$INSTALL_DIR/nixclient"
|
chmod 755 "$INSTALL_DIR/nixserver" "$INSTALL_DIR/nixclient"
|
||||||
find "$INSTALL_DIR/nixserver/dist" -type d -exec chmod 755 {} \;
|
find "$INSTALL_DIR/nixserver/dist" -type d -exec chmod 755 {} \;
|
||||||
@@ -42,7 +49,7 @@ success "Frontends rebuilt"
|
|||||||
|
|
||||||
step "Rebuilding server"
|
step "Rebuilding server"
|
||||||
cd "$INSTALL_DIR/server"
|
cd "$INSTALL_DIR/server"
|
||||||
npm run build --silent 2>/dev/null || npx tsc 2>/dev/null || true
|
npm run build 2>/dev/null || npx tsc 2>/dev/null || true
|
||||||
success "Server rebuilt"
|
success "Server rebuilt"
|
||||||
|
|
||||||
step "Restarting service"
|
step "Restarting service"
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@ export async function buildApp() {
|
|||||||
|
|
||||||
fastify.get('/api/health', async () => ({
|
fastify.get('/api/health', async () => ({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
version: '0.4.7',
|
version: '0.6.2',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -1,671 +0,0 @@
|
|||||||
import type { FastifyInstance } from 'fastify'
|
|
||||||
import bcrypt from 'bcryptjs'
|
|
||||||
import { z } from 'zod'
|
|
||||||
import prisma from '../../db/client.js'
|
|
||||||
import { requireAdminOrReseller } from '../../middleware/auth.js'
|
|
||||||
import { exec, validateUsername, validateDomain } from '../../core/exec.js'
|
|
||||||
import { enableVhost, disableVhost, suspendVhost, enablePhpFpmPool, disablePhpFpmPool } from '../../core/apache.js'
|
|
||||||
import { createZone, removeZone } from '../../core/dns.js'
|
|
||||||
import { config } from '../../config.js'
|
|
||||||
|
|
||||||
const CreateAccountSchema = z.object({
|
|
||||||
username: z.string().min(1).max(32),
|
|
||||||
password: z.string().min(8),
|
|
||||||
email: z.string().email(),
|
|
||||||
domain: z.string().min(4),
|
|
||||||
packageId: z.number().int().positive().optional(),
|
|
||||||
resellerId: z.number().int().positive().optional(),
|
|
||||||
ipId: z.number().int().positive().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const UpdateAccountSchema = z.object({
|
|
||||||
email: z.string().email().optional(),
|
|
||||||
packageId: z.number().int().positive().optional(),
|
|
||||||
password: z.string().min(8).optional(),
|
|
||||||
notes: z.string().max(500).optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export default async function accountRoutes(fastify: FastifyInstance) {
|
|
||||||
const preHandler = [requireAdminOrReseller]
|
|
||||||
|
|
||||||
// GET /api/nixserver/accounts
|
|
||||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
|
||||||
const q = request.query as Record<string, string>
|
|
||||||
const page = Math.max(1, parseInt(q.page ?? '1', 10))
|
|
||||||
const limit = Math.min(100, Math.max(1, parseInt(q.limit ?? '25', 10)))
|
|
||||||
const skip = (page - 1) * limit
|
|
||||||
|
|
||||||
const where: Record<string, unknown> = {}
|
|
||||||
if (q.search) {
|
|
||||||
where.OR = [
|
|
||||||
{ username: { contains: q.search } },
|
|
||||||
{ domain: { contains: q.search } },
|
|
||||||
{ email: { contains: q.search } },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
if (q.status) where.status = q.status
|
|
||||||
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
|
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
|
||||||
prisma.account.findMany({
|
|
||||||
where,
|
|
||||||
skip,
|
|
||||||
take: limit,
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
select: {
|
|
||||||
id: true, username: true, domain: true, email: true, status: true,
|
|
||||||
diskUsedMb: true, bandwidthUsedMb: true, ipAddress: true,
|
|
||||||
createdAt: true, suspendedAt: true, suspendReason: true,
|
|
||||||
package: { select: { name: true, diskMb: true, bandwidthMb: true } },
|
|
||||||
reseller: { select: { username: true } },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.account.count({ where }),
|
|
||||||
])
|
|
||||||
|
|
||||||
return reply.send({
|
|
||||||
success: true,
|
|
||||||
data: { items, total, page, limit, totalPages: Math.ceil(total / limit) },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// GET /api/nixserver/accounts/stats
|
|
||||||
fastify.get('/stats', { preHandler }, async (request, reply) => {
|
|
||||||
const where = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
|
|
||||||
|
|
||||||
const [total, active, suspended, terminated] = await Promise.all([
|
|
||||||
prisma.account.count({ where }),
|
|
||||||
prisma.account.count({ where: { ...where, status: 'active' } }),
|
|
||||||
prisma.account.count({ where: { ...where, status: 'suspended' } }),
|
|
||||||
prisma.account.count({ where: { ...where, status: 'terminated' } }),
|
|
||||||
])
|
|
||||||
|
|
||||||
return reply.send({ success: true, data: { total, active, suspended, terminated } })
|
|
||||||
})
|
|
||||||
|
|
||||||
// GET /api/nixserver/accounts/parked-domains
|
|
||||||
fastify.get('/parked-domains', { preHandler }, async (request, reply) => {
|
|
||||||
const where = request.user!.role === 'reseller'
|
|
||||||
? { account: { resellerId: request.user!.sub } }
|
|
||||||
: {}
|
|
||||||
|
|
||||||
const items = await prisma.parkedDomain.findMany({
|
|
||||||
where,
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
include: {
|
|
||||||
account: { select: { username: true, domain: true } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return reply.send({ success: true, data: items })
|
|
||||||
})
|
|
||||||
|
|
||||||
// GET /api/nixserver/accounts/subdomains
|
|
||||||
fastify.get('/subdomains', { preHandler }, async (request, reply) => {
|
|
||||||
const where = request.user!.role === 'reseller'
|
|
||||||
? { account: { resellerId: request.user!.sub } }
|
|
||||||
: {}
|
|
||||||
|
|
||||||
const items = await prisma.subdomain.findMany({
|
|
||||||
where,
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
include: {
|
|
||||||
account: { select: { username: true } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return reply.send({ success: true, data: items })
|
|
||||||
})
|
|
||||||
|
|
||||||
// GET /api/nixserver/accounts/over-quota
|
|
||||||
fastify.get('/over-quota', { preHandler }, async (request, reply) => {
|
|
||||||
const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
|
|
||||||
|
|
||||||
const accounts = await prisma.account.findMany({
|
|
||||||
where: {
|
|
||||||
...resellerWhere,
|
|
||||||
package: { isNot: null },
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true, username: true, domain: true, status: true,
|
|
||||||
diskUsedMb: true,
|
|
||||||
package: { select: { name: true, diskMb: true, bandwidthMb: true } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const overQuota = accounts.filter((a: typeof accounts[number]) => a.package && a.diskUsedMb > a.package.diskMb)
|
|
||||||
|
|
||||||
return reply.send({ success: true, data: overQuota })
|
|
||||||
})
|
|
||||||
|
|
||||||
// GET /api/nixserver/accounts/bandwidth
|
|
||||||
fastify.get('/bandwidth', { preHandler }, async (request, reply) => {
|
|
||||||
const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
|
|
||||||
|
|
||||||
const items = await prisma.account.findMany({
|
|
||||||
where: resellerWhere,
|
|
||||||
orderBy: { bandwidthUsedMb: 'desc' },
|
|
||||||
select: {
|
|
||||||
id: true, username: true, domain: true, status: true,
|
|
||||||
bandwidthUsedMb: true, suspendReason: true,
|
|
||||||
package: { select: { name: true, bandwidthMb: true } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return reply.send({ success: true, data: items })
|
|
||||||
})
|
|
||||||
|
|
||||||
// POST /api/nixserver/accounts/:id/change-ip
|
|
||||||
fastify.post('/:id/change-ip', { preHandler }, async (request, reply) => {
|
|
||||||
const { id } = request.params as { id: string }
|
|
||||||
const body = z.object({ ipAddress: z.string().min(1) }).safeParse(request.body)
|
|
||||||
if (!body.success) {
|
|
||||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
|
||||||
}
|
|
||||||
|
|
||||||
const where: Record<string, unknown> = { id: parseInt(id, 10) }
|
|
||||||
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
|
|
||||||
|
|
||||||
const account = await prisma.account.findFirst({ where })
|
|
||||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
|
||||||
|
|
||||||
const updated = await prisma.account.update({
|
|
||||||
where: { id: account.id },
|
|
||||||
data: { ipAddress: body.data.ipAddress },
|
|
||||||
})
|
|
||||||
|
|
||||||
await prisma.auditLog.create({
|
|
||||||
data: {
|
|
||||||
action: 'account.change-ip',
|
|
||||||
target: account.username,
|
|
||||||
details: `IP changed to ${body.data.ipAddress}`,
|
|
||||||
sourceIp: request.ip,
|
|
||||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { passwordHash: _, ...safe } = updated
|
|
||||||
return reply.send({ success: true, data: safe })
|
|
||||||
})
|
|
||||||
|
|
||||||
// POST /api/nixserver/accounts/:id/reset-bandwidth
|
|
||||||
fastify.post('/:id/reset-bandwidth', { preHandler }, async (request, reply) => {
|
|
||||||
const { id } = request.params as { id: string }
|
|
||||||
|
|
||||||
const where: Record<string, unknown> = { id: parseInt(id, 10) }
|
|
||||||
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
|
|
||||||
|
|
||||||
const account = await prisma.account.findFirst({ where })
|
|
||||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
|
||||||
|
|
||||||
await prisma.account.update({
|
|
||||||
where: { id: account.id },
|
|
||||||
data: { bandwidthUsedMb: 0 },
|
|
||||||
})
|
|
||||||
|
|
||||||
await prisma.auditLog.create({
|
|
||||||
data: {
|
|
||||||
action: 'account.reset-bandwidth',
|
|
||||||
target: account.username,
|
|
||||||
sourceIp: request.ip,
|
|
||||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return reply.send({ success: true, data: { message: `Bandwidth reset for ${account.username}` } })
|
|
||||||
})
|
|
||||||
|
|
||||||
// PUT /api/nixserver/accounts/:id/quota
|
|
||||||
fastify.put('/:id/quota', { preHandler }, async (request, reply) => {
|
|
||||||
const { id } = request.params as { id: string }
|
|
||||||
const body = z.object({
|
|
||||||
diskMb: z.number().int().positive(),
|
|
||||||
bandwidthMb: z.number().int().positive(),
|
|
||||||
}).safeParse(request.body)
|
|
||||||
|
|
||||||
if (!body.success) {
|
|
||||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
|
||||||
}
|
|
||||||
|
|
||||||
const where: Record<string, unknown> = { id: parseInt(id, 10) }
|
|
||||||
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
|
|
||||||
|
|
||||||
const account = await prisma.account.findFirst({
|
|
||||||
where,
|
|
||||||
include: { package: true },
|
|
||||||
})
|
|
||||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
|
||||||
|
|
||||||
if (account.packageId) {
|
|
||||||
await prisma.package.update({
|
|
||||||
where: { id: account.packageId },
|
|
||||||
data: { diskMb: body.data.diskMb, bandwidthMb: body.data.bandwidthMb },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.auditLog.create({
|
|
||||||
data: {
|
|
||||||
action: 'account.quota-modification',
|
|
||||||
target: account.username,
|
|
||||||
details: `diskMb=${body.data.diskMb}, bandwidthMb=${body.data.bandwidthMb}`,
|
|
||||||
sourceIp: request.ip,
|
|
||||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return reply.send({ success: true, data: { message: 'Quota updated', ...body.data } })
|
|
||||||
})
|
|
||||||
|
|
||||||
// POST /api/nixserver/accounts/unsuspend-bandwidth-exceeded
|
|
||||||
fastify.post('/unsuspend-bandwidth-exceeded', { preHandler }, async (request, reply) => {
|
|
||||||
const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
|
|
||||||
|
|
||||||
const accounts = await prisma.account.findMany({
|
|
||||||
where: {
|
|
||||||
...resellerWhere,
|
|
||||||
status: 'suspended',
|
|
||||||
suspendReason: { contains: 'bandwidth' },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const results: { id: number; username: string; success: boolean; error?: string }[] = []
|
|
||||||
|
|
||||||
for (const account of accounts) {
|
|
||||||
try {
|
|
||||||
await exec('usermod', ['-U', account.username])
|
|
||||||
await prisma.account.update({
|
|
||||||
where: { id: account.id },
|
|
||||||
data: { status: 'active', suspendedAt: null, suspendReason: null },
|
|
||||||
})
|
|
||||||
results.push({ id: account.id, username: account.username, success: true })
|
|
||||||
} catch (e) {
|
|
||||||
results.push({ id: account.id, username: account.username, success: false, error: String(e) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.auditLog.create({
|
|
||||||
data: {
|
|
||||||
action: 'account.unsuspend-bandwidth-exceeded',
|
|
||||||
target: `${results.length} accounts`,
|
|
||||||
sourceIp: request.ip,
|
|
||||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return reply.send({ success: true, data: results })
|
|
||||||
})
|
|
||||||
|
|
||||||
// GET /api/nixserver/accounts/:id
|
|
||||||
fastify.get('/:id', { preHandler }, async (request, reply) => {
|
|
||||||
const { id } = request.params as { id: string }
|
|
||||||
const where: Record<string, unknown> = { id: parseInt(id, 10) }
|
|
||||||
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
|
|
||||||
|
|
||||||
const account = await prisma.account.findFirst({
|
|
||||||
where,
|
|
||||||
include: {
|
|
||||||
package: true,
|
|
||||||
reseller: { select: { username: true, company: true } },
|
|
||||||
ip: true,
|
|
||||||
dnsZones: { select: { id: true, domain: true, status: true } },
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
emailAccounts: true, databases: true, ftpAccounts: true,
|
|
||||||
subdomains: true, addonDomains: true, sslCertificates: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
|
||||||
|
|
||||||
const { passwordHash, ...safe } = account
|
|
||||||
return reply.send({ success: true, data: safe })
|
|
||||||
})
|
|
||||||
|
|
||||||
// POST /api/nixserver/accounts
|
|
||||||
fastify.post('/', { preHandler }, async (request, reply) => {
|
|
||||||
const body = CreateAccountSchema.safeParse(request.body)
|
|
||||||
if (!body.success) {
|
|
||||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { username, password, email, domain, packageId, resellerId, ipId } = body.data
|
|
||||||
|
|
||||||
if (!validateUsername(username)) {
|
|
||||||
return reply.code(400).send({ success: false, error: 'Invalid username format' })
|
|
||||||
}
|
|
||||||
if (!validateDomain(domain)) {
|
|
||||||
return reply.code(400).send({ success: false, error: 'Invalid domain format' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const exists = await prisma.account.findFirst({
|
|
||||||
where: { OR: [{ username }, { domain }] },
|
|
||||||
})
|
|
||||||
if (exists) {
|
|
||||||
return reply.code(409).send({ success: false, error: 'Username or domain already in use' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, 12)
|
|
||||||
const homedir = `${config.paths.homeDir}/${username}`
|
|
||||||
|
|
||||||
// Create system user
|
|
||||||
const userAdd = await exec('useradd', [
|
|
||||||
'-m', '-d', homedir, '-s', '/usr/sbin/nologin', username,
|
|
||||||
])
|
|
||||||
if (userAdd.exitCode !== 0 && !config.isDev) {
|
|
||||||
return reply.code(500).send({ success: false, error: `Failed to create system user: ${userAdd.stderr}` })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create directory structure
|
|
||||||
const dirs = [
|
|
||||||
`${homedir}/public_html`,
|
|
||||||
`${homedir}/logs`,
|
|
||||||
`${homedir}/tmp`,
|
|
||||||
`${homedir}/mail`,
|
|
||||||
`${homedir}/.ssh`,
|
|
||||||
]
|
|
||||||
for (const dir of dirs) {
|
|
||||||
await exec('mkdir', ['-p', dir])
|
|
||||||
}
|
|
||||||
await exec('chown', ['-R', `${username}:${username}`, homedir])
|
|
||||||
await exec('chmod', ['750', homedir])
|
|
||||||
await exec('chmod', ['755', `${homedir}/public_html`])
|
|
||||||
|
|
||||||
// Resolve IP for vhost
|
|
||||||
let ipAddress: string | undefined
|
|
||||||
if (ipId) {
|
|
||||||
const ip = await prisma.ipAddress.findUnique({ where: { id: ipId } })
|
|
||||||
ipAddress = ip?.address
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create account in DB
|
|
||||||
const account = await prisma.account.create({
|
|
||||||
data: {
|
|
||||||
username,
|
|
||||||
passwordHash,
|
|
||||||
email,
|
|
||||||
domain,
|
|
||||||
homedir,
|
|
||||||
status: 'active',
|
|
||||||
packageId: packageId ?? null,
|
|
||||||
resellerId: resellerId ?? (request.user!.role === 'reseller' ? request.user!.sub : null),
|
|
||||||
ipId: ipId ?? null,
|
|
||||||
ipAddress: ipAddress ?? null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create DNS zone
|
|
||||||
if (ipAddress) {
|
|
||||||
const serverHostname = (await exec('hostname', ['-f'])).stdout || 'ns1.server.com'
|
|
||||||
try {
|
|
||||||
const zoneFile = await createZone({
|
|
||||||
domain,
|
|
||||||
ip: ipAddress,
|
|
||||||
ns1: `ns1.${domain}`,
|
|
||||||
ns2: `ns2.${domain}`,
|
|
||||||
})
|
|
||||||
await prisma.dnsZone.create({
|
|
||||||
data: { domain, zoneFile, accountId: account.id },
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
// Non-fatal — log and continue
|
|
||||||
console.warn(`DNS zone creation failed for ${domain}:`, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create Apache vhost + PHP-FPM pool
|
|
||||||
const phpVersion = (await prisma.package.findUnique({ where: { id: packageId ?? 0 } }))?.phpVersion ?? '8.3'
|
|
||||||
try {
|
|
||||||
await enablePhpFpmPool(username, phpVersion)
|
|
||||||
await enableVhost({ domain, username, documentRoot: `${homedir}/public_html`, phpVersion })
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`Apache/PHP-FPM setup failed for ${domain}:`, e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audit log
|
|
||||||
await prisma.auditLog.create({
|
|
||||||
data: {
|
|
||||||
action: 'account.create',
|
|
||||||
target: username,
|
|
||||||
details: `Created hosting account for domain ${domain}`,
|
|
||||||
sourceIp: request.ip,
|
|
||||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { passwordHash: _, ...safe } = account
|
|
||||||
return reply.code(201).send({ success: true, data: safe })
|
|
||||||
})
|
|
||||||
|
|
||||||
// PUT /api/nixserver/accounts/:id
|
|
||||||
fastify.put('/:id', { preHandler }, async (request, reply) => {
|
|
||||||
const { id } = request.params as { id: string }
|
|
||||||
const body = UpdateAccountSchema.safeParse(request.body)
|
|
||||||
if (!body.success) {
|
|
||||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
|
||||||
}
|
|
||||||
|
|
||||||
const where: Record<string, unknown> = { id: parseInt(id, 10) }
|
|
||||||
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
|
|
||||||
|
|
||||||
const existing = await prisma.account.findFirst({ where })
|
|
||||||
if (!existing) return reply.code(404).send({ success: false, error: 'Account not found' })
|
|
||||||
|
|
||||||
const updateData: Record<string, unknown> = {}
|
|
||||||
if (body.data.email) updateData.email = body.data.email
|
|
||||||
if (body.data.packageId) updateData.packageId = body.data.packageId
|
|
||||||
if (body.data.notes !== undefined) updateData.notes = body.data.notes
|
|
||||||
if (body.data.password) {
|
|
||||||
updateData.passwordHash = await bcrypt.hash(body.data.password, 12)
|
|
||||||
// Update system user password
|
|
||||||
await exec('passwd', ['--stdin', existing.username])
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await prisma.account.update({
|
|
||||||
where: { id: parseInt(id, 10) },
|
|
||||||
data: updateData,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { passwordHash: _, ...safe } = updated
|
|
||||||
return reply.send({ success: true, data: safe })
|
|
||||||
})
|
|
||||||
|
|
||||||
// POST /api/nixserver/accounts/:id/suspend
|
|
||||||
fastify.post('/:id/suspend', { preHandler }, async (request, reply) => {
|
|
||||||
const { id } = request.params as { id: string }
|
|
||||||
const { reason } = (request.body as Record<string, string>) ?? {}
|
|
||||||
|
|
||||||
const account = await prisma.account.findFirst({
|
|
||||||
where: { id: parseInt(id, 10), status: 'active' },
|
|
||||||
})
|
|
||||||
if (!account) return reply.code(404).send({ success: false, error: 'Active account not found' })
|
|
||||||
|
|
||||||
await exec('usermod', ['-L', account.username])
|
|
||||||
try { await suspendVhost(account.domain, account.username) } catch (e) {
|
|
||||||
console.warn(`suspendVhost failed for ${account.domain}:`, e)
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.account.update({
|
|
||||||
where: { id: account.id },
|
|
||||||
data: { status: 'suspended', suspendedAt: new Date(), suspendReason: reason ?? null },
|
|
||||||
})
|
|
||||||
|
|
||||||
await prisma.auditLog.create({
|
|
||||||
data: {
|
|
||||||
action: 'account.suspend',
|
|
||||||
target: account.username,
|
|
||||||
details: reason ?? 'No reason given',
|
|
||||||
sourceIp: request.ip,
|
|
||||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return reply.send({ success: true, data: { message: `Account ${account.username} suspended` } })
|
|
||||||
})
|
|
||||||
|
|
||||||
// POST /api/nixserver/accounts/:id/unsuspend
|
|
||||||
fastify.post('/:id/unsuspend', { preHandler }, async (request, reply) => {
|
|
||||||
const { id } = request.params as { id: string }
|
|
||||||
|
|
||||||
const account = await prisma.account.findFirst({
|
|
||||||
where: { id: parseInt(id, 10), status: 'suspended' },
|
|
||||||
})
|
|
||||||
if (!account) return reply.code(404).send({ success: false, error: 'Suspended account not found' })
|
|
||||||
|
|
||||||
await exec('usermod', ['-U', account.username])
|
|
||||||
try {
|
|
||||||
await enableVhost({ domain: account.domain, username: account.username, documentRoot: `${account.homedir}/public_html` })
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`enableVhost failed on unsuspend for ${account.domain}:`, e)
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.account.update({
|
|
||||||
where: { id: account.id },
|
|
||||||
data: { status: 'active', suspendedAt: null, suspendReason: null },
|
|
||||||
})
|
|
||||||
|
|
||||||
await prisma.auditLog.create({
|
|
||||||
data: {
|
|
||||||
action: 'account.unsuspend',
|
|
||||||
target: account.username,
|
|
||||||
sourceIp: request.ip,
|
|
||||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return reply.send({ success: true, data: { message: `Account ${account.username} unsuspended` } })
|
|
||||||
})
|
|
||||||
|
|
||||||
// DELETE /api/nixserver/accounts/:id
|
|
||||||
fastify.delete('/:id', { preHandler }, async (request, reply) => {
|
|
||||||
const { id } = request.params as { id: string }
|
|
||||||
const { removeFiles } = (request.body as Record<string, boolean>) ?? {}
|
|
||||||
|
|
||||||
const account = await prisma.account.findUnique({ where: { id: parseInt(id, 10) } })
|
|
||||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
|
||||||
|
|
||||||
// Remove Apache vhost + PHP-FPM pool
|
|
||||||
try {
|
|
||||||
await disableVhost(account.domain)
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`Failed to remove vhost for ${account.domain}:`, e)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const phpVersion = account.packageId
|
|
||||||
? (await prisma.package.findUnique({ where: { id: account.packageId } }))?.phpVersion ?? '8.3'
|
|
||||||
: '8.3'
|
|
||||||
await disablePhpFpmPool(account.username, phpVersion)
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`Failed to remove PHP-FPM pool for ${account.username}:`, e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove DNS zone
|
|
||||||
try {
|
|
||||||
await removeZone(account.domain)
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`Failed to remove DNS zone for ${account.domain}:`, e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove system user
|
|
||||||
const delArgs = removeFiles ? ['-r', account.username] : [account.username]
|
|
||||||
await exec('userdel', delArgs)
|
|
||||||
|
|
||||||
// Mark as terminated in DB (cascade deletes related records)
|
|
||||||
await prisma.account.update({
|
|
||||||
where: { id: account.id },
|
|
||||||
data: { status: 'terminated', terminatedAt: new Date() },
|
|
||||||
})
|
|
||||||
|
|
||||||
await prisma.auditLog.create({
|
|
||||||
data: {
|
|
||||||
action: 'account.terminate',
|
|
||||||
target: account.username,
|
|
||||||
details: removeFiles ? 'Files removed' : 'Files retained',
|
|
||||||
sourceIp: request.ip,
|
|
||||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return reply.send({ success: true, data: { message: `Account ${account.username} terminated` } })
|
|
||||||
})
|
|
||||||
|
|
||||||
// PUT /api/nixserver/accounts/:id/shell — enable or disable shell access
|
|
||||||
fastify.put('/:id/shell', { preHandler }, async (request, reply) => {
|
|
||||||
const { id } = request.params as { id: string }
|
|
||||||
const { enabled } = (request.body ?? {}) as { enabled?: boolean }
|
|
||||||
if (typeof enabled !== 'boolean') {
|
|
||||||
return reply.code(400).send({ success: false, error: '"enabled" boolean required' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const account = await prisma.account.findUnique({ where: { id: parseInt(id, 10) } })
|
|
||||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
|
||||||
|
|
||||||
const shell = enabled ? '/bin/bash' : '/usr/sbin/nologin'
|
|
||||||
await exec('usermod', ['-s', shell, account.username])
|
|
||||||
await prisma.account.update({ where: { id: account.id }, data: { shell } })
|
|
||||||
|
|
||||||
await prisma.auditLog.create({
|
|
||||||
data: {
|
|
||||||
action: enabled ? 'account.shell.enable' : 'account.shell.disable',
|
|
||||||
target: account.username,
|
|
||||||
sourceIp: request.ip,
|
|
||||||
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return reply.send({ success: true, data: { shell } })
|
|
||||||
})
|
|
||||||
|
|
||||||
// POST /api/nixserver/accounts/bulk
|
|
||||||
fastify.post('/bulk', { preHandler }, async (request, reply) => {
|
|
||||||
const body = z.object({
|
|
||||||
action: z.enum(['suspend', 'unsuspend', 'terminate']),
|
|
||||||
ids: z.array(z.number().int().positive()).min(1).max(100),
|
|
||||||
reason: z.string().optional(),
|
|
||||||
}).safeParse(request.body)
|
|
||||||
|
|
||||||
if (!body.success) {
|
|
||||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { action, ids, reason } = body.data
|
|
||||||
const results: { id: number; success: boolean; error?: string }[] = []
|
|
||||||
|
|
||||||
for (const id of ids) {
|
|
||||||
try {
|
|
||||||
const account = await prisma.account.findUnique({ where: { id } })
|
|
||||||
if (!account) {
|
|
||||||
results.push({ id, success: false, error: 'Not found' })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === 'suspend') {
|
|
||||||
await exec('usermod', ['-L', account.username])
|
|
||||||
await prisma.account.update({
|
|
||||||
where: { id },
|
|
||||||
data: { status: 'suspended', suspendedAt: new Date(), suspendReason: reason ?? null },
|
|
||||||
})
|
|
||||||
} else if (action === 'unsuspend') {
|
|
||||||
await exec('usermod', ['-U', account.username])
|
|
||||||
await prisma.account.update({
|
|
||||||
where: { id },
|
|
||||||
data: { status: 'active', suspendedAt: null, suspendReason: null },
|
|
||||||
})
|
|
||||||
} else if (action === 'terminate') {
|
|
||||||
await exec('userdel', [account.username])
|
|
||||||
await prisma.account.update({
|
|
||||||
where: { id },
|
|
||||||
data: { status: 'terminated', terminatedAt: new Date() },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
results.push({ id, success: true })
|
|
||||||
} catch (e) {
|
|
||||||
results.push({ id, success: false, error: String(e) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply.send({ success: true, data: results })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import prisma from '../../../db/client.js'
|
||||||
|
import { requireAdminOrReseller } from '../../../middleware/auth.js'
|
||||||
|
import { exec, validateUsername, validateDomain } from '../../../core/exec.js'
|
||||||
|
import { enableVhost, enablePhpFpmPool } from '../../../core/apache.js'
|
||||||
|
import { createZone } from '../../../core/dns.js'
|
||||||
|
import { config } from '../../../config.js'
|
||||||
|
|
||||||
|
const CreateAccountSchema = z.object({
|
||||||
|
username: z.string().min(1).max(32),
|
||||||
|
password: z.string().min(8),
|
||||||
|
email: z.string().email(),
|
||||||
|
domain: z.string().min(4),
|
||||||
|
packageId: z.number().int().positive().optional(),
|
||||||
|
resellerId: z.number().int().positive().optional(),
|
||||||
|
ipId: z.number().int().positive().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default async function createRoute(fastify: FastifyInstance) {
|
||||||
|
// POST /api/nixserver/accounts
|
||||||
|
fastify.post('/', { preHandler: [requireAdminOrReseller] }, async (request, reply) => {
|
||||||
|
const body = CreateAccountSchema.safeParse(request.body)
|
||||||
|
if (!body.success) {
|
||||||
|
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, password, email, domain, packageId, resellerId, ipId } = body.data
|
||||||
|
|
||||||
|
if (!validateUsername(username)) {
|
||||||
|
return reply.code(400).send({ success: false, error: 'Invalid username format' })
|
||||||
|
}
|
||||||
|
if (!validateDomain(domain)) {
|
||||||
|
return reply.code(400).send({ success: false, error: 'Invalid domain format' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = await prisma.account.findFirst({ where: { OR: [{ username }, { domain }] } })
|
||||||
|
if (exists) {
|
||||||
|
return reply.code(409).send({ success: false, error: 'Username or domain already in use' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12)
|
||||||
|
const homedir = `${config.paths.homeDir}/${username}`
|
||||||
|
|
||||||
|
const userAdd = await exec('useradd', ['-m', '-d', homedir, '-s', '/usr/sbin/nologin', username])
|
||||||
|
if (userAdd.exitCode !== 0 && !config.isDev) {
|
||||||
|
return reply.code(500).send({ success: false, error: `Failed to create system user: ${userAdd.stderr}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirs = [
|
||||||
|
`${homedir}/public_html`,
|
||||||
|
`${homedir}/logs`,
|
||||||
|
`${homedir}/tmp`,
|
||||||
|
`${homedir}/mail`,
|
||||||
|
`${homedir}/.ssh`,
|
||||||
|
]
|
||||||
|
for (const dir of dirs) await exec('mkdir', ['-p', dir])
|
||||||
|
await exec('chown', ['-R', `${username}:${username}`, homedir])
|
||||||
|
await exec('chmod', ['750', homedir])
|
||||||
|
await exec('chmod', ['755', `${homedir}/public_html`])
|
||||||
|
|
||||||
|
let ipAddress: string | undefined
|
||||||
|
if (ipId) {
|
||||||
|
const ip = await prisma.ipAddress.findUnique({ where: { id: ipId } })
|
||||||
|
ipAddress = ip?.address
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await prisma.account.create({
|
||||||
|
data: {
|
||||||
|
username, passwordHash, email, domain, homedir,
|
||||||
|
status: 'active',
|
||||||
|
packageId: packageId ?? null,
|
||||||
|
resellerId: resellerId ?? (request.user!.role === 'reseller' ? request.user!.sub : null),
|
||||||
|
ipId: ipId ?? null,
|
||||||
|
ipAddress: ipAddress ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (ipAddress) {
|
||||||
|
try {
|
||||||
|
const zoneFile = await createZone({ domain, ip: ipAddress, ns1: `ns1.${domain}`, ns2: `ns2.${domain}` })
|
||||||
|
await prisma.dnsZone.create({ data: { domain, zoneFile, accountId: account.id } })
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`DNS zone creation failed for ${domain}:`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const phpVersion = (await prisma.package.findUnique({ where: { id: packageId ?? 0 } }))?.phpVersion ?? '8.3'
|
||||||
|
try {
|
||||||
|
await enablePhpFpmPool(username, phpVersion)
|
||||||
|
await enableVhost({ domain, username, documentRoot: `${homedir}/public_html`, phpVersion })
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Apache/PHP-FPM setup failed for ${domain}:`, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
action: 'account.create',
|
||||||
|
target: username,
|
||||||
|
details: `Created hosting account for domain ${domain}`,
|
||||||
|
sourceIp: request.ip,
|
||||||
|
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { passwordHash: _, ...safe } = account
|
||||||
|
return reply.code(201).send({ success: true, data: safe })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import listRoutes from './list.js'
|
||||||
|
import createRoute from './create.js'
|
||||||
|
import modifyRoutes from './modify.js'
|
||||||
|
import statusRoutes from './status.js'
|
||||||
|
|
||||||
|
export default async function accountRoutes(fastify: FastifyInstance) {
|
||||||
|
fastify.register(listRoutes)
|
||||||
|
fastify.register(createRoute)
|
||||||
|
fastify.register(modifyRoutes)
|
||||||
|
fastify.register(statusRoutes)
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import prisma from '../../../db/client.js'
|
||||||
|
import { requireAdminOrReseller } from '../../../middleware/auth.js'
|
||||||
|
|
||||||
|
const preHandler = [requireAdminOrReseller]
|
||||||
|
|
||||||
|
export default async function listRoutes(fastify: FastifyInstance) {
|
||||||
|
// GET /api/nixserver/accounts
|
||||||
|
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||||
|
const q = request.query as Record<string, string>
|
||||||
|
const page = Math.max(1, parseInt(q.page ?? '1', 10))
|
||||||
|
const limit = Math.min(100, Math.max(1, parseInt(q.limit ?? '25', 10)))
|
||||||
|
const skip = (page - 1) * limit
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = {}
|
||||||
|
if (q.search) {
|
||||||
|
where.OR = [
|
||||||
|
{ username: { contains: q.search } },
|
||||||
|
{ domain: { contains: q.search } },
|
||||||
|
{ email: { contains: q.search } },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (q.status) where.status = q.status
|
||||||
|
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
prisma.account.findMany({
|
||||||
|
where, skip, take: limit,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true, username: true, domain: true, email: true, status: true,
|
||||||
|
diskUsedMb: true, bandwidthUsedMb: true, ipAddress: true,
|
||||||
|
createdAt: true, suspendedAt: true, suspendReason: true,
|
||||||
|
package: { select: { name: true, diskMb: true, bandwidthMb: true } },
|
||||||
|
reseller: { select: { username: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.account.count({ where }),
|
||||||
|
])
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
data: { items, total, page, limit, totalPages: Math.ceil(total / limit) },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/nixserver/accounts/stats
|
||||||
|
fastify.get('/stats', { preHandler }, async (request, reply) => {
|
||||||
|
const where = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
|
||||||
|
|
||||||
|
const [total, active, suspended, terminated] = await Promise.all([
|
||||||
|
prisma.account.count({ where }),
|
||||||
|
prisma.account.count({ where: { ...where, status: 'active' } }),
|
||||||
|
prisma.account.count({ where: { ...where, status: 'suspended' } }),
|
||||||
|
prisma.account.count({ where: { ...where, status: 'terminated' } }),
|
||||||
|
])
|
||||||
|
|
||||||
|
return reply.send({ success: true, data: { total, active, suspended, terminated } })
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/nixserver/accounts/parked-domains
|
||||||
|
fastify.get('/parked-domains', { preHandler }, async (request, reply) => {
|
||||||
|
const where = request.user!.role === 'reseller'
|
||||||
|
? { account: { resellerId: request.user!.sub } } : {}
|
||||||
|
|
||||||
|
const items = await prisma.parkedDomain.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { account: { select: { username: true, domain: true } } },
|
||||||
|
})
|
||||||
|
return reply.send({ success: true, data: items })
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/nixserver/accounts/subdomains
|
||||||
|
fastify.get('/subdomains', { preHandler }, async (request, reply) => {
|
||||||
|
const where = request.user!.role === 'reseller'
|
||||||
|
? { account: { resellerId: request.user!.sub } } : {}
|
||||||
|
|
||||||
|
const items = await prisma.subdomain.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { account: { select: { username: true } } },
|
||||||
|
})
|
||||||
|
return reply.send({ success: true, data: items })
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/nixserver/accounts/over-quota
|
||||||
|
fastify.get('/over-quota', { preHandler }, async (request, reply) => {
|
||||||
|
const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
|
||||||
|
|
||||||
|
const accounts = await prisma.account.findMany({
|
||||||
|
where: { ...resellerWhere, package: { isNot: null } },
|
||||||
|
select: {
|
||||||
|
id: true, username: true, domain: true, status: true, diskUsedMb: true,
|
||||||
|
package: { select: { name: true, diskMb: true, bandwidthMb: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const overQuota = accounts.filter(
|
||||||
|
(a: typeof accounts[number]) => a.package && a.diskUsedMb > a.package.diskMb
|
||||||
|
)
|
||||||
|
return reply.send({ success: true, data: overQuota })
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/nixserver/accounts/bandwidth
|
||||||
|
fastify.get('/bandwidth', { preHandler }, async (request, reply) => {
|
||||||
|
const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
|
||||||
|
|
||||||
|
const items = await prisma.account.findMany({
|
||||||
|
where: resellerWhere,
|
||||||
|
orderBy: { bandwidthUsedMb: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true, username: true, domain: true, status: true,
|
||||||
|
bandwidthUsedMb: true, suspendReason: true,
|
||||||
|
package: { select: { name: true, bandwidthMb: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return reply.send({ success: true, data: items })
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/nixserver/accounts/:id
|
||||||
|
fastify.get('/:id', { preHandler }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string }
|
||||||
|
const where: Record<string, unknown> = { id: parseInt(id, 10) }
|
||||||
|
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
|
||||||
|
|
||||||
|
const account = await prisma.account.findFirst({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
package: true,
|
||||||
|
reseller: { select: { username: true, company: true } },
|
||||||
|
ip: true,
|
||||||
|
dnsZones: { select: { id: true, domain: true, status: true } },
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
emailAccounts: true, databases: true, ftpAccounts: true,
|
||||||
|
subdomains: true, addonDomains: true, sslCertificates: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||||
|
const { passwordHash, ...safe } = account
|
||||||
|
return reply.send({ success: true, data: safe })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import prisma from '../../../db/client.js'
|
||||||
|
import { requireAdminOrReseller } from '../../../middleware/auth.js'
|
||||||
|
import { exec } from '../../../core/exec.js'
|
||||||
|
|
||||||
|
const UpdateAccountSchema = z.object({
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
packageId: z.number().int().positive().optional(),
|
||||||
|
password: z.string().min(8).optional(),
|
||||||
|
notes: z.string().max(500).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const preHandler = [requireAdminOrReseller]
|
||||||
|
|
||||||
|
export default async function modifyRoutes(fastify: FastifyInstance) {
|
||||||
|
// PUT /api/nixserver/accounts/:id
|
||||||
|
fastify.put('/:id', { preHandler }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string }
|
||||||
|
const body = UpdateAccountSchema.safeParse(request.body)
|
||||||
|
if (!body.success) {
|
||||||
|
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = { id: parseInt(id, 10) }
|
||||||
|
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
|
||||||
|
|
||||||
|
const existing = await prisma.account.findFirst({ where })
|
||||||
|
if (!existing) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {}
|
||||||
|
if (body.data.email) updateData.email = body.data.email
|
||||||
|
if (body.data.packageId) updateData.packageId = body.data.packageId
|
||||||
|
if (body.data.notes !== undefined) updateData.notes = body.data.notes
|
||||||
|
if (body.data.password) {
|
||||||
|
updateData.passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||||
|
await exec('passwd', ['--stdin', existing.username])
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.account.update({
|
||||||
|
where: { id: parseInt(id, 10) },
|
||||||
|
data: updateData,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { passwordHash: _, ...safe } = updated
|
||||||
|
return reply.send({ success: true, data: safe })
|
||||||
|
})
|
||||||
|
|
||||||
|
// PUT /api/nixserver/accounts/:id/quota
|
||||||
|
fastify.put('/:id/quota', { preHandler }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string }
|
||||||
|
const body = z.object({
|
||||||
|
diskMb: z.number().int().positive(),
|
||||||
|
bandwidthMb: z.number().int().positive(),
|
||||||
|
}).safeParse(request.body)
|
||||||
|
|
||||||
|
if (!body.success) {
|
||||||
|
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = { id: parseInt(id, 10) }
|
||||||
|
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
|
||||||
|
|
||||||
|
const account = await prisma.account.findFirst({ where, include: { package: true } })
|
||||||
|
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||||
|
|
||||||
|
if (account.packageId) {
|
||||||
|
await prisma.package.update({
|
||||||
|
where: { id: account.packageId },
|
||||||
|
data: { diskMb: body.data.diskMb, bandwidthMb: body.data.bandwidthMb },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
action: 'account.quota-modification',
|
||||||
|
target: account.username,
|
||||||
|
details: `diskMb=${body.data.diskMb}, bandwidthMb=${body.data.bandwidthMb}`,
|
||||||
|
sourceIp: request.ip,
|
||||||
|
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return reply.send({ success: true, data: { message: 'Quota updated', ...body.data } })
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/nixserver/accounts/:id/change-ip
|
||||||
|
fastify.post('/:id/change-ip', { preHandler }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string }
|
||||||
|
const body = z.object({ ipAddress: z.string().min(1) }).safeParse(request.body)
|
||||||
|
if (!body.success) {
|
||||||
|
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = { id: parseInt(id, 10) }
|
||||||
|
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
|
||||||
|
|
||||||
|
const account = await prisma.account.findFirst({ where })
|
||||||
|
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||||
|
|
||||||
|
const updated = await prisma.account.update({
|
||||||
|
where: { id: account.id },
|
||||||
|
data: { ipAddress: body.data.ipAddress },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
action: 'account.change-ip',
|
||||||
|
target: account.username,
|
||||||
|
details: `IP changed to ${body.data.ipAddress}`,
|
||||||
|
sourceIp: request.ip,
|
||||||
|
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { passwordHash: _, ...safe } = updated
|
||||||
|
return reply.send({ success: true, data: safe })
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/nixserver/accounts/:id/reset-bandwidth
|
||||||
|
fastify.post('/:id/reset-bandwidth', { preHandler }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string }
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = { id: parseInt(id, 10) }
|
||||||
|
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
|
||||||
|
|
||||||
|
const account = await prisma.account.findFirst({ where })
|
||||||
|
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||||
|
|
||||||
|
await prisma.account.update({ where: { id: account.id }, data: { bandwidthUsedMb: 0 } })
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
action: 'account.reset-bandwidth',
|
||||||
|
target: account.username,
|
||||||
|
sourceIp: request.ip,
|
||||||
|
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return reply.send({ success: true, data: { message: `Bandwidth reset for ${account.username}` } })
|
||||||
|
})
|
||||||
|
|
||||||
|
// PUT /api/nixserver/accounts/:id/shell
|
||||||
|
fastify.put('/:id/shell', { preHandler }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string }
|
||||||
|
const { enabled } = (request.body ?? {}) as { enabled?: boolean }
|
||||||
|
if (typeof enabled !== 'boolean') {
|
||||||
|
return reply.code(400).send({ success: false, error: '"enabled" boolean required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await prisma.account.findUnique({ where: { id: parseInt(id, 10) } })
|
||||||
|
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||||
|
|
||||||
|
const shell = enabled ? '/bin/bash' : '/usr/sbin/nologin'
|
||||||
|
await exec('usermod', ['-s', shell, account.username])
|
||||||
|
await prisma.account.update({ where: { id: account.id }, data: { shell } })
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
action: enabled ? 'account.shell.enable' : 'account.shell.disable',
|
||||||
|
target: account.username,
|
||||||
|
sourceIp: request.ip,
|
||||||
|
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return reply.send({ success: true, data: { shell } })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import prisma from '../../../db/client.js'
|
||||||
|
import { requireAdminOrReseller } from '../../../middleware/auth.js'
|
||||||
|
import { exec } from '../../../core/exec.js'
|
||||||
|
import { enableVhost, disableVhost, suspendVhost, disablePhpFpmPool } from '../../../core/apache.js'
|
||||||
|
import { removeZone } from '../../../core/dns.js'
|
||||||
|
|
||||||
|
const preHandler = [requireAdminOrReseller]
|
||||||
|
|
||||||
|
export default async function statusRoutes(fastify: FastifyInstance) {
|
||||||
|
// POST /api/nixserver/accounts/:id/suspend
|
||||||
|
fastify.post('/:id/suspend', { preHandler }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string }
|
||||||
|
const { reason } = (request.body as Record<string, string>) ?? {}
|
||||||
|
|
||||||
|
const account = await prisma.account.findFirst({
|
||||||
|
where: { id: parseInt(id, 10), status: 'active' },
|
||||||
|
})
|
||||||
|
if (!account) return reply.code(404).send({ success: false, error: 'Active account not found' })
|
||||||
|
|
||||||
|
await exec('usermod', ['-L', account.username])
|
||||||
|
try { await suspendVhost(account.domain, account.username) } catch (e) {
|
||||||
|
console.warn(`suspendVhost failed for ${account.domain}:`, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.account.update({
|
||||||
|
where: { id: account.id },
|
||||||
|
data: { status: 'suspended', suspendedAt: new Date(), suspendReason: reason ?? null },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
action: 'account.suspend',
|
||||||
|
target: account.username,
|
||||||
|
details: reason ?? 'No reason given',
|
||||||
|
sourceIp: request.ip,
|
||||||
|
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return reply.send({ success: true, data: { message: `Account ${account.username} suspended` } })
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/nixserver/accounts/:id/unsuspend
|
||||||
|
fastify.post('/:id/unsuspend', { preHandler }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string }
|
||||||
|
|
||||||
|
const account = await prisma.account.findFirst({
|
||||||
|
where: { id: parseInt(id, 10), status: 'suspended' },
|
||||||
|
})
|
||||||
|
if (!account) return reply.code(404).send({ success: false, error: 'Suspended account not found' })
|
||||||
|
|
||||||
|
await exec('usermod', ['-U', account.username])
|
||||||
|
try {
|
||||||
|
await enableVhost({ domain: account.domain, username: account.username, documentRoot: `${account.homedir}/public_html` })
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`enableVhost failed on unsuspend for ${account.domain}:`, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.account.update({
|
||||||
|
where: { id: account.id },
|
||||||
|
data: { status: 'active', suspendedAt: null, suspendReason: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
action: 'account.unsuspend',
|
||||||
|
target: account.username,
|
||||||
|
sourceIp: request.ip,
|
||||||
|
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return reply.send({ success: true, data: { message: `Account ${account.username} unsuspended` } })
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/nixserver/accounts/unsuspend-bandwidth-exceeded
|
||||||
|
fastify.post('/unsuspend-bandwidth-exceeded', { preHandler }, async (request, reply) => {
|
||||||
|
const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
|
||||||
|
|
||||||
|
const accounts = await prisma.account.findMany({
|
||||||
|
where: { ...resellerWhere, status: 'suspended', suspendReason: { contains: 'bandwidth' } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const results: { id: number; username: string; success: boolean; error?: string }[] = []
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
try {
|
||||||
|
await exec('usermod', ['-U', account.username])
|
||||||
|
await prisma.account.update({
|
||||||
|
where: { id: account.id },
|
||||||
|
data: { status: 'active', suspendedAt: null, suspendReason: null },
|
||||||
|
})
|
||||||
|
results.push({ id: account.id, username: account.username, success: true })
|
||||||
|
} catch (e) {
|
||||||
|
results.push({ id: account.id, username: account.username, success: false, error: String(e) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
action: 'account.unsuspend-bandwidth-exceeded',
|
||||||
|
target: `${results.length} accounts`,
|
||||||
|
sourceIp: request.ip,
|
||||||
|
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return reply.send({ success: true, data: results })
|
||||||
|
})
|
||||||
|
|
||||||
|
// DELETE /api/nixserver/accounts/:id
|
||||||
|
fastify.delete('/:id', { preHandler }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string }
|
||||||
|
const { removeFiles } = (request.body as Record<string, boolean>) ?? {}
|
||||||
|
|
||||||
|
const account = await prisma.account.findUnique({ where: { id: parseInt(id, 10) } })
|
||||||
|
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||||
|
|
||||||
|
try { await disableVhost(account.domain) } catch (e) {
|
||||||
|
console.warn(`Failed to remove vhost for ${account.domain}:`, e)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const phpVersion = account.packageId
|
||||||
|
? (await prisma.package.findUnique({ where: { id: account.packageId } }))?.phpVersion ?? '8.3'
|
||||||
|
: '8.3'
|
||||||
|
await disablePhpFpmPool(account.username, phpVersion)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Failed to remove PHP-FPM pool for ${account.username}:`, e)
|
||||||
|
}
|
||||||
|
try { await removeZone(account.domain) } catch (e) {
|
||||||
|
console.warn(`Failed to remove DNS zone for ${account.domain}:`, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
await exec('userdel', removeFiles ? ['-r', account.username] : [account.username])
|
||||||
|
|
||||||
|
await prisma.account.update({
|
||||||
|
where: { id: account.id },
|
||||||
|
data: { status: 'terminated', terminatedAt: new Date() },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
action: 'account.terminate',
|
||||||
|
target: account.username,
|
||||||
|
details: removeFiles ? 'Files removed' : 'Files retained',
|
||||||
|
sourceIp: request.ip,
|
||||||
|
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return reply.send({ success: true, data: { message: `Account ${account.username} terminated` } })
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/nixserver/accounts/bulk
|
||||||
|
fastify.post('/bulk', { preHandler }, async (request, reply) => {
|
||||||
|
const body = z.object({
|
||||||
|
action: z.enum(['suspend', 'unsuspend', 'terminate']),
|
||||||
|
ids: z.array(z.number().int().positive()).min(1).max(100),
|
||||||
|
reason: z.string().optional(),
|
||||||
|
}).safeParse(request.body)
|
||||||
|
|
||||||
|
if (!body.success) {
|
||||||
|
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { action, ids, reason } = body.data
|
||||||
|
const results: { id: number; success: boolean; error?: string }[] = []
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
const account = await prisma.account.findUnique({ where: { id } })
|
||||||
|
if (!account) { results.push({ id, success: false, error: 'Not found' }); continue }
|
||||||
|
|
||||||
|
if (action === 'suspend') {
|
||||||
|
await exec('usermod', ['-L', account.username])
|
||||||
|
await prisma.account.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'suspended', suspendedAt: new Date(), suspendReason: reason ?? null },
|
||||||
|
})
|
||||||
|
} else if (action === 'unsuspend') {
|
||||||
|
await exec('usermod', ['-U', account.username])
|
||||||
|
await prisma.account.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'active', suspendedAt: null, suspendReason: null },
|
||||||
|
})
|
||||||
|
} else if (action === 'terminate') {
|
||||||
|
await exec('userdel', [account.username])
|
||||||
|
await prisma.account.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'terminated', terminatedAt: new Date() },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({ id, success: true })
|
||||||
|
} catch (e) {
|
||||||
|
results.push({ id, success: false, error: String(e) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send({ success: true, data: results })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from 'fastify'
|
import type { FastifyInstance } from 'fastify'
|
||||||
import accountRoutes from './accounts.js'
|
import accountRoutes from './accounts/index.js'
|
||||||
import packageRoutes from './packages.js'
|
import packageRoutes from './packages.js'
|
||||||
import resellerRoutes from './resellers.js'
|
import resellerRoutes from './resellers.js'
|
||||||
import dnsRoutes from './dns.js'
|
import dnsRoutes from './dns.js'
|
||||||
|
|||||||
@@ -99,6 +99,20 @@ export default async function systemRoutes(fastify: FastifyInstance) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// GET /api/nixserver/system/network-interfaces
|
||||||
|
fastify.get('/network-interfaces', { preHandler: [requireAdmin] }, async (_request, reply) => {
|
||||||
|
try {
|
||||||
|
const result = await exec('ls', ['/sys/class/net'])
|
||||||
|
const ifaces = result.stdout
|
||||||
|
.split('\n')
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(s => s && s !== 'lo') // exclude loopback
|
||||||
|
return reply.send({ success: true, data: ifaces })
|
||||||
|
} catch {
|
||||||
|
return reply.send({ success: true, data: [] })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// GET /api/nixserver/system/settings
|
// GET /api/nixserver/system/settings
|
||||||
fastify.get('/settings', { preHandler: [requireAdmin] }, async (request, reply) => {
|
fastify.get('/settings', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||||
const settings = await prisma.setting.findMany({ orderBy: { key: 'asc' } })
|
const settings = await prisma.setting.findMany({ orderBy: { key: 'asc' } })
|
||||||
|
|||||||
Reference in New Issue
Block a user