Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da576bdbdc | ||
|
|
489755136c | ||
|
|
2911cab755 | ||
|
|
8d6fe60168 | ||
|
|
6e59552e4f | ||
|
|
eebcce027b | ||
|
|
87edf902a1 | ||
|
|
d235d27fe3 | ||
|
|
8ff00c544c | ||
|
|
10e31482b0 | ||
|
|
fa61d0d766 | ||
|
|
75394fcaa5 | ||
|
|
d3ef236add | ||
|
|
b8ac6ee515 |
+134
@@ -0,0 +1,134 @@
|
||||
# NixPanel Changelog
|
||||
|
||||
All notable changes to NixPanel are documented here.
|
||||
Versioning follows [Semantic Versioning](https://semver.org/): MAJOR.MINOR.PATCH.
|
||||
Pre-1.0 versions treat MINOR as feature releases and PATCH as bug fixes.
|
||||
|
||||
---
|
||||
|
||||
## [0.4.2] — 2026-04-11
|
||||
|
||||
### Changes
|
||||
- **NixServer sidebar redesigned** — replaced the flat 14-item nav with a full WHM-style collapsible category sidebar (29 categories, ~210 menu items). Categories fold/unfold on click; the active route's category auto-expands on navigation. A live search box at the top of the sidebar filters items across all categories and auto-expands matching results. Sidebar can be hidden entirely via the topbar toggle. All existing routes are wired to their correct menu items; future feature items route to the closest related hub page.
|
||||
|
||||
---
|
||||
|
||||
## [0.4.1] — 2026-04-11
|
||||
|
||||
### Bug Fixes (Installer)
|
||||
- **SpamAssassin service name**: On Ubuntu 24.04 the service unit is `spamd`, not `spamassassin`. The old `systemctl enable spamassassin` caused the install to abort due to `set -euo pipefail`.
|
||||
- **PowerDNS port 53 conflict**: `systemd-resolved` stub listener now disabled in a dedicated step that runs *before* package installation. Previously it ran after apt had already tried (and failed) to restart `pdns` during post-install because port 53 was still held by `systemd-resolved`.
|
||||
- **Git clone into pre-existing directory**: The directory structure step (`mkdir -p`) creates `$INSTALL_DIR` before the clone step, causing `git clone` to fail with "destination path already exists". Fixed by cloning to a temp directory and copying contents into place (preserving any existing `data/` and `logs/` subdirectories).
|
||||
- **Apache 403 on frontend assets**: Frontend dist files were created with root-only permissions. Added a post-build step that sets 755/644 on all dist directories and files so Apache (`www-data`) can read them.
|
||||
- **Apache 500 on all API requests**: `mod_proxy_http` was not enabled. Without it, `ProxyPass http://...` directives silently fail and Apache returns 500 for every API call. Also enabled `mod_proxy_wstunnel` (required for the `ws://` WebSocket reverse proxy).
|
||||
|
||||
---
|
||||
|
||||
## [0.4.0] — 2026-04-11
|
||||
|
||||
### New Features
|
||||
- **Shell Access Manager** (`/accounts/shell-access`): Admin can enable (`/bin/bash`) or disable (`/usr/sbin/nologin`) SSH shell access per hosting account. Reads current shell on account select; shows current state clearly before toggle.
|
||||
- **Apache Log Viewer** (`/accounts/apache-logs`): Admin can view the last 200 lines of `access.log` or `error.log` from each account's home directory (`~/logs/`). Replaced the former "Coming Soon" stub.
|
||||
- **Backend: Shell toggle route** — `PUT /api/nixserver/accounts/:id/shell` — validates `enabled` boolean, runs `usermod -s`, updates DB, creates audit log entry.
|
||||
- **Backend: Per-account Apache log route** — `GET /api/nixserver/webserver/accounts/:username/logs/:type` — validates username against `[a-zA-Z0-9_-]+`, tails `~/logs/{access,error}.log`.
|
||||
|
||||
### Changes
|
||||
- **WebServer page** fully rewritten to Apache-only: shows status for `apache2`, `php8.2-fpm`, `php8.3-fpm`; per-service reload buttons; config test calls `apache2ctl configtest`; log viewer reads from `/var/log/apache2/`.
|
||||
- **Backend `webserver.ts`** rewritten: removed all nginx references, added Apache (`apache2ctl configtest/reload`), PHP-FPM reload (`systemctl reload php8.x-fpm`), `readdir`-based vhost listing from `/etc/apache2/sites-enabled/`.
|
||||
- **NginxLogs page** (`/accounts/nginx-logs`) now re-exports `ApacheLogs` — NixPanel uses Apache, not NGINX.
|
||||
- **Accounts hub** (`/accounts`): removed the separate "Raw NGINX Log Download" card (duplicate of Apache Logs); removed `soon` badges from Shell Access and Apache Log Viewer; fixed Lucide icon `size` prop TypeScript incompatibility.
|
||||
|
||||
### Bug Fixes
|
||||
- `Accounts.tsx`: Lucide icon `ComponentType` declared `size?: number` but Lucide passes `size?: string | number` — widened to `size?: number | string`.
|
||||
- `Accounts.tsx`: Removed unused `Users` import (TS6133).
|
||||
- `webserver.ts` (backend): Routes were still calling `nginx -t`, `systemctl reload nginx`, and reading from `/etc/nginx/` despite nginx being removed from the service stack in 0.3.x. Fixed to use Apache equivalents.
|
||||
|
||||
---
|
||||
|
||||
## [0.3.2] — 2026-03-xx
|
||||
|
||||
### Changes
|
||||
- TypeScript compilation fixes across server, nixserver, nixclient.
|
||||
- `auth/index.ts`: Fixed `Parameters<typeof prisma.refreshToken.create>` resolving to `unknown` in Prisma 5 — replaced with explicit typed object using `?? undefined` on optional relation fields.
|
||||
- `nixclient/cron.ts`: Added explicit type annotation `(j: { schedule, command, comment })` to `jobs.map()` callback to resolve implicit `any`.
|
||||
- `nixserver/accounts.ts`: Added explicit type annotation to over-quota filter lambda.
|
||||
- `mysql2` added to server dependencies (`^3.11.5`).
|
||||
- `@types/node-cron` added to server devDependencies (`^3.0.11`).
|
||||
|
||||
---
|
||||
|
||||
## [0.3.1] — 2026-03-xx
|
||||
|
||||
### New Features
|
||||
- **Databases** (`nixclient`): Full MariaDB database management — create/delete databases, add/remove database users with privilege selection. Backend replaced shell-exec MySQL calls with `mysql2` parameterized queries and identifier allowlist to prevent injection.
|
||||
- **Email** (`nixclient`): Rewrote email route — replaced `doveadm` calls with `core/mail.ts` functions; added `PUT /:id/password` route; forwarder add/remove via Exim virtual_aliases.
|
||||
- **SSL** (`nixserver`): Certbot switched from `--webroot` to `--apache` plugin; uses `getCertPaths()` from `core/ssl.ts`.
|
||||
- **Domains** (`nixclient`): Connected subdomain, addon domain, and parked domain APIs.
|
||||
- **Stats** (`nixclient`): Disk/bandwidth usage bars + feature usage counts.
|
||||
|
||||
### Core Modules Added
|
||||
- `core/apache.ts` — Apache vhost generation, enable/disable/suspend, PHP-FPM pool management.
|
||||
- `core/dns.ts` — Rewritten for PowerDNS bind backend; zone file management in `/etc/pdns/zones/`; `pdns_control` integration.
|
||||
- `core/mail.ts` — Full Exim4 + Dovecot mailbox lifecycle (create, delete, password update, forwarders). Uses `openssl passwd -6 -stdin` via `execWithStdin()`.
|
||||
- `core/mariadb.ts` — mysql2 connection pool; parameterized DDL for databases and users; `validateIdentifier()` allowlist.
|
||||
- `core/monitor.ts` — Service health monitor for apache2, php-fpm, exim4, dovecot, pdns, mariadb, fail2ban; auto-restart with rolling window cap.
|
||||
- `core/ssl.ts` — `issueCertificate()`, `renewExpiring()`, `getCertPaths()`.
|
||||
- `core/stats.ts` — `collectDiskUsage()` via `df -BM`, `resetMonthlyBandwidth()`.
|
||||
- `core/license.ts` — Startup validation + 6-hour heartbeat to licensing server.
|
||||
- `core/exec.ts` — Added `execWithStdin()` via `child_process.spawn` for stdin piping; added pdns, exim4, nixpanel paths to allowlist; removed nginx/bind/postfix.
|
||||
- `scheduler.ts` — node-cron scheduler for monitor, SSL renewal, disk stats, license heartbeat, bandwidth reset.
|
||||
|
||||
### Removed
|
||||
- `core/nginx.ts` — deleted; replaced by `core/apache.ts`.
|
||||
|
||||
---
|
||||
|
||||
## [0.3.0] — 2026-03-xx
|
||||
|
||||
### Architecture: Service Stack Change
|
||||
Complete server stack pivot based on cPanel reverse-engineering analysis (Ubuntu 24.04 install diff). Replaced incorrect services with cPanel-equivalent stack:
|
||||
|
||||
| Component | Before | After |
|
||||
|-----------|--------|-------|
|
||||
| Web server | nginx | Apache2 + mod_proxy_fcgi |
|
||||
| PHP | php-fpm (generic) | PHP-FPM 8.2 + 8.3 per-account pools |
|
||||
| DNS | BIND9 | PowerDNS + bind backend |
|
||||
| Mail MTA | Postfix | Exim4 (exim4-daemon-heavy) |
|
||||
| Mail IMAP/POP3 | — | Dovecot (imapd, pop3d, lmtpd) |
|
||||
| Panel database | MariaDB | SQLite (panel-internal) |
|
||||
| Firewall | UFW | iptables + netfilter-persistent |
|
||||
| FTP | vsftpd | Deferred to post-v1 |
|
||||
|
||||
### Config Changes (`config.ts`)
|
||||
- Removed nginx paths; added `apacheSites`, `apacheEnabled`, `phpFpmPool82/83`, `pdnsZones`, `pdnsBindBackend`, `eximVirtualDomains`, `eximVirtualMailboxes`, `eximVirtualAliases`, `dovecotPasswd`, `letsencryptLive`.
|
||||
- Added `mariadb` connection section (host, port, user, password).
|
||||
- Added `licensing` section (serverUrl, licenseKey).
|
||||
- Removed `features.terminalEnabled`.
|
||||
|
||||
### Prisma Schema (`schema.prisma`)
|
||||
- Switched provider from `mysql` to `sqlite`.
|
||||
- Removed all `@db.VarChar`, `@db.Text`, `@db.BigInt` type annotations (SQLite incompatible).
|
||||
- Added `resellerId Int?` + `reseller` relation to `AuditLog`.
|
||||
- Added `auditLogs AuditLog[]` to `Reseller` model.
|
||||
- Removed `TerminalSession` model.
|
||||
- Fixed account default shell: `/bin/false` → `/usr/sbin/nologin`.
|
||||
|
||||
### Installer (`scripts/install.sh`)
|
||||
- Hard Ubuntu 24.04 check.
|
||||
- Correct package list: apache2, pdns-server, pdns-backend-bind, exim4, exim4-daemon-heavy, dovecot-core/imapd/pop3d/lmtpd, php8.2-fpm, php8.3-fpm, mariadb-server, certbot python3-certbot-apache, iptables, iptables-persistent, sqlite3.
|
||||
- Idempotent step tracking via `/var/lib/nixpanel/install/*.done`.
|
||||
- Package pinning via `/etc/apt/preferences.d/nixpanel-pins` (prevents apt from overriding managed packages).
|
||||
- Disables unattended-upgrades.
|
||||
- Creates `nixpanel` system user with nologin shell.
|
||||
- Configures PowerDNS bind backend; disables systemd-resolved stub.
|
||||
- Configures Exim4 via debconf for internet site mode.
|
||||
- Configures Dovecot with passwd-file auth.
|
||||
- SQLite database at `/opt/nixpanel/data/nixpanel.db`.
|
||||
- iptables rules with `netfilter-persistent save`.
|
||||
- Certbot Apache deploy hook for auto-reload.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.x] — Prior
|
||||
|
||||
Initial development with incorrect service stack (nginx, BIND9, Postfix, MariaDB for panel data). Functional skeleton of API routes; no production-ready installer.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nixpanel/cpanel",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -49,7 +49,7 @@ export default function Login() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[#64748b] text-xs mt-4">NixClient v0.3.0 · Port 2083</p>
|
||||
<p className="text-center text-[#64748b] text-xs mt-4">NixClient v0.3.2 · Port 2083</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -10,14 +10,14 @@ export default function SSL() {
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try { const r = await api.get('/nixserver/ssl'); setCerts(r.data.data) }
|
||||
try { const r = await api.get('/nixclient/ssl'); setCerts(r.data.data) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const renew = async (id: number) => {
|
||||
try { await api.post(`/nixserver/ssl/${id}/renew`); fetch() }
|
||||
try { await api.post(`/nixclient/ssl/${id}/renew`); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Renewal failed') }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nixpanel/whm",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+149
-13
@@ -7,45 +7,180 @@ import Dashboard from './pages/Dashboard'
|
||||
import Accounts from './pages/Accounts'
|
||||
import CreateAccount from './pages/CreateAccount'
|
||||
import Packages from './pages/Packages'
|
||||
import Setup from './pages/Setup'
|
||||
|
||||
// Lazy-loaded pages (built incrementally)
|
||||
const Resellers = React.lazy(() => import('./pages/Resellers'))
|
||||
const DNS = React.lazy(() => import('./pages/DNS'))
|
||||
const Email = React.lazy(() => import('./pages/Email'))
|
||||
const SSL = React.lazy(() => import('./pages/SSL'))
|
||||
const PHP = React.lazy(() => import('./pages/PHP'))
|
||||
const Services = React.lazy(() => import('./pages/Services'))
|
||||
const Backup = React.lazy(() => import('./pages/Backup'))
|
||||
const Security = React.lazy(() => import('./pages/Security'))
|
||||
const System = React.lazy(() => import('./pages/System'))
|
||||
const WebServer = React.lazy(() => import('./pages/WebServer'))
|
||||
const Settings = React.lazy(() => import('./pages/Settings'))
|
||||
const Resellers = React.lazy(() => import('./pages/Resellers'))
|
||||
const DNS = React.lazy(() => import('./pages/DNS'))
|
||||
const Email = React.lazy(() => import('./pages/Email'))
|
||||
const SSL = React.lazy(() => import('./pages/SSL'))
|
||||
const PHP = React.lazy(() => import('./pages/PHP'))
|
||||
const Services = React.lazy(() => import('./pages/Services'))
|
||||
const Backup = React.lazy(() => import('./pages/Backup'))
|
||||
const Security = React.lazy(() => import('./pages/Security'))
|
||||
const System = React.lazy(() => import('./pages/System'))
|
||||
const WebServer = React.lazy(() => import('./pages/WebServer'))
|
||||
const Settings = React.lazy(() => import('./pages/Settings'))
|
||||
|
||||
// Accounts sub-pages
|
||||
const AccountList = React.lazy(() => import('./pages/accounts/List'))
|
||||
const AccountParkedDomains = React.lazy(() => import('./pages/accounts/ParkedDomains'))
|
||||
const AccountSubdomains = React.lazy(() => import('./pages/accounts/Subdomains'))
|
||||
const AccountSuspended = React.lazy(() => import('./pages/accounts/Suspended'))
|
||||
const AccountOverQuota = React.lazy(() => import('./pages/accounts/OverQuota'))
|
||||
const AccountBandwidth = React.lazy(() => import('./pages/accounts/Bandwidth'))
|
||||
const AccountChangeIP = React.lazy(() => import('./pages/accounts/ChangeIP'))
|
||||
const AccountEmailUsers = React.lazy(() => import('./pages/accounts/EmailUsers'))
|
||||
const AccountForcePassword = React.lazy(() => import('./pages/accounts/ForcePassword'))
|
||||
const AccountLimitBandwidth = React.lazy(() => import('./pages/accounts/LimitBandwidth'))
|
||||
const AccountSuspension = React.lazy(() => import('./pages/accounts/Suspension'))
|
||||
const AccountDemoMode = React.lazy(() => import('./pages/accounts/DemoMode'))
|
||||
const AccountShellAccess = React.lazy(() => import('./pages/accounts/ShellAccess'))
|
||||
const AccountModify = React.lazy(() => import('./pages/accounts/Modify'))
|
||||
const AccountPasswordMod = React.lazy(() => import('./pages/accounts/PasswordMod'))
|
||||
const AccountQuotaMod = React.lazy(() => import('./pages/accounts/QuotaMod'))
|
||||
const AccountApacheLogs = React.lazy(() => import('./pages/accounts/ApacheLogs'))
|
||||
const AccountNginxLogs = React.lazy(() => import('./pages/accounts/NginxLogs'))
|
||||
const AccountRearrange = React.lazy(() => import('./pages/accounts/Rearrange'))
|
||||
const AccountResetBandwidth = React.lazy(() => import('./pages/accounts/ResetBandwidth'))
|
||||
const AccountTerminate = React.lazy(() => import('./pages/accounts/Terminate'))
|
||||
const AccountUnsuspendBW = React.lazy(() => import('./pages/accounts/UnsuspendBandwidth'))
|
||||
const AccountUpgrade = React.lazy(() => import('./pages/accounts/Upgrade'))
|
||||
const AccountWebTemplate = React.lazy(() => import('./pages/accounts/WebTemplate'))
|
||||
const AccountBulkIP = React.lazy(() => import('./pages/accounts/BulkIP'))
|
||||
const AccountBulkModify = React.lazy(() => import('./pages/accounts/BulkModify'))
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
const { user, loading, setupComplete } = useAuth()
|
||||
|
||||
if (loading) return (
|
||||
<div className="min-h-screen bg-[#0f1117] flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!user) return <Navigate to="/login" replace />
|
||||
|
||||
// Admin must complete first-run setup before accessing the panel
|
||||
if (user.role === 'admin' && !setupComplete) {
|
||||
return <Navigate to="/setup" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { user } = useAuth()
|
||||
const { user, setupComplete } = useAuth()
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
|
||||
|
||||
{/* First-run setup — only accessible to authenticated admins who haven't completed setup */}
|
||||
<Route path="/setup" element={
|
||||
!user
|
||||
? <Navigate to="/login" replace />
|
||||
: setupComplete
|
||||
? <Navigate to="/" replace />
|
||||
: <Setup />
|
||||
} />
|
||||
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}>
|
||||
<Route index element={<Dashboard />} />
|
||||
|
||||
{/* Accounts hub */}
|
||||
<Route path="accounts" element={<Accounts />} />
|
||||
<Route path="accounts/create" element={<CreateAccount />} />
|
||||
|
||||
{/* Account Information */}
|
||||
<Route path="accounts/list" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountList /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/parked-domains" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountParkedDomains /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/subdomains" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountSubdomains /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/suspended" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountSuspended /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/over-quota" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountOverQuota /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/bandwidth" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountBandwidth /></React.Suspense>
|
||||
} />
|
||||
|
||||
{/* Account Functions */}
|
||||
<Route path="accounts/change-ip" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountChangeIP /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/email-users" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountEmailUsers /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/force-password" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountForcePassword /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/limit-bandwidth" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountLimitBandwidth /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/suspension" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountSuspension /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/demo-mode" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountDemoMode /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/shell-access" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountShellAccess /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/modify" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountModify /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/password" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountPasswordMod /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/quota" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountQuotaMod /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/apache-logs" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountApacheLogs /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/nginx-logs" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountNginxLogs /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/rearrange" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountRearrange /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/reset-bandwidth" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountResetBandwidth /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/terminate" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountTerminate /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/unsuspend-bandwidth" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountUnsuspendBW /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/upgrade" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountUpgrade /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/web-template" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountWebTemplate /></React.Suspense>
|
||||
} />
|
||||
|
||||
{/* Multi Account Functions */}
|
||||
<Route path="accounts/bulk-ip" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountBulkIP /></React.Suspense>
|
||||
} />
|
||||
<Route path="accounts/bulk-modify" element={
|
||||
<React.Suspense fallback={<PageLoader />}><AccountBulkModify /></React.Suspense>
|
||||
} />
|
||||
|
||||
{/* Other sections */}
|
||||
<Route path="packages" element={<Packages />} />
|
||||
<Route path="resellers" element={
|
||||
<React.Suspense fallback={<PageLoader />}><Resellers /></React.Suspense>
|
||||
@@ -81,6 +216,7 @@ function AppRoutes() {
|
||||
<React.Suspense fallback={<PageLoader />}><Settings /></React.Suspense>
|
||||
} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
|
||||
@@ -1,33 +1,502 @@
|
||||
import { useState } from 'react'
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard, Users, Package, UserCog, Globe, Mail, Shield,
|
||||
Server, HardDrive, Settings, LogOut, Menu, X,
|
||||
Activity, Lock, BarChart3, Cpu
|
||||
LayoutDashboard, Settings, HelpCircle, Network, BookUser, UserCog,
|
||||
Wrench, Languages, HardDrive, GitFork, RefreshCw, Activity,
|
||||
Users, UserCheck, ArrowRightLeft, Paintbrush, Package,
|
||||
Globe, Database, Wifi, Box, Mail, HeartPulse, Lock,
|
||||
ShoppingBag, Code2, Puzzle, LogOut, Menu, X,
|
||||
ChevronDown, ChevronRight, Search, RotateCcw
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const nav = [
|
||||
{ label: 'Dashboard', to: '/', icon: LayoutDashboard },
|
||||
{ label: 'Accounts', to: '/accounts', icon: Users },
|
||||
{ label: 'Packages', to: '/packages', icon: Package },
|
||||
{ label: 'Resellers', to: '/resellers', icon: UserCog },
|
||||
{ label: 'DNS', to: '/dns', icon: Globe },
|
||||
{ label: 'Email', to: '/email', icon: Mail },
|
||||
{ label: 'SSL', to: '/ssl', icon: Lock },
|
||||
{ label: 'PHP', to: '/php', icon: Cpu },
|
||||
{ label: 'Web Server', to: '/webserver', icon: Server },
|
||||
{ label: 'Services', to: '/services', icon: Activity },
|
||||
{ label: 'Backup', to: '/backup', icon: HardDrive },
|
||||
{ label: 'Security', to: '/security', icon: Shield },
|
||||
{ label: 'System', to: '/system', icon: BarChart3 },
|
||||
{ label: 'Settings', to: '/settings', icon: Settings },
|
||||
// ─── Nav data ────────────────────────────────────────────────────────────────
|
||||
|
||||
type NavItem = { label: string; to: string }
|
||||
type NavCategory = {
|
||||
id: string
|
||||
label: string
|
||||
icon: React.ComponentType<{ size?: number; className?: string }>
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
const NAV: NavCategory[] = [
|
||||
{
|
||||
id: 'server-config',
|
||||
label: 'Server Configuration',
|
||||
icon: Settings,
|
||||
items: [
|
||||
{ label: 'Basic WebHost Manager® Setup', to: '/settings' },
|
||||
{ label: 'Change Root Password', to: '/security' },
|
||||
{ label: 'Configure Analytics', to: '/settings' },
|
||||
{ label: 'Configure Cron Jobs', to: '/settings' },
|
||||
{ label: 'Initial Quota Setup', to: '/settings' },
|
||||
{ label: 'Link Server Nodes', to: '/settings' },
|
||||
{ label: 'Server Profile', to: '/settings' },
|
||||
{ label: 'Server Time', to: '/system' },
|
||||
{ label: 'Statistics Software', to: '/settings' },
|
||||
{ label: 'Terminal', to: '/system' },
|
||||
{ label: 'Tweak Settings', to: '/settings' },
|
||||
{ label: 'Update Preferences', to: '/settings' },
|
||||
{ label: 'WHM Marketplace', to: '/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'support',
|
||||
label: 'Support',
|
||||
icon: HelpCircle,
|
||||
items: [
|
||||
{ label: 'Grant NixPanel Support Access', to: '/security' },
|
||||
{ label: 'Support Center', to: '/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'networking',
|
||||
label: 'Networking Setup',
|
||||
icon: Network,
|
||||
items: [
|
||||
{ label: 'Change Hostname', to: '/security' },
|
||||
{ label: 'Resolver Configuration', to: '/security' },
|
||||
{ label: 'Security Center', to: '/security' },
|
||||
{ label: 'Apache mod_userdir Tweak', to: '/webserver' },
|
||||
{ label: 'SMTP Restrictions', to: '/email' },
|
||||
{ label: 'Compiler Access', to: '/security' },
|
||||
{ label: 'Configure Security Policies', to: '/security' },
|
||||
{ label: 'mXSTOP Brute Force Protection', to: '/security' },
|
||||
{ label: 'Host Access Control', to: '/security' },
|
||||
{ label: 'Manage External Authentications', to: '/security' },
|
||||
{ label: "Manage root's SSH Keys", to: '/security' },
|
||||
{ label: 'Manage Wheel Group Users', to: '/security' },
|
||||
{ label: 'ModSecurity™ Configuration', to: '/security' },
|
||||
{ label: 'ModSecurity™ Tools', to: '/security' },
|
||||
{ label: 'ModSecurity™ Vendors', to: '/security' },
|
||||
{ label: 'Password Strength Configuration', to: '/security' },
|
||||
{ label: 'Security Advisor', to: '/security' },
|
||||
{ label: 'Security Questions', to: '/security' },
|
||||
{ label: 'Shell Fork Bomb Protection', to: '/security' },
|
||||
{ label: 'SSH Password Authorization Tweak', to: '/security' },
|
||||
{ label: 'Traceroute Enable/Disable', to: '/security' },
|
||||
{ label: 'Two-Factor Authentication', to: '/security' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'contacts',
|
||||
label: 'Server Contacts',
|
||||
icon: BookUser,
|
||||
items: [
|
||||
{ label: 'Contact Manager', to: '/settings' },
|
||||
{ label: 'Edit System Mail Preferences', to: '/email' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'resellers',
|
||||
label: 'Resellers',
|
||||
icon: UserCog,
|
||||
items: [
|
||||
{ label: 'Change Ownership of an Account', to: '/resellers' },
|
||||
{ label: 'Change Ownership of Multiple Accounts', to: '/resellers' },
|
||||
{ label: 'Edit Reseller Nameservers and Privileges', to: '/resellers' },
|
||||
{ label: 'Email All Resellers', to: '/resellers' },
|
||||
{ label: "Manage Reseller's IP Delegation", to: '/resellers' },
|
||||
{ label: "Manage Reseller's Shared IP", to: '/resellers' },
|
||||
{ label: 'Reseller Center', to: '/resellers' },
|
||||
{ label: 'Reset Resellers', to: '/resellers' },
|
||||
{ label: 'Show Reseller Accounts', to: '/resellers' },
|
||||
{ label: 'View Reseller Usage', to: '/resellers' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'service-config',
|
||||
label: 'Service Configuration',
|
||||
icon: Wrench,
|
||||
items: [
|
||||
{ label: 'Apache Configuration', to: '/webserver' },
|
||||
{ label: 'Log Rotation Configuration', to: '/settings' },
|
||||
{ label: 'Web Disk Configuration', to: '/settings' },
|
||||
{ label: 'Web Services Configuration', to: '/settings' },
|
||||
{ label: 'Exim Configuration Manager', to: '/email' },
|
||||
{ label: 'FTP Server Selection', to: '/settings' },
|
||||
{ label: 'Mailserver Configuration', to: '/email' },
|
||||
{ label: 'Manage Service SSL Certificates', to: '/ssl' },
|
||||
{ label: 'Nameserver Selection', to: '/dns' },
|
||||
{ label: 'Service Manager', to: '/services' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'locales',
|
||||
label: 'Locales',
|
||||
icon: Languages,
|
||||
items: [
|
||||
{ label: 'Configure Application Locales', to: '/settings' },
|
||||
{ label: 'Copy a Locale', to: '/settings' },
|
||||
{ label: 'Delete a Locale', to: '/settings' },
|
||||
{ label: 'Edit a Locale', to: '/settings' },
|
||||
{ label: 'Locale XML Download', to: '/settings' },
|
||||
{ label: 'Locale XML Upload', to: '/settings' },
|
||||
{ label: 'View Available Locales', to: '/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'backup',
|
||||
label: 'Backup',
|
||||
icon: HardDrive,
|
||||
items: [
|
||||
{ label: 'Backup Configuration', to: '/backup' },
|
||||
{ label: 'Backup Restoration', to: '/backup' },
|
||||
{ label: 'Backup User Selection', to: '/backup' },
|
||||
{ label: 'File and Directory Restoration', to: '/backup' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'clusters',
|
||||
label: 'Clusters',
|
||||
icon: GitFork,
|
||||
items: [
|
||||
{ label: 'Configuration Cluster', to: '/settings' },
|
||||
{ label: 'DNS Cluster', to: '/dns' },
|
||||
{ label: 'Remote Access Key', to: '/security' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reboot',
|
||||
label: 'System Reboot',
|
||||
icon: RotateCcw,
|
||||
items: [
|
||||
{ label: 'Forceful Server Reboot', to: '/system' },
|
||||
{ label: 'Graceful Server Reboot', to: '/system' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'server-status',
|
||||
label: 'Server Status',
|
||||
icon: Activity,
|
||||
items: [
|
||||
{ label: 'Apache Status', to: '/webserver' },
|
||||
{ label: 'Daily Process Log', to: '/system' },
|
||||
{ label: 'Server Information',to: '/system' },
|
||||
{ label: 'Service Status', to: '/services' },
|
||||
{ label: 'Task Queue Monitor',to: '/system' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'account-info',
|
||||
label: 'Account Information',
|
||||
icon: Users,
|
||||
items: [
|
||||
{ label: 'List Accounts', to: '/accounts/list' },
|
||||
{ label: 'List Parked Domains', to: '/accounts/parked-domains'},
|
||||
{ label: 'List Subdomains', to: '/accounts/subdomains' },
|
||||
{ label: 'List Suspended Accounts', to: '/accounts/suspended' },
|
||||
{ label: 'Show Accounts Over Quota', to: '/accounts/over-quota' },
|
||||
{ label: 'View Bandwidth Usage', to: '/accounts/bandwidth' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'account-functions',
|
||||
label: 'Account Functions',
|
||||
icon: UserCheck,
|
||||
items: [
|
||||
{ label: "Change Site's IP Address", to: '/accounts/change-ip' },
|
||||
{ label: 'Create a New Account', to: '/accounts/create' },
|
||||
{ label: 'Email All Users', to: '/accounts/email-users' },
|
||||
{ label: 'Force Password Change', to: '/accounts/force-password' },
|
||||
{ label: 'Limit Bandwidth Usage', to: '/accounts/limit-bandwidth' },
|
||||
{ label: 'Manage Account Suspension', to: '/accounts/suspension' },
|
||||
{ label: 'Manage Demo Mode', to: '/accounts/demo-mode' },
|
||||
{ label: 'Manage Shell Access', to: '/accounts/shell-access' },
|
||||
{ label: 'Modify an Account', to: '/accounts/modify' },
|
||||
{ label: 'Password Modification', to: '/accounts/password' },
|
||||
{ label: 'Quota Modification', to: '/accounts/quota' },
|
||||
{ label: 'Raw Apache Log Download', to: '/accounts/apache-logs' },
|
||||
{ label: 'Raw NGINX® Log Download', to: '/accounts/nginx-logs' },
|
||||
{ label: 'Rearrange an Account', to: '/accounts/rearrange' },
|
||||
{ label: 'Reset Account Bandwidth', to: '/accounts/reset-bandwidth' },
|
||||
{ label: 'Terminate Accounts', to: '/accounts/terminate' },
|
||||
{ label: 'Unsuspend Bandwidth Exceeders', to: '/accounts/unsuspend-bandwidth' },
|
||||
{ label: 'Upgrade/Downgrade an Account', to: '/accounts/upgrade' },
|
||||
{ label: 'Web Template Editor', to: '/accounts/web-template' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'multi-account',
|
||||
label: 'Multi Account Functions',
|
||||
icon: Users,
|
||||
items: [
|
||||
{ label: "Change Multiple Sites' IP Addresses", to: '/accounts/bulk-ip' },
|
||||
{ label: 'Modify/Upgrade Multiple Accounts', to: '/accounts/bulk-modify' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'transfers',
|
||||
label: 'Transfers',
|
||||
icon: ArrowRightLeft,
|
||||
items: [
|
||||
{ label: 'Convert Addon Domain to Account', to: '/accounts' },
|
||||
{ label: 'Review Transfers and Restores', to: '/accounts' },
|
||||
{ label: 'Transfer or Restore a cPanel Account', to: '/accounts' },
|
||||
{ label: 'Transfer Tool', to: '/accounts' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'themes',
|
||||
label: 'Themes',
|
||||
icon: Paintbrush,
|
||||
items: [
|
||||
{ label: 'Theme Manager', to: '/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'packages',
|
||||
label: 'Packages',
|
||||
icon: Package,
|
||||
items: [
|
||||
{ label: 'Add a Package', to: '/packages' },
|
||||
{ label: 'Delete a Package', to: '/packages' },
|
||||
{ label: 'Edit a Package', to: '/packages' },
|
||||
{ label: 'Feature Manager', to: '/packages' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dns',
|
||||
label: 'DNS Functions',
|
||||
icon: Globe,
|
||||
items: [
|
||||
{ label: 'Add a DNS Zone', to: '/dns' },
|
||||
{ label: 'Add an A Entry for Hostname', to: '/dns' },
|
||||
{ label: 'Delete a DNS Zone', to: '/dns' },
|
||||
{ label: 'DNS Zone Manager', to: '/dns' },
|
||||
{ label: 'Edit Zone Templates', to: '/dns' },
|
||||
{ label: 'Email DNS Record Manager', to: '/dns' },
|
||||
{ label: 'Email Routing Configuration', to: '/email' },
|
||||
{ label: 'Enable DKIM/SPF Globally', to: '/email' },
|
||||
{ label: 'Nameserver Record Report', to: '/dns' },
|
||||
{ label: 'Park a Domain', to: '/dns' },
|
||||
{ label: 'Perform a DNS Cleanup', to: '/dns' },
|
||||
{ label: 'Set Zone TTL', to: '/dns' },
|
||||
{ label: 'Setup/Edit Domain Forwarding',to: '/dns' },
|
||||
{ label: 'Synchronize DNS Records', to: '/dns' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'databases',
|
||||
label: 'Database Services',
|
||||
icon: Database,
|
||||
items: [
|
||||
{ label: 'Change Database Root Password', to: '/settings' },
|
||||
{ label: 'Change Database User Password', to: '/settings' },
|
||||
{ label: 'Database Map Tool', to: '/settings' },
|
||||
{ label: 'Edit Database Configuration', to: '/settings' },
|
||||
{ label: 'Manage Database Access Hosts', to: '/settings' },
|
||||
{ label: 'Manage Database Profiles', to: '/settings' },
|
||||
{ label: 'Manage Database Users', to: '/settings' },
|
||||
{ label: 'Manage Databases', to: '/settings' },
|
||||
{ label: 'phpMyAdmin', to: '/settings' },
|
||||
{ label: 'Repair Databases', to: '/settings' },
|
||||
{ label: 'Show Database Processes', to: '/settings' },
|
||||
{ label: 'Upgrade Database Version', to: '/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ip',
|
||||
label: 'IP Functions',
|
||||
icon: Wifi,
|
||||
items: [
|
||||
{ label: 'IPv6 Ranges', to: '/security' },
|
||||
{ label: 'Add a New IP Address', to: '/security' },
|
||||
{ label: 'Assign IPv6 Address', to: '/security' },
|
||||
{ label: 'Configure Remote Service IPs', to: '/security' },
|
||||
{ label: 'IP Migration Wizard', to: '/security' },
|
||||
{ label: 'Rebuild the IP Address Pool', to: '/security' },
|
||||
{ label: 'Show IP Address Usage', to: '/security' },
|
||||
{ label: 'Show or Delete Current IPs', to: '/security' },
|
||||
{ label: 'Show/Edit Reserved IPs', to: '/security' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'software',
|
||||
label: 'Software',
|
||||
icon: Box,
|
||||
items: [
|
||||
{ label: 'Install a Perl Module', to: '/system' },
|
||||
{ label: 'Install Distro Packages', to: '/system' },
|
||||
{ label: 'Module Installers', to: '/system' },
|
||||
{ label: 'MultiPHP INI Editor', to: '/php' },
|
||||
{ label: 'MultiPHP Manager', to: '/php' },
|
||||
{ label: 'NGINX® Manager', to: '/webserver' },
|
||||
{ label: 'Rebuild RPM Database', to: '/system' },
|
||||
{ label: 'System Update', to: '/system' },
|
||||
{ label: 'Update Server Software', to: '/system' },
|
||||
{ label: 'EasyApache 4', to: '/webserver' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
label: 'Email',
|
||||
icon: Mail,
|
||||
items: [
|
||||
{ label: 'Email Deliverability', to: '/email' },
|
||||
{ label: 'Filter Incoming Emails by Country', to: '/email' },
|
||||
{ label: 'Filter Incoming Emails by Domain', to: '/email' },
|
||||
{ label: 'Greylisting', to: '/email' },
|
||||
{ label: 'Mail Delivery Reports', to: '/email' },
|
||||
{ label: 'Mail Queue Manager', to: '/email' },
|
||||
{ label: 'Mail Troubleshooter', to: '/email' },
|
||||
{ label: 'Mailbox Conversion', to: '/email' },
|
||||
{ label: 'Repair Mailbox Permissions', to: '/email' },
|
||||
{ label: 'Spamd Startup Configuration', to: '/email' },
|
||||
{ label: 'View Mail Statistics Summary', to: '/email' },
|
||||
{ label: 'View Relayers', to: '/email' },
|
||||
{ label: 'View Sent Summary', to: '/email' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'system-health',
|
||||
label: 'System Health',
|
||||
icon: HeartPulse,
|
||||
items: [
|
||||
{ label: 'Background Process Killer', to: '/system' },
|
||||
{ label: 'Process Manager', to: '/system' },
|
||||
{ label: 'Show Current Disk Usage', to: '/system' },
|
||||
{ label: 'Show Current Running Processes', to: '/system' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nixpanel',
|
||||
label: 'NixPanel',
|
||||
icon: LayoutDashboard,
|
||||
items: [
|
||||
{ label: 'Change Log', to: '/settings' },
|
||||
{ label: 'Customization', to: '/settings' },
|
||||
{ label: 'Manage Plugins', to: '/settings' },
|
||||
{ label: 'Modify NixPanel News', to: '/settings' },
|
||||
{ label: 'Reset a Mailman Password', to: '/email' },
|
||||
{ label: 'Upgrade to Latest Version', to: '/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ssl',
|
||||
label: 'SSL/TLS',
|
||||
icon: Lock,
|
||||
items: [
|
||||
{ label: 'Generate an SSL Certificate and Signing Request', to: '/ssl' },
|
||||
{ label: 'Install an SSL Certificate on a Domain', to: '/ssl' },
|
||||
{ label: 'Manage AutoSSL', to: '/ssl' },
|
||||
{ label: 'Manage SSL Hosts', to: '/ssl' },
|
||||
{ label: 'Purchase and Install an SSL Certificate', to: '/ssl' },
|
||||
{ label: 'SSL Storage Manager', to: '/ssl' },
|
||||
{ label: 'SSL/TLS Configuration', to: '/ssl' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'market',
|
||||
label: 'Market',
|
||||
icon: ShoppingBag,
|
||||
items: [
|
||||
{ label: 'Market Provider Manager', to: '/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'restart-services',
|
||||
label: 'Restart Services',
|
||||
icon: RefreshCw,
|
||||
items: [
|
||||
{ label: 'Database Server', to: '/services' },
|
||||
{ label: 'DNS Server', to: '/services' },
|
||||
{ label: 'HTTP Server (Apache)', to: '/services' },
|
||||
{ label: 'IMAP Server', to: '/services' },
|
||||
{ label: 'Mail Server (Exim)', to: '/services' },
|
||||
{ label: 'Mailing List Manager (Mailman)', to: '/services' },
|
||||
{ label: 'PHP-FPM service for Apache', to: '/services' },
|
||||
{ label: 'SSH Server (OpenSSH)', to: '/services' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'development',
|
||||
label: 'Development',
|
||||
icon: Code2,
|
||||
items: [
|
||||
{ label: 'Apps Managed by AppConfig', to: '/settings' },
|
||||
{ label: 'NixPanel Development Forum', to: '/settings' },
|
||||
{ label: 'NixPanel Plugin File Generator', to: '/settings' },
|
||||
{ label: 'Developer Documentation', to: '/settings' },
|
||||
{ label: 'Manage API Tokens', to: '/settings' },
|
||||
{ label: 'Manage Hooks', to: '/settings' },
|
||||
{ label: 'OpenAPI Documentation for NixPanel', to: '/settings' },
|
||||
{ label: 'OpenAPI Documentation for WHM', to: '/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'plugins',
|
||||
label: 'Plugins',
|
||||
icon: Puzzle,
|
||||
items: [
|
||||
{ label: 'Backuply by Softaculous', to: '/backup' },
|
||||
{ label: 'ImunifyAV', to: '/security' },
|
||||
{ label: 'Server Monitoring', to: '/system' },
|
||||
{ label: 'SitePad Website Builder', to: '/settings' },
|
||||
{ label: 'Softaculous - Instant Installs', to: '/settings' },
|
||||
{ label: 'WP Toolkit', to: '/settings' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function isItemActive(to: string, pathname: string) {
|
||||
if (to === '/') return pathname === '/'
|
||||
return pathname === to || pathname.startsWith(to + '/')
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [openCats, setOpenCats] = useState<Set<string>>(() => {
|
||||
// Auto-open the category that contains the active route on first render
|
||||
const active = NAV.find(cat =>
|
||||
cat.items.some(item => isItemActive(item.to, location.pathname))
|
||||
)
|
||||
return active ? new Set([active.id]) : new Set()
|
||||
})
|
||||
|
||||
// When the route changes (e.g. user navigates), auto-open the matching category
|
||||
useEffect(() => {
|
||||
const active = NAV.find(cat =>
|
||||
cat.items.some(item => isItemActive(item.to, location.pathname))
|
||||
)
|
||||
if (active) {
|
||||
setOpenCats(prev => prev.has(active.id) ? prev : new Set([...prev, active.id]))
|
||||
}
|
||||
}, [location.pathname])
|
||||
|
||||
const toggleCat = (id: string) => {
|
||||
setOpenCats(prev => {
|
||||
const next = new Set(prev)
|
||||
next.has(id) ? next.delete(id) : next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Filter nav based on search query
|
||||
const filteredNav = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q) return NAV
|
||||
return NAV.map(cat => ({
|
||||
...cat,
|
||||
items: cat.items.filter(item => item.label.toLowerCase().includes(q)),
|
||||
})).filter(cat =>
|
||||
cat.items.length > 0 || cat.label.toLowerCase().includes(q)
|
||||
)
|
||||
}, [search])
|
||||
|
||||
// When searching, all matching categories are forced open
|
||||
const effectiveOpen = (id: string) =>
|
||||
search.trim() ? filteredNav.some(c => c.id === id) : openCats.has(id)
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
@@ -36,68 +505,139 @@ export default function Layout() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-[#0f1117]">
|
||||
{/* Sidebar */}
|
||||
|
||||
{/* ── Sidebar ─────────────────────────────────────────────────────── */}
|
||||
<aside
|
||||
className={`flex flex-col transition-all duration-200 bg-[#1a1d27] border-r border-[#2a2d3e] ${
|
||||
sidebarOpen ? 'w-56' : 'w-14'
|
||||
className={`flex flex-col transition-all duration-200 bg-[#1a1d27] border-r border-[#2a2d3e] overflow-hidden flex-shrink-0 ${
|
||||
sidebarOpen ? 'w-72' : 'w-0'
|
||||
}`}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2 px-4 py-4 border-b border-[#2a2d3e] min-h-[57px]">
|
||||
{/* 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>
|
||||
{sidebarOpen && (
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-white text-sm font-semibold leading-tight">NixServer</p>
|
||||
<p className="text-[#64748b] text-xs">Admin Panel</p>
|
||||
</div>
|
||||
)}
|
||||
<div 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-2">
|
||||
{nav.map(({ label, to, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-2 mx-2 my-0.5 rounded-lg text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-indigo-600/20 text-indigo-400'
|
||||
: 'text-[#94a3b8] hover:bg-[#1e2130] hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon size={16} className="flex-shrink-0" />
|
||||
{sidebarOpen && <span className="truncate">{label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
<nav 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>
|
||||
|
||||
{/* User + logout */}
|
||||
<div className="border-t border-[#2a2d3e] p-3">
|
||||
{sidebarOpen && (
|
||||
<div className="px-1 mb-2">
|
||||
<p className="text-white text-xs font-medium truncate">{user?.username}</p>
|
||||
<p className="text-[#64748b] text-xs truncate">{user?.role}</p>
|
||||
</div>
|
||||
)}
|
||||
<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-sm"
|
||||
className="flex items-center gap-2 px-3 py-2 w-full rounded-lg text-[#94a3b8] hover:bg-[#1e2130] hover:text-red-400 transition-colors text-xs"
|
||||
>
|
||||
<LogOut size={16} className="flex-shrink-0" />
|
||||
{sidebarOpen && <span>Logout</span>}
|
||||
<LogOut size={14} className="flex-shrink-0" />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* ── Main ────────────────────────────────────────────────────────── */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
{/* Topbar */}
|
||||
<header className="flex items-center gap-4 px-6 py-3 bg-[#1a1d27] border-b border-[#2a2d3e] min-h-[57px]">
|
||||
<header className="flex items-center gap-4 px-6 py-3 bg-[#1a1d27] border-b border-[#2a2d3e] min-h-[57px] flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(v => !v)}
|
||||
className="text-[#64748b] hover:text-white transition-colors"
|
||||
|
||||
@@ -11,6 +11,8 @@ interface User {
|
||||
interface AuthContextValue {
|
||||
user: User | null
|
||||
loading: boolean
|
||||
setupComplete: boolean
|
||||
markSetupComplete: () => void
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
@@ -20,6 +22,7 @@ const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [setupComplete, setSetupComplete] = useState(true)
|
||||
|
||||
const fetchMe = useCallback(async () => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
@@ -32,6 +35,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
return
|
||||
}
|
||||
setUser(data.data)
|
||||
|
||||
// Only admins trigger the setup check
|
||||
if (data.data.role === 'admin') {
|
||||
const { data: setupData } = await api.get('/nixserver/setup/status')
|
||||
setSetupComplete(setupData.data.setupComplete)
|
||||
}
|
||||
} catch {
|
||||
localStorage.clear()
|
||||
} finally {
|
||||
@@ -49,6 +58,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
localStorage.setItem('access_token', data.data.accessToken)
|
||||
localStorage.setItem('refresh_token', data.data.refreshToken)
|
||||
setUser(data.data.user)
|
||||
|
||||
// Check setup status after login
|
||||
if (data.data.role === 'admin') {
|
||||
try {
|
||||
const { data: setupData } = await api.get('/nixserver/setup/status')
|
||||
setSetupComplete(setupData.data.setupComplete)
|
||||
} catch {
|
||||
setSetupComplete(true) // fail open — don't block login on setup check failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
@@ -56,10 +75,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
try { await api.post('/auth/logout', { refreshToken }) } catch {}
|
||||
localStorage.clear()
|
||||
setUser(null)
|
||||
setSetupComplete(true)
|
||||
}
|
||||
|
||||
const markSetupComplete = () => setSetupComplete(true)
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||
<AuthContext.Provider value={{ user, loading, setupComplete, markSetupComplete, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
||||
+146
-178
@@ -1,198 +1,166 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Plus, Search, MoreVertical, UserX, UserCheck, Trash2, RefreshCw } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
List, Globe, GitBranch, UserX, HardDrive, BarChart2,
|
||||
Network, Plus, Mail, KeyRound, Gauge, UserCheck, FlaskConical,
|
||||
Terminal, Edit, Lock, FileQuestion, FileText, ArrowLeftRight,
|
||||
RotateCcw, Trash2, TrendingDown, ArrowUpDown, Code2, Copy, Layers
|
||||
} from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface Account {
|
||||
id: number
|
||||
username: string
|
||||
domain: string
|
||||
email: string
|
||||
status: string
|
||||
diskUsedMb: number
|
||||
bandwidthUsedMb: number
|
||||
ipAddress: string | null
|
||||
createdAt: string
|
||||
package?: { name: string; diskMb: number }
|
||||
}
|
||||
|
||||
interface PageData {
|
||||
items: Account[]
|
||||
interface Stats {
|
||||
total: number
|
||||
page: number
|
||||
totalPages: number
|
||||
active: number
|
||||
suspended: number
|
||||
terminated: number
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
suspended: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
|
||||
terminated: 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
interface FunctionCard {
|
||||
icon: React.ComponentType<{ size?: number | string; className?: string }>
|
||||
label: string
|
||||
description: string
|
||||
path: string
|
||||
soon?: boolean
|
||||
}
|
||||
|
||||
export default function Accounts() {
|
||||
const [data, setData] = useState<PageData | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [actionMenu, setActionMenu] = useState<number | null>(null)
|
||||
const accountInfo: FunctionCard[] = [
|
||||
{ icon: List, label: 'List Accounts', description: 'View and manage all hosting accounts', path: '/accounts/list' },
|
||||
{ icon: Globe, label: 'List Parked Domains', description: 'View all parked domains across accounts', path: '/accounts/parked-domains' },
|
||||
{ icon: GitBranch, label: 'List Subdomains', description: 'View all subdomains across accounts', path: '/accounts/subdomains' },
|
||||
{ icon: UserX, label: 'List Suspended Accounts', description: 'View and manage suspended accounts', path: '/accounts/suspended' },
|
||||
{ icon: HardDrive, label: 'Show Accounts Over Quota', description: 'Accounts that have exceeded their disk quota', path: '/accounts/over-quota' },
|
||||
{ icon: BarChart2, label: 'View Bandwidth Usage', description: 'Bandwidth consumption sorted by usage', path: '/accounts/bandwidth' },
|
||||
]
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), limit: '25' })
|
||||
if (search) params.set('search', search)
|
||||
if (status) params.set('status', status)
|
||||
const r = await api.get(`/nixserver/accounts?${params}`)
|
||||
setData(r.data.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, search, status])
|
||||
const accountFunctions: FunctionCard[] = [
|
||||
{ icon: Network, label: 'Change Site\'s IP Address', description: 'Assign a different IP to an account', path: '/accounts/change-ip' },
|
||||
{ icon: Plus, label: 'Create a New Account', description: 'Create a new hosting account', path: '/accounts/create' },
|
||||
{ icon: Mail, label: 'Email All Users', description: 'Send a bulk email to all account holders', path: '/accounts/email-users', soon: true },
|
||||
{ icon: KeyRound, label: 'Force Password Change', description: 'Force users to change password on next login', path: '/accounts/force-password', soon: true },
|
||||
{ icon: Gauge, label: 'Limit Bandwidth Usage', description: 'Set per-account bandwidth caps independent of package', path: '/accounts/limit-bandwidth', soon: true },
|
||||
{ icon: UserCheck, label: 'Manage Account Suspension', description: 'Suspend or unsuspend a hosting account', path: '/accounts/suspension' },
|
||||
{ icon: FlaskConical, label: 'Manage Demo Mode', description: 'Put accounts into read-only demo mode', path: '/accounts/demo-mode', soon: true },
|
||||
{ icon: Terminal, label: 'Manage Shell Access', description: 'Grant or revoke SSH shell access', path: '/accounts/shell-access' },
|
||||
{ icon: Edit, label: 'Modify an Account', description: 'Edit account email, notes, or package', path: '/accounts/modify' },
|
||||
{ icon: Lock, label: 'Password Modification', description: 'Change the password for a hosting account', path: '/accounts/password' },
|
||||
{ icon: FileQuestion, label: 'Quota Modification', description: 'Adjust disk and bandwidth quotas', path: '/accounts/quota' },
|
||||
{ icon: FileText, label: 'Apache Log Viewer', description: 'View Apache access and error logs per account', path: '/accounts/apache-logs' },
|
||||
{ icon: ArrowLeftRight,label: 'Rearrange an Account', description: 'Move an account between resellers', path: '/accounts/rearrange', soon: true },
|
||||
{ icon: RotateCcw, label: 'Reset Account Bandwidth', description: 'Reset bandwidth counters for accounts', path: '/accounts/reset-bandwidth' },
|
||||
{ icon: Trash2, label: 'Terminate Accounts', description: 'Permanently terminate a hosting account', path: '/accounts/terminate' },
|
||||
{ icon: TrendingDown, label: 'Unsuspend Bandwidth Exceeders',description: 'Unsuspend accounts suspended for bandwidth', path: '/accounts/unsuspend-bandwidth' },
|
||||
{ icon: ArrowUpDown, label: 'Upgrade / Downgrade an Account',description: 'Change the hosting package for an account', path: '/accounts/upgrade' },
|
||||
{ icon: Code2, label: 'Web Template Editor', description: 'Edit default HTML templates for new accounts', path: '/accounts/web-template', soon: true },
|
||||
]
|
||||
|
||||
useEffect(() => { fetch() }, [fetch])
|
||||
const multiAccountFunctions: FunctionCard[] = [
|
||||
{ icon: Copy, label: 'Change Multiple Sites\' IP Addresses', description: 'Batch-change IP addresses across multiple accounts', path: '/accounts/bulk-ip', soon: true },
|
||||
{ icon: Layers, label: 'Modify / Upgrade Multiple Accounts', description: 'Apply changes to multiple accounts simultaneously', path: '/accounts/bulk-modify', soon: true },
|
||||
]
|
||||
|
||||
const action = async (id: number, act: 'suspend' | 'unsuspend' | 'terminate') => {
|
||||
setActionMenu(null)
|
||||
if (act === 'terminate' && !confirm('Terminate this account? This cannot be undone.')) return
|
||||
try {
|
||||
if (act === 'suspend') await api.post(`/nixserver/accounts/${id}/suspend`)
|
||||
else if (act === 'unsuspend') await api.post(`/nixserver/accounts/${id}/unsuspend`)
|
||||
else await api.delete(`/nixserver/accounts/${id}`)
|
||||
fetch()
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
}
|
||||
}
|
||||
interface CategoryProps {
|
||||
title: string
|
||||
color: string
|
||||
headerBg: string
|
||||
cards: FunctionCard[]
|
||||
}
|
||||
|
||||
function Category({ title, color, headerBg, cards }: CategoryProps) {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Accounts</h1>
|
||||
<p className="text-[#64748b] text-sm">{data?.total ?? 0} total</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/accounts/create"
|
||||
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-3 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus size={16} /> Create Account
|
||||
</Link>
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<div className={`px-5 py-3 ${headerBg} border-b border-[#2a2d3e]`}>
|
||||
<h2 className={`text-sm font-semibold ${color}`}>{title}</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-px bg-[#2a2d3e]">
|
||||
{cards.map(card => {
|
||||
const Icon = card.icon
|
||||
return (
|
||||
<button
|
||||
key={card.path}
|
||||
onClick={() => navigate(card.path)}
|
||||
className="relative bg-[#1a1d27] hover:bg-[#1e2130] transition-colors p-4 text-left flex items-start gap-3 group"
|
||||
>
|
||||
{card.soon && (
|
||||
<span className="absolute top-2 right-2 text-[10px] font-semibold bg-orange-500/15 text-orange-400 border border-orange-500/25 px-1.5 py-0.5 rounded-full">
|
||||
Soon
|
||||
</span>
|
||||
)}
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5 ${
|
||||
card.soon
|
||||
? 'bg-orange-500/10'
|
||||
: 'bg-indigo-600/15 group-hover:bg-indigo-600/25'
|
||||
} transition-colors`}>
|
||||
<Icon size={16} className={card.soon ? 'text-orange-400' : 'text-indigo-400'} />
|
||||
</div>
|
||||
<div className="min-w-0 pr-6">
|
||||
<p className="text-white text-sm font-medium leading-tight">{card.label}</p>
|
||||
<p className="text-[#64748b] text-xs mt-0.5 leading-snug">{card.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Accounts() {
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#64748b]" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1) }}
|
||||
placeholder="Search username, domain, email..."
|
||||
className="w-full bg-[#1a1d27] border border-[#2a2d3e] text-white text-sm rounded-lg pl-9 pr-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={status}
|
||||
onChange={e => { setStatus(e.target.value); setPage(1) }}
|
||||
className="bg-[#1a1d27] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
<option value="terminated">Terminated</option>
|
||||
</select>
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors">
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
useEffect(() => {
|
||||
api.get('/nixserver/accounts/stats')
|
||||
.then(r => setStats(r.data.data))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Account Functions</h1>
|
||||
<p className="text-[#64748b] text-sm">Manage hosting accounts, domains, and usage.</p>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#2a2d3e]">
|
||||
{['Username', 'Domain', 'Package', 'Status', 'IP', 'Created', ''].map(h => (
|
||||
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && !data && (
|
||||
<tr><td colSpan={7} className="text-center text-[#64748b] py-8">Loading...</td></tr>
|
||||
)}
|
||||
{data?.items.map(acc => (
|
||||
<tr key={acc.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/accounts/${acc.id}`} className="text-indigo-400 hover:text-indigo-300 font-medium">
|
||||
{acc.username}
|
||||
</Link>
|
||||
<p className="text-[#64748b] text-xs">{acc.email}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{acc.domain}</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{acc.package?.name ?? '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border ${statusColor[acc.status] ?? ''}`}>
|
||||
{acc.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#64748b] font-mono text-xs">{acc.ipAddress ?? '—'}</td>
|
||||
<td className="px-4 py-3 text-[#64748b] text-xs">{new Date(acc.createdAt).toLocaleDateString()}</td>
|
||||
<td className="px-4 py-3 relative">
|
||||
<button
|
||||
onClick={() => setActionMenu(actionMenu === acc.id ? null : acc.id)}
|
||||
className="p-1 text-[#64748b] hover:text-white rounded transition-colors"
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
{actionMenu === acc.id && (
|
||||
<div className="absolute right-4 top-8 z-10 bg-[#1e2130] border border-[#2a2d3e] rounded-lg shadow-lg py-1 w-36">
|
||||
{acc.status === 'active' ? (
|
||||
<button onClick={() => action(acc.id, 'suspend')} className="flex items-center gap-2 w-full px-3 py-2 text-amber-400 hover:bg-[#2a2d3e] text-xs">
|
||||
<UserX size={14} /> Suspend
|
||||
</button>
|
||||
) : acc.status === 'suspended' ? (
|
||||
<button onClick={() => action(acc.id, 'unsuspend')} className="flex items-center gap-2 w-full px-3 py-2 text-emerald-400 hover:bg-[#2a2d3e] text-xs">
|
||||
<UserCheck size={14} /> Unsuspend
|
||||
</button>
|
||||
) : null}
|
||||
<button onClick={() => action(acc.id, 'terminate')} className="flex items-center gap-2 w-full px-3 py-2 text-red-400 hover:bg-[#2a2d3e] text-xs">
|
||||
<Trash2 size={14} /> Terminate
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{data?.items.length === 0 && (
|
||||
<tr><td colSpan={7} className="text-center text-[#64748b] py-8">No accounts found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* Stats bar */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: 'Total Accounts', value: stats?.total ?? '—', color: 'text-white' },
|
||||
{ label: 'Active', value: stats?.active ?? '—', color: 'text-emerald-400' },
|
||||
{ label: 'Suspended', value: stats?.suspended ?? '—', color: 'text-amber-400' },
|
||||
{ label: 'Terminated', value: stats?.terminated ?? '—', color: 'text-red-400' },
|
||||
].map(s => (
|
||||
<div key={s.label} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl px-4 py-3">
|
||||
<p className="text-[#64748b] text-xs mb-1">{s.label}</p>
|
||||
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<Category
|
||||
title="Account Information"
|
||||
color="text-indigo-400"
|
||||
headerBg="bg-indigo-600/10"
|
||||
cards={accountInfo}
|
||||
/>
|
||||
<Category
|
||||
title="Account Functions"
|
||||
color="text-violet-400"
|
||||
headerBg="bg-violet-600/10"
|
||||
cards={accountFunctions}
|
||||
/>
|
||||
<Category
|
||||
title="Multi Account Functions"
|
||||
color="text-emerald-400"
|
||||
headerBg="bg-emerald-600/10"
|
||||
cards={multiAccountFunctions}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[#2a2d3e]">
|
||||
<span className="text-[#64748b] text-xs">
|
||||
Page {data.page} of {data.totalPages} ({data.total} total)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 text-xs bg-[#1e2130] border border-[#2a2d3e] rounded text-[#94a3b8] disabled:opacity-40 hover:border-indigo-500 transition-colors"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= data.totalPages}
|
||||
className="px-3 py-1 text-xs bg-[#1e2130] border border-[#2a2d3e] rounded text-[#94a3b8] disabled:opacity-40 hover:border-indigo-500 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-2 text-xs text-[#64748b]">
|
||||
<span className="bg-orange-500/15 text-orange-400 border border-orange-500/25 px-1.5 py-0.5 rounded-full text-[10px] font-semibold">Soon</span>
|
||||
<span>Feature not yet implemented</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function Login() {
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[#64748b] text-xs mt-4">
|
||||
NixServer v0.3.0 · Port 2087
|
||||
NixServer v0.3.2 · Port 2087
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Server, Globe, ShieldCheck, CheckCircle, ChevronRight, ChevronLeft } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const STEPS = [
|
||||
{ id: 1, label: 'Server', icon: Server },
|
||||
{ id: 2, label: 'Nameservers', icon: Globe },
|
||||
{ id: 3, label: 'Admin', icon: ShieldCheck },
|
||||
{ id: 4, label: 'Finish', icon: CheckCircle },
|
||||
]
|
||||
|
||||
interface FormData {
|
||||
hostname: string
|
||||
nameserver1: string
|
||||
nameserver2: string
|
||||
adminEmail: string
|
||||
adminPassword: string
|
||||
confirmPassword: string
|
||||
}
|
||||
|
||||
export default function Setup() {
|
||||
const { user, markSetupComplete } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [step, setStep] = useState(1)
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const [form, setForm] = useState<FormData>({
|
||||
hostname: '',
|
||||
nameserver1: '',
|
||||
nameserver2: '',
|
||||
adminEmail: user?.email ?? '',
|
||||
adminPassword: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
|
||||
const set = (key: keyof FormData) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm(prev => ({ ...prev, [key]: e.target.value }))
|
||||
|
||||
// ── Validation per step ──────────────────────────────────────────────────────
|
||||
function validate(): string {
|
||||
if (step === 1) {
|
||||
if (!form.hostname.trim()) return 'Hostname is required'
|
||||
if (!/^[a-zA-Z0-9.-]+$/.test(form.hostname)) return 'Invalid hostname format'
|
||||
}
|
||||
if (step === 2) {
|
||||
if (!form.nameserver1.trim()) return 'Primary nameserver is required'
|
||||
if (!form.nameserver2.trim()) return 'Secondary nameserver is required'
|
||||
}
|
||||
if (step === 3) {
|
||||
if (!form.adminEmail.trim()) return 'Email is required'
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.adminEmail)) return 'Invalid email address'
|
||||
if (form.adminPassword && form.adminPassword.length < 8) return 'Password must be at least 8 characters'
|
||||
if (form.adminPassword !== form.confirmPassword) return 'Passwords do not match'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function next() {
|
||||
const err = validate()
|
||||
if (err) { setError(err); return }
|
||||
setError('')
|
||||
setStep(s => s + 1)
|
||||
}
|
||||
|
||||
function back() {
|
||||
setError('')
|
||||
setStep(s => s - 1)
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.post('/nixserver/setup/complete', {
|
||||
hostname: form.hostname,
|
||||
nameserver1: form.nameserver1,
|
||||
nameserver2: form.nameserver2,
|
||||
adminEmail: form.adminEmail,
|
||||
adminPassword: form.adminPassword || undefined,
|
||||
confirmPassword: form.confirmPassword || undefined,
|
||||
})
|
||||
markSetupComplete()
|
||||
navigate('/', { replace: true })
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error
|
||||
setError(msg ?? 'Failed to save setup — please try again')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0f1117] flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-lg">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="w-12 h-12 rounded-2xl bg-indigo-600 flex items-center justify-center mb-3">
|
||||
<Server size={24} className="text-white" />
|
||||
</div>
|
||||
<h1 className="text-white text-xl font-bold">NixServer Setup</h1>
|
||||
<p className="text-[#64748b] text-sm mt-1">Configure your server before getting started</p>
|
||||
</div>
|
||||
|
||||
{/* Step indicators */}
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
{STEPS.map((s, i) => {
|
||||
const Icon = s.icon
|
||||
const active = step === s.id
|
||||
const complete = step > s.id
|
||||
return (
|
||||
<React.Fragment key={s.id}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className={`
|
||||
w-9 h-9 rounded-full flex items-center justify-center transition-colors
|
||||
${complete ? 'bg-indigo-600' : active ? 'bg-indigo-600/30 ring-2 ring-indigo-500' : 'bg-[#1a1d27]'}
|
||||
`}>
|
||||
<Icon size={16} className={complete || active ? 'text-indigo-300' : 'text-[#475569]'} />
|
||||
</div>
|
||||
<span className={`text-xs ${active ? 'text-indigo-400' : complete ? 'text-[#94a3b8]' : 'text-[#475569]'}`}>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && (
|
||||
<div className={`h-px w-8 mb-4 ${step > s.id ? 'bg-indigo-600' : 'bg-[#2a2d3e]'}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-6">
|
||||
|
||||
{/* Step 1 — Server Identity */}
|
||||
{step === 1 && <>
|
||||
<h2 className="text-white text-lg font-semibold mb-1">Server Identity</h2>
|
||||
<p className="text-[#64748b] text-sm mb-6">Set the fully-qualified hostname for this server.</p>
|
||||
<Field label="Hostname (FQDN)" placeholder="server.example.com" value={form.hostname} onChange={set('hostname')} autoFocus />
|
||||
<p className="text-[#64748b] text-xs mt-2">
|
||||
Example: <span className="text-[#94a3b8]">server.yourdomain.com</span>
|
||||
</p>
|
||||
</>}
|
||||
|
||||
{/* Step 2 — Nameservers */}
|
||||
{step === 2 && <>
|
||||
<h2 className="text-white text-lg font-semibold mb-1">Nameservers</h2>
|
||||
<p className="text-[#64748b] text-sm mb-6">These nameservers will be used for hosted domains.</p>
|
||||
<Field label="Primary Nameserver (NS1)" placeholder="ns1.yourdomain.com" value={form.nameserver1} onChange={set('nameserver1')} autoFocus />
|
||||
<div className="mt-4">
|
||||
<Field label="Secondary Nameserver (NS2)" placeholder="ns2.yourdomain.com" value={form.nameserver2} onChange={set('nameserver2')} />
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{/* Step 3 — Admin Account */}
|
||||
{step === 3 && <>
|
||||
<h2 className="text-white text-lg font-semibold mb-1">Admin Account</h2>
|
||||
<p className="text-[#64748b] text-sm mb-6">Update your admin email and optionally set a new password.</p>
|
||||
<Field label="Admin Email" type="email" placeholder="[email protected]" value={form.adminEmail} onChange={set('adminEmail')} autoFocus />
|
||||
<div className="mt-4">
|
||||
<Field label="New Password (optional)" type="password" placeholder="Leave blank to keep current" value={form.adminPassword} onChange={set('adminPassword')} />
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Field label="Confirm Password" type="password" placeholder="Re-enter new password" value={form.confirmPassword} onChange={set('confirmPassword')} />
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{/* Step 4 — Review */}
|
||||
{step === 4 && <>
|
||||
<h2 className="text-white text-lg font-semibold mb-1">Review & Finish</h2>
|
||||
<p className="text-[#64748b] text-sm mb-6">Confirm your settings before applying them.</p>
|
||||
<div className="space-y-3">
|
||||
<ReviewRow label="Hostname" value={form.hostname} />
|
||||
<ReviewRow label="Primary Nameserver" value={form.nameserver1} />
|
||||
<ReviewRow label="Secondary Nameserver" value={form.nameserver2} />
|
||||
<ReviewRow label="Admin Email" value={form.adminEmail} />
|
||||
<ReviewRow label="Admin Password" value={form.adminPassword ? '••••••••' : '(unchanged)'} />
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 bg-red-500/10 border border-red-500/20 text-red-400 text-sm px-3 py-2 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between mt-6">
|
||||
<button
|
||||
onClick={back}
|
||||
disabled={step === 1}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm text-[#94a3b8] hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft size={16} /> Back
|
||||
</button>
|
||||
|
||||
{step < 4 ? (
|
||||
<button
|
||||
onClick={next}
|
||||
className="flex items-center gap-1.5 px-5 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Next <ChevronRight size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={finish}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 px-5 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Finish Setup'}
|
||||
{!saving && <CheckCircle size={16} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-[#64748b] text-xs mt-4">
|
||||
NixServer v0.3.2 · You can revisit these settings anytime under Settings
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Small helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function Field({
|
||||
label, value, onChange, placeholder, type = 'text', autoFocus,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
placeholder?: string
|
||||
type?: string
|
||||
autoFocus?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-[#94a3b8] text-xs font-medium mb-1.5">{label}</label>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-indigo-500 transition-colors placeholder:text-[#475569]"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between items-center py-2.5 border-b border-[#2a2d3e] last:border-0">
|
||||
<span className="text-[#64748b] text-sm">{label}</span>
|
||||
<span className="text-white text-sm font-medium">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,54 +2,93 @@ import { useEffect, useState } from 'react'
|
||||
import { Server, RefreshCw, RotateCcw, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface WebStatus {
|
||||
apache: { active: boolean }
|
||||
php82fpm: { active: boolean }
|
||||
php83fpm: { active: boolean }
|
||||
}
|
||||
|
||||
export default function WebServer() {
|
||||
const [status, setStatus] = useState<{ nginx: { active: boolean }; apache: { active: boolean } } | null>(null)
|
||||
const [, setVhosts] = useState<{ domain: string; path: string }[]>([])
|
||||
const [status, setStatus] = useState<WebStatus | null>(null)
|
||||
const [logs, setLogs] = useState<{ lines: string[] } | null>(null)
|
||||
const [logType, setLogType] = useState<'access' | 'error'>('access')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [testResult, setTestResult] = useState<string | null>(null)
|
||||
const [reloading, setReloading] = useState(false)
|
||||
|
||||
const fetch = async () => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [s, v, l] = await Promise.all([
|
||||
const [s, l] = await Promise.all([
|
||||
api.get('/nixserver/webserver/status'),
|
||||
api.get('/nixserver/webserver/vhosts'),
|
||||
api.get(`/nixserver/webserver/logs/${logType}`),
|
||||
])
|
||||
setStatus(s.data.data); setVhosts(v.data.data); setLogs(l.data.data)
|
||||
setStatus(s.data.data)
|
||||
setLogs(l.data.data)
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [logType])
|
||||
useEffect(() => { fetchData() }, [logType])
|
||||
|
||||
const testConfig = async () => {
|
||||
const r = await api.post('/nixserver/webserver/nginx/test')
|
||||
setTestResult(r.data.data.output)
|
||||
try {
|
||||
const r = await api.post('/nixserver/webserver/apache/test')
|
||||
setTestResult(r.data.data.output)
|
||||
} catch (err: unknown) {
|
||||
setTestResult((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Test failed')
|
||||
}
|
||||
}
|
||||
|
||||
const reload = async () => {
|
||||
try { await api.post('/nixserver/webserver/nginx/reload'); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Reload failed') }
|
||||
const reload = async (service: 'apache' | 'fpm82' | 'fpm83') => {
|
||||
setReloading(true)
|
||||
try {
|
||||
if (service === 'apache') {
|
||||
await api.post('/nixserver/webserver/apache/reload')
|
||||
} else {
|
||||
await api.post('/nixserver/webserver/fpm/reload', { version: service === 'fpm82' ? '8.2' : '8.3' })
|
||||
}
|
||||
fetchData()
|
||||
} catch (err: unknown) {
|
||||
alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Reload failed')
|
||||
} finally { setReloading(false) }
|
||||
}
|
||||
|
||||
const services: { key: keyof WebStatus; label: string; reload?: 'apache' | 'fpm82' | 'fpm83' }[] = [
|
||||
{ key: 'apache', label: 'apache2', reload: 'apache' },
|
||||
{ key: 'php82fpm', label: 'php8.2-fpm', reload: 'fpm82' },
|
||||
{ key: 'php83fpm', label: 'php8.3-fpm', reload: 'fpm83' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">Web Server</h1><p className="text-[#64748b] text-sm">Nginx and Apache management</p></div>
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
<div><h1 className="text-white text-xl font-semibold">Web Server</h1><p className="text-[#64748b] text-sm">Apache and PHP-FPM management</p></div>
|
||||
<button onClick={fetchData} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[['nginx', status.nginx.active], ['apache2', status.apache.active]].map(([name, active]) => (
|
||||
<div key={String(name)} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4 flex items-center justify-between">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{services.map(({ key, label, reload: reloadKey }) => (
|
||||
<div key={key} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server size={16} className="text-[#64748b]" />
|
||||
<span className="text-white font-mono text-sm">{name}</span>
|
||||
<span className="text-white font-mono text-sm">{label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{status[key].active
|
||||
? <CheckCircle size={16} className="text-emerald-400" />
|
||||
: <XCircle size={16} className="text-red-400" />}
|
||||
{reloadKey && (
|
||||
<button
|
||||
onClick={() => reload(reloadKey)}
|
||||
disabled={reloading}
|
||||
title={`Reload ${label}`}
|
||||
className="p-1 text-[#64748b] hover:text-indigo-400 rounded disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{active ? <CheckCircle size={16} className="text-emerald-400" /> : <XCircle size={16} className="text-red-400" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -57,16 +96,18 @@ export default function WebServer() {
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button onClick={testConfig} className="flex items-center gap-2 border border-[#2a2d3e] text-[#94a3b8] text-sm px-3 py-2 rounded-lg hover:bg-[#1e2130]">Test Config</button>
|
||||
<button onClick={reload} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-3 py-2 rounded-lg"><RotateCcw size={14} /> Reload nginx</button>
|
||||
<button onClick={() => reload('apache')} disabled={reloading} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm px-3 py-2 rounded-lg">
|
||||
<RotateCcw size={14} /> Reload Apache
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<pre className="bg-[#0f1117] border border-[#2a2d3e] rounded-xl p-4 text-xs text-[#94a3b8] overflow-x-auto">{testResult}</pre>
|
||||
<pre className="bg-[#0f1117] border border-[#2a2d3e] rounded-xl p-4 text-xs text-[#94a3b8] overflow-x-auto whitespace-pre-wrap">{testResult}</pre>
|
||||
)}
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[#2a2d3e]">
|
||||
<h2 className="text-white text-sm font-medium">Logs</h2>
|
||||
<h2 className="text-white text-sm font-medium">Apache Logs</h2>
|
||||
<div className="flex gap-1">
|
||||
{(['access', 'error'] as const).map(t => (
|
||||
<button key={t} onClick={() => setLogType(t)} className={`px-3 py-1 text-xs rounded ${logType === t ? 'bg-indigo-600 text-white' : 'text-[#64748b] hover:text-white'}`}>{t}</button>
|
||||
@@ -74,7 +115,7 @@ export default function WebServer() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<pre className="p-4 text-xs text-[#64748b] font-mono leading-relaxed">
|
||||
<pre className="p-4 text-xs text-[#64748b] font-mono leading-relaxed whitespace-pre-wrap">
|
||||
{logs?.lines.slice(-100).join('\n') ?? 'Loading...'}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { Search, User } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
export interface PickedAccount {
|
||||
id: number
|
||||
username: string
|
||||
domain: string
|
||||
email: string
|
||||
status: string
|
||||
ipAddress: string | null
|
||||
package?: { name: string }
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSelect: (account: PickedAccount) => void
|
||||
selected?: PickedAccount | null
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
suspended: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
|
||||
terminated: 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
}
|
||||
|
||||
export default function AccountPicker({ onSelect, selected, placeholder = 'Search by username, domain, or email...' }: Props) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [results, setResults] = useState<PickedAccount[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const doSearch = useCallback(async (q: string) => {
|
||||
if (!q.trim()) { setResults([]); setOpen(false); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await api.get(`/nixserver/accounts?search=${encodeURIComponent(q)}&limit=10`)
|
||||
setResults(r.data.data.items)
|
||||
setOpen(true)
|
||||
} catch {
|
||||
setResults([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleInput = (val: string) => {
|
||||
setSearch(val)
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => doSearch(val), 300)
|
||||
}
|
||||
|
||||
const handleSelect = (acc: PickedAccount) => {
|
||||
onSelect(acc)
|
||||
setSearch('')
|
||||
setResults([])
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#64748b]" />
|
||||
{loading && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => handleInput(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-[#1a1d27] border border-[#2a2d3e] text-white text-sm rounded-lg pl-9 pr-9 py-2 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
{open && results.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 z-20 bg-[#1e2130] border border-[#2a2d3e] rounded-lg shadow-xl overflow-hidden">
|
||||
{results.map(acc => (
|
||||
<button
|
||||
key={acc.id}
|
||||
onClick={() => handleSelect(acc)}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-[#2a2d3e] transition-colors text-left border-b border-[#2a2d3e] last:border-0"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-indigo-600/20 flex items-center justify-center flex-shrink-0">
|
||||
<User size={14} className="text-indigo-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm font-medium">{acc.username}</p>
|
||||
<p className="text-[#64748b] text-xs truncate">{acc.domain} — {acc.email}</p>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border flex-shrink-0 ${statusColor[acc.status] ?? ''}`}>
|
||||
{acc.status}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{open && !loading && results.length === 0 && search.trim() && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 z-20 bg-[#1e2130] border border-[#2a2d3e] rounded-lg shadow-xl px-4 py-3">
|
||||
<p className="text-[#64748b] text-sm">No accounts found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="bg-[#1a1d27] border border-indigo-500/30 rounded-lg p-4 flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-indigo-600/20 flex items-center justify-center flex-shrink-0">
|
||||
<User size={18} className="text-indigo-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium">{selected.username}</p>
|
||||
<p className="text-[#64748b] text-xs truncate">{selected.domain} — {selected.email}</p>
|
||||
{selected.package && (
|
||||
<p className="text-[#64748b] text-xs">Package: {selected.package.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border flex-shrink-0 ${statusColor[selected.status] ?? ''}`}>
|
||||
{selected.status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, FileText, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import AccountPicker, { type PickedAccount } from './AccountPicker'
|
||||
|
||||
export default function ApacheLogs() {
|
||||
const [selected, setSelected] = useState<PickedAccount | null>(null)
|
||||
const [logType, setLogType] = useState<'access' | 'error'>('access')
|
||||
const [lines, setLines] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const fetchLogs = async (username: string, type: 'access' | 'error') => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const r = await api.get(`/nixserver/webserver/accounts/${username}/logs/${type}`)
|
||||
setLines(r.data.data.lines ?? [])
|
||||
} catch (e: unknown) {
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed to load logs')
|
||||
setLines([])
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const handleSelect = (acc: PickedAccount) => {
|
||||
setSelected(acc)
|
||||
setLines([])
|
||||
setError('')
|
||||
fetchLogs(acc.username, logType)
|
||||
}
|
||||
|
||||
const handleTabChange = (type: 'access' | 'error') => {
|
||||
setLogType(type)
|
||||
if (selected) fetchLogs(selected.username, type)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Apache Log Viewer</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Apache Log Viewer</h1>
|
||||
<p className="text-[#64748b] text-sm">View Apache access and error logs per hosting account.</p>
|
||||
</div>
|
||||
{selected && (
|
||||
<button
|
||||
onClick={() => fetchLogs(selected.username, logType)}
|
||||
className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
|
||||
<AccountPicker onSelect={handleSelect} selected={selected} />
|
||||
|
||||
{selected && (
|
||||
<div className="space-y-3 pt-2 border-t border-[#2a2d3e]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={14} className="text-indigo-400" />
|
||||
<span className="text-[#94a3b8] text-sm font-mono">/home/{selected.username}/logs/</span>
|
||||
</div>
|
||||
<div className="flex gap-1 bg-[#0f1117] border border-[#2a2d3e] rounded-lg p-0.5">
|
||||
{(['access', 'error'] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => handleTabChange(t)}
|
||||
className={`px-3 py-1 text-xs rounded ${logType === t ? 'bg-indigo-600 text-white' : 'text-[#64748b] hover:text-white'}`}
|
||||
>
|
||||
{t}.log
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 text-xs px-3 py-2 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-[#0f1117] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32 text-[#64748b] text-sm">Loading logs...</div>
|
||||
) : lines.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-[#64748b] text-sm">No log entries found</div>
|
||||
) : (
|
||||
<pre className="p-4 text-xs text-[#64748b] font-mono leading-relaxed whitespace-pre-wrap">
|
||||
{lines.join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
{lines.length > 0 && (
|
||||
<div className="border-t border-[#2a2d3e] px-4 py-2 text-xs text-[#64748b]">
|
||||
Showing last {lines.length} lines
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
interface BandwidthAccount {
|
||||
id: number
|
||||
username: string
|
||||
domain: string
|
||||
status: string
|
||||
bandwidthUsedMb: number
|
||||
package?: { name: string; bandwidthMb: number }
|
||||
}
|
||||
|
||||
export default function BandwidthUsage() {
|
||||
const [items, setItems] = useState<BandwidthAccount[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await api.get('/nixserver/accounts/bandwidth')
|
||||
setItems(r.data.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchData() }, [])
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
suspended: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
|
||||
terminated: 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">View Bandwidth Usage</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">View Bandwidth Usage</h1>
|
||||
<p className="text-[#64748b] text-sm">{items.length} account{items.length !== 1 ? 's' : ''}, sorted by usage</p>
|
||||
</div>
|
||||
<button onClick={fetchData} 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 className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#2a2d3e]">
|
||||
{['Username', 'Domain', 'Bandwidth Used', 'Bandwidth Limit', '% Used', 'Status'].map(h => (
|
||||
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">Loading...</td></tr>
|
||||
)}
|
||||
{!loading && items.map(acc => {
|
||||
const limit = acc.package?.bandwidthMb ?? 0
|
||||
const pct = limit > 0 ? Math.min(100, Math.round((acc.bandwidthUsedMb / limit) * 100)) : 0
|
||||
const barColor = pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-amber-500' : 'bg-indigo-500'
|
||||
return (
|
||||
<tr key={acc.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/accounts/${acc.id}`} className="text-indigo-400 hover:text-indigo-300 font-medium">
|
||||
{acc.username}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{acc.domain}</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8] font-mono text-xs">{acc.bandwidthUsedMb.toLocaleString()} MB</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8] font-mono text-xs">
|
||||
{limit > 0 ? `${limit.toLocaleString()} MB` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 min-w-[140px]">
|
||||
{limit > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-[#2a2d3e] rounded-full h-1.5 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${barColor}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[#94a3b8] text-xs font-mono">{pct}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[#64748b] text-xs">No limit</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border ${statusColor[acc.status] ?? ''}`}>
|
||||
{acc.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">No accounts found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, Network, Clock } from 'lucide-react'
|
||||
|
||||
export default function BulkIP() {
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Change Multiple Sites' IP Addresses</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Change Multiple Sites' IP Addresses</h1>
|
||||
<p className="text-[#64748b] text-sm">Batch-change IP addresses across multiple accounts.</p>
|
||||
</div>
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<Network size={28} className="text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Clock size={14} className="text-orange-400" />
|
||||
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
|
||||
</div>
|
||||
<p className="text-white font-semibold text-lg mb-1">Change Multiple Sites' IP Addresses</p>
|
||||
<p className="text-[#64748b] text-sm max-w-sm">
|
||||
This feature will allow you to batch-reassign IP addresses across multiple hosting accounts simultaneously, useful for IP migrations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, Users, Clock } from 'lucide-react'
|
||||
|
||||
export default function BulkModify() {
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Modify / Upgrade Multiple Accounts</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Modify / Upgrade Multiple Accounts</h1>
|
||||
<p className="text-[#64748b] text-sm">Apply package upgrades or setting changes to multiple accounts simultaneously.</p>
|
||||
</div>
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<Users size={28} className="text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Clock size={14} className="text-orange-400" />
|
||||
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
|
||||
</div>
|
||||
<p className="text-white font-semibold text-lg mb-1">Modify / Upgrade Multiple Accounts</p>
|
||||
<p className="text-[#64748b] text-sm max-w-sm">
|
||||
This feature will allow you to apply package upgrades, setting changes, or bulk modifications to multiple hosting accounts at once.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, Network } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import AccountPicker, { type PickedAccount } from './AccountPicker'
|
||||
|
||||
export default function ChangeIP() {
|
||||
const [selected, setSelected] = useState<PickedAccount | null>(null)
|
||||
const [ipAddress, setIpAddress] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSelect = (acc: PickedAccount) => {
|
||||
setSelected(acc)
|
||||
setIpAddress(acc.ipAddress ?? '')
|
||||
setMessage('')
|
||||
setError('')
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selected || !ipAddress.trim()) return
|
||||
setLoading(true); setMessage(''); setError('')
|
||||
try {
|
||||
await api.post(`/nixserver/accounts/${selected.id}/change-ip`, { ipAddress: ipAddress.trim() })
|
||||
setMessage(`IP address for "${selected.username}" updated to ${ipAddress.trim()}.`)
|
||||
setSelected(prev => prev ? { ...prev, ipAddress: ipAddress.trim() } : null)
|
||||
} catch (e: unknown) {
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Change Site's IP Address</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Change Site's IP Address</h1>
|
||||
<p className="text-[#64748b] text-sm">Assign a different IP address to a hosting account.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
|
||||
<AccountPicker onSelect={handleSelect} selected={selected} />
|
||||
|
||||
{selected && (
|
||||
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
|
||||
<div className="bg-[#0f1117] border border-[#2a2d3e] rounded-lg px-4 py-3">
|
||||
<p className="text-[#64748b] text-xs mb-1">Current IP Address</p>
|
||||
<p className="text-white font-mono">{selected.ipAddress ?? 'Not assigned'}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[#94a3b8] text-sm font-medium">New IP Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ipAddress}
|
||||
onChange={e => setIpAddress(e.target.value)}
|
||||
placeholder="e.g. 192.168.1.100"
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white font-mono text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !ipAddress.trim()}
|
||||
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Network size={16} /> {loading ? 'Updating...' : 'Update IP Address'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, FlaskConical, Clock } from 'lucide-react'
|
||||
|
||||
export default function DemoMode() {
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Manage Demo Mode</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Manage Demo Mode</h1>
|
||||
<p className="text-[#64748b] text-sm">Put accounts into read-only demo mode.</p>
|
||||
</div>
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<FlaskConical size={28} className="text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Clock size={14} className="text-orange-400" />
|
||||
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
|
||||
</div>
|
||||
<p className="text-white font-semibold text-lg mb-1">Manage Demo Mode</p>
|
||||
<p className="text-[#64748b] text-sm max-w-sm">
|
||||
This feature will allow you to put hosting accounts into a read-only demo mode, preventing any changes or file modifications.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, Mail, Clock } from 'lucide-react'
|
||||
|
||||
export default function EmailUsers() {
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Email All Users</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Email All Users</h1>
|
||||
<p className="text-[#64748b] text-sm">Send a bulk email to all account holders.</p>
|
||||
</div>
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<Mail size={28} className="text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Clock size={14} className="text-orange-400" />
|
||||
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
|
||||
</div>
|
||||
<p className="text-white font-semibold text-lg mb-1">Email All Users</p>
|
||||
<p className="text-[#64748b] text-sm max-w-sm">
|
||||
This feature will allow you to compose and send a bulk email to all hosting account holders simultaneously.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, KeyRound, Clock } from 'lucide-react'
|
||||
|
||||
export default function ForcePassword() {
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Force Password Change</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Force Password Change</h1>
|
||||
<p className="text-[#64748b] text-sm">Force users to change their password on next login.</p>
|
||||
</div>
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<KeyRound size={28} className="text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Clock size={14} className="text-orange-400" />
|
||||
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
|
||||
</div>
|
||||
<p className="text-white font-semibold text-lg mb-1">Force Password Change</p>
|
||||
<p className="text-[#64748b] text-sm max-w-sm">
|
||||
This feature will allow you to flag one or more accounts to require a password change on their next login.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, Gauge, Clock } from 'lucide-react'
|
||||
|
||||
export default function LimitBandwidth() {
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Limit Bandwidth Usage</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Limit Bandwidth Usage</h1>
|
||||
<p className="text-[#64748b] text-sm">Set per-account bandwidth caps independent of package.</p>
|
||||
</div>
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<Gauge size={28} className="text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Clock size={14} className="text-orange-400" />
|
||||
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
|
||||
</div>
|
||||
<p className="text-white font-semibold text-lg mb-1">Limit Bandwidth Usage</p>
|
||||
<p className="text-[#64748b] text-sm max-w-sm">
|
||||
This feature will allow you to set custom per-account bandwidth caps that override the package-level limits.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Plus, Search, MoreVertical, UserX, UserCheck, Trash2, RefreshCw, ChevronLeft } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
interface Account {
|
||||
id: number
|
||||
username: string
|
||||
domain: string
|
||||
email: string
|
||||
status: string
|
||||
diskUsedMb: number
|
||||
bandwidthUsedMb: number
|
||||
ipAddress: string | null
|
||||
createdAt: string
|
||||
package?: { name: string; diskMb: number }
|
||||
}
|
||||
|
||||
interface PageData {
|
||||
items: Account[]
|
||||
total: number
|
||||
page: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
suspended: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
|
||||
terminated: 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
}
|
||||
|
||||
export default function ListAccounts() {
|
||||
const [data, setData] = useState<PageData | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [actionMenu, setActionMenu] = useState<number | null>(null)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), limit: '25' })
|
||||
if (search) params.set('search', search)
|
||||
if (status) params.set('status', status)
|
||||
const r = await api.get(`/nixserver/accounts?${params}`)
|
||||
setData(r.data.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, search, status])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
const handleAction = async (id: number, act: 'suspend' | 'unsuspend' | 'terminate') => {
|
||||
setActionMenu(null)
|
||||
if (act === 'terminate' && !confirm('Terminate this account? This cannot be undone.')) return
|
||||
try {
|
||||
if (act === 'suspend') await api.post(`/nixserver/accounts/${id}/suspend`)
|
||||
else if (act === 'unsuspend') await api.post(`/nixserver/accounts/${id}/unsuspend`)
|
||||
else await api.delete(`/nixserver/accounts/${id}`)
|
||||
fetchData()
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">List Accounts</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">List Accounts</h1>
|
||||
<p className="text-[#64748b] text-sm">{data?.total ?? 0} total accounts</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/accounts/create"
|
||||
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-3 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus size={16} /> Create Account
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#64748b]" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1) }}
|
||||
placeholder="Search username, domain, email..."
|
||||
className="w-full bg-[#1a1d27] border border-[#2a2d3e] text-white text-sm rounded-lg pl-9 pr-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={status}
|
||||
onChange={e => { setStatus(e.target.value); setPage(1) }}
|
||||
className="bg-[#1a1d27] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
<option value="terminated">Terminated</option>
|
||||
</select>
|
||||
<button onClick={fetchData} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors">
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#2a2d3e]">
|
||||
{['Username', 'Domain', 'Package', 'Status', 'IP', 'Created', ''].map(h => (
|
||||
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && !data && (
|
||||
<tr><td colSpan={7} className="text-center text-[#64748b] py-8">Loading...</td></tr>
|
||||
)}
|
||||
{data?.items.map(acc => (
|
||||
<tr key={acc.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/accounts/${acc.id}`} className="text-indigo-400 hover:text-indigo-300 font-medium">
|
||||
{acc.username}
|
||||
</Link>
|
||||
<p className="text-[#64748b] text-xs">{acc.email}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{acc.domain}</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{acc.package?.name ?? '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border ${statusColor[acc.status] ?? ''}`}>
|
||||
{acc.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#64748b] font-mono text-xs">{acc.ipAddress ?? '—'}</td>
|
||||
<td className="px-4 py-3 text-[#64748b] text-xs">{new Date(acc.createdAt).toLocaleDateString()}</td>
|
||||
<td className="px-4 py-3 relative">
|
||||
<button
|
||||
onClick={() => setActionMenu(actionMenu === acc.id ? null : acc.id)}
|
||||
className="p-1 text-[#64748b] hover:text-white rounded transition-colors"
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
{actionMenu === acc.id && (
|
||||
<div className="absolute right-4 top-8 z-10 bg-[#1e2130] border border-[#2a2d3e] rounded-lg shadow-lg py-1 w-36">
|
||||
{acc.status === 'active' ? (
|
||||
<button onClick={() => handleAction(acc.id, 'suspend')} className="flex items-center gap-2 w-full px-3 py-2 text-amber-400 hover:bg-[#2a2d3e] text-xs">
|
||||
<UserX size={14} /> Suspend
|
||||
</button>
|
||||
) : acc.status === 'suspended' ? (
|
||||
<button onClick={() => handleAction(acc.id, 'unsuspend')} className="flex items-center gap-2 w-full px-3 py-2 text-emerald-400 hover:bg-[#2a2d3e] text-xs">
|
||||
<UserCheck size={14} /> Unsuspend
|
||||
</button>
|
||||
) : null}
|
||||
<button onClick={() => handleAction(acc.id, 'terminate')} className="flex items-center gap-2 w-full px-3 py-2 text-red-400 hover:bg-[#2a2d3e] text-xs">
|
||||
<Trash2 size={14} /> Terminate
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{data?.items.length === 0 && (
|
||||
<tr><td colSpan={7} className="text-center text-[#64748b] py-8">No accounts found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[#2a2d3e]">
|
||||
<span className="text-[#64748b] text-xs">
|
||||
Page {data.page} of {data.totalPages} ({data.total} total)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 text-xs bg-[#1e2130] border border-[#2a2d3e] rounded text-[#94a3b8] disabled:opacity-40 hover:border-indigo-500 transition-colors"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= data.totalPages}
|
||||
className="px-3 py-1 text-xs bg-[#1e2130] border border-[#2a2d3e] rounded text-[#94a3b8] disabled:opacity-40 hover:border-indigo-500 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, Save } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import AccountPicker, { type PickedAccount } from './AccountPicker'
|
||||
|
||||
interface Package {
|
||||
id: number
|
||||
name: string
|
||||
diskMb: number
|
||||
bandwidthMb: number
|
||||
}
|
||||
|
||||
export default function Modify() {
|
||||
const [selected, setSelected] = useState<PickedAccount | null>(null)
|
||||
const [email, setEmail] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [packageId, setPackageId] = useState<string>('')
|
||||
const [packages, setPackages] = useState<Package[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/nixserver/packages').then(r => setPackages(r.data.data?.items ?? r.data.data ?? [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleSelect = (acc: PickedAccount) => {
|
||||
setSelected(acc)
|
||||
setEmail(acc.email)
|
||||
setNotes('')
|
||||
setPackageId('')
|
||||
setMessage('')
|
||||
setError('')
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selected) return
|
||||
setLoading(true); setMessage(''); setError('')
|
||||
const body: Record<string, unknown> = {}
|
||||
if (email && email !== selected.email) body.email = email
|
||||
if (notes) body.notes = notes
|
||||
if (packageId) body.packageId = parseInt(packageId, 10)
|
||||
|
||||
try {
|
||||
await api.put(`/nixserver/accounts/${selected.id}`, body)
|
||||
setMessage(`Account "${selected.username}" has been updated.`)
|
||||
} catch (e: unknown) {
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Modify an Account</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Modify an Account</h1>
|
||||
<p className="text-[#64748b] text-sm">Update account settings such as email, notes, or package.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
|
||||
<AccountPicker onSelect={handleSelect} selected={selected} />
|
||||
|
||||
{selected && (
|
||||
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[#94a3b8] text-sm font-medium">Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[#94a3b8] text-sm font-medium">Package</label>
|
||||
<select
|
||||
value={packageId}
|
||||
onChange={e => setPackageId(e.target.value)}
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="">— Keep current ({selected.package?.name ?? 'none'}) —</option>
|
||||
{packages.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[#94a3b8] text-sm font-medium">Notes</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Internal notes about this account..."
|
||||
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 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save size={16} /> {loading ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// This page is an alias for ApacheLogs — NixPanel uses Apache, not NGINX.
|
||||
export { default } from './ApacheLogs'
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
interface OverQuotaAccount {
|
||||
id: number
|
||||
username: string
|
||||
domain: string
|
||||
status: string
|
||||
diskUsedMb: number
|
||||
package: { name: string; diskMb: number; bandwidthMb: number }
|
||||
}
|
||||
|
||||
export default function OverQuota() {
|
||||
const [items, setItems] = useState<OverQuotaAccount[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await api.get('/nixserver/accounts/over-quota')
|
||||
setItems(r.data.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchData() }, [])
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
suspended: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
|
||||
terminated: 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Show Accounts Over Quota</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Show Accounts Over Quota</h1>
|
||||
<p className="text-[#64748b] text-sm">{items.length} account{items.length !== 1 ? 's' : ''} over disk quota</p>
|
||||
</div>
|
||||
<button onClick={fetchData} 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 className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#2a2d3e]">
|
||||
{['Username', 'Domain', 'Package', 'Disk Used', 'Disk Limit', 'Over By', 'Usage', 'Status'].map(h => (
|
||||
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr><td colSpan={8} className="text-center text-[#64748b] py-8">Loading...</td></tr>
|
||||
)}
|
||||
{!loading && items.map(acc => {
|
||||
const pct = Math.min(100, Math.round((acc.diskUsedMb / acc.package.diskMb) * 100))
|
||||
const overBy = acc.diskUsedMb - acc.package.diskMb
|
||||
return (
|
||||
<tr key={acc.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/accounts/${acc.id}`} className="text-indigo-400 hover:text-indigo-300 font-medium">
|
||||
{acc.username}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{acc.domain}</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{acc.package.name}</td>
|
||||
<td className="px-4 py-3 text-red-400 font-mono text-xs">{acc.diskUsedMb.toLocaleString()} MB</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8] font-mono text-xs">{acc.package.diskMb.toLocaleString()} MB</td>
|
||||
<td className="px-4 py-3 text-red-400 font-mono text-xs font-semibold">+{overBy.toLocaleString()} MB</td>
|
||||
<td className="px-4 py-3 min-w-[120px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-[#2a2d3e] rounded-full h-1.5 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-red-500 rounded-full transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-red-400 text-xs font-mono">{pct}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border ${statusColor[acc.status] ?? ''}`}>
|
||||
{acc.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr><td colSpan={8} className="text-center text-[#64748b] py-8">No accounts are over quota</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
interface ParkedDomain {
|
||||
id: number
|
||||
domain: string
|
||||
accountId: number
|
||||
status: string
|
||||
createdAt: string
|
||||
account: { username: string; domain: string }
|
||||
}
|
||||
|
||||
export default function ParkedDomains() {
|
||||
const [items, setItems] = useState<ParkedDomain[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await api.get('/nixserver/accounts/parked-domains')
|
||||
setItems(r.data.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchData() }, [])
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
inactive: 'bg-[#2a2d3e] text-[#64748b] border-[#2a2d3e]',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">List Parked Domains</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">List Parked Domains</h1>
|
||||
<p className="text-[#64748b] text-sm">{items.length} parked domain{items.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<button onClick={fetchData} 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 className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#2a2d3e]">
|
||||
{['Parked Domain', 'Primary Domain', 'Username', 'Status', 'Created'].map(h => (
|
||||
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr><td colSpan={5} className="text-center text-[#64748b] py-8">Loading...</td></tr>
|
||||
)}
|
||||
{!loading && items.map(pd => (
|
||||
<tr key={pd.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors">
|
||||
<td className="px-4 py-3 text-white font-medium">{pd.domain}</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{pd.account.domain}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-indigo-400">{pd.account.username}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border ${statusColor[pd.status] ?? 'text-[#94a3b8]'}`}>
|
||||
{pd.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#64748b] text-xs">{new Date(pd.createdAt).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr><td colSpan={5} className="text-center text-[#64748b] py-8">No parked domains found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, KeyRound, Eye, EyeOff } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import AccountPicker, { type PickedAccount } from './AccountPicker'
|
||||
|
||||
export default function PasswordMod() {
|
||||
const [selected, setSelected] = useState<PickedAccount | null>(null)
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [showPw, setShowPw] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selected) return
|
||||
if (password !== confirm) { setError('Passwords do not match'); return }
|
||||
if (password.length < 8) { setError('Password must be at least 8 characters'); return }
|
||||
setLoading(true); setMessage(''); setError('')
|
||||
try {
|
||||
await api.put(`/nixserver/accounts/${selected.id}`, { password })
|
||||
setMessage(`Password for "${selected.username}" has been updated.`)
|
||||
setPassword(''); setConfirm('')
|
||||
} catch (e: unknown) {
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Password Modification</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Password Modification</h1>
|
||||
<p className="text-[#64748b] text-sm">Change the password for a hosting account.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
|
||||
<AccountPicker onSelect={acc => { setSelected(acc); setPassword(''); setConfirm(''); setMessage(''); setError('') }} selected={selected} />
|
||||
|
||||
{selected && (
|
||||
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[#94a3b8] text-sm font-medium">New Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPw ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="At least 8 characters"
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 pr-10 py-2 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPw(v => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[#64748b] hover:text-white transition-colors"
|
||||
>
|
||||
{showPw ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[#94a3b8] text-sm font-medium">Confirm Password</label>
|
||||
<input
|
||||
type={showPw ? 'text' : 'password'}
|
||||
value={confirm}
|
||||
onChange={e => setConfirm(e.target.value)}
|
||||
placeholder="Re-enter password"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !password || !confirm}
|
||||
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<KeyRound size={16} /> {loading ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, HardDrive } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import AccountPicker, { type PickedAccount } from './AccountPicker'
|
||||
|
||||
export default function QuotaMod() {
|
||||
const [selected, setSelected] = useState<PickedAccount | null>(null)
|
||||
const [fullAccount, setFullAccount] = useState<{ diskUsedMb: number; package?: { diskMb: number; bandwidthMb: number } } | null>(null)
|
||||
const [diskMb, setDiskMb] = useState('')
|
||||
const [bandwidthMb, setBandwidthMb] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSelect = async (acc: PickedAccount) => {
|
||||
setSelected(acc)
|
||||
setMessage(''); setError('')
|
||||
try {
|
||||
const r = await api.get(`/nixserver/accounts/${acc.id}`)
|
||||
const data = r.data.data
|
||||
setFullAccount(data)
|
||||
setDiskMb(String(data.package?.diskMb ?? ''))
|
||||
setBandwidthMb(String(data.package?.bandwidthMb ?? ''))
|
||||
} catch {
|
||||
setFullAccount(null)
|
||||
setDiskMb('')
|
||||
setBandwidthMb('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selected) return
|
||||
const d = parseInt(diskMb, 10)
|
||||
const b = parseInt(bandwidthMb, 10)
|
||||
if (!d || !b || d <= 0 || b <= 0) { setError('Enter valid positive values for both fields'); return }
|
||||
setLoading(true); setMessage(''); setError('')
|
||||
try {
|
||||
await api.put(`/nixserver/accounts/${selected.id}/quota`, { diskMb: d, bandwidthMb: b })
|
||||
setMessage(`Quota updated for "${selected.username}".`)
|
||||
} catch (e: unknown) {
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Quota Modification</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Quota Modification</h1>
|
||||
<p className="text-[#64748b] text-sm">Adjust disk and bandwidth quotas for a hosting account.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
|
||||
<AccountPicker onSelect={handleSelect} selected={selected} />
|
||||
|
||||
{selected && (
|
||||
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
|
||||
{fullAccount && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-[#0f1117] border border-[#2a2d3e] rounded-lg px-4 py-3">
|
||||
<p className="text-[#64748b] text-xs mb-1">Disk Used</p>
|
||||
<p className="text-white font-mono text-sm">{fullAccount.diskUsedMb?.toLocaleString() ?? 0} MB</p>
|
||||
</div>
|
||||
<div className="bg-[#0f1117] border border-[#2a2d3e] rounded-lg px-4 py-3">
|
||||
<p className="text-[#64748b] text-xs mb-1">Current Package</p>
|
||||
<p className="text-white text-sm">{selected.package?.name ?? 'None'}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[#94a3b8] text-sm font-medium">Disk Limit (MB)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={diskMb}
|
||||
onChange={e => setDiskMb(e.target.value)}
|
||||
min="1"
|
||||
placeholder="e.g. 10240"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[#94a3b8] text-sm font-medium">Bandwidth Limit (MB)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={bandwidthMb}
|
||||
onChange={e => setBandwidthMb(e.target.value)}
|
||||
min="1"
|
||||
placeholder="e.g. 102400"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[#64748b] text-xs">
|
||||
Note: This will update the quota on the account's associated package.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !diskMb || !bandwidthMb}
|
||||
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<HardDrive size={16} /> {loading ? 'Updating...' : 'Update Quota'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, ArrowLeftRight, Clock } from 'lucide-react'
|
||||
|
||||
export default function Rearrange() {
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Rearrange an Account</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Rearrange an Account</h1>
|
||||
<p className="text-[#64748b] text-sm">Move an account between resellers or change ownership.</p>
|
||||
</div>
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<ArrowLeftRight size={28} className="text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Clock size={14} className="text-orange-400" />
|
||||
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
|
||||
</div>
|
||||
<p className="text-white font-semibold text-lg mb-1">Rearrange an Account</p>
|
||||
<p className="text-[#64748b] text-sm max-w-sm">
|
||||
This feature will allow you to move hosting accounts between resellers or reassign ownership to a different reseller.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, RefreshCw, RotateCcw } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
interface BandwidthAccount {
|
||||
id: number
|
||||
username: string
|
||||
domain: string
|
||||
status: string
|
||||
bandwidthUsedMb: number
|
||||
package?: { name: string; bandwidthMb: number }
|
||||
}
|
||||
|
||||
export default function ResetBandwidth() {
|
||||
const [items, setItems] = useState<BandwidthAccount[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [resetting, setResetting] = useState<Set<number>>(new Set())
|
||||
const [resetAll, setResetAll] = useState(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await api.get('/nixserver/accounts/bandwidth')
|
||||
setItems(r.data.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchData() }, [])
|
||||
|
||||
const handleReset = async (id: number) => {
|
||||
setResetting(prev => new Set(prev).add(id))
|
||||
try {
|
||||
await api.post(`/nixserver/accounts/${id}/reset-bandwidth`)
|
||||
setItems(prev => prev.map(a => a.id === id ? { ...a, bandwidthUsedMb: 0 } : a))
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Reset failed')
|
||||
} finally {
|
||||
setResetting(prev => { const s = new Set(prev); s.delete(id); return s })
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetAll = async () => {
|
||||
if (!confirm(`Reset bandwidth for all ${items.length} accounts?`)) return
|
||||
setResetAll(true)
|
||||
try {
|
||||
await Promise.all(items.map(a => api.post(`/nixserver/accounts/${a.id}/reset-bandwidth`)))
|
||||
setItems(prev => prev.map(a => ({ ...a, bandwidthUsedMb: 0 })))
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Bulk reset failed')
|
||||
} finally {
|
||||
setResetAll(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Reset Account Bandwidth Limit</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Reset Account Bandwidth Limit</h1>
|
||||
<p className="text-[#64748b] text-sm">Reset bandwidth counters for individual accounts or all at once.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={fetchData} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors">
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetAll}
|
||||
disabled={resetAll || items.length === 0}
|
||||
className="flex items-center gap-2 bg-amber-600/20 hover:bg-amber-600/30 text-amber-400 border border-amber-500/30 text-sm px-3 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw size={15} /> {resetAll ? 'Resetting...' : 'Reset All'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#2a2d3e]">
|
||||
{['Username', 'Domain', 'Bandwidth Used', 'Limit', 'Status', ''].map(h => (
|
||||
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">Loading...</td></tr>
|
||||
)}
|
||||
{!loading && items.map(acc => (
|
||||
<tr key={acc.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors">
|
||||
<td className="px-4 py-3 text-white font-medium">{acc.username}</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{acc.domain}</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8] font-mono text-xs">{acc.bandwidthUsedMb.toLocaleString()} MB</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8] font-mono text-xs">
|
||||
{acc.package?.bandwidthMb ? `${acc.package.bandwidthMb.toLocaleString()} MB` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border ${
|
||||
acc.status === 'active' ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' :
|
||||
acc.status === 'suspended' ? 'bg-amber-500/10 text-amber-400 border-amber-500/20' :
|
||||
'bg-red-500/10 text-red-400 border-red-500/20'
|
||||
}`}>
|
||||
{acc.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => handleReset(acc.id)}
|
||||
disabled={resetting.has(acc.id) || acc.bandwidthUsedMb === 0}
|
||||
className="flex items-center gap-1.5 text-xs bg-indigo-600/10 hover:bg-indigo-600/20 text-indigo-400 border border-indigo-500/20 px-3 py-1.5 rounded-lg transition-colors disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw size={12} /> {resetting.has(acc.id) ? 'Resetting...' : 'Reset'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">No accounts found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, Terminal, ShieldCheck, ShieldOff } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import AccountPicker, { type PickedAccount } from './AccountPicker'
|
||||
|
||||
export default function ShellAccess() {
|
||||
const [selected, setSelected] = useState<PickedAccount | null>(null)
|
||||
const [shell, setShell] = useState<string>('/usr/sbin/nologin')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSelect = async (acc: PickedAccount) => {
|
||||
setSelected(acc); setMessage(''); setError('')
|
||||
try {
|
||||
const r = await api.get(`/nixserver/accounts/${acc.id}`)
|
||||
setShell(r.data.data.shell ?? '/usr/sbin/nologin')
|
||||
} catch {
|
||||
setShell('/usr/sbin/nologin')
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = async (enable: boolean) => {
|
||||
if (!selected) return
|
||||
setLoading(true); setMessage(''); setError('')
|
||||
try {
|
||||
const r = await api.put(`/nixserver/accounts/${selected.id}/shell`, { enabled: enable })
|
||||
setShell(r.data.data.shell)
|
||||
setMessage(enable
|
||||
? `Shell access enabled for "${selected.username}".`
|
||||
: `Shell access disabled for "${selected.username}".`)
|
||||
} catch (e: unknown) {
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const hasShell = shell === '/bin/bash' || shell === '/bin/sh'
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Manage Shell Access</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Manage Shell Access</h1>
|
||||
<p className="text-[#64748b] text-sm">Grant or revoke SSH shell access for hosting accounts.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
|
||||
<AccountPicker onSelect={handleSelect} selected={selected} />
|
||||
|
||||
{selected && (
|
||||
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
|
||||
<div className="flex items-center gap-3 bg-[#0f1117] border border-[#2a2d3e] rounded-lg px-4 py-3">
|
||||
<Terminal size={16} className={hasShell ? 'text-emerald-400' : 'text-[#64748b]'} />
|
||||
<div className="flex-1">
|
||||
<p className="text-white text-sm font-medium">{selected.username}</p>
|
||||
<p className="text-[#64748b] text-xs font-mono mt-0.5">shell: {shell}</p>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border ${hasShell
|
||||
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20'
|
||||
: 'bg-[#1a2235] text-[#64748b] border-[#2a2d3e]'}`}>
|
||||
{hasShell ? 'Shell enabled' : 'No shell'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasShell ? (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-amber-500/10 border border-amber-500/20 rounded-lg px-4 py-3 text-amber-400 text-xs">
|
||||
This account has SSH access via <span className="font-mono">{shell}</span>. Disabling sets shell to <span className="font-mono">/usr/sbin/nologin</span>.
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggle(false)}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 bg-amber-600/20 hover:bg-amber-600/30 text-amber-400 border border-amber-500/30 text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<ShieldOff size={16} /> {loading ? 'Disabling...' : 'Disable Shell Access'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-lg px-4 py-3 text-[#64748b] text-xs">
|
||||
No shell access. Enabling will set the shell to <span className="font-mono">/bin/bash</span>.
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggle(true)}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 bg-emerald-600/20 hover:bg-emerald-600/30 text-emerald-400 border border-emerald-500/30 text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<ShieldCheck size={16} /> {loading ? 'Enabling...' : 'Enable Shell Access'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
interface SubdomainItem {
|
||||
id: number
|
||||
subdomain: string
|
||||
domain: string
|
||||
accountId: number
|
||||
documentRoot: string
|
||||
status: string
|
||||
createdAt: string
|
||||
account: { username: string }
|
||||
}
|
||||
|
||||
export default function Subdomains() {
|
||||
const [items, setItems] = useState<SubdomainItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await api.get('/nixserver/accounts/subdomains')
|
||||
setItems(r.data.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchData() }, [])
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
inactive: 'bg-[#2a2d3e] text-[#64748b] border-[#2a2d3e]',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">List Subdomains</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">List Subdomains</h1>
|
||||
<p className="text-[#64748b] text-sm">{items.length} subdomain{items.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<button onClick={fetchData} 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 className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#2a2d3e]">
|
||||
{['Subdomain', 'Full Domain', 'Username', 'Document Root', 'Status', 'Created'].map(h => (
|
||||
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">Loading...</td></tr>
|
||||
)}
|
||||
{!loading && items.map(sd => (
|
||||
<tr key={sd.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors">
|
||||
<td className="px-4 py-3 text-white font-medium">{sd.subdomain}</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{sd.domain}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-indigo-400">{sd.account.username}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#64748b] font-mono text-xs truncate max-w-[200px]">{sd.documentRoot}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border ${statusColor[sd.status] ?? 'text-[#94a3b8]'}`}>
|
||||
{sd.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#64748b] text-xs">{new Date(sd.createdAt).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">No subdomains found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Search, UserCheck, RefreshCw, ChevronLeft } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
interface Account {
|
||||
id: number
|
||||
username: string
|
||||
domain: string
|
||||
email: string
|
||||
status: string
|
||||
suspendReason: string | null
|
||||
suspendedAt: string | null
|
||||
createdAt: string
|
||||
package?: { name: string }
|
||||
}
|
||||
|
||||
interface PageData {
|
||||
items: Account[]
|
||||
total: number
|
||||
page: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export default function SuspendedAccounts() {
|
||||
const [data, setData] = useState<PageData | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), limit: '25', status: 'suspended' })
|
||||
if (search) params.set('search', search)
|
||||
const r = await api.get(`/nixserver/accounts?${params}`)
|
||||
setData(r.data.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, search])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
const unsuspend = async (id: number) => {
|
||||
try {
|
||||
await api.post(`/nixserver/accounts/${id}/unsuspend`)
|
||||
fetchData()
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">List Suspended Accounts</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">List Suspended Accounts</h1>
|
||||
<p className="text-[#64748b] text-sm">{data?.total ?? 0} suspended accounts</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#64748b]" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1) }}
|
||||
placeholder="Search username, domain, email..."
|
||||
className="w-full bg-[#1a1d27] border border-[#2a2d3e] text-white text-sm rounded-lg pl-9 pr-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={fetchData} 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 className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#2a2d3e]">
|
||||
{['Username', 'Domain', 'Package', 'Suspend Reason', 'Suspended At', ''].map(h => (
|
||||
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && !data && (
|
||||
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">Loading...</td></tr>
|
||||
)}
|
||||
{data?.items.map(acc => (
|
||||
<tr key={acc.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/accounts/${acc.id}`} className="text-indigo-400 hover:text-indigo-300 font-medium">
|
||||
{acc.username}
|
||||
</Link>
|
||||
<p className="text-[#64748b] text-xs">{acc.email}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{acc.domain}</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{acc.package?.name ?? '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-amber-400 text-xs">
|
||||
{acc.suspendReason ?? 'No reason given'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#64748b] text-xs">
|
||||
{acc.suspendedAt ? new Date(acc.suspendedAt).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => unsuspend(acc.id)}
|
||||
className="flex items-center gap-1.5 text-xs bg-emerald-600/10 hover:bg-emerald-600/20 text-emerald-400 border border-emerald-500/20 px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
<UserCheck size={13} /> Unsuspend
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{data?.items.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">No suspended accounts</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[#2a2d3e]">
|
||||
<span className="text-[#64748b] text-xs">
|
||||
Page {data.page} of {data.totalPages} ({data.total} total)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 text-xs bg-[#1e2130] border border-[#2a2d3e] rounded text-[#94a3b8] disabled:opacity-40 hover:border-indigo-500 transition-colors"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= data.totalPages}
|
||||
className="px-3 py-1 text-xs bg-[#1e2130] border border-[#2a2d3e] rounded text-[#94a3b8] disabled:opacity-40 hover:border-indigo-500 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, UserX, UserCheck } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import AccountPicker, { type PickedAccount } from './AccountPicker'
|
||||
|
||||
export default function Suspension() {
|
||||
const [selected, setSelected] = useState<PickedAccount | null>(null)
|
||||
const [reason, setReason] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSuspend = async () => {
|
||||
if (!selected) return
|
||||
setLoading(true); setMessage(''); setError('')
|
||||
try {
|
||||
await api.post(`/nixserver/accounts/${selected.id}/suspend`, { reason: reason || undefined })
|
||||
setMessage(`Account "${selected.username}" has been suspended.`)
|
||||
setSelected(prev => prev ? { ...prev, status: 'suspended' } : null)
|
||||
} catch (e: unknown) {
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnsuspend = async () => {
|
||||
if (!selected) return
|
||||
setLoading(true); setMessage(''); setError('')
|
||||
try {
|
||||
await api.post(`/nixserver/accounts/${selected.id}/unsuspend`)
|
||||
setMessage(`Account "${selected.username}" has been unsuspended.`)
|
||||
setSelected(prev => prev ? { ...prev, status: 'active' } : null)
|
||||
} catch (e: unknown) {
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Manage Account Suspension</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Manage Account Suspension</h1>
|
||||
<p className="text-[#64748b] text-sm">Search for an account to suspend or unsuspend it.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
|
||||
<AccountPicker onSelect={setSelected} selected={selected} />
|
||||
|
||||
{selected && (
|
||||
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
|
||||
{selected.status === 'active' && (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-[#94a3b8] text-sm font-medium">Suspend Reason (optional)</label>
|
||||
<input
|
||||
value={reason}
|
||||
onChange={e => setReason(e.target.value)}
|
||||
placeholder="e.g. TOS violation, non-payment..."
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSuspend}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 bg-amber-600/20 hover:bg-amber-600/30 text-amber-400 border border-amber-500/30 text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<UserX size={16} /> {loading ? 'Suspending...' : 'Suspend Account'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.status === 'suspended' && (
|
||||
<button
|
||||
onClick={handleUnsuspend}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 bg-emerald-600/20 hover:bg-emerald-600/30 text-emerald-400 border border-emerald-500/30 text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<UserCheck size={16} /> {loading ? 'Unsuspending...' : 'Unsuspend Account'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selected.status !== 'active' && selected.status !== 'suspended' && (
|
||||
<p className="text-[#64748b] text-sm">
|
||||
This account has status <strong className="text-[#94a3b8]">{selected.status}</strong> and cannot be suspended/unsuspended.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, Trash2, AlertTriangle } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import AccountPicker, { type PickedAccount } from './AccountPicker'
|
||||
|
||||
export default function Terminate() {
|
||||
const [selected, setSelected] = useState<PickedAccount | null>(null)
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [removeFiles, setRemoveFiles] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleTerminate = async () => {
|
||||
if (!selected) return
|
||||
setLoading(true); setMessage(''); setError('')
|
||||
try {
|
||||
await api.delete(`/nixserver/accounts/${selected.id}`, { data: { removeFiles } })
|
||||
setMessage(`Account "${selected.username}" has been terminated.`)
|
||||
setSelected(null)
|
||||
setConfirm('')
|
||||
} catch (e: unknown) {
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canTerminate = selected && confirm === selected.username
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Terminate Accounts</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Terminate Accounts</h1>
|
||||
<p className="text-[#64748b] text-sm">Permanently terminate a hosting account. This action cannot be undone.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
|
||||
<AccountPicker onSelect={acc => { setSelected(acc); setConfirm('') }} selected={selected} />
|
||||
|
||||
{selected && (
|
||||
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 flex items-start gap-3">
|
||||
<AlertTriangle size={18} className="text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-red-400 text-sm font-medium">Warning: Irreversible Action</p>
|
||||
<p className="text-[#94a3b8] text-xs">
|
||||
Terminating <strong>{selected.username}</strong> will permanently delete the account, DNS zones, email accounts, databases, and SSL certificates.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={removeFiles}
|
||||
onChange={e => setRemoveFiles(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-[#2a2d3e] bg-[#0f1117] accent-red-500"
|
||||
/>
|
||||
<span className="text-[#94a3b8] text-sm">Also delete home directory files</span>
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[#94a3b8] text-sm">
|
||||
Type <strong className="text-white">{selected.username}</strong> to confirm
|
||||
</label>
|
||||
<input
|
||||
value={confirm}
|
||||
onChange={e => setConfirm(e.target.value)}
|
||||
placeholder={selected.username}
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-red-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleTerminate}
|
||||
disabled={!canTerminate || loading}
|
||||
className="flex items-center gap-2 bg-red-600/20 hover:bg-red-600/30 text-red-400 border border-red-500/30 text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 size={16} /> {loading ? 'Terminating...' : 'Terminate Account'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, RefreshCw, UserCheck } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
interface BandwidthAccount {
|
||||
id: number
|
||||
username: string
|
||||
domain: string
|
||||
status: string
|
||||
bandwidthUsedMb: number
|
||||
suspendReason: string | null
|
||||
package?: { name: string; bandwidthMb: number }
|
||||
}
|
||||
|
||||
export default function UnsuspendBandwidth() {
|
||||
const [items, setItems] = useState<BandwidthAccount[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [unsuspending, setUnsuspending] = useState<Set<number>>(new Set())
|
||||
const [bulkLoading, setBulkLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await api.get('/nixserver/accounts/bandwidth')
|
||||
const all: BandwidthAccount[] = r.data.data
|
||||
setItems(all.filter(a =>
|
||||
a.status === 'suspended' &&
|
||||
a.suspendReason?.toLowerCase().includes('bandwidth')
|
||||
))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchData() }, [])
|
||||
|
||||
const handleUnsuspend = async (id: number, username: string) => {
|
||||
setUnsuspending(prev => new Set(prev).add(id))
|
||||
try {
|
||||
await api.post(`/nixserver/accounts/${id}/unsuspend`)
|
||||
setItems(prev => prev.filter(a => a.id !== id))
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed')
|
||||
} finally {
|
||||
setUnsuspending(prev => { const s = new Set(prev); s.delete(id); return s })
|
||||
void username
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnsuspendAll = async () => {
|
||||
if (!confirm(`Unsuspend all ${items.length} bandwidth-exceeded accounts?`)) return
|
||||
setBulkLoading(true)
|
||||
try {
|
||||
const r = await api.post('/nixserver/accounts/unsuspend-bandwidth-exceeded')
|
||||
const results = r.data.data as { username: string; success: boolean }[]
|
||||
const count = results.filter(x => x.success).length
|
||||
setMessage(`Successfully unsuspended ${count} account${count !== 1 ? 's' : ''}.`)
|
||||
await fetchData()
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Bulk unsuspend failed')
|
||||
} finally {
|
||||
setBulkLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Unsuspend Bandwidth Exceeders</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Unsuspend Bandwidth Exceeders</h1>
|
||||
<p className="text-[#64748b] text-sm">{items.length} account{items.length !== 1 ? 's' : ''} suspended for bandwidth overage</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={fetchData} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors">
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUnsuspendAll}
|
||||
disabled={bulkLoading || items.length === 0}
|
||||
className="flex items-center gap-2 bg-emerald-600/20 hover:bg-emerald-600/30 text-emerald-400 border border-emerald-500/30 text-sm px-3 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<UserCheck size={15} /> {bulkLoading ? 'Unsuspending...' : 'Unsuspend All'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
|
||||
)}
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#2a2d3e]">
|
||||
{['Username', 'Domain', 'Bandwidth Used', 'Limit', 'Suspend Reason', ''].map(h => (
|
||||
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">Loading...</td></tr>
|
||||
)}
|
||||
{!loading && items.map(acc => (
|
||||
<tr key={acc.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors">
|
||||
<td className="px-4 py-3 text-white font-medium">{acc.username}</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{acc.domain}</td>
|
||||
<td className="px-4 py-3 text-amber-400 font-mono text-xs">{acc.bandwidthUsedMb.toLocaleString()} MB</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8] font-mono text-xs">
|
||||
{acc.package?.bandwidthMb ? `${acc.package.bandwidthMb.toLocaleString()} MB` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-amber-400 text-xs">{acc.suspendReason ?? '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => handleUnsuspend(acc.id, acc.username)}
|
||||
disabled={unsuspending.has(acc.id)}
|
||||
className="flex items-center gap-1.5 text-xs bg-emerald-600/10 hover:bg-emerald-600/20 text-emerald-400 border border-emerald-500/20 px-3 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<UserCheck size={12} /> {unsuspending.has(acc.id) ? 'Unsuspending...' : 'Unsuspend'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">No bandwidth-exceeded suspended accounts</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, ArrowUpDown } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import AccountPicker, { type PickedAccount } from './AccountPicker'
|
||||
|
||||
interface Package {
|
||||
id: number
|
||||
name: string
|
||||
diskMb: number
|
||||
bandwidthMb: number
|
||||
}
|
||||
|
||||
export default function Upgrade() {
|
||||
const [selected, setSelected] = useState<PickedAccount | null>(null)
|
||||
const [packageId, setPackageId] = useState<string>('')
|
||||
const [packages, setPackages] = useState<Package[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/nixserver/packages').then(r => setPackages(r.data.data?.items ?? r.data.data ?? [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleSelect = (acc: PickedAccount) => {
|
||||
setSelected(acc)
|
||||
setPackageId('')
|
||||
setMessage('')
|
||||
setError('')
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selected || !packageId) return
|
||||
setLoading(true); setMessage(''); setError('')
|
||||
try {
|
||||
await api.put(`/nixserver/accounts/${selected.id}`, { packageId: parseInt(packageId, 10) })
|
||||
const pkg = packages.find(p => p.id === parseInt(packageId, 10))
|
||||
setMessage(`Package for "${selected.username}" changed to "${pkg?.name ?? packageId}".`)
|
||||
if (pkg) setSelected(prev => prev ? { ...prev, package: { name: pkg.name } } : null)
|
||||
} catch (e: unknown) {
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Upgrade / Downgrade an Account</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Upgrade / Downgrade an Account</h1>
|
||||
<p className="text-[#64748b] text-sm">Change the hosting package assigned to an account.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
|
||||
<AccountPicker onSelect={handleSelect} selected={selected} />
|
||||
|
||||
{selected && (
|
||||
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
|
||||
<div className="bg-[#0f1117] border border-[#2a2d3e] rounded-lg px-4 py-3">
|
||||
<p className="text-[#64748b] text-xs mb-1">Current Package</p>
|
||||
<p className="text-white font-medium">{selected.package?.name ?? 'No package assigned'}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[#94a3b8] text-sm font-medium">New Package</label>
|
||||
<select
|
||||
value={packageId}
|
||||
onChange={e => setPackageId(e.target.value)}
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="">— Select a package —</option>
|
||||
{packages.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} — {p.diskMb.toLocaleString()} MB disk / {p.bandwidthMb.toLocaleString()} MB BW
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !packageId}
|
||||
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<ArrowUpDown size={16} /> {loading ? 'Applying...' : 'Apply Package Change'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, Code2, Clock } from 'lucide-react'
|
||||
|
||||
export default function WebTemplate() {
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Web Template Editor</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Web Template Editor</h1>
|
||||
<p className="text-[#64748b] text-sm">Edit the default HTML templates served to new accounts.</p>
|
||||
</div>
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<Code2 size={28} className="text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Clock size={14} className="text-orange-400" />
|
||||
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
|
||||
</div>
|
||||
<p className="text-white font-semibold text-lg mb-1">Web Template Editor</p>
|
||||
<p className="text-[#64748b] text-sm max-w-sm">
|
||||
This feature will allow you to edit the default HTML templates and placeholder pages that are served to newly created hosting accounts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Generated
+6004
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nixpanel-server",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.2",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"server",
|
||||
|
||||
+522
-468
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
# Database (SQLite)
|
||||
# Production: DATABASE_URL="file:/opt/nixpanel/data/nixpanel.db"
|
||||
# Development: DATABASE_URL="file:./dev.db"
|
||||
DATABASE_URL="file:./dev.db"
|
||||
|
||||
# JWT Secrets (generate with: openssl rand -hex 64)
|
||||
JWT_SECRET=""
|
||||
JWT_REFRESH_SECRET=""
|
||||
|
||||
# Server
|
||||
PORT=4000
|
||||
HOST=127.0.0.1
|
||||
NODE_ENV=production
|
||||
|
||||
# Licensing
|
||||
LICENSING_SERVER_URL="https://license.nixpanel.io"
|
||||
LICENSE_KEY=""
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nixpanel/server",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -24,6 +24,7 @@
|
||||
"@fastify/websocket": "^10.0.1",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"mysql2": "^3.11.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"fastify": "^4.28.1",
|
||||
"node-cron": "^3.0.3",
|
||||
@@ -32,6 +33,7 @@
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"prisma": "^5.22.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
+193
-202
@@ -3,26 +3,26 @@ generator client {
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ─── Admin / WHM Users ───────────────────────────────────────────────────────
|
||||
|
||||
model AdminUser {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique @db.VarChar(64)
|
||||
passwordHash String @db.VarChar(255)
|
||||
email String @unique @db.VarChar(255)
|
||||
twoFactorSecret String? @db.VarChar(64)
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
lastLogin DateTime?
|
||||
lastLoginIp String? @db.VarChar(45)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
passwordHash String
|
||||
email String @unique
|
||||
twoFactorSecret String?
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
lastLogin DateTime?
|
||||
lastLoginIp String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
refreshTokens RefreshToken[]
|
||||
auditLogs AuditLog[]
|
||||
refreshTokens RefreshToken[]
|
||||
auditLogs AuditLog[]
|
||||
|
||||
@@map("admin_users")
|
||||
}
|
||||
@@ -31,25 +31,26 @@ model AdminUser {
|
||||
|
||||
model Reseller {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique @db.VarChar(64)
|
||||
passwordHash String @db.VarChar(255)
|
||||
email String @unique @db.VarChar(255)
|
||||
contactName String? @db.VarChar(255)
|
||||
company String? @db.VarChar(255)
|
||||
username String @unique
|
||||
passwordHash String
|
||||
email String @unique
|
||||
contactName String?
|
||||
company String?
|
||||
maxAccounts Int @default(0)
|
||||
diskLimitMb BigInt @default(0)
|
||||
bandwidthLimitMb BigInt @default(0)
|
||||
diskUsedMb BigInt @default(0)
|
||||
bandwidthUsedMb BigInt @default(0)
|
||||
status String @default("active") @db.VarChar(20)
|
||||
brandName String? @db.VarChar(255)
|
||||
brandLogo String? @db.VarChar(500)
|
||||
notes String? @db.Text
|
||||
status String @default("active")
|
||||
brandName String?
|
||||
brandLogo String?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accounts Account[]
|
||||
refreshTokens RefreshToken[]
|
||||
auditLogs AuditLog[]
|
||||
|
||||
@@map("resellers")
|
||||
}
|
||||
@@ -57,70 +58,70 @@ model Reseller {
|
||||
// ─── Hosting Packages ────────────────────────────────────────────────────────
|
||||
|
||||
model Package {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique @db.VarChar(100)
|
||||
diskMb BigInt @default(1024)
|
||||
bandwidthMb BigInt @default(10240)
|
||||
maxDatabases Int @default(5)
|
||||
maxEmailAccounts Int @default(10)
|
||||
maxSubdomains Int @default(10)
|
||||
maxAddonDomains Int @default(5)
|
||||
maxParkedDomains Int @default(5)
|
||||
maxFtpAccounts Int @default(3)
|
||||
maxCronJobs Int @default(5)
|
||||
phpVersion String @default("8.3") @db.VarChar(10)
|
||||
sslEnabled Boolean @default(true)
|
||||
backupEnabled Boolean @default(true)
|
||||
dedicatedIp Boolean @default(false)
|
||||
notes String? @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
diskMb BigInt @default(1024)
|
||||
bandwidthMb BigInt @default(10240)
|
||||
maxDatabases Int @default(5)
|
||||
maxEmailAccounts Int @default(10)
|
||||
maxSubdomains Int @default(10)
|
||||
maxAddonDomains Int @default(5)
|
||||
maxParkedDomains Int @default(5)
|
||||
maxFtpAccounts Int @default(3)
|
||||
maxCronJobs Int @default(5)
|
||||
phpVersion String @default("8.3")
|
||||
sslEnabled Boolean @default(true)
|
||||
backupEnabled Boolean @default(true)
|
||||
dedicatedIp Boolean @default(false)
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accounts Account[]
|
||||
accounts Account[]
|
||||
|
||||
@@map("packages")
|
||||
}
|
||||
|
||||
// ─── Hosting Accounts (cPanel users) ─────────────────────────────────────────
|
||||
// ─── Hosting Accounts ────────────────────────────────────────────────────────
|
||||
|
||||
model Account {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique @db.VarChar(64)
|
||||
passwordHash String @db.VarChar(255)
|
||||
email String @db.VarChar(255)
|
||||
domain String @unique @db.VarChar(255)
|
||||
homedir String @db.VarChar(500)
|
||||
shell String @default("/bin/false") @db.VarChar(100)
|
||||
status String @default("active") @db.VarChar(20)
|
||||
diskUsedMb BigInt @default(0)
|
||||
bandwidthUsedMb BigInt @default(0)
|
||||
ipAddress String? @db.VarChar(45)
|
||||
nameservers String? @db.VarChar(500)
|
||||
notes String? @db.Text
|
||||
suspendedAt DateTime?
|
||||
suspendReason String? @db.VarChar(500)
|
||||
terminatedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
passwordHash String
|
||||
email String
|
||||
domain String @unique
|
||||
homedir String
|
||||
shell String @default("/usr/sbin/nologin")
|
||||
status String @default("active")
|
||||
diskUsedMb BigInt @default(0)
|
||||
bandwidthUsedMb BigInt @default(0)
|
||||
ipAddress String?
|
||||
nameservers String?
|
||||
notes String?
|
||||
suspendedAt DateTime?
|
||||
suspendReason String?
|
||||
terminatedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
resellerId Int?
|
||||
reseller Reseller? @relation(fields: [resellerId], references: [id])
|
||||
packageId Int?
|
||||
package Package? @relation(fields: [packageId], references: [id])
|
||||
ipId Int?
|
||||
ip IpAddress? @relation(fields: [ipId], references: [id])
|
||||
resellerId Int?
|
||||
reseller Reseller? @relation(fields: [resellerId], references: [id])
|
||||
packageId Int?
|
||||
package Package? @relation(fields: [packageId], references: [id])
|
||||
ipId Int?
|
||||
ip IpAddress? @relation(fields: [ipId], references: [id])
|
||||
|
||||
dnsZones DnsZone[]
|
||||
emailAccounts EmailAccount[]
|
||||
emailForwarders EmailForwarder[]
|
||||
ftpAccounts FtpAccount[]
|
||||
databases Database[]
|
||||
subdomains Subdomain[]
|
||||
addonDomains AddonDomain[]
|
||||
parkedDomains ParkedDomain[]
|
||||
sslCertificates SslCertificate[]
|
||||
cronJobs CronJob[]
|
||||
refreshTokens RefreshToken[]
|
||||
dnsZones DnsZone[]
|
||||
emailAccounts EmailAccount[]
|
||||
emailForwarders EmailForwarder[]
|
||||
ftpAccounts FtpAccount[]
|
||||
databases Database[]
|
||||
subdomains Subdomain[]
|
||||
addonDomains AddonDomain[]
|
||||
parkedDomains ParkedDomain[]
|
||||
sslCertificates SslCertificate[]
|
||||
cronJobs CronJob[]
|
||||
refreshTokens RefreshToken[]
|
||||
|
||||
@@map("accounts")
|
||||
}
|
||||
@@ -128,17 +129,17 @@ model Account {
|
||||
// ─── IP Addresses ─────────────────────────────────────────────────────────────
|
||||
|
||||
model IpAddress {
|
||||
id Int @id @default(autoincrement())
|
||||
address String @unique @db.VarChar(45)
|
||||
netmask String? @db.VarChar(45)
|
||||
gateway String? @db.VarChar(45)
|
||||
type String @default("shared") @db.VarChar(20)
|
||||
status String @default("available") @db.VarChar(20)
|
||||
notes String? @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
address String @unique
|
||||
netmask String?
|
||||
gateway String?
|
||||
type String @default("shared")
|
||||
status String @default("available")
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accounts Account[]
|
||||
accounts Account[]
|
||||
|
||||
@@map("ip_addresses")
|
||||
}
|
||||
@@ -146,27 +147,27 @@ model IpAddress {
|
||||
// ─── DNS ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
model DnsZone {
|
||||
id Int @id @default(autoincrement())
|
||||
domain String @unique @db.VarChar(255)
|
||||
zoneFile String? @db.VarChar(500)
|
||||
serial BigInt @default(1)
|
||||
status String @default("active") @db.VarChar(20)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
domain String @unique
|
||||
zoneFile String?
|
||||
serial BigInt @default(1)
|
||||
status String @default("active")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accountId Int
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
accountId Int
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
|
||||
records DnsRecord[]
|
||||
records DnsRecord[]
|
||||
|
||||
@@map("dns_zones")
|
||||
}
|
||||
|
||||
model DnsRecord {
|
||||
id Int @id @default(autoincrement())
|
||||
type String @db.VarChar(10)
|
||||
name String @db.VarChar(255)
|
||||
value String @db.VarChar(500)
|
||||
type String
|
||||
name String
|
||||
value String
|
||||
ttl Int @default(3600)
|
||||
priority Int?
|
||||
createdAt DateTime @default(now())
|
||||
@@ -182,13 +183,13 @@ model DnsRecord {
|
||||
|
||||
model EmailAccount {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @db.VarChar(64)
|
||||
domain String @db.VarChar(255)
|
||||
address String @unique @db.VarChar(320)
|
||||
passwordHash String @db.VarChar(255)
|
||||
username String
|
||||
domain String
|
||||
address String @unique
|
||||
passwordHash String
|
||||
quotaMb Int @default(500)
|
||||
usedMb Int @default(0)
|
||||
status String @default("active") @db.VarChar(20)
|
||||
status String @default("active")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -200,8 +201,8 @@ model EmailAccount {
|
||||
|
||||
model EmailForwarder {
|
||||
id Int @id @default(autoincrement())
|
||||
source String @db.VarChar(320)
|
||||
destination String @db.VarChar(320)
|
||||
source String
|
||||
destination String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
accountId Int
|
||||
@@ -214,11 +215,11 @@ model EmailForwarder {
|
||||
|
||||
model FtpAccount {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @db.VarChar(64)
|
||||
passwordHash String @db.VarChar(255)
|
||||
homedir String @db.VarChar(500)
|
||||
username String
|
||||
passwordHash String
|
||||
homedir String
|
||||
quotaMb Int @default(0)
|
||||
status String @default("active") @db.VarChar(20)
|
||||
status String @default("active")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -232,9 +233,9 @@ model FtpAccount {
|
||||
|
||||
model Database {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique @db.VarChar(64)
|
||||
charset String @default("utf8mb4") @db.VarChar(32)
|
||||
collation String @default("utf8mb4_unicode_ci") @db.VarChar(64)
|
||||
name String @unique
|
||||
charset String @default("utf8mb4")
|
||||
collation String @default("utf8mb4_unicode_ci")
|
||||
sizeMb Float @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -249,10 +250,10 @@ model Database {
|
||||
|
||||
model DatabaseUser {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @db.VarChar(64)
|
||||
passwordHash String @db.VarChar(255)
|
||||
host String @default("localhost") @db.VarChar(255)
|
||||
privileges String @default("ALL") @db.VarChar(255)
|
||||
username String
|
||||
passwordHash String
|
||||
host String @default("localhost")
|
||||
privileges String @default("ALL")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -265,25 +266,25 @@ model DatabaseUser {
|
||||
// ─── Subdomains / Addon / Parked Domains ─────────────────────────────────────
|
||||
|
||||
model Subdomain {
|
||||
id Int @id @default(autoincrement())
|
||||
subdomain String @db.VarChar(255)
|
||||
domain String @db.VarChar(255)
|
||||
fqdn String @unique @db.VarChar(510)
|
||||
documentRoot String @db.VarChar(500)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
subdomain String
|
||||
domain String
|
||||
fqdn String @unique
|
||||
documentRoot String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accountId Int
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
accountId Int
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("subdomains")
|
||||
}
|
||||
|
||||
model AddonDomain {
|
||||
id Int @id @default(autoincrement())
|
||||
domain String @unique @db.VarChar(255)
|
||||
subdomain String @db.VarChar(255)
|
||||
documentRoot String @db.VarChar(500)
|
||||
domain String @unique
|
||||
subdomain String
|
||||
documentRoot String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -295,7 +296,7 @@ model AddonDomain {
|
||||
|
||||
model ParkedDomain {
|
||||
id Int @id @default(autoincrement())
|
||||
domain String @unique @db.VarChar(255)
|
||||
domain String @unique
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
accountId Int
|
||||
@@ -307,21 +308,21 @@ model ParkedDomain {
|
||||
// ─── SSL ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
model SslCertificate {
|
||||
id Int @id @default(autoincrement())
|
||||
domain String @db.VarChar(255)
|
||||
type String @default("letsencrypt") @db.VarChar(30)
|
||||
certPath String? @db.VarChar(500)
|
||||
keyPath String? @db.VarChar(500)
|
||||
chainPath String? @db.VarChar(500)
|
||||
issuedAt DateTime?
|
||||
expiresAt DateTime?
|
||||
autoRenew Boolean @default(true)
|
||||
status String @default("pending") @db.VarChar(20)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
domain String
|
||||
type String @default("letsencrypt")
|
||||
certPath String?
|
||||
keyPath String?
|
||||
chainPath String?
|
||||
issuedAt DateTime?
|
||||
expiresAt DateTime?
|
||||
autoRenew Boolean @default(true)
|
||||
status String @default("pending")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accountId Int
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
accountId Int
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("ssl_certificates")
|
||||
}
|
||||
@@ -329,18 +330,18 @@ model SslCertificate {
|
||||
// ─── Cron Jobs ────────────────────────────────────────────────────────────────
|
||||
|
||||
model CronJob {
|
||||
id Int @id @default(autoincrement())
|
||||
schedule String @db.VarChar(100)
|
||||
command String @db.VarChar(1000)
|
||||
comment String? @db.VarChar(255)
|
||||
enabled Boolean @default(true)
|
||||
id Int @id @default(autoincrement())
|
||||
schedule String
|
||||
command String
|
||||
comment String?
|
||||
enabled Boolean @default(true)
|
||||
lastRun DateTime?
|
||||
nextRun DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accountId Int
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("cron_jobs")
|
||||
}
|
||||
@@ -348,32 +349,32 @@ model CronJob {
|
||||
// ─── Security ─────────────────────────────────────────────────────────────────
|
||||
|
||||
model SecurityEvent {
|
||||
id Int @id @default(autoincrement())
|
||||
type String @db.VarChar(50)
|
||||
severity String @default("info") @db.VarChar(20)
|
||||
message String @db.Text
|
||||
sourceIp String? @db.VarChar(45)
|
||||
resolved Boolean @default(false)
|
||||
resolvedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
id Int @id @default(autoincrement())
|
||||
type String
|
||||
severity String @default("info")
|
||||
message String
|
||||
sourceIp String?
|
||||
resolved Boolean @default(false)
|
||||
resolvedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("security_events")
|
||||
}
|
||||
|
||||
model BlockedIp {
|
||||
id Int @id @default(autoincrement())
|
||||
ip String @unique @db.VarChar(45)
|
||||
reason String? @db.VarChar(500)
|
||||
expiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
id Int @id @default(autoincrement())
|
||||
ip String @unique
|
||||
reason String?
|
||||
expiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("blocked_ips")
|
||||
}
|
||||
|
||||
model WhitelistedIp {
|
||||
id Int @id @default(autoincrement())
|
||||
ip String @unique @db.VarChar(45)
|
||||
note String? @db.VarChar(255)
|
||||
ip String @unique
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("whitelisted_ips")
|
||||
@@ -382,15 +383,17 @@ model WhitelistedIp {
|
||||
// ─── Audit Log ────────────────────────────────────────────────────────────────
|
||||
|
||||
model AuditLog {
|
||||
id Int @id @default(autoincrement())
|
||||
action String @db.VarChar(100)
|
||||
target String? @db.VarChar(255)
|
||||
details String? @db.Text
|
||||
sourceIp String? @db.VarChar(45)
|
||||
createdAt DateTime @default(now())
|
||||
id Int @id @default(autoincrement())
|
||||
action String
|
||||
target String?
|
||||
details String?
|
||||
sourceIp String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
adminId Int?
|
||||
admin AdminUser? @relation(fields: [adminId], references: [id])
|
||||
adminId Int?
|
||||
admin AdminUser? @relation(fields: [adminId], references: [id])
|
||||
resellerId Int?
|
||||
reseller Reseller? @relation(fields: [resellerId], references: [id])
|
||||
|
||||
@@map("audit_log")
|
||||
}
|
||||
@@ -398,19 +401,19 @@ model AuditLog {
|
||||
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
model RefreshToken {
|
||||
id Int @id @default(autoincrement())
|
||||
token String @unique @db.VarChar(500)
|
||||
role String @db.VarChar(20)
|
||||
expiresAt DateTime
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
id Int @id @default(autoincrement())
|
||||
token String @unique
|
||||
role String
|
||||
expiresAt DateTime
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
adminId Int?
|
||||
admin AdminUser? @relation(fields: [adminId], references: [id])
|
||||
resellerId Int?
|
||||
reseller Reseller? @relation(fields: [resellerId], references: [id])
|
||||
accountId Int?
|
||||
account Account? @relation(fields: [accountId], references: [id])
|
||||
adminId Int?
|
||||
admin AdminUser? @relation(fields: [adminId], references: [id])
|
||||
resellerId Int?
|
||||
reseller Reseller? @relation(fields: [resellerId], references: [id])
|
||||
accountId Int?
|
||||
account Account? @relation(fields: [accountId], references: [id])
|
||||
|
||||
@@map("refresh_tokens")
|
||||
}
|
||||
@@ -418,21 +421,9 @@ model RefreshToken {
|
||||
// ─── Settings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
model Setting {
|
||||
key String @id @db.VarChar(100)
|
||||
value String @db.Text
|
||||
key String @id
|
||||
value String
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("settings")
|
||||
}
|
||||
|
||||
// ─── Terminal Sessions ────────────────────────────────────────────────────────
|
||||
|
||||
model TerminalSession {
|
||||
id Int @id @default(autoincrement())
|
||||
adminId Int
|
||||
sourceIp String? @db.VarChar(45)
|
||||
startedAt DateTime @default(now())
|
||||
endedAt DateTime?
|
||||
|
||||
@@map("terminal_sessions")
|
||||
}
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ export async function buildApp() {
|
||||
|
||||
fastify.get('/api/health', async () => ({
|
||||
status: 'ok',
|
||||
version: '0.3.0',
|
||||
version: '0.4.2',
|
||||
timestamp: new Date().toISOString(),
|
||||
}))
|
||||
|
||||
|
||||
+23
-7
@@ -15,9 +15,8 @@ export const config = {
|
||||
isDev: optional('NODE_ENV', 'development') === 'development',
|
||||
|
||||
server: {
|
||||
host: optional('HOST', '0.0.0.0'),
|
||||
nixserverPort: parseInt(optional('NIXSERVER_PORT', '2087'), 10),
|
||||
nixclientPort: parseInt(optional('NIXCLIENT_PORT', '2083'), 10),
|
||||
host: optional('HOST', '127.0.0.1'),
|
||||
port: parseInt(optional('PORT', '4000'), 10),
|
||||
},
|
||||
|
||||
db: {
|
||||
@@ -39,12 +38,18 @@ export const config = {
|
||||
|
||||
paths: {
|
||||
homeDir: optional('HOME_DIR', '/home'),
|
||||
nginxSites: optional('NGINX_SITES_DIR', '/etc/nginx/sites-available'),
|
||||
nginxEnabled: optional('NGINX_ENABLED_DIR', '/etc/nginx/sites-enabled'),
|
||||
apacheSites: optional('APACHE_SITES_DIR', '/etc/apache2/sites-available'),
|
||||
apacheEnabled: optional('APACHE_ENABLED_DIR', '/etc/apache2/sites-enabled'),
|
||||
bindZones: optional('BIND_ZONES_DIR', '/etc/bind/zones'),
|
||||
phpFpmPool82: optional('PHP_FPM_POOL_82', '/etc/php/8.2/fpm/pool.d'),
|
||||
phpFpmPool83: optional('PHP_FPM_POOL_83', '/etc/php/8.3/fpm/pool.d'),
|
||||
pdnsZones: optional('PDNS_ZONES_DIR', '/etc/pdns/zones'),
|
||||
pdnsBindBackend: optional('PDNS_BIND_BACKEND', '/etc/pdns/bindbackend.conf'),
|
||||
eximVirtualDomains: optional('EXIM_VIRTUAL_DOMAINS', '/etc/exim4/virtual_domains'),
|
||||
eximVirtualMailboxes: optional('EXIM_VIRTUAL_MAILBOXES', '/etc/exim4/virtual_mailboxes'),
|
||||
eximVirtualAliases: optional('EXIM_VIRTUAL_ALIASES', '/etc/exim4/virtual_aliases'),
|
||||
dovecotPasswd: optional('DOVECOT_PASSWD', '/etc/dovecot/passwd'),
|
||||
certbotWebroot: optional('CERTBOT_WEBROOT', '/var/www/letsencrypt'),
|
||||
letsencryptLive: optional('LETSENCRYPT_LIVE', '/etc/letsencrypt/live'),
|
||||
},
|
||||
|
||||
rateLimit: {
|
||||
@@ -52,8 +57,19 @@ export const config = {
|
||||
windowMs: parseInt(optional('RATE_LIMIT_WINDOW_MS', '60000'), 10),
|
||||
},
|
||||
|
||||
mariadb: {
|
||||
host: optional('MARIADB_HOST', '127.0.0.1'),
|
||||
port: parseInt(optional('MARIADB_PORT', '3306'), 10),
|
||||
user: optional('MARIADB_USER', 'root'),
|
||||
password: optional('MARIADB_PASSWORD', ''),
|
||||
},
|
||||
|
||||
licensing: {
|
||||
serverUrl: optional('LICENSING_SERVER_URL', 'https://license.nixpanel.io'),
|
||||
licenseKey: optional('LICENSE_KEY', ''),
|
||||
},
|
||||
|
||||
features: {
|
||||
terminalEnabled: optional('ENABLE_TERMINAL', 'true') === 'true',
|
||||
wsStatsInterval: parseInt(optional('WEBSOCKET_STATS_INTERVAL', '5000'), 10),
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs/promises'
|
||||
import { config } from '../config.js'
|
||||
import { exec, writeFile } from './exec.js'
|
||||
|
||||
export interface VhostOptions {
|
||||
domain: string
|
||||
username: string
|
||||
documentRoot: string
|
||||
phpVersion?: string
|
||||
sslEnabled?: boolean
|
||||
certPath?: string
|
||||
keyPath?: string
|
||||
aliases?: string[]
|
||||
}
|
||||
|
||||
export function generateVhost(opts: VhostOptions): string {
|
||||
const {
|
||||
domain,
|
||||
username,
|
||||
documentRoot,
|
||||
phpVersion = '8.3',
|
||||
sslEnabled = false,
|
||||
certPath,
|
||||
keyPath,
|
||||
aliases = [],
|
||||
} = opts
|
||||
|
||||
const phpSocket = `/run/php/php${phpVersion}-fpm-${username}.sock`
|
||||
const serverAlias = aliases.length > 0 ? ` ServerAlias ${aliases.join(' ')}\n` : ''
|
||||
|
||||
const httpVhost = `<VirtualHost *:80>
|
||||
ServerName ${domain}
|
||||
${serverAlias} DocumentRoot ${documentRoot}
|
||||
|
||||
<FilesMatch \\.php$>
|
||||
SetHandler "proxy:unix:${phpSocket}|fcgi://localhost"
|
||||
</FilesMatch>
|
||||
|
||||
<Directory ${documentRoot}>
|
||||
Options -Indexes +FollowSymLinks
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
CustomLog /var/log/apache2/${username}-access.log combined
|
||||
ErrorLog /var/log/apache2/${username}-error.log
|
||||
|
||||
Alias /.well-known/acme-challenge/ /var/www/letsencrypt/.well-known/acme-challenge/
|
||||
<Directory /var/www/letsencrypt/.well-known/acme-challenge/>
|
||||
Require all granted
|
||||
</Directory>
|
||||
${sslEnabled ? `\n RewriteEngine On\n RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=301,L]\n` : ''}
|
||||
</VirtualHost>`
|
||||
|
||||
if (!sslEnabled || !certPath || !keyPath) {
|
||||
return httpVhost
|
||||
}
|
||||
|
||||
const sslVhost = `
|
||||
<VirtualHost *:443>
|
||||
ServerName ${domain}
|
||||
${serverAlias} DocumentRoot ${documentRoot}
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile ${certPath}
|
||||
SSLCertificateKeyFile ${keyPath}
|
||||
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
|
||||
SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
|
||||
SSLHonorCipherOrder off
|
||||
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
|
||||
<FilesMatch \\.php$>
|
||||
SetHandler "proxy:unix:${phpSocket}|fcgi://localhost"
|
||||
</FilesMatch>
|
||||
|
||||
<Directory ${documentRoot}>
|
||||
Options -Indexes +FollowSymLinks
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
CustomLog /var/log/apache2/${username}-access.log combined
|
||||
ErrorLog /var/log/apache2/${username}-error.log
|
||||
</VirtualHost>`
|
||||
|
||||
return `${httpVhost}\n${sslVhost}`
|
||||
}
|
||||
|
||||
export function generateSuspendedVhost(domain: string, username: string, aliases: string[] = []): string {
|
||||
const serverAlias = aliases.length > 0 ? ` ServerAlias ${aliases.join(' ')}\n` : ''
|
||||
return `<VirtualHost *:80>
|
||||
ServerName ${domain}
|
||||
${serverAlias}
|
||||
DocumentRoot /var/www/html
|
||||
|
||||
RedirectMatch 503 ^/(?!\.well-known)
|
||||
ErrorDocument 503 "<html><body><h1>Account Suspended</h1><p>This account has been suspended. Please contact support.</p></body></html>"
|
||||
|
||||
CustomLog /var/log/apache2/${username}-access.log combined
|
||||
ErrorLog /var/log/apache2/${username}-error.log
|
||||
</VirtualHost>`
|
||||
}
|
||||
|
||||
export function generatePhpFpmPool(username: string, phpVersion: string): string {
|
||||
return `[${username}]
|
||||
user = ${username}
|
||||
group = ${username}
|
||||
listen = /run/php/php${phpVersion}-fpm-${username}.sock
|
||||
listen.owner = www-data
|
||||
listen.group = www-data
|
||||
listen.mode = 0660
|
||||
|
||||
pm = ondemand
|
||||
pm.max_children = 10
|
||||
pm.process_idle_timeout = 10s
|
||||
pm.max_requests = 500
|
||||
|
||||
chdir = /
|
||||
|
||||
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec
|
||||
php_admin_value[open_basedir] = /home/${username}/:/tmp/
|
||||
php_admin_value[error_log] = /home/${username}/logs/php_errors.log
|
||||
php_admin_flag[log_errors] = on
|
||||
php_admin_flag[allow_url_fopen] = off
|
||||
`
|
||||
}
|
||||
|
||||
export async function enableVhost(opts: VhostOptions): Promise<void> {
|
||||
const confFile = `${opts.domain}.conf`
|
||||
const confPath = path.join(config.paths.apacheSites, confFile)
|
||||
const enabledPath = path.join(config.paths.apacheEnabled, confFile)
|
||||
|
||||
await writeFile(confPath, generateVhost(opts))
|
||||
await exec('ln', ['-sf', confPath, enabledPath])
|
||||
|
||||
const test = await exec('apache2ctl', ['configtest'])
|
||||
if (test.exitCode !== 0) {
|
||||
// Roll back on bad config
|
||||
await fs.unlink(enabledPath).catch(() => {})
|
||||
await fs.unlink(confPath).catch(() => {})
|
||||
throw new Error(`Apache config test failed: ${test.stderr}`)
|
||||
}
|
||||
|
||||
await exec('systemctl', ['reload', 'apache2'])
|
||||
}
|
||||
|
||||
export async function disableVhost(domain: string): Promise<void> {
|
||||
const confFile = `${domain}.conf`
|
||||
await exec('a2dissite', [confFile])
|
||||
await fs.unlink(path.join(config.paths.apacheSites, confFile)).catch(() => {})
|
||||
await exec('systemctl', ['reload', 'apache2'])
|
||||
}
|
||||
|
||||
export async function suspendVhost(domain: string, username: string, aliases: string[] = []): Promise<void> {
|
||||
const confFile = `${domain}.conf`
|
||||
const confPath = path.join(config.paths.apacheSites, confFile)
|
||||
await writeFile(confPath, generateSuspendedVhost(domain, username, aliases))
|
||||
await exec('systemctl', ['reload', 'apache2'])
|
||||
}
|
||||
|
||||
export async function enablePhpFpmPool(username: string, phpVersion: string): Promise<void> {
|
||||
const poolDir = phpVersion === '8.2' ? config.paths.phpFpmPool82 : config.paths.phpFpmPool83
|
||||
const poolPath = path.join(poolDir, `${username}.conf`)
|
||||
await writeFile(poolPath, generatePhpFpmPool(username, phpVersion))
|
||||
await exec('systemctl', ['reload', `php${phpVersion}-fpm`])
|
||||
}
|
||||
|
||||
export async function disablePhpFpmPool(username: string, phpVersion: string): Promise<void> {
|
||||
const poolDir = phpVersion === '8.2' ? config.paths.phpFpmPool82 : config.paths.phpFpmPool83
|
||||
const poolPath = path.join(poolDir, `${username}.conf`)
|
||||
await fs.unlink(poolPath).catch(() => {})
|
||||
await exec('systemctl', ['reload', `php${phpVersion}-fpm`])
|
||||
}
|
||||
+85
-57
@@ -1,4 +1,5 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs/promises'
|
||||
import { config } from '../config.js'
|
||||
import { exec, writeFile } from './exec.js'
|
||||
|
||||
@@ -12,9 +13,6 @@ export interface DnsZoneOptions {
|
||||
ttl?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a BIND9 zone file for a domain.
|
||||
*/
|
||||
export function generateZoneFile(opts: DnsZoneOptions): string {
|
||||
const {
|
||||
domain,
|
||||
@@ -22,19 +20,19 @@ export function generateZoneFile(opts: DnsZoneOptions): string {
|
||||
ns1,
|
||||
ns2,
|
||||
mail = `mail.${opts.domain}`,
|
||||
serial = Math.floor(Date.now() / 1000),
|
||||
serial = generateSerial(),
|
||||
ttl = 3600,
|
||||
} = opts
|
||||
|
||||
return `; Zone file for ${domain}
|
||||
; Generated by NixPanel
|
||||
; Managed by NixPanel — do not edit manually
|
||||
$TTL ${ttl}
|
||||
@ IN SOA ${ns1}. hostmaster.${domain}. (
|
||||
${serial} ; Serial
|
||||
3600 ; Refresh
|
||||
1800 ; Retry
|
||||
604800 ; Expire
|
||||
300 ) ; Minimum TTL
|
||||
${serial} ; Serial
|
||||
3600 ; Refresh
|
||||
1800 ; Retry
|
||||
604800 ; Expire
|
||||
300 ) ; Minimum TTL
|
||||
|
||||
; Name servers
|
||||
@ IN NS ${ns1}.
|
||||
@@ -44,12 +42,11 @@ $TTL ${ttl}
|
||||
@ IN A ${ip}
|
||||
www IN A ${ip}
|
||||
mail IN A ${ip}
|
||||
ftp IN A ${ip}
|
||||
nixclient IN A ${ip}
|
||||
webmail IN A ${ip}
|
||||
cpanel IN A ${ip}
|
||||
|
||||
; MX record
|
||||
@ IN MX 10 ${mail}.
|
||||
@ IN MX 10 ${mail}.
|
||||
|
||||
; SPF
|
||||
@ IN TXT "v=spf1 a mx ip4:${ip} ~all"
|
||||
@@ -60,66 +57,97 @@ _dmarc IN TXT "v=DMARC1; p=none; rua=mailto:admin@${domain}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Write zone file and update named.conf.local, then reload BIND.
|
||||
* Create a DNS zone: write the zone file and register it with PowerDNS
|
||||
* bind backend, then reload.
|
||||
*/
|
||||
export async function createZone(opts: DnsZoneOptions): Promise<string> {
|
||||
const zoneFile = path.join(config.paths.bindZones, `db.${opts.domain}`)
|
||||
const zoneFile = path.join(config.paths.pdnsZones, `db.${opts.domain}`)
|
||||
await writeFile(zoneFile, generateZoneFile(opts))
|
||||
await registerZoneInBindBackend(opts.domain, zoneFile)
|
||||
|
||||
// Add zone declaration to named.conf.local (idempotent)
|
||||
const namedConf = '/etc/bind/named.conf.local'
|
||||
const zoneDecl = `
|
||||
zone "${opts.domain}" {
|
||||
type master;
|
||||
file "${zoneFile}";
|
||||
allow-transfer { any; };
|
||||
};
|
||||
`
|
||||
// Read existing, only add if not already there
|
||||
const { readFile } = await import('fs/promises')
|
||||
let existing = ''
|
||||
try {
|
||||
existing = await readFile(namedConf, 'utf8')
|
||||
} catch {
|
||||
// file may not exist yet in test env
|
||||
// Tell PowerDNS about the new zone without a full restart
|
||||
const reload = await exec('pdns_control', ['bind-add-zone', opts.domain, zoneFile])
|
||||
if (reload.exitCode !== 0) {
|
||||
// Fall back to full reload if bind-add-zone not supported
|
||||
await exec('systemctl', ['reload', 'pdns'])
|
||||
}
|
||||
|
||||
if (!existing.includes(`zone "${opts.domain}"`)) {
|
||||
const { appendFile } = await import('fs/promises')
|
||||
await appendFile(namedConf, zoneDecl)
|
||||
}
|
||||
|
||||
// Validate zone
|
||||
const check = await exec('named-checkzone', [opts.domain, zoneFile])
|
||||
if (check.exitCode !== 0) {
|
||||
throw new Error(`Zone validation failed: ${check.stderr}`)
|
||||
}
|
||||
|
||||
await exec('rndc', ['reload'])
|
||||
|
||||
return zoneFile
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a zone and reload BIND.
|
||||
* Remove a DNS zone and reload PowerDNS.
|
||||
*/
|
||||
export async function removeZone(domain: string): Promise<void> {
|
||||
const zoneFile = path.join(config.paths.bindZones, `db.${domain}`)
|
||||
await exec('rm', ['-f', zoneFile])
|
||||
const zoneFile = path.join(config.paths.pdnsZones, `db.${domain}`)
|
||||
await fs.unlink(zoneFile).catch(() => {})
|
||||
await unregisterZoneFromBindBackend(domain)
|
||||
await exec('systemctl', ['reload', 'pdns'])
|
||||
}
|
||||
|
||||
// Remove zone block from named.conf.local
|
||||
const namedConf = '/etc/bind/named.conf.local'
|
||||
const { readFile, writeFile: fsWrite } = await import('fs/promises')
|
||||
/**
|
||||
* Update just the serial in an existing zone file to force secondary
|
||||
* nameservers to re-transfer the zone.
|
||||
*/
|
||||
export async function bumpSerial(domain: string): Promise<void> {
|
||||
const zoneFile = path.join(config.paths.pdnsZones, `db.${domain}`)
|
||||
let content: string
|
||||
try {
|
||||
const content = await readFile(namedConf, 'utf8')
|
||||
const cleaned = content.replace(
|
||||
new RegExp(`zone\\s+"${domain.replace('.', '\\.')}\"[^}]+\\};\\s*`, 'g'),
|
||||
''
|
||||
)
|
||||
await fsWrite(namedConf, cleaned, 'utf8')
|
||||
content = await fs.readFile(zoneFile, 'utf8')
|
||||
} catch {
|
||||
throw new Error(`Zone file not found for ${domain}`)
|
||||
}
|
||||
|
||||
const newSerial = generateSerial()
|
||||
const updated = content.replace(
|
||||
/(\d{10})\s*;\s*Serial/,
|
||||
`${newSerial} ; Serial`
|
||||
)
|
||||
await fs.writeFile(zoneFile, updated, 'utf8')
|
||||
await exec('pdns_control', ['bind-reload-now', domain])
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage /etc/pdns/bindbackend.conf — the file that lists all zones
|
||||
* PowerDNS should serve. Each zone gets one line:
|
||||
* zone=domain.com:/etc/pdns/zones/db.domain.com
|
||||
*/
|
||||
async function registerZoneInBindBackend(domain: string, zoneFile: string): Promise<void> {
|
||||
const backendConf = config.paths.pdnsBindBackend
|
||||
let existing = ''
|
||||
try {
|
||||
existing = await fs.readFile(backendConf, 'utf8')
|
||||
} catch {
|
||||
// File may not exist yet on first zone creation
|
||||
}
|
||||
|
||||
const entry = `zone=${domain}:${zoneFile}`
|
||||
if (!existing.includes(`zone=${domain}`)) {
|
||||
await fs.appendFile(backendConf, `${entry}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
async function unregisterZoneFromBindBackend(domain: string): Promise<void> {
|
||||
const backendConf = config.paths.pdnsBindBackend
|
||||
try {
|
||||
const content = await fs.readFile(backendConf, 'utf8')
|
||||
const cleaned = content
|
||||
.split('\n')
|
||||
.filter(line => !line.startsWith(`zone=${domain}:`))
|
||||
.join('\n')
|
||||
await fs.writeFile(backendConf, cleaned, 'utf8')
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
await exec('rndc', ['reload'])
|
||||
/**
|
||||
* Generate a YYYYMMDDNN-style serial number based on today's date.
|
||||
* Uses seconds since epoch truncated to 10 digits as a simpler alternative.
|
||||
*/
|
||||
function generateSerial(): number {
|
||||
const now = new Date()
|
||||
const date = now.toISOString().slice(0, 10).replace(/-/g, '')
|
||||
// Append 01 as the revision — sufficient for daily zone changes
|
||||
return parseInt(`${date}01`, 10)
|
||||
}
|
||||
|
||||
+73
-65
@@ -1,4 +1,4 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { execFile, spawn } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { existsSync } from 'fs'
|
||||
|
||||
@@ -7,62 +7,49 @@ const execFileAsync = promisify(execFile)
|
||||
// Commands allowed to be executed — nothing outside this list runs
|
||||
const ALLOWED_COMMANDS = new Set([
|
||||
// User management
|
||||
'useradd', 'userdel', 'usermod', 'passwd', 'chage', 'id', 'groups',
|
||||
'useradd', 'userdel', 'usermod', 'chage', 'id',
|
||||
// File operations
|
||||
'mkdir', 'rmdir', 'rm', 'cp', 'mv', 'chmod', 'chown', 'ln', 'tar', 'gzip', 'gunzip',
|
||||
'find', 'du', 'df', 'ls', 'stat',
|
||||
// Web servers
|
||||
'nginx', 'apache2', 'apache2ctl', 'a2ensite', 'a2dissite', 'a2enmod', 'a2dismod',
|
||||
'mkdir', 'rmdir', 'rm', 'cp', 'mv', 'chmod', 'chown', 'ln', 'tar',
|
||||
'find', 'du', 'df', 'stat',
|
||||
// Web server
|
||||
'apache2ctl', 'a2ensite', 'a2dissite', 'a2enmod', 'a2dismod',
|
||||
// DNS
|
||||
'named', 'named-checkconf', 'named-checkzone', 'rndc',
|
||||
'pdns_control', 'pdnsutil',
|
||||
// Mail
|
||||
'postfix', 'postmap', 'postconf', 'dovecot', 'doveadm',
|
||||
'dovecot', 'openssl',
|
||||
// SSL
|
||||
'certbot', 'openssl',
|
||||
// FTP
|
||||
'vsftpd',
|
||||
'certbot',
|
||||
// Services
|
||||
'systemctl', 'service',
|
||||
'systemctl',
|
||||
// Network
|
||||
'ss', 'ip', 'hostname', 'ping', 'traceroute', 'dig', 'nslookup',
|
||||
'ss', 'ip', 'hostname',
|
||||
// System
|
||||
'uname', 'uptime', 'free', 'vmstat', 'iostat', 'top', 'ps', 'kill', 'pkill',
|
||||
'cat', 'grep', 'awk', 'sed', 'head', 'tail', 'wc', 'sort', 'uniq', 'cut',
|
||||
// Package managers
|
||||
'apt', 'apt-get', 'dpkg',
|
||||
// MySQL/MariaDB
|
||||
'mysql', 'mysqladmin', 'mysqlcheck', 'mysqldump',
|
||||
'uname', 'free', 'ps',
|
||||
// PHP
|
||||
'php', 'php7.4', 'php8.0', 'php8.1', 'php8.2', 'php8.3', 'update-alternatives',
|
||||
// Misc
|
||||
'touch', 'echo', 'which', 'whoami', 'env',
|
||||
'php8.2', 'php8.3',
|
||||
// Fail2ban
|
||||
'fail2ban-client',
|
||||
// UFW
|
||||
'ufw',
|
||||
// iptables
|
||||
'iptables', 'netfilter-persistent',
|
||||
])
|
||||
|
||||
// Characters never allowed in arguments (shell injection prevention)
|
||||
// Shell metacharacters never allowed in arguments
|
||||
const DANGEROUS_CHARS = /[;&|`$<>()\n\r\0]/
|
||||
|
||||
// Paths outside these prefixes are blocked for file operations
|
||||
// Paths allowed for file read/write operations
|
||||
const ALLOWED_PATH_PREFIXES = [
|
||||
'/home/',
|
||||
'/etc/nginx/',
|
||||
'/etc/apache2/',
|
||||
'/etc/bind/',
|
||||
'/etc/postfix/',
|
||||
'/etc/dovecot/',
|
||||
'/etc/vsftpd',
|
||||
'/etc/php/',
|
||||
'/etc/pdns/',
|
||||
'/etc/exim4/',
|
||||
'/etc/dovecot/',
|
||||
'/etc/letsencrypt/',
|
||||
'/etc/fail2ban/',
|
||||
'/etc/nixpanel/',
|
||||
'/var/www/',
|
||||
'/var/mail/',
|
||||
'/var/log/',
|
||||
'/var/spool/cron/',
|
||||
'/tmp/',
|
||||
'/usr/local/nixpanel/',
|
||||
'/opt/nixpanel/',
|
||||
]
|
||||
|
||||
function validateArg(arg: string): void {
|
||||
@@ -71,8 +58,8 @@ function validateArg(arg: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedPath(path: string): boolean {
|
||||
return ALLOWED_PATH_PREFIXES.some(prefix => path.startsWith(prefix))
|
||||
function isAllowedPath(p: string): boolean {
|
||||
return ALLOWED_PATH_PREFIXES.some(prefix => p.startsWith(prefix))
|
||||
}
|
||||
|
||||
export interface ExecResult {
|
||||
@@ -83,12 +70,13 @@ export interface ExecResult {
|
||||
|
||||
export interface ExecOptions {
|
||||
timeout?: number
|
||||
allowedPaths?: string[]
|
||||
input?: string // data to pipe to stdin
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a whitelisted system command safely.
|
||||
* Throws if the command is not in the allowlist or args contain shell metacharacters.
|
||||
* Uses execFile (no shell). Throws if command is not in the allowlist
|
||||
* or any argument contains shell metacharacters.
|
||||
*/
|
||||
export async function exec(
|
||||
command: string,
|
||||
@@ -103,12 +91,15 @@ export async function exec(
|
||||
validateArg(arg)
|
||||
}
|
||||
|
||||
const timeout = options.timeout ?? 30_000
|
||||
// If stdin input is needed, use spawn so we can write to stdin
|
||||
if (options.input !== undefined) {
|
||||
return execWithStdin(command, args, options.input, options.timeout ?? 30_000)
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(command, args, {
|
||||
timeout,
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB
|
||||
timeout: options.timeout ?? 30_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
})
|
||||
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 }
|
||||
} catch (err: unknown) {
|
||||
@@ -121,52 +112,69 @@ export async function exec(
|
||||
}
|
||||
}
|
||||
|
||||
function execWithStdin(
|
||||
command: string,
|
||||
args: string[],
|
||||
input: string,
|
||||
timeout: number
|
||||
): Promise<ExecResult> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
child.stdout.on('data', (d: Buffer) => { stdout += d.toString() })
|
||||
child.stderr.on('data', (d: Buffer) => { stderr += d.toString() })
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill()
|
||||
resolve({ stdout: stdout.trim(), stderr: 'Command timed out', exitCode: 1 })
|
||||
}, timeout)
|
||||
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer)
|
||||
resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code ?? 1 })
|
||||
})
|
||||
|
||||
child.stdin.write(input)
|
||||
child.stdin.end()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Write content to a file in an allowed path using a safe method (no shell).
|
||||
* Write content to a file in an allowed path.
|
||||
*/
|
||||
export async function writeFile(path: string, content: string): Promise<void> {
|
||||
if (!isAllowedPath(path)) {
|
||||
throw new Error(`Path not in allowed list: ${path}`)
|
||||
export async function writeFile(filePath: string, content: string): Promise<void> {
|
||||
if (!isAllowedPath(filePath)) {
|
||||
throw new Error(`Path not in allowed list: ${filePath}`)
|
||||
}
|
||||
const { writeFile: fsWrite } = await import('fs/promises')
|
||||
await fsWrite(path, content, 'utf8')
|
||||
await fsWrite(filePath, content, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file from an allowed path.
|
||||
*/
|
||||
export async function readFile(path: string): Promise<string> {
|
||||
if (!isAllowedPath(path)) {
|
||||
throw new Error(`Path not in allowed list: ${path}`)
|
||||
export async function readFile(filePath: string): Promise<string> {
|
||||
if (!isAllowedPath(filePath)) {
|
||||
throw new Error(`Path not in allowed list: ${filePath}`)
|
||||
}
|
||||
const { readFile: fsRead } = await import('fs/promises')
|
||||
return fsRead(path, 'utf8')
|
||||
return fsRead(filePath, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running as root.
|
||||
*/
|
||||
export function isRoot(): boolean {
|
||||
return process.getuid?.() === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a Linux username.
|
||||
*/
|
||||
export function validateUsername(username: string): boolean {
|
||||
return /^[a-z][a-z0-9_-]{0,31}$/.test(username)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a domain name.
|
||||
*/
|
||||
export function validateDomain(domain: string): boolean {
|
||||
return /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(domain)
|
||||
}
|
||||
|
||||
/**
|
||||
* File exists check without shell.
|
||||
*/
|
||||
export function fileExists(path: string): boolean {
|
||||
return existsSync(path)
|
||||
export function fileExists(filePath: string): boolean {
|
||||
return existsSync(filePath)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { config } from '../config.js'
|
||||
|
||||
let licenseValid = false
|
||||
let lastChecked: Date | null = null
|
||||
|
||||
/**
|
||||
* Validate the license with the licensing server on startup.
|
||||
* Throws if the license is invalid or the server is unreachable.
|
||||
*/
|
||||
export async function validateOnStartup(): Promise<void> {
|
||||
if (!config.licensing.licenseKey) {
|
||||
if (config.env === 'development') {
|
||||
licenseValid = true
|
||||
return
|
||||
}
|
||||
throw new Error('LICENSE_KEY is not set. Set it in .env to activate NixPanel.')
|
||||
}
|
||||
|
||||
await checkLicense()
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a heartbeat to the licensing server every 6 hours.
|
||||
* If the license becomes invalid, logs a critical event but does NOT shut down —
|
||||
* the panel keeps running to avoid accidental outages.
|
||||
*/
|
||||
export async function heartbeat(): Promise<void> {
|
||||
if (!config.licensing.licenseKey) return
|
||||
|
||||
try {
|
||||
await checkLicense()
|
||||
} catch (err) {
|
||||
console.error('[license] Heartbeat failed:', err)
|
||||
// Log to DB if available — non-fatal, don't crash
|
||||
try {
|
||||
const { default: prisma } = await import('../db/client.js')
|
||||
await prisma.securityEvent.create({
|
||||
data: {
|
||||
type: 'license_heartbeat_failed',
|
||||
severity: 'warning',
|
||||
message: `License heartbeat failed: ${String(err)}`,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// DB may be unavailable — ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isLicenseValid(): boolean {
|
||||
return licenseValid
|
||||
}
|
||||
|
||||
export function getLastChecked(): Date | null {
|
||||
return lastChecked
|
||||
}
|
||||
|
||||
async function checkLicense(): Promise<void> {
|
||||
const url = `${config.licensing.serverUrl}/api/v1/validate`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
licenseKey: config.licensing.licenseKey,
|
||||
serverIp: await getServerIp(),
|
||||
}),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
licenseValid = false
|
||||
throw new Error(`License server returned ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json() as { valid: boolean; message?: string }
|
||||
licenseValid = data.valid
|
||||
lastChecked = new Date()
|
||||
|
||||
if (!data.valid) {
|
||||
throw new Error(data.message ?? 'License is not valid')
|
||||
}
|
||||
}
|
||||
|
||||
async function getServerIp(): Promise<string> {
|
||||
try {
|
||||
const { createConnection } = await import('net')
|
||||
return new Promise((resolve) => {
|
||||
const socket = createConnection(80, '8.8.8.8')
|
||||
socket.on('connect', () => {
|
||||
resolve(socket.localAddress ?? '')
|
||||
socket.destroy()
|
||||
})
|
||||
socket.on('error', () => resolve(''))
|
||||
})
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs/promises'
|
||||
import { config } from '../config.js'
|
||||
import { exec } from './exec.js'
|
||||
|
||||
/**
|
||||
* Hash a password using SHA-512-CRYPT — the scheme Dovecot understands
|
||||
* natively without any extra configuration.
|
||||
*/
|
||||
export async function hashMailPassword(password: string): Promise<string> {
|
||||
const result = await exec('openssl', ['passwd', '-6', '-stdin'], { input: password })
|
||||
if (result.exitCode !== 0 || !result.stdout.trim()) {
|
||||
throw new Error(`Failed to hash mail password: ${result.stderr}`)
|
||||
}
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an email address to /etc/dovecot/passwd.
|
||||
* Format: address:hash:::::::
|
||||
*/
|
||||
export async function addMailboxToPasswd(address: string, passwordHash: string): Promise<void> {
|
||||
const entry = `${address}:${passwordHash}:::::::\n`
|
||||
await fs.appendFile(config.paths.dovecotPasswd, entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an email address from /etc/dovecot/passwd.
|
||||
*/
|
||||
export async function removeMailboxFromPasswd(address: string): Promise<void> {
|
||||
await removeLineStartingWith(config.paths.dovecotPasswd, `${address}:`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the password for an existing mailbox in /etc/dovecot/passwd.
|
||||
*/
|
||||
export async function updateMailboxPassword(address: string, newPassword: string): Promise<void> {
|
||||
const hash = await hashMailPassword(newPassword)
|
||||
const passwd = config.paths.dovecotPasswd
|
||||
const content = await fs.readFile(passwd, 'utf8')
|
||||
const updated = content
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
if (line.startsWith(`${address}:`)) {
|
||||
const parts = line.split(':')
|
||||
parts[1] = hash
|
||||
return parts.join(':')
|
||||
}
|
||||
return line
|
||||
})
|
||||
.join('\n')
|
||||
await fs.writeFile(passwd, updated, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a domain is listed in /etc/exim4/virtual_domains.
|
||||
*/
|
||||
export async function addVirtualDomain(domain: string): Promise<void> {
|
||||
const content = await readFileOrEmpty(config.paths.eximVirtualDomains)
|
||||
const domains = content.split('\n').map(l => l.trim()).filter(Boolean)
|
||||
if (!domains.includes(domain)) {
|
||||
await fs.appendFile(config.paths.eximVirtualDomains, `${domain}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a domain from /etc/exim4/virtual_domains if no mailboxes remain.
|
||||
* The caller is responsible for checking whether mailboxes exist before calling.
|
||||
*/
|
||||
export async function removeVirtualDomain(domain: string): Promise<void> {
|
||||
await removeLineStartingWith(config.paths.eximVirtualDomains, domain)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a mailbox entry to /etc/exim4/virtual_mailboxes.
|
||||
* Format: address@domain domain/address/Maildir/
|
||||
*/
|
||||
export async function addMailboxToExim(address: string, maildirPath: string): Promise<void> {
|
||||
const entry = `${address} ${maildirPath}/\n`
|
||||
await fs.appendFile(config.paths.eximVirtualMailboxes, entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a mailbox entry from /etc/exim4/virtual_mailboxes.
|
||||
*/
|
||||
export async function removeMailboxFromExim(address: string): Promise<void> {
|
||||
await removeLineStartingWith(config.paths.eximVirtualMailboxes, `${address} `)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a forwarder to /etc/exim4/virtual_aliases.
|
||||
* Format: source@domain destination@domain
|
||||
*/
|
||||
export async function addForwarder(source: string, destination: string): Promise<void> {
|
||||
const entry = `${source} ${destination}\n`
|
||||
await fs.appendFile(config.paths.eximVirtualAliases, entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific forwarder from /etc/exim4/virtual_aliases.
|
||||
*/
|
||||
export async function removeForwarder(source: string): Promise<void> {
|
||||
await removeLineStartingWith(config.paths.eximVirtualAliases, `${source} `)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Maildir directory structure for a mailbox.
|
||||
* Path: /home/{username}/mail/{domain}/{localpart}/
|
||||
*/
|
||||
export async function createMaildir(username: string, domain: string, localpart: string): Promise<string> {
|
||||
const maildirBase = path.join(config.paths.homeDir, username, 'mail', domain, localpart)
|
||||
await fs.mkdir(path.join(maildirBase, 'new'), { recursive: true })
|
||||
await fs.mkdir(path.join(maildirBase, 'cur'), { recursive: true })
|
||||
await fs.mkdir(path.join(maildirBase, 'tmp'), { recursive: true })
|
||||
await exec('chown', ['-R', `${username}:${username}`, path.join(config.paths.homeDir, username, 'mail')])
|
||||
await exec('chmod', ['700', path.join(config.paths.homeDir, username, 'mail')])
|
||||
return maildirBase
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the Maildir for a mailbox.
|
||||
*/
|
||||
export async function removeMaildir(username: string, domain: string, localpart: string): Promise<void> {
|
||||
const maildirBase = path.join(config.paths.homeDir, username, 'mail', domain, localpart)
|
||||
await fs.rm(maildirBase, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Full lifecycle: create a mailbox including Exim entry, Dovecot passwd entry,
|
||||
* and Maildir on disk. Reloads both Exim and Dovecot.
|
||||
*/
|
||||
export async function createMailbox(
|
||||
username: string,
|
||||
address: string,
|
||||
password: string
|
||||
): Promise<void> {
|
||||
const [localpart, domain] = address.split('@')
|
||||
if (!localpart || !domain) throw new Error(`Invalid email address: ${address}`)
|
||||
|
||||
const hash = await hashMailPassword(password)
|
||||
const maildirPath = await createMaildir(username, domain, localpart)
|
||||
|
||||
await addVirtualDomain(domain)
|
||||
await addMailboxToExim(address, maildirPath)
|
||||
await addMailboxToPasswd(address, hash)
|
||||
|
||||
await reloadMailServices()
|
||||
}
|
||||
|
||||
/**
|
||||
* Full lifecycle: remove a mailbox. Does NOT remove the virtual domain —
|
||||
* caller should check if other mailboxes exist for the domain first.
|
||||
*/
|
||||
export async function removeMailbox(
|
||||
username: string,
|
||||
address: string,
|
||||
deleteMaildir = false
|
||||
): Promise<void> {
|
||||
const [localpart, domain] = address.split('@')
|
||||
if (!localpart || !domain) throw new Error(`Invalid email address: ${address}`)
|
||||
|
||||
await removeMailboxFromExim(address)
|
||||
await removeMailboxFromPasswd(address)
|
||||
if (deleteMaildir) {
|
||||
await removeMaildir(username, domain, localpart)
|
||||
}
|
||||
|
||||
await reloadMailServices()
|
||||
}
|
||||
|
||||
async function reloadMailServices(): Promise<void> {
|
||||
await exec('systemctl', ['reload', 'exim4'])
|
||||
await exec('systemctl', ['reload', 'dovecot'])
|
||||
}
|
||||
|
||||
async function readFileOrEmpty(filePath: string): Promise<string> {
|
||||
try {
|
||||
return await fs.readFile(filePath, 'utf8')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLineStartingWith(filePath: string, prefix: string): Promise<void> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf8')
|
||||
const cleaned = content
|
||||
.split('\n')
|
||||
.filter(line => !line.startsWith(prefix))
|
||||
.join('\n')
|
||||
await fs.writeFile(filePath, cleaned, 'utf8')
|
||||
} catch {
|
||||
// best effort — file may not exist
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import mysql from 'mysql2/promise'
|
||||
import { config } from '../config.js'
|
||||
|
||||
let pool: mysql.Pool | null = null
|
||||
|
||||
function getPool(): mysql.Pool {
|
||||
if (!pool) {
|
||||
pool = mysql.createPool({
|
||||
host: config.mariadb.host,
|
||||
port: config.mariadb.port,
|
||||
user: config.mariadb.user,
|
||||
password: config.mariadb.password,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 5,
|
||||
multipleStatements: false, // Never allow multiple statements
|
||||
})
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a hosting account database.
|
||||
*/
|
||||
export async function createDatabase(
|
||||
dbName: string,
|
||||
charset = 'utf8mb4',
|
||||
collation = 'utf8mb4_unicode_ci'
|
||||
): Promise<void> {
|
||||
validateIdentifier(dbName)
|
||||
validateIdentifier(charset)
|
||||
validateIdentifier(collation)
|
||||
const conn = await getPool().getConnection()
|
||||
try {
|
||||
await conn.query(
|
||||
`CREATE DATABASE IF NOT EXISTS \`${dbName}\` CHARACTER SET ${charset} COLLATE ${collation}`
|
||||
)
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a hosting account database.
|
||||
*/
|
||||
export async function dropDatabase(dbName: string): Promise<void> {
|
||||
validateIdentifier(dbName)
|
||||
const conn = await getPool().getConnection()
|
||||
try {
|
||||
await conn.query(`DROP DATABASE IF EXISTS \`${dbName}\``)
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a MariaDB user for a hosting account database.
|
||||
*/
|
||||
export async function createDatabaseUser(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<void> {
|
||||
validateIdentifier(username)
|
||||
const conn = await getPool().getConnection()
|
||||
try {
|
||||
await conn.execute(
|
||||
`CREATE USER IF NOT EXISTS ?@'localhost' IDENTIFIED BY ?`,
|
||||
[username, password]
|
||||
)
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant privileges on a database to a user.
|
||||
* privileges must be a validated string like 'ALL' or 'SELECT,INSERT,UPDATE,DELETE'.
|
||||
*/
|
||||
export async function grantPrivileges(
|
||||
dbName: string,
|
||||
username: string,
|
||||
privileges: string
|
||||
): Promise<void> {
|
||||
validateIdentifier(dbName)
|
||||
validateIdentifier(username)
|
||||
const safePrivileges = validatePrivileges(privileges)
|
||||
const conn = await getPool().getConnection()
|
||||
try {
|
||||
await conn.query(
|
||||
`GRANT ${safePrivileges} ON \`${dbName}\`.* TO ?@'localhost'`,
|
||||
[username]
|
||||
)
|
||||
await conn.query('FLUSH PRIVILEGES')
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a MariaDB user.
|
||||
*/
|
||||
export async function dropDatabaseUser(username: string): Promise<void> {
|
||||
validateIdentifier(username)
|
||||
const conn = await getPool().getConnection()
|
||||
try {
|
||||
await conn.execute(`DROP USER IF EXISTS ?@'localhost'`, [username])
|
||||
await conn.query('FLUSH PRIVILEGES')
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of a database in MB.
|
||||
*/
|
||||
export async function getDatabaseSizeMb(dbName: string): Promise<number> {
|
||||
validateIdentifier(dbName)
|
||||
const conn = await getPool().getConnection()
|
||||
try {
|
||||
const [rows] = await conn.execute<mysql.RowDataPacket[]>(
|
||||
`SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS size_mb
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = ?`,
|
||||
[dbName]
|
||||
)
|
||||
return rows[0]?.size_mb ?? 0
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
export async function closePool(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.end()
|
||||
pool = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a database/user identifier only contains safe characters.
|
||||
* Prevents identifier injection even when backtick-quoting.
|
||||
*/
|
||||
function validateIdentifier(value: string): void {
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(value)) {
|
||||
throw new Error(`Invalid identifier: ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a privileges string against an allowlist.
|
||||
*/
|
||||
function validatePrivileges(privileges: string): string {
|
||||
const allowed = new Set([
|
||||
'ALL', 'SELECT', 'INSERT', 'UPDATE', 'DELETE',
|
||||
'CREATE', 'DROP', 'INDEX', 'ALTER', 'REFERENCES',
|
||||
])
|
||||
const parts = privileges.toUpperCase().split(',').map(p => p.trim())
|
||||
for (const part of parts) {
|
||||
if (!allowed.has(part)) {
|
||||
throw new Error(`Invalid privilege: ${part}`)
|
||||
}
|
||||
}
|
||||
return parts.join(', ')
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import prisma from '../db/client.js'
|
||||
import { exec } from './exec.js'
|
||||
import { getServiceStatus } from './system.js'
|
||||
|
||||
interface MonitoredService {
|
||||
name: string
|
||||
restartOnFail: boolean
|
||||
maxRestarts: number
|
||||
restartWindowMinutes: number
|
||||
}
|
||||
|
||||
const MONITORED: MonitoredService[] = [
|
||||
{ name: 'apache2', restartOnFail: true, maxRestarts: 3, restartWindowMinutes: 10 },
|
||||
{ name: 'php8.2-fpm', restartOnFail: true, maxRestarts: 3, restartWindowMinutes: 10 },
|
||||
{ name: 'php8.3-fpm', restartOnFail: true, maxRestarts: 3, restartWindowMinutes: 10 },
|
||||
{ name: 'exim4', restartOnFail: true, maxRestarts: 3, restartWindowMinutes: 10 },
|
||||
{ name: 'dovecot', restartOnFail: true, maxRestarts: 3, restartWindowMinutes: 10 },
|
||||
{ name: 'pdns', restartOnFail: true, maxRestarts: 3, restartWindowMinutes: 10 },
|
||||
{ name: 'mariadb', restartOnFail: true, maxRestarts: 2, restartWindowMinutes: 10 },
|
||||
{ name: 'fail2ban', restartOnFail: true, maxRestarts: 2, restartWindowMinutes: 30 },
|
||||
]
|
||||
|
||||
// In-memory restart history: service name → timestamps of recent restarts
|
||||
const restartHistory = new Map<string, Date[]>()
|
||||
|
||||
export async function checkAll(): Promise<void> {
|
||||
for (const svc of MONITORED) {
|
||||
try {
|
||||
await checkService(svc)
|
||||
} catch (err) {
|
||||
// Don't let one service check failure stop the rest
|
||||
console.error(`[monitor] Error checking ${svc.name}:`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkService(svc: MonitoredService): Promise<void> {
|
||||
const status = await getServiceStatus(svc.name)
|
||||
|
||||
if (status.status !== 'failed') return
|
||||
|
||||
if (!svc.restartOnFail) {
|
||||
await logEvent('service_failed', `Service ${svc.name} is in failed state`, 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
const recentRestarts = getRecentRestartCount(svc.name, svc.restartWindowMinutes)
|
||||
|
||||
if (recentRestarts >= svc.maxRestarts) {
|
||||
await logEvent(
|
||||
'service_restart_loop',
|
||||
`Service ${svc.name} failed ${recentRestarts} times in ${svc.restartWindowMinutes} minutes — not restarting`,
|
||||
'critical'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await exec('systemctl', ['restart', svc.name])
|
||||
recordRestart(svc.name)
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
await logEvent(
|
||||
'service_restart_failed',
|
||||
`Failed to restart ${svc.name}: ${result.stderr}`,
|
||||
'critical'
|
||||
)
|
||||
} else {
|
||||
await logEvent(
|
||||
'service_restarted',
|
||||
`Service ${svc.name} was in failed state — automatically restarted`,
|
||||
'warning'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getRecentRestartCount(serviceName: string, windowMinutes: number): number {
|
||||
const history = restartHistory.get(serviceName) ?? []
|
||||
const cutoff = new Date(Date.now() - windowMinutes * 60 * 1000)
|
||||
const recent = history.filter(ts => ts > cutoff)
|
||||
restartHistory.set(serviceName, recent)
|
||||
return recent.length
|
||||
}
|
||||
|
||||
function recordRestart(serviceName: string): void {
|
||||
const history = restartHistory.get(serviceName) ?? []
|
||||
history.push(new Date())
|
||||
restartHistory.set(serviceName, history)
|
||||
}
|
||||
|
||||
async function logEvent(type: string, message: string, severity: 'info' | 'warning' | 'critical'): Promise<void> {
|
||||
try {
|
||||
await prisma.securityEvent.create({ data: { type, message, severity } })
|
||||
} catch {
|
||||
// Don't let a DB error crash the monitor
|
||||
console.error(`[monitor] Failed to log event: ${type} — ${message}`)
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import path from 'path'
|
||||
import { config } from '../config.js'
|
||||
import { exec, writeFile } from './exec.js'
|
||||
|
||||
export interface VhostOptions {
|
||||
domain: string
|
||||
username: string
|
||||
documentRoot: string
|
||||
phpVersion?: string
|
||||
sslEnabled?: boolean
|
||||
certPath?: string
|
||||
keyPath?: string
|
||||
chainPath?: string
|
||||
aliases?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an nginx vhost config for a hosting account.
|
||||
*/
|
||||
export function generateVhost(opts: VhostOptions): string {
|
||||
const {
|
||||
domain,
|
||||
username,
|
||||
documentRoot,
|
||||
phpVersion = '8.3',
|
||||
sslEnabled = false,
|
||||
certPath,
|
||||
keyPath,
|
||||
chainPath,
|
||||
aliases = [],
|
||||
} = opts
|
||||
|
||||
const phpSocket = `/run/php/php${phpVersion}-fpm-${username}.sock`
|
||||
const serverName = [domain, ...aliases].join(' ')
|
||||
|
||||
const sslBlock = sslEnabled && certPath && keyPath
|
||||
? `
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
|
||||
ssl_certificate ${certPath};
|
||||
ssl_certificate_key ${keyPath};
|
||||
${chainPath ? `ssl_trusted_certificate ${chainPath};` : ''}
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
`
|
||||
: `
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
`
|
||||
|
||||
const redirectBlock = sslEnabled
|
||||
? `
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ${serverName};
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
`
|
||||
: ''
|
||||
|
||||
return `${redirectBlock}
|
||||
server {
|
||||
${sslBlock.trim()}
|
||||
server_name ${serverName};
|
||||
|
||||
root ${documentRoot};
|
||||
index index.php index.html index.htm;
|
||||
|
||||
access_log /var/log/nginx/${username}.access.log;
|
||||
error_log /var/log/nginx/${username}.error.log;
|
||||
|
||||
client_max_body_size 64M;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \\.php$ {
|
||||
include snippets/fastcgi-php.conf;
|
||||
fastcgi_pass unix:${phpSocket};
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
|
||||
location ~ /\\.ht {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location ~ /\\.git {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# Let's Encrypt webroot
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/letsencrypt;
|
||||
}
|
||||
}
|
||||
`.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an nginx vhost to disk and enable it.
|
||||
*/
|
||||
export async function enableVhost(opts: VhostOptions): Promise<void> {
|
||||
const confPath = path.join(config.paths.nginxSites, `${opts.domain}.conf`)
|
||||
const enabledPath = path.join(config.paths.nginxEnabled, `${opts.domain}.conf`)
|
||||
|
||||
await writeFile(confPath, generateVhost(opts))
|
||||
|
||||
// Symlink to sites-enabled
|
||||
await exec('ln', ['-sf', confPath, enabledPath])
|
||||
|
||||
// Test and reload
|
||||
const test = await exec('nginx', ['-t'])
|
||||
if (test.exitCode !== 0) {
|
||||
throw new Error(`nginx config test failed: ${test.stderr}`)
|
||||
}
|
||||
|
||||
await exec('systemctl', ['reload', 'nginx'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a vhost config and reload nginx.
|
||||
*/
|
||||
export async function disableVhost(domain: string): Promise<void> {
|
||||
const confPath = path.join(config.paths.nginxSites, `${domain}.conf`)
|
||||
const enabledPath = path.join(config.paths.nginxEnabled, `${domain}.conf`)
|
||||
|
||||
await exec('rm', ['-f', enabledPath])
|
||||
await exec('rm', ['-f', confPath])
|
||||
await exec('systemctl', ['reload', 'nginx'])
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import prisma from '../db/client.js'
|
||||
import { exec } from './exec.js'
|
||||
import { config } from '../config.js'
|
||||
|
||||
const RENEW_DAYS_BEFORE_EXPIRY = 30
|
||||
|
||||
/**
|
||||
* Issue a Let's Encrypt certificate for a domain via certbot Apache plugin.
|
||||
*/
|
||||
export async function issueCertificate(domain: string, email: string): Promise<void> {
|
||||
const result = await exec('certbot', [
|
||||
'certonly',
|
||||
'--apache',
|
||||
'--non-interactive',
|
||||
'--agree-tos',
|
||||
'--email', email,
|
||||
'-d', domain,
|
||||
'-d', `www.${domain}`,
|
||||
], { timeout: 120_000 })
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`certbot failed for ${domain}: ${result.stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renew a specific certificate.
|
||||
*/
|
||||
export async function renewCertificate(domain: string): Promise<void> {
|
||||
const result = await exec('certbot', [
|
||||
'renew',
|
||||
'--cert-name', domain,
|
||||
'--non-interactive',
|
||||
'--quiet',
|
||||
], { timeout: 120_000 })
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`certbot renew failed for ${domain}: ${result.stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke and delete a certificate.
|
||||
*/
|
||||
export async function revokeCertificate(domain: string): Promise<void> {
|
||||
await exec('certbot', ['delete', '--cert-name', domain, '--non-interactive'], { timeout: 60_000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all certificates expiring within RENEW_DAYS_BEFORE_EXPIRY days
|
||||
* and attempt renewal. Called by the daily scheduler cron.
|
||||
*/
|
||||
export async function renewExpiring(): Promise<void> {
|
||||
const cutoff = new Date()
|
||||
cutoff.setDate(cutoff.getDate() + RENEW_DAYS_BEFORE_EXPIRY)
|
||||
|
||||
const expiring = await prisma.sslCertificate.findMany({
|
||||
where: {
|
||||
autoRenew: true,
|
||||
status: 'active',
|
||||
expiresAt: { lte: cutoff },
|
||||
},
|
||||
})
|
||||
|
||||
for (const cert of expiring) {
|
||||
try {
|
||||
await renewCertificate(cert.domain)
|
||||
await prisma.sslCertificate.update({
|
||||
where: { id: cert.id },
|
||||
data: {
|
||||
expiresAt: newExpiryFromNow(),
|
||||
status: 'active',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`[ssl] Failed to renew cert for ${cert.domain}:`, err)
|
||||
await prisma.securityEvent.create({
|
||||
data: {
|
||||
type: 'ssl_renewal_failed',
|
||||
severity: 'warning',
|
||||
message: `SSL renewal failed for ${cert.domain}: ${String(err)}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cert/key paths for a domain from the letsencrypt live directory.
|
||||
*/
|
||||
export function getCertPaths(domain: string): { certPath: string; keyPath: string; chainPath: string } {
|
||||
const base = `${config.paths.letsencryptLive}/${domain}`
|
||||
return {
|
||||
certPath: `${base}/fullchain.pem`,
|
||||
keyPath: `${base}/privkey.pem`,
|
||||
chainPath: `${base}/chain.pem`,
|
||||
}
|
||||
}
|
||||
|
||||
function newExpiryFromNow(): Date {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + 90) // Let's Encrypt issues 90-day certs
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs/promises'
|
||||
import prisma from '../db/client.js'
|
||||
import { exec } from './exec.js'
|
||||
import { config } from '../config.js'
|
||||
|
||||
/**
|
||||
* Collect disk usage for all active accounts and update the DB.
|
||||
* Called every 15 minutes by the scheduler.
|
||||
*/
|
||||
export async function collectDiskUsage(): Promise<void> {
|
||||
const accounts = await prisma.account.findMany({
|
||||
where: { status: { in: ['active', 'suspended'] } },
|
||||
select: { id: true, username: true, homedir: true },
|
||||
})
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
const usedMb = await getDirSizeMb(account.homedir)
|
||||
await prisma.account.update({
|
||||
where: { id: account.id },
|
||||
data: { diskUsedMb: BigInt(Math.round(usedMb)) },
|
||||
})
|
||||
} catch {
|
||||
// Non-fatal — homedir may not exist yet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset bandwidth counters for all accounts on the 1st of each month.
|
||||
* Called by the scheduler monthly cron.
|
||||
*/
|
||||
export async function resetMonthlyBandwidth(): Promise<void> {
|
||||
await prisma.account.updateMany({
|
||||
where: { status: { in: ['active', 'suspended'] } },
|
||||
data: { bandwidthUsedMb: BigInt(0) },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get disk usage of a directory in MB using `du`.
|
||||
*/
|
||||
async function getDirSizeMb(dirPath: string): Promise<number> {
|
||||
// du is not in exec allowlist — read from /proc or use stat directly
|
||||
// Use fs.stat recursively for safety, or fall back to parsing /proc
|
||||
try {
|
||||
const result = await exec('df', ['-BM', '--output=used', dirPath])
|
||||
// df output: header line + value line like "123M"
|
||||
const lines = result.stdout.split('\n').filter(Boolean)
|
||||
const valueStr = lines[lines.length - 1]?.replace('M', '').trim() ?? '0'
|
||||
return parseInt(valueStr, 10) || 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get per-account Apache access log size as a bandwidth proxy.
|
||||
* This is a rough estimate — real bandwidth tracking requires log parsing.
|
||||
*/
|
||||
export async function getAccessLogSizeMb(username: string): Promise<number> {
|
||||
const logPath = `/var/log/apache2/${username}-access.log`
|
||||
try {
|
||||
const stat = await fs.stat(logPath)
|
||||
return Math.round(stat.size / 1024 / 1024)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,13 @@ export async function getServiceStatus(name: string): Promise<ServiceStatus> {
|
||||
}
|
||||
|
||||
export const MANAGED_SERVICES = [
|
||||
'nginx', 'apache2', 'mysql', 'mariadb',
|
||||
'postfix', 'dovecot', 'vsftpd', 'bind9',
|
||||
'fail2ban', 'ufw', 'cron', 'ssh',
|
||||
'apache2',
|
||||
'php8.2-fpm',
|
||||
'php8.3-fpm',
|
||||
'exim4',
|
||||
'dovecot',
|
||||
'pdns',
|
||||
'mariadb',
|
||||
'fail2ban',
|
||||
'ssh',
|
||||
]
|
||||
|
||||
@@ -73,10 +73,10 @@ async function main() {
|
||||
// Default settings
|
||||
const settings = [
|
||||
{ key: 'php_default_version', value: '8.3' },
|
||||
{ key: 'mail_server', value: 'postfix' },
|
||||
{ key: 'ftp_server', value: 'vsftpd' },
|
||||
{ key: 'webserver', value: 'nginx' },
|
||||
{ key: 'dns_server', value: 'bind9' },
|
||||
{ key: 'mail_server', value: 'exim4' },
|
||||
{ key: 'ftp_server', value: 'disabled' },
|
||||
{ key: 'webserver', value: 'apache2' },
|
||||
{ key: 'dns_server', value: 'powerdns' },
|
||||
{ key: 'ssl_provider', value: 'letsencrypt' },
|
||||
{ key: 'backup_enabled', value: 'true' },
|
||||
{ key: 'backup_retention_days', value: '7' },
|
||||
|
||||
+9
-9
@@ -2,32 +2,32 @@ import 'dotenv/config'
|
||||
import { buildApp } from './app.js'
|
||||
import { config } from './config.js'
|
||||
import prisma from './db/client.js'
|
||||
import { startScheduler, stopScheduler } from './scheduler.js'
|
||||
|
||||
async function start() {
|
||||
let app
|
||||
|
||||
try {
|
||||
// Verify DB connection
|
||||
await prisma.$connect()
|
||||
console.log('Database connected')
|
||||
console.log('[nixpanel] Database connected')
|
||||
|
||||
app = await buildApp()
|
||||
|
||||
// NixServer on port 2087
|
||||
await app.listen({ port: config.server.nixserverPort, host: config.server.host })
|
||||
console.log(`NixServer running on port ${config.server.nixserverPort}`)
|
||||
console.log(`NixClient API running on port ${config.server.nixserverPort} (/api/cpanel)`)
|
||||
await app.listen({ port: config.server.port, host: config.server.host })
|
||||
console.log(`[nixpanel] API running on ${config.server.host}:${config.server.port}`)
|
||||
|
||||
startScheduler()
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to start server:', err)
|
||||
console.error('[nixpanel] Failed to start:', err)
|
||||
await prisma.$disconnect()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async (signal: string) => {
|
||||
console.log(`${signal} received, shutting down...`)
|
||||
console.log(`[nixpanel] ${signal} received, shutting down...`)
|
||||
await app?.close()
|
||||
await stopScheduler()
|
||||
await prisma.$disconnect()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ function signTokens(
|
||||
|
||||
const refreshToken = fastify.jwt.sign(
|
||||
{ ...payload, type: 'refresh' },
|
||||
{ secret: config.jwt.refreshSecret, expiresIn: config.jwt.refreshExpiresIn }
|
||||
{ expiresIn: config.jwt.refreshExpiresIn }
|
||||
)
|
||||
|
||||
return { accessToken, refreshToken }
|
||||
@@ -157,8 +157,7 @@ export default async function authRoutes(fastify: FastifyInstance) {
|
||||
let payload: JwtPayload & { type?: string }
|
||||
try {
|
||||
payload = fastify.jwt.verify<JwtPayload & { type?: string }>(
|
||||
body.data.refreshToken,
|
||||
{ secret: config.jwt.refreshSecret }
|
||||
body.data.refreshToken
|
||||
)
|
||||
} catch {
|
||||
return reply.code(401).send({ success: false, error: 'Invalid or expired refresh token' })
|
||||
@@ -183,20 +182,20 @@ export default async function authRoutes(fastify: FastifyInstance) {
|
||||
const accessToken = fastify.jwt.sign(newPayload, { expiresIn: config.jwt.expiresIn })
|
||||
const newRefresh = fastify.jwt.sign(
|
||||
{ ...newPayload, type: 'refresh' },
|
||||
{ secret: config.jwt.refreshSecret, expiresIn: config.jwt.refreshExpiresIn }
|
||||
{ expiresIn: config.jwt.refreshExpiresIn }
|
||||
)
|
||||
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 7)
|
||||
|
||||
const createData: Parameters<typeof prisma.refreshToken.create>[0]['data'] = {
|
||||
const createData = {
|
||||
token: newRefresh,
|
||||
role: payload.role,
|
||||
expiresAt,
|
||||
adminId: stored.adminId ?? undefined,
|
||||
resellerId: stored.resellerId ?? undefined,
|
||||
accountId: stored.accountId ?? undefined,
|
||||
}
|
||||
if (stored.adminId) createData.adminId = stored.adminId
|
||||
if (stored.resellerId) createData.resellerId = stored.resellerId
|
||||
if (stored.accountId) createData.accountId = stored.accountId
|
||||
|
||||
await prisma.refreshToken.create({ data: createData })
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ async function writeCrontab(username: string, accountId: number): Promise<void>
|
||||
`# NixPanel crontab for ${username}`,
|
||||
`# DO NOT EDIT — managed by NixPanel`,
|
||||
'',
|
||||
...jobs.map(j => `${j.schedule} ${j.command}${j.comment ? ` # ${j.comment}` : ''}`),
|
||||
...jobs.map((j: { schedule: string; command: string; comment: string | null }) => `${j.schedule} ${j.command}${j.comment ? ` # ${j.comment}` : ''}`),
|
||||
'',
|
||||
]
|
||||
|
||||
|
||||
@@ -3,11 +3,15 @@ import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
import {
|
||||
createDatabase,
|
||||
dropDatabase,
|
||||
createDatabaseUser,
|
||||
grantPrivileges,
|
||||
dropDatabaseUser,
|
||||
} from '../../core/mariadb.js'
|
||||
|
||||
function mysqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "\\'").replace(/\\/g, '\\\\')
|
||||
}
|
||||
const nameSchema = z.string().min(1).max(32).regex(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores allowed')
|
||||
|
||||
export default async function databaseRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
@@ -15,7 +19,9 @@ export default async function databaseRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/nixclient/databases
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
const accountId = user.role === 'user'
|
||||
? user.sub
|
||||
: parseInt((request.query as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const databases = await prisma.database.findMany({
|
||||
where: { accountId },
|
||||
@@ -28,10 +34,12 @@ export default async function databaseRoutes(fastify: FastifyInstance) {
|
||||
// POST /api/nixclient/databases
|
||||
fastify.post('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : parseInt((request.body as Record<string, string>).accountId ?? '0', 10)
|
||||
const accountId = user.role === 'user'
|
||||
? user.sub
|
||||
: parseInt((request.body as Record<string, string>).accountId ?? '0', 10)
|
||||
|
||||
const body = z.object({
|
||||
name: z.string().min(1).max(32).regex(/^[a-zA-Z0-9_]+$/),
|
||||
name: nameSchema,
|
||||
charset: z.string().default('utf8mb4'),
|
||||
collation: z.string().default('utf8mb4_unicode_ci'),
|
||||
}).safeParse(request.body)
|
||||
@@ -46,7 +54,6 @@ export default async function databaseRoutes(fastify: FastifyInstance) {
|
||||
})
|
||||
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
|
||||
|
||||
// Check quota
|
||||
if (account.package && account.package.maxDatabases > 0) {
|
||||
const count = await prisma.database.count({ where: { accountId } })
|
||||
if (count >= account.package.maxDatabases) {
|
||||
@@ -54,19 +61,11 @@ export default async function databaseRoutes(fastify: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
// Prefix database name with username to avoid conflicts
|
||||
const dbName = `${account.username}_${body.data.name}`
|
||||
const exists = await prisma.database.findUnique({ where: { name: dbName } })
|
||||
if (exists) return reply.code(409).send({ success: false, error: 'Database already exists' })
|
||||
|
||||
// Create the actual MariaDB database
|
||||
const result = await exec('mysql', [
|
||||
'-e',
|
||||
`CREATE DATABASE \`${mysqlEscape(dbName)}\` CHARACTER SET ${mysqlEscape(body.data.charset)} COLLATE ${mysqlEscape(body.data.collation)};`,
|
||||
])
|
||||
if (result.exitCode !== 0 && !process.env.NODE_ENV?.startsWith('dev')) {
|
||||
return reply.code(500).send({ success: false, error: `Failed to create database: ${result.stderr}` })
|
||||
}
|
||||
await createDatabase(dbName, body.data.charset, body.data.collation)
|
||||
|
||||
const db = await prisma.database.create({
|
||||
data: { name: dbName, charset: body.data.charset, collation: body.data.collation, accountId },
|
||||
@@ -84,10 +83,15 @@ export default async function databaseRoutes(fastify: FastifyInstance) {
|
||||
id: parseInt(id, 10),
|
||||
...(user.role === 'user' ? { accountId: user.sub } : {}),
|
||||
},
|
||||
include: { dbUsers: true },
|
||||
})
|
||||
if (!db) return reply.code(404).send({ success: false, error: 'Database not found' })
|
||||
|
||||
await exec('mysql', ['-e', `DROP DATABASE IF EXISTS \`${mysqlEscape(db.name)}\`;`])
|
||||
// Drop all associated users first
|
||||
for (const dbUser of db.dbUsers) {
|
||||
await dropDatabaseUser(dbUser.username)
|
||||
}
|
||||
await dropDatabase(db.name)
|
||||
await prisma.database.delete({ where: { id: db.id } })
|
||||
|
||||
return reply.send({ success: true, data: { message: `Database ${db.name} deleted` } })
|
||||
@@ -99,9 +103,9 @@ export default async function databaseRoutes(fastify: FastifyInstance) {
|
||||
const user = request.user!
|
||||
|
||||
const body = z.object({
|
||||
username: z.string().min(1).max(16).regex(/^[a-zA-Z0-9_]+$/),
|
||||
username: nameSchema,
|
||||
password: z.string().min(8),
|
||||
privileges: z.string().default('ALL'),
|
||||
privileges: z.enum(['ALL', 'SELECT', 'SELECT,INSERT,UPDATE,DELETE']).default('ALL'),
|
||||
}).safeParse(request.body)
|
||||
|
||||
if (!body.success) {
|
||||
@@ -118,12 +122,9 @@ export default async function databaseRoutes(fastify: FastifyInstance) {
|
||||
if (!db) return reply.code(404).send({ success: false, error: 'Database not found' })
|
||||
|
||||
const dbUsername = `${db.account.username}_${body.data.username}`
|
||||
const escapedPass = mysqlEscape(body.data.password)
|
||||
|
||||
// Create MariaDB user and grant privileges
|
||||
await exec('mysql', ['-e', `CREATE USER '${mysqlEscape(dbUsername)}'@'localhost' IDENTIFIED BY '${escapedPass}';`])
|
||||
await exec('mysql', ['-e', `GRANT ${mysqlEscape(body.data.privileges)} ON \`${mysqlEscape(db.name)}\`.* TO '${mysqlEscape(dbUsername)}'@'localhost';`])
|
||||
await exec('mysql', ['-e', 'FLUSH PRIVILEGES;'])
|
||||
await createDatabaseUser(dbUsername, body.data.password)
|
||||
await grantPrivileges(db.name, dbUsername, body.data.privileges)
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
const dbUser = await prisma.databaseUser.create({
|
||||
@@ -142,14 +143,18 @@ export default async function databaseRoutes(fastify: FastifyInstance) {
|
||||
// DELETE /api/nixclient/databases/:id/users/:userId
|
||||
fastify.delete('/:id/users/:userId', { preHandler }, async (request, reply) => {
|
||||
const { id, userId } = request.params as { id: string; userId: string }
|
||||
const user = request.user!
|
||||
|
||||
const dbUser = await prisma.databaseUser.findFirst({
|
||||
where: { id: parseInt(userId, 10), databaseId: parseInt(id, 10) },
|
||||
where: {
|
||||
id: parseInt(userId, 10),
|
||||
databaseId: parseInt(id, 10),
|
||||
...(user.role === 'user' ? { database: { accountId: user.sub } } : {}),
|
||||
},
|
||||
})
|
||||
if (!dbUser) return reply.code(404).send({ success: false, error: 'Database user not found' })
|
||||
|
||||
await exec('mysql', ['-e', `DROP USER IF EXISTS '${mysqlEscape(dbUser.username)}'@'localhost';`])
|
||||
await exec('mysql', ['-e', 'FLUSH PRIVILEGES;'])
|
||||
await dropDatabaseUser(dbUser.username)
|
||||
await prisma.databaseUser.delete({ where: { id: dbUser.id } })
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Database user deleted' } })
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { validateDomain, exec } from '../../core/exec.js'
|
||||
import { enableVhost } from '../../core/nginx.js'
|
||||
import { enableVhost, disableVhost } from '../../core/apache.js'
|
||||
|
||||
export default async function domainRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
@@ -48,7 +48,7 @@ export default async function domainRoutes(fastify: FastifyInstance) {
|
||||
await exec('mkdir', ['-p', docRoot])
|
||||
await exec('chown', [`${account.username}:${account.username}`, docRoot])
|
||||
|
||||
// Create nginx vhost for subdomain
|
||||
// Create Apache vhost for subdomain
|
||||
try {
|
||||
await enableVhost({ domain: fqdn, username: account.username, documentRoot: docRoot })
|
||||
} catch (e) {
|
||||
@@ -70,7 +70,7 @@ export default async function domainRoutes(fastify: FastifyInstance) {
|
||||
})
|
||||
if (!subdomain) return reply.code(404).send({ success: false, error: 'Subdomain not found' })
|
||||
|
||||
const { disableVhost } = await import('../../core/nginx.js')
|
||||
// disableVhost already imported at top
|
||||
try { await disableVhost(subdomain.fqdn) } catch {}
|
||||
|
||||
await prisma.subdomain.delete({ where: { id: subdomain.id } })
|
||||
@@ -131,7 +131,7 @@ export default async function domainRoutes(fastify: FastifyInstance) {
|
||||
})
|
||||
if (!addon) return reply.code(404).send({ success: false, error: 'Addon domain not found' })
|
||||
|
||||
const { disableVhost } = await import('../../core/nginx.js')
|
||||
// disableVhost already imported at top
|
||||
try { await disableVhost(addon.domain) } catch {}
|
||||
|
||||
await prisma.addonDomain.delete({ where: { id: addon.id } })
|
||||
@@ -161,7 +161,7 @@ export default async function domainRoutes(fastify: FastifyInstance) {
|
||||
const existing = await prisma.parkedDomain.findUnique({ where: { domain: body.data.domain } })
|
||||
if (existing) return reply.code(409).send({ success: false, error: 'Parked domain already exists' })
|
||||
|
||||
// Parked domains point to main public_html
|
||||
// Parked domains point to main public_html (Apache vhost with alias)
|
||||
try {
|
||||
await enableVhost({
|
||||
domain: body.data.domain,
|
||||
@@ -188,7 +188,7 @@ export default async function domainRoutes(fastify: FastifyInstance) {
|
||||
})
|
||||
if (!parked) return reply.code(404).send({ success: false, error: 'Parked domain not found' })
|
||||
|
||||
const { disableVhost } = await import('../../core/nginx.js')
|
||||
// disableVhost already imported at top
|
||||
try { await disableVhost(parked.domain) } catch {}
|
||||
|
||||
await prisma.parkedDomain.delete({ where: { id: parked.id } })
|
||||
|
||||
@@ -3,9 +3,9 @@ import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
import { createMailbox, removeMailbox, addForwarder, removeForwarder, updateMailboxPassword } from '../../core/mail.js'
|
||||
|
||||
export default async function cpanelEmailRoutes(fastify: FastifyInstance) {
|
||||
export default async function emailRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
// GET /api/nixclient/email
|
||||
@@ -27,7 +27,7 @@ export default async function cpanelEmailRoutes(fastify: FastifyInstance) {
|
||||
const accountId = user.role === 'user' ? user.sub : (request.body as Record<string, unknown>).accountId as number
|
||||
|
||||
const body = z.object({
|
||||
username: z.string().min(1).max(64).regex(/^[a-zA-Z0-9._+-]+$/),
|
||||
username: z.string().min(1).max(64).regex(/^[a-zA-Z0-9._+-]+$/, 'Invalid email username'),
|
||||
password: z.string().min(8),
|
||||
quotaMb: z.number().int().min(0).default(500),
|
||||
}).safeParse(request.body)
|
||||
@@ -50,33 +50,72 @@ export default async function cpanelEmailRoutes(fastify: FastifyInstance) {
|
||||
const exists = await prisma.emailAccount.findUnique({ where: { address } })
|
||||
if (exists) return reply.code(409).send({ success: false, error: 'Email address already exists' })
|
||||
|
||||
await createMailbox(account.username, address, body.data.password)
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
|
||||
await exec('doveadm', ['user', 'import', '-u', address, '-p', body.data.password])
|
||||
|
||||
const emailAccount = await prisma.emailAccount.create({
|
||||
data: { username: body.data.username, domain: account.domain, address, passwordHash, quotaMb: body.data.quotaMb, accountId: account.id },
|
||||
data: {
|
||||
username: body.data.username,
|
||||
domain: account.domain,
|
||||
address,
|
||||
passwordHash,
|
||||
quotaMb: body.data.quotaMb,
|
||||
accountId: account.id,
|
||||
},
|
||||
})
|
||||
|
||||
const { passwordHash: _, ...safe } = emailAccount
|
||||
return reply.code(201).send({ success: true, data: safe })
|
||||
})
|
||||
|
||||
// PUT /api/nixclient/email/:id/password
|
||||
fastify.put('/:id/password', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const { id } = request.params as { id: string }
|
||||
|
||||
const body = z.object({ password: z.string().min(8) }).safeParse(request.body)
|
||||
if (!body.success) return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
|
||||
const emailAccount = await prisma.emailAccount.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
})
|
||||
if (!emailAccount) return reply.code(404).send({ success: false, error: 'Email account not found' })
|
||||
|
||||
await updateMailboxPassword(emailAccount.address, body.data.password)
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12)
|
||||
await prisma.emailAccount.update({
|
||||
where: { id: emailAccount.id },
|
||||
data: { passwordHash },
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Password updated' } })
|
||||
})
|
||||
|
||||
// DELETE /api/nixclient/email/:id
|
||||
fastify.delete('/:id', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : 0
|
||||
const { id } = request.params as { id: string }
|
||||
|
||||
const emailAccount = await prisma.emailAccount.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
const emailAccount = await prisma.emailAccount.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
include: { account: true },
|
||||
})
|
||||
if (!emailAccount) return reply.code(404).send({ success: false, error: 'Email account not found' })
|
||||
if (user.role === 'user' && emailAccount.accountId !== accountId) {
|
||||
return reply.code(403).send({ success: false, error: 'Forbidden' })
|
||||
|
||||
// Check if domain has other mailboxes before potentially removing the domain entry
|
||||
const remaining = await prisma.emailAccount.count({
|
||||
where: { accountId: emailAccount.accountId, domain: emailAccount.domain, id: { not: emailAccount.id } },
|
||||
})
|
||||
|
||||
await removeMailbox(emailAccount.account.username, emailAccount.address, true)
|
||||
|
||||
if (remaining === 0) {
|
||||
const { removeVirtualDomain } = await import('../../core/mail.js')
|
||||
await removeVirtualDomain(emailAccount.domain)
|
||||
}
|
||||
|
||||
await exec('doveadm', ['user', 'delete', '-u', emailAccount.address])
|
||||
await prisma.emailAccount.delete({ where: { id: emailAccount.id } })
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Email account deleted' } })
|
||||
})
|
||||
|
||||
@@ -103,6 +142,13 @@ export default async function cpanelEmailRoutes(fastify: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const existing = await prisma.emailForwarder.findFirst({
|
||||
where: { source: body.data.source, accountId },
|
||||
})
|
||||
if (existing) return reply.code(409).send({ success: false, error: 'Forwarder already exists' })
|
||||
|
||||
await addForwarder(body.data.source, body.data.destination)
|
||||
|
||||
const forwarder = await prisma.emailForwarder.create({ data: { ...body.data, accountId } })
|
||||
return reply.code(201).send({ success: true, data: forwarder })
|
||||
})
|
||||
@@ -110,15 +156,14 @@ export default async function cpanelEmailRoutes(fastify: FastifyInstance) {
|
||||
// DELETE /api/nixclient/email/forwarders/:id
|
||||
fastify.delete('/forwarders/:id', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.role === 'user' ? user.sub : 0
|
||||
const { id } = request.params as { id: string }
|
||||
|
||||
const forwarder = await prisma.emailForwarder.findUnique({ where: { id: parseInt(id, 10) } })
|
||||
const forwarder = await prisma.emailForwarder.findFirst({
|
||||
where: { id: parseInt(id, 10), ...(user.role === 'user' ? { accountId: user.sub } : {}) },
|
||||
})
|
||||
if (!forwarder) return reply.code(404).send({ success: false, error: 'Forwarder not found' })
|
||||
if (user.role === 'user' && forwarder.accountId !== accountId) {
|
||||
return reply.code(403).send({ success: false, error: 'Forbidden' })
|
||||
}
|
||||
|
||||
await removeForwarder(forwarder.source)
|
||||
await prisma.emailForwarder.delete({ where: { id: forwarder.id } })
|
||||
return reply.send({ success: true, data: { message: 'Forwarder deleted' } })
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import domainRoutes from './domains.js'
|
||||
import ftpRoutes from './ftp.js'
|
||||
import cronRoutes from './cron.js'
|
||||
import statsRoutes from './stats.js'
|
||||
import sslRoutes from './ssl.js'
|
||||
|
||||
export default async function nixclientRoutes(fastify: FastifyInstance) {
|
||||
fastify.register(fileRoutes, { prefix: '/files' })
|
||||
@@ -15,4 +16,5 @@ export default async function nixclientRoutes(fastify: FastifyInstance) {
|
||||
fastify.register(ftpRoutes, { prefix: '/ftp' })
|
||||
fastify.register(cronRoutes, { prefix: '/cron' })
|
||||
fastify.register(statsRoutes, { prefix: '/stats' })
|
||||
fastify.register(sslRoutes, { prefix: '/ssl' })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAuth } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
|
||||
export default async function sslRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAuth]
|
||||
|
||||
// GET /api/nixclient/ssl — certs for the authenticated user's account only
|
||||
fastify.get('/', { preHandler }, async (request, reply) => {
|
||||
const user = request.user!
|
||||
const accountId = user.sub
|
||||
|
||||
const certs = await prisma.sslCertificate.findMany({
|
||||
where: { accountId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
return reply.send({ success: true, data: certs })
|
||||
})
|
||||
|
||||
// POST /api/nixclient/ssl/:id/renew
|
||||
fastify.post('/:id/renew', { preHandler }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const user = request.user!
|
||||
|
||||
const cert = await prisma.sslCertificate.findFirst({
|
||||
where: { id: parseInt(id, 10), accountId: user.sub },
|
||||
})
|
||||
if (!cert) return reply.code(404).send({ success: false, error: 'Certificate not found' })
|
||||
|
||||
const result = await exec('certbot', ['renew', '--cert-name', cert.domain, '--non-interactive'])
|
||||
if (result.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: `Renewal failed: ${result.stderr}` })
|
||||
}
|
||||
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + 90)
|
||||
|
||||
const updated = await prisma.sslCertificate.update({
|
||||
where: { id: cert.id },
|
||||
data: { status: 'active', issuedAt: new Date(), expiresAt },
|
||||
})
|
||||
|
||||
return reply.send({ success: true, data: updated })
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,7 @@ 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 } from '../../core/nginx.js'
|
||||
import { enableVhost, disableVhost, suspendVhost, enablePhpFpmPool, disablePhpFpmPool } from '../../core/apache.js'
|
||||
import { createZone, removeZone } from '../../core/dns.js'
|
||||
import { config } from '../../config.js'
|
||||
|
||||
@@ -83,6 +83,218 @@ export default async function accountRoutes(fastify: FastifyInstance) {
|
||||
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 }
|
||||
@@ -139,7 +351,7 @@ export default async function accountRoutes(fastify: FastifyInstance) {
|
||||
|
||||
// Create system user
|
||||
const userAdd = await exec('useradd', [
|
||||
'-m', '-d', homedir, '-s', '/bin/false', username,
|
||||
'-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}` })
|
||||
@@ -202,15 +414,13 @@ export default async function accountRoutes(fastify: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
// Create nginx vhost
|
||||
// Create Apache vhost + PHP-FPM pool
|
||||
const phpVersion = (await prisma.package.findUnique({ where: { id: packageId ?? 0 } }))?.phpVersion ?? '8.3'
|
||||
try {
|
||||
await enableVhost({
|
||||
domain,
|
||||
username,
|
||||
documentRoot: `${homedir}/public_html`,
|
||||
})
|
||||
await enablePhpFpmPool(username, phpVersion)
|
||||
await enableVhost({ domain, username, documentRoot: `${homedir}/public_html`, phpVersion })
|
||||
} catch (e) {
|
||||
console.warn(`Nginx vhost creation failed for ${domain}:`, e)
|
||||
console.warn(`Apache/PHP-FPM setup failed for ${domain}:`, e)
|
||||
}
|
||||
|
||||
// Audit log
|
||||
@@ -272,6 +482,9 @@ export default async function accountRoutes(fastify: FastifyInstance) {
|
||||
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 },
|
||||
@@ -301,6 +514,11 @@ export default async function accountRoutes(fastify: FastifyInstance) {
|
||||
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 },
|
||||
@@ -327,12 +545,20 @@ export default async function accountRoutes(fastify: FastifyInstance) {
|
||||
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 nginx vhost
|
||||
// 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 {
|
||||
@@ -364,6 +590,33 @@ export default async function accountRoutes(fastify: FastifyInstance) {
|
||||
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({
|
||||
|
||||
@@ -11,8 +11,10 @@ import systemRoutes from './system.js'
|
||||
import securityRoutes from './security.js'
|
||||
import backupRoutes from './backup.js'
|
||||
import webserverRoutes from './webserver.js'
|
||||
import setupRoutes from './setup.js'
|
||||
|
||||
export default async function nixserverRoutes(fastify: FastifyInstance) {
|
||||
fastify.register(setupRoutes, { prefix: '/setup' })
|
||||
fastify.register(accountRoutes, { prefix: '/accounts' })
|
||||
fastify.register(packageRoutes, { prefix: '/packages' })
|
||||
fastify.register(resellerRoutes, { prefix: '/resellers' })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import { requireAdmin } from '../../middleware/auth.js'
|
||||
import { exec, MANAGED_SERVICES as _ } from '../../core/exec.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
import { getServiceStatus, MANAGED_SERVICES } from '../../core/system.js'
|
||||
|
||||
const ServiceActionSchema = z.object({
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdmin } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
|
||||
const SetupSchema = z.object({
|
||||
hostname: z.string().min(1).max(253),
|
||||
nameserver1: z.string().min(1).max(253),
|
||||
nameserver2: z.string().min(1).max(253),
|
||||
adminEmail: z.string().email(),
|
||||
adminPassword: z.string().min(8).optional(),
|
||||
confirmPassword: z.string().optional(),
|
||||
}).refine(data => {
|
||||
if (data.adminPassword && data.adminPassword !== data.confirmPassword) return false
|
||||
return true
|
||||
}, { message: 'Passwords do not match', path: ['confirmPassword'] })
|
||||
|
||||
export default async function setupRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/nixserver/setup/status — check if first-run setup has been completed
|
||||
fastify.get('/status', { preHandler: [requireAdmin] }, async (_request, reply) => {
|
||||
const setting = await prisma.setting.findUnique({ where: { key: 'setup_complete' } })
|
||||
return reply.send({ success: true, data: { setupComplete: setting?.value === 'true' } })
|
||||
})
|
||||
|
||||
// POST /api/nixserver/setup/complete — save setup values and mark as done
|
||||
fastify.post('/complete', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const body = SetupSchema.safeParse(request.body)
|
||||
if (!body.success) {
|
||||
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
|
||||
}
|
||||
|
||||
const { hostname, nameserver1, nameserver2, adminEmail, adminPassword } = body.data
|
||||
const user = request.user!
|
||||
|
||||
// Persist settings
|
||||
const settings = [
|
||||
{ key: 'hostname', value: hostname },
|
||||
{ key: 'nameserver1', value: nameserver1 },
|
||||
{ key: 'nameserver2', value: nameserver2 },
|
||||
{ key: 'setup_complete', value: 'true' },
|
||||
]
|
||||
|
||||
for (const { key, value } of settings) {
|
||||
await prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value },
|
||||
})
|
||||
}
|
||||
|
||||
// Update admin email
|
||||
await prisma.adminUser.update({
|
||||
where: { id: user.sub },
|
||||
data: { email: adminEmail },
|
||||
})
|
||||
|
||||
// Update admin password if provided
|
||||
if (adminPassword) {
|
||||
const hash = await bcrypt.hash(adminPassword, 12)
|
||||
await prisma.adminUser.update({
|
||||
where: { id: user.sub },
|
||||
data: { passwordHash: hash },
|
||||
})
|
||||
}
|
||||
|
||||
// Apply hostname to the system (non-fatal if it fails)
|
||||
await exec('hostnamectl', ['set-hostname', hostname]).catch(() => {})
|
||||
|
||||
// Log the action
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
adminId: user.sub,
|
||||
action: 'setup.complete',
|
||||
target: 'system',
|
||||
details: `First-run setup completed. Hostname: ${hostname}`,
|
||||
},
|
||||
}).catch(() => {})
|
||||
|
||||
return reply.send({ success: true, data: { message: 'Setup complete' } })
|
||||
})
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import path from 'path'
|
||||
import prisma from '../../db/client.js'
|
||||
import { requireAdminOrReseller } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
import { enableVhost } from '../../core/nginx.js'
|
||||
import { config } from '../../config.js'
|
||||
import { enableVhost } from '../../core/apache.js'
|
||||
import { getCertPaths } from '../../core/ssl.js'
|
||||
|
||||
export default async function sslRoutes(fastify: FastifyInstance) {
|
||||
const preHandler = [requireAdminOrReseller]
|
||||
@@ -47,23 +46,19 @@ export default async function sslRoutes(fastify: FastifyInstance) {
|
||||
if (includeWww) domains.push(`www.${domain}`)
|
||||
|
||||
const certbotArgs = [
|
||||
'certonly', '--webroot',
|
||||
'-w', config.paths.certbotWebroot,
|
||||
'certonly', '--apache',
|
||||
'--non-interactive',
|
||||
'--agree-tos',
|
||||
'--email', account.email,
|
||||
...domains.flatMap(d => ['-d', d]),
|
||||
]
|
||||
|
||||
const result = await exec('certbot', certbotArgs)
|
||||
const result = await exec('certbot', certbotArgs, { timeout: 120_000 })
|
||||
if (result.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: `certbot failed: ${result.stderr}` })
|
||||
}
|
||||
|
||||
const certDir = `/etc/letsencrypt/live/${domain}`
|
||||
const certPath = `${certDir}/fullchain.pem`
|
||||
const keyPath = `${certDir}/privkey.pem`
|
||||
const chainPath = `${certDir}/chain.pem`
|
||||
const { certPath, keyPath, chainPath } = getCertPaths(domain)
|
||||
|
||||
const issuedAt = new Date()
|
||||
const expiresAt = new Date()
|
||||
@@ -82,7 +77,7 @@ export default async function sslRoutes(fastify: FastifyInstance) {
|
||||
})
|
||||
}
|
||||
|
||||
// Update nginx vhost with SSL
|
||||
// Regenerate Apache vhost with SSL enabled
|
||||
try {
|
||||
await enableVhost({
|
||||
domain,
|
||||
@@ -91,10 +86,9 @@ export default async function sslRoutes(fastify: FastifyInstance) {
|
||||
sslEnabled: true,
|
||||
certPath,
|
||||
keyPath,
|
||||
chainPath,
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn(`Nginx SSL update failed for ${domain}:`, e)
|
||||
console.warn(`Apache SSL vhost update failed for ${domain}:`, e)
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: cert })
|
||||
|
||||
@@ -1,34 +1,42 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import { requireAdmin } from '../../middleware/auth.js'
|
||||
import { exec } from '../../core/exec.js'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { readdir } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
export default async function webserverRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/nixserver/webserver/status
|
||||
fastify.get('/status', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const [nginx, apache] = await Promise.all([
|
||||
exec('systemctl', ['is-active', 'nginx']),
|
||||
const [apache, fpm82, fpm83] = await Promise.all([
|
||||
exec('systemctl', ['is-active', 'apache2']),
|
||||
exec('systemctl', ['is-active', 'php8.2-fpm']),
|
||||
exec('systemctl', ['is-active', 'php8.3-fpm']),
|
||||
])
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
nginx: { active: nginx.stdout.trim() === 'active' },
|
||||
apache: { active: apache.stdout.trim() === 'active' },
|
||||
php82fpm: { active: fpm82.stdout.trim() === 'active' },
|
||||
php83fpm: { active: fpm83.stdout.trim() === 'active' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/nixserver/webserver/vhosts
|
||||
fastify.get('/vhosts', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const result = await exec('find', ['/etc/nginx/sites-enabled', '-name', '*.conf', '-type', 'f'])
|
||||
const files = result.stdout.split('\n').filter(Boolean)
|
||||
const vhosts = files.map(f => ({
|
||||
path: f,
|
||||
domain: f.split('/').pop()?.replace('.conf', '') ?? '',
|
||||
}))
|
||||
return reply.send({ success: true, data: vhosts })
|
||||
try {
|
||||
const files = await readdir('/etc/apache2/sites-enabled')
|
||||
const vhosts = files
|
||||
.filter(f => f.endsWith('.conf'))
|
||||
.map(f => ({
|
||||
path: `/etc/apache2/sites-enabled/${f}`,
|
||||
domain: f.replace('.conf', ''),
|
||||
}))
|
||||
return reply.send({ success: true, data: vhosts })
|
||||
} catch {
|
||||
return reply.send({ success: true, data: [] })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/nixserver/webserver/vhosts/:domain
|
||||
@@ -38,36 +46,60 @@ export default async function webserverRoutes(fastify: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid domain' })
|
||||
}
|
||||
try {
|
||||
const content = await readFile(`/etc/nginx/sites-available/${domain}.conf`, 'utf8')
|
||||
const content = await readFile(`/etc/apache2/sites-available/${domain}.conf`, 'utf8')
|
||||
return reply.send({ success: true, data: { domain, config: content } })
|
||||
} catch {
|
||||
return reply.code(404).send({ success: false, error: 'Vhost not found' })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/nixserver/webserver/nginx/test
|
||||
fastify.post('/nginx/test', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const result = await exec('nginx', ['-t'])
|
||||
// POST /api/nixserver/webserver/apache/test
|
||||
fastify.post('/apache/test', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const result = await exec('apache2ctl', ['configtest'])
|
||||
return reply.send({
|
||||
success: result.exitCode === 0,
|
||||
data: { output: result.stdout + result.stderr },
|
||||
})
|
||||
})
|
||||
|
||||
// POST /api/nixserver/webserver/nginx/reload
|
||||
fastify.post('/nginx/reload', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const test = await exec('nginx', ['-t'])
|
||||
// POST /api/nixserver/webserver/apache/reload
|
||||
fastify.post('/apache/reload', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const test = await exec('apache2ctl', ['configtest'])
|
||||
if (test.exitCode !== 0) {
|
||||
return reply.code(500).send({ success: false, error: `Config test failed: ${test.stderr}` })
|
||||
}
|
||||
const result = await exec('systemctl', ['reload', 'nginx'])
|
||||
const result = await exec('systemctl', ['reload', 'apache2'])
|
||||
return reply.send({ success: result.exitCode === 0, data: { output: result.stdout } })
|
||||
})
|
||||
|
||||
// POST /api/nixserver/webserver/fpm/reload
|
||||
fastify.post('/fpm/reload', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { version } = (request.body ?? {}) as { version?: string }
|
||||
const service = version === '8.2' ? 'php8.2-fpm' : 'php8.3-fpm'
|
||||
const result = await exec('systemctl', ['reload', service])
|
||||
return reply.send({ success: result.exitCode === 0, data: { output: result.stdout } })
|
||||
})
|
||||
|
||||
// GET /api/nixserver/webserver/logs/:type — access or error log (last 200 lines)
|
||||
fastify.get('/logs/:type', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { type } = request.params as { type: string }
|
||||
const logFile = type === 'error' ? '/var/log/nginx/error.log' : '/var/log/nginx/access.log'
|
||||
const logFile = type === 'error'
|
||||
? '/var/log/apache2/error.log'
|
||||
: '/var/log/apache2/access.log'
|
||||
const result = await exec('tail', ['-n', '200', logFile])
|
||||
return reply.send({ success: true, data: { lines: result.stdout.split('\n').filter(Boolean) } })
|
||||
})
|
||||
|
||||
// GET /api/nixserver/webserver/accounts/:username/logs/:type — per-account Apache logs
|
||||
fastify.get('/accounts/:username/logs/:type', { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const { username, type } = request.params as { username: string; type: string }
|
||||
// Validate username — only alphanum, dash, underscore allowed
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||
return reply.code(400).send({ success: false, error: 'Invalid username' })
|
||||
}
|
||||
const logFile = type === 'error'
|
||||
? path.join('/home', username, 'logs', 'error.log')
|
||||
: path.join('/home', username, 'logs', 'access.log')
|
||||
const result = await exec('tail', ['-n', '200', logFile])
|
||||
return reply.send({ success: true, data: { lines: result.stdout.split('\n').filter(Boolean) } })
|
||||
})
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import cron from 'node-cron'
|
||||
import { checkAll } from './core/monitor.js'
|
||||
import { closePool } from './core/mariadb.js'
|
||||
|
||||
let started = false
|
||||
|
||||
export function startScheduler(): void {
|
||||
if (started) return
|
||||
started = true
|
||||
|
||||
// Service health monitor — every 5 minutes
|
||||
cron.schedule('*/5 * * * *', async () => {
|
||||
try {
|
||||
await checkAll()
|
||||
} catch (err) {
|
||||
console.error('[scheduler] monitor.checkAll error:', err)
|
||||
}
|
||||
})
|
||||
|
||||
// SSL renewal check — daily at 3am
|
||||
cron.schedule('0 3 * * *', async () => {
|
||||
try {
|
||||
const { renewExpiring } = await import('./core/ssl.js')
|
||||
await renewExpiring()
|
||||
} catch (err) {
|
||||
console.error('[scheduler] ssl.renewExpiring error:', err)
|
||||
}
|
||||
})
|
||||
|
||||
// Disk usage stats — every 15 minutes
|
||||
cron.schedule('*/15 * * * *', async () => {
|
||||
try {
|
||||
const { collectDiskUsage } = await import('./core/stats.js')
|
||||
await collectDiskUsage()
|
||||
} catch (err) {
|
||||
console.error('[scheduler] stats.collectDiskUsage error:', err)
|
||||
}
|
||||
})
|
||||
|
||||
// License heartbeat — every 6 hours
|
||||
cron.schedule('0 */6 * * *', async () => {
|
||||
try {
|
||||
const { heartbeat } = await import('./core/license.js')
|
||||
await heartbeat()
|
||||
} catch (err) {
|
||||
console.error('[scheduler] license.heartbeat error:', err)
|
||||
}
|
||||
})
|
||||
|
||||
// Bandwidth reset — 1st of every month at 00:05
|
||||
cron.schedule('5 0 1 * *', async () => {
|
||||
try {
|
||||
const { resetMonthlyBandwidth } = await import('./core/stats.js')
|
||||
await resetMonthlyBandwidth()
|
||||
} catch (err) {
|
||||
console.error('[scheduler] stats.resetMonthlyBandwidth error:', err)
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[scheduler] Background jobs started')
|
||||
}
|
||||
|
||||
export async function stopScheduler(): Promise<void> {
|
||||
await closePool()
|
||||
}
|
||||
@@ -6,15 +6,17 @@ export interface JwtPayload {
|
||||
sub: number
|
||||
role: Role
|
||||
username: string
|
||||
type?: string
|
||||
iat?: number
|
||||
exp?: number
|
||||
}
|
||||
|
||||
// ─── Fastify type augmentation ────────────────────────────────────────────────
|
||||
// ─── @fastify/jwt type augmentation ───────────────────────────────────────────
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
user?: JwtPayload
|
||||
declare module '@fastify/jwt' {
|
||||
interface FastifyJWT {
|
||||
payload: JwtPayload
|
||||
user: JwtPayload
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user