15 Commits
Author SHA1 Message Date
NixReaper a0e3fc7b1e feat(nixserver): version display and one-click panel upgrade
Add version badge to sidebar footer showing installed vs latest version,
fetched from /api/nixserver/system/version. Badge shows green when up to
date or amber with target version when an update is available.

Upgrade button triggers POST /api/nixserver/system/upgrade which spawns
scripts/update.sh detached (git pull + npm install + prisma migrate +
build + restart). Response is returned before the service restarts so
the client receives confirmation. Panel comes back online in ~60 s.
2026-04-11 11:55:25 -05:00
NixReaper da576bdbdc feat(nixserver): WHM-style collapsible category sidebar
Replace the flat 14-item nav with a full category sidebar matching
the WHM layout. 29 collapsible categories, ~210 menu items, live
search that filters across all categories and auto-expands results,
active route auto-expands the matching category, sidebar toggle
hides/shows the full panel.
2026-04-11 11:51:18 -05:00
NixReaper 489755136c chore: bump version to 0.4.1, update CHANGELOG 2026-04-11 09:05:14 -05:00
NixReaper 2911cab755 fix(install): enable proxy_http and proxy_wstunnel Apache modules
Without proxy_http, ProxyPass to HTTP backends silently fails and Apache
returns 500 for all API requests. Without proxy_wstunnel, the WebSocket
RewriteRule for live stats cannot proxy ws:// connections.
2026-04-11 09:01:31 -05:00
NixReaper 8d6fe60168 fix(install): set world-readable permissions on frontend dist dirs
Apache (www-data) couldn't traverse /opt/nixpanel/nixserver because the
directories lacked execute permission for other. Add an explicit chmod
step after the build to ensure all dist directories are 755 and files
are 644 so Apache can serve the static assets.
2026-04-11 08:53:47 -05:00
NixReaper 6e59552e4f fix(install): handle pre-existing install dir during git clone
The 'Creating directory structure' step runs mkdir -p on INSTALL_DIR
before the clone step, so git clone fails if the directory is non-empty.
Clone to a temp directory and copy contents into place to preserve any
existing data/ and logs/ subdirectories.
2026-04-11 08:31:42 -05:00
NixReaper eebcce027b fix(install): correct SpamAssassin service name and resolve PowerDNS port 53 conflict
On Ubuntu 24.04 the SpamAssassin service unit is 'spamd', not 'spamassassin'.
The old 'systemctl enable spamassassin' call exited with an error because the
unit file does not exist, killing the install due to set -euo pipefail.

Also moved the systemd-resolved stub listener disable to a dedicated step that
runs before the package installation step. Previously it ran inside the PowerDNS
config block — after apt had already tried (and failed) to restart pdns during
post-install because systemd-resolved was still holding port 53.
2026-04-11 08:19:49 -05:00
NixReaper 87edf902a1 fix: correct server port config and seed default settings
- config.ts: rename nixserverPort/nixclientPort to single port, default
  HOST to 127.0.0.1 (installer binds to loopback, Apache proxies)
- index.ts: use config.server.port
- seed.ts: fix wrong default settings (nginx->apache2, bind9->powerdns,
  postfix->exim4, vsftpd->disabled)
2026-04-11 08:05:26 -05:00
NixReaper d235d27fe3 fix: nixclient SSL calls correct user-scoped route, not admin endpoint
- Add /api/nixclient/ssl (GET) — returns certs for authenticated user only
- Add /api/nixclient/ssl/:id/renew (POST) — validates cert belongs to user
- Register sslRoutes in nixclient index.ts
- nixclient SSL.tsx was calling /nixserver/ssl (requireAdminOrReseller)
  which would always 401 for regular users — fixed to /nixclient/ssl
2026-04-11 05:39:01 -05:00
NixReaper 8ff00c544c feat: v0.4.0 — Apache service stack, shell access, log viewer, changelog
Service stack (backend):
- webserver.ts rewritten: apache2/php-fpm routes replacing nginx; per-account
  Apache log endpoint; PHP-FPM reload per version; apache2ctl configtest/reload
- accounts.ts: added PUT /:id/shell to toggle /bin/bash vs /usr/sbin/nologin,
  runs usermod -s and writes audit log

New UI features (nixserver):
- ShellAccess page: grant/revoke SSH access per account via AccountPicker
- ApacheLogs page: live log viewer (access/error) per account; replaces stub
- NginxLogs: re-exports ApacheLogs (NixPanel uses Apache, not NGINX)
- WebServer page: shows apache2, php8.2-fpm, php8.3-fpm status with reload
  buttons; config test; Apache log viewer

Bug fixes:
- Accounts.tsx: Lucide icon size prop type widened (string|number); removed
  unused Users import; dropped duplicate NGINX log card
- Removed soon badge from ShellAccess and ApacheLogs entries

Project:
- CHANGELOG.md created with full history from 0.2.x through 0.4.0
- All packages bumped from 0.3.2 to 0.4.0
2026-04-11 05:17:49 -05:00
NixReaper 10e31482b0 fix: setup wizard inputs losing focus on every keystroke
StepContent was a component defined inside Setup, so every state
change recreated it as a new component type, causing React to
unmount/remount it and snap focus back to autoFocus. Replaced with
inline conditional JSX blocks.
2026-04-05 13:23:24 -05:00
NixReaper fa61d0d766 feat: first-run setup wizard on initial admin login
After first login, admins are redirected to a 4-step setup wizard:
1. Server hostname (FQDN)
2. Primary and secondary nameservers
3. Admin email and optional password change
4. Review and confirm

Setup state is persisted in the settings table (setup_complete=true).
Once complete, the redirect never triggers again. Settings are saved
to the DB and hostname is applied via hostnamectl.
2026-04-05 13:17:16 -05:00
NixReaper 75394fcaa5 fix: resolve EADDRINUSE port conflict — Node.js now listens on internal port 4000
Nginx listens publicly on 2087/2083 and reverse-proxies to 127.0.0.1:4000.
Previously both Nginx and Node.js were trying to bind port 2087 causing the
nixpanel service to crash-loop. HOST changed to 127.0.0.1 so the API is
not directly reachable from outside.

Bumps version to 0.3.1.
2026-04-05 12:21:28 -05:00
NixReaper d3ef236add fix: add type? to JwtPayload for refresh token signing — bump 0.3.2 2026-04-05 12:16:20 -05:00
NixReaper b8ac6ee515 fix: resolve server TS errors — jwt type augmentation, sign options, services import
bump version to 0.3.1
2026-04-05 12:13:06 -05:00
76 changed files with 12522 additions and 1349 deletions
+145
View File
@@ -0,0 +1,145 @@
# 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.3] — 2026-04-11
### New Features
- **Version display in sidebar** — the sidebar footer shows the currently installed version (e.g. `v0.4.3`), fetched live from the API. A green checkmark indicates up to date; an amber alert indicates an update is available.
- **One-click panel upgrade** — "Upgrade panel" button in the sidebar footer. When a newer version is available it highlights amber and shows the target version. Clicking requires a confirmation step before running. Calls `POST /api/nixserver/system/upgrade` which fires `scripts/update.sh` in the background (git pull → npm install → prisma migrate → build frontends + server → restart service). The panel comes back online automatically after ~60 s.
- **Check for updates button** — small refresh icon next to the version badge re-fetches version info on demand.
- **Backend: `GET /api/nixserver/system/version`** — returns `currentVersion` (read from `/opt/nixpanel/package.json`), `latestVersion` (fetched from GitHub raw content with a 5 s timeout), and `updateAvailable` boolean.
- **Backend: `POST /api/nixserver/system/upgrade`** — admin-only; responds immediately then spawns `scripts/update.sh` detached so the HTTP response is flushed before the service restarts.
---
## [0.4.2] — 2026-04-11
### Changes
- **NixServer sidebar redesigned** — replaced the flat 14-item nav with a full WHM-style collapsible category sidebar (29 categories, ~210 menu items). Categories fold/unfold on click; the active route's category auto-expands on navigation. A live search box at the top of the sidebar filters items across all categories and auto-expands matching results. Sidebar can be hidden entirely via the topbar toggle. All existing routes are wired to their correct menu items; future feature items route to the closest related hub page.
---
## [0.4.1] — 2026-04-11
### Bug Fixes (Installer)
- **SpamAssassin service name**: On Ubuntu 24.04 the service unit is `spamd`, not `spamassassin`. The old `systemctl enable spamassassin` caused the install to abort due to `set -euo pipefail`.
- **PowerDNS port 53 conflict**: `systemd-resolved` stub listener now disabled in a dedicated step that runs *before* package installation. Previously it ran after apt had already tried (and failed) to restart `pdns` during post-install because port 53 was still held by `systemd-resolved`.
- **Git clone into pre-existing directory**: The directory structure step (`mkdir -p`) creates `$INSTALL_DIR` before the clone step, causing `git clone` to fail with "destination path already exists". Fixed by cloning to a temp directory and copying contents into place (preserving any existing `data/` and `logs/` subdirectories).
- **Apache 403 on frontend assets**: Frontend dist files were created with root-only permissions. Added a post-build step that sets 755/644 on all dist directories and files so Apache (`www-data`) can read them.
- **Apache 500 on all API requests**: `mod_proxy_http` was not enabled. Without it, `ProxyPass http://...` directives silently fail and Apache returns 500 for every API call. Also enabled `mod_proxy_wstunnel` (required for the `ws://` WebSocket reverse proxy).
---
## [0.4.0] — 2026-04-11
### New Features
- **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 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@nixpanel/cpanel", "name": "@nixpanel/cpanel",
"version": "0.3.0", "version": "0.4.3",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -49,7 +49,7 @@ export default function Login() {
</button> </button>
</form> </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>
</div> </div>
) )
+2 -2
View File
@@ -10,14 +10,14 @@ export default function SSL() {
const fetch = async () => { const fetch = async () => {
setLoading(true) 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) } finally { setLoading(false) }
} }
useEffect(() => { fetch() }, []) useEffect(() => { fetch() }, [])
const renew = async (id: number) => { 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') } catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Renewal failed') }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@nixpanel/whm", "name": "@nixpanel/whm",
"version": "0.3.0", "version": "0.4.3",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+149 -13
View File
@@ -7,45 +7,180 @@ import Dashboard from './pages/Dashboard'
import Accounts from './pages/Accounts' import Accounts from './pages/Accounts'
import CreateAccount from './pages/CreateAccount' import CreateAccount from './pages/CreateAccount'
import Packages from './pages/Packages' import Packages from './pages/Packages'
import Setup from './pages/Setup'
// Lazy-loaded pages (built incrementally) // Lazy-loaded pages (built incrementally)
const Resellers = React.lazy(() => import('./pages/Resellers')) const Resellers = React.lazy(() => import('./pages/Resellers'))
const DNS = React.lazy(() => import('./pages/DNS')) const DNS = React.lazy(() => import('./pages/DNS'))
const Email = React.lazy(() => import('./pages/Email')) const Email = React.lazy(() => import('./pages/Email'))
const SSL = React.lazy(() => import('./pages/SSL')) const SSL = React.lazy(() => import('./pages/SSL'))
const PHP = React.lazy(() => import('./pages/PHP')) const PHP = React.lazy(() => import('./pages/PHP'))
const Services = React.lazy(() => import('./pages/Services')) const Services = React.lazy(() => import('./pages/Services'))
const Backup = React.lazy(() => import('./pages/Backup')) const Backup = React.lazy(() => import('./pages/Backup'))
const Security = React.lazy(() => import('./pages/Security')) const Security = React.lazy(() => import('./pages/Security'))
const System = React.lazy(() => import('./pages/System')) const System = React.lazy(() => import('./pages/System'))
const WebServer = React.lazy(() => import('./pages/WebServer')) const WebServer = React.lazy(() => import('./pages/WebServer'))
const Settings = React.lazy(() => import('./pages/Settings')) const Settings = React.lazy(() => import('./pages/Settings'))
// 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 }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth() const { user, loading, setupComplete } = useAuth()
if (loading) return ( if (loading) return (
<div className="min-h-screen bg-[#0f1117] flex items-center justify-center"> <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 className="w-6 h-6 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
</div> </div>
) )
if (!user) return <Navigate to="/login" replace /> 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}</> return <>{children}</>
} }
function AppRoutes() { function AppRoutes() {
const { user } = useAuth() const { user, setupComplete } = useAuth()
return ( return (
<Routes> <Routes>
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} /> <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={ <Route path="/" element={
<ProtectedRoute> <ProtectedRoute>
<Layout /> <Layout />
</ProtectedRoute> </ProtectedRoute>
}> }>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
{/* Accounts hub */}
<Route path="accounts" element={<Accounts />} /> <Route path="accounts" element={<Accounts />} />
<Route path="accounts/create" element={<CreateAccount />} /> <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="packages" element={<Packages />} />
<Route path="resellers" element={ <Route path="resellers" element={
<React.Suspense fallback={<PageLoader />}><Resellers /></React.Suspense> <React.Suspense fallback={<PageLoader />}><Resellers /></React.Suspense>
@@ -81,6 +216,7 @@ function AppRoutes() {
<React.Suspense fallback={<PageLoader />}><Settings /></React.Suspense> <React.Suspense fallback={<PageLoader />}><Settings /></React.Suspense>
} /> } />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
) )
+719 -63
View File
@@ -1,33 +1,535 @@
import { useState } from 'react' import { useState, useMemo, useEffect, useCallback } from 'react'
import { Outlet, NavLink, useNavigate } from 'react-router-dom' import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
import { import {
LayoutDashboard, Users, Package, UserCog, Globe, Mail, Shield, LayoutDashboard, Settings, HelpCircle, Network, BookUser, UserCog,
Server, HardDrive, Settings, LogOut, Menu, X, Wrench, Languages, HardDrive, GitFork, RefreshCw, Activity,
Activity, Lock, BarChart3, Cpu Users, UserCheck, ArrowRightLeft, Paintbrush, Package,
Globe, Database, Wifi, Box, Mail, HeartPulse, Lock,
ShoppingBag, Code2, Puzzle, LogOut, Menu, X,
ChevronDown, ChevronRight, Search, RotateCcw, RefreshCcw,
ArrowUpCircle, CheckCircle2, AlertCircle, Loader2
} from 'lucide-react' } from 'lucide-react'
import axios from 'axios'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
const nav = [ // ─── Nav data ────────────────────────────────────────────────────────────────
{ label: 'Dashboard', to: '/', icon: LayoutDashboard },
{ label: 'Accounts', to: '/accounts', icon: Users }, type NavItem = { label: string; to: string }
{ label: 'Packages', to: '/packages', icon: Package }, type NavCategory = {
{ label: 'Resellers', to: '/resellers', icon: UserCog }, id: string
{ label: 'DNS', to: '/dns', icon: Globe }, label: string
{ label: 'Email', to: '/email', icon: Mail }, icon: React.ComponentType<{ size?: number; className?: string }>
{ label: 'SSL', to: '/ssl', icon: Lock }, items: NavItem[]
{ label: 'PHP', to: '/php', icon: Cpu }, }
{ label: 'Web Server', to: '/webserver', icon: Server },
{ label: 'Services', to: '/services', icon: Activity }, const NAV: NavCategory[] = [
{ label: 'Backup', to: '/backup', icon: HardDrive }, {
{ label: 'Security', to: '/security', icon: Shield }, id: 'server-config',
{ label: 'System', to: '/system', icon: BarChart3 }, label: 'Server Configuration',
{ label: 'Settings', to: '/settings', icon: Settings }, icon: Settings,
items: [
{ label: 'Basic WebHost Manager® Setup', to: '/settings' },
{ label: 'Change Root Password', to: '/security' },
{ label: 'Configure Analytics', to: '/settings' },
{ label: 'Configure Cron Jobs', to: '/settings' },
{ label: 'Initial Quota Setup', to: '/settings' },
{ label: 'Link Server Nodes', to: '/settings' },
{ label: 'Server Profile', to: '/settings' },
{ label: 'Server Time', to: '/system' },
{ label: 'Statistics Software', to: '/settings' },
{ label: 'Terminal', to: '/system' },
{ label: 'Tweak Settings', to: '/settings' },
{ label: 'Update Preferences', to: '/settings' },
{ label: 'WHM Marketplace', to: '/settings' },
],
},
{
id: 'support',
label: 'Support',
icon: HelpCircle,
items: [
{ label: 'Grant NixPanel Support Access', to: '/security' },
{ label: 'Support Center', to: '/settings' },
],
},
{
id: 'networking',
label: 'Networking Setup',
icon: Network,
items: [
{ label: 'Change Hostname', to: '/security' },
{ label: 'Resolver Configuration', to: '/security' },
{ label: 'Security Center', to: '/security' },
{ label: 'Apache mod_userdir Tweak', to: '/webserver' },
{ label: 'SMTP Restrictions', to: '/email' },
{ label: 'Compiler Access', to: '/security' },
{ label: 'Configure Security Policies', to: '/security' },
{ label: 'mXSTOP Brute Force Protection', to: '/security' },
{ label: 'Host Access Control', to: '/security' },
{ label: 'Manage External Authentications', to: '/security' },
{ label: "Manage root's SSH Keys", to: '/security' },
{ label: 'Manage Wheel Group Users', to: '/security' },
{ label: 'ModSecurity™ Configuration', to: '/security' },
{ label: 'ModSecurity™ Tools', to: '/security' },
{ label: 'ModSecurity™ Vendors', to: '/security' },
{ label: 'Password Strength Configuration', to: '/security' },
{ label: 'Security Advisor', to: '/security' },
{ label: 'Security Questions', to: '/security' },
{ label: 'Shell Fork Bomb Protection', to: '/security' },
{ label: 'SSH Password Authorization Tweak', to: '/security' },
{ label: 'Traceroute Enable/Disable', to: '/security' },
{ label: 'Two-Factor Authentication', to: '/security' },
],
},
{
id: 'contacts',
label: 'Server Contacts',
icon: BookUser,
items: [
{ label: 'Contact Manager', to: '/settings' },
{ label: 'Edit System Mail Preferences', to: '/email' },
],
},
{
id: 'resellers',
label: 'Resellers',
icon: UserCog,
items: [
{ label: 'Change Ownership of an Account', to: '/resellers' },
{ label: 'Change Ownership of Multiple Accounts', to: '/resellers' },
{ label: 'Edit Reseller Nameservers and Privileges', to: '/resellers' },
{ label: 'Email All Resellers', to: '/resellers' },
{ label: "Manage Reseller's IP Delegation", to: '/resellers' },
{ label: "Manage Reseller's Shared IP", to: '/resellers' },
{ label: 'Reseller Center', to: '/resellers' },
{ label: 'Reset Resellers', to: '/resellers' },
{ label: 'Show Reseller Accounts', to: '/resellers' },
{ label: 'View Reseller Usage', to: '/resellers' },
],
},
{
id: 'service-config',
label: 'Service Configuration',
icon: Wrench,
items: [
{ label: 'Apache Configuration', to: '/webserver' },
{ label: 'Log Rotation Configuration', to: '/settings' },
{ label: 'Web Disk Configuration', to: '/settings' },
{ label: 'Web Services Configuration', to: '/settings' },
{ label: 'Exim Configuration Manager', to: '/email' },
{ label: 'FTP Server Selection', to: '/settings' },
{ label: 'Mailserver Configuration', to: '/email' },
{ label: 'Manage Service SSL Certificates', to: '/ssl' },
{ label: 'Nameserver Selection', to: '/dns' },
{ label: 'Service Manager', to: '/services' },
],
},
{
id: 'locales',
label: 'Locales',
icon: Languages,
items: [
{ label: 'Configure Application Locales', to: '/settings' },
{ label: 'Copy a Locale', to: '/settings' },
{ label: 'Delete a Locale', to: '/settings' },
{ label: 'Edit a Locale', to: '/settings' },
{ label: 'Locale XML Download', to: '/settings' },
{ label: 'Locale XML Upload', to: '/settings' },
{ label: 'View Available Locales', to: '/settings' },
],
},
{
id: 'backup',
label: 'Backup',
icon: HardDrive,
items: [
{ label: 'Backup Configuration', to: '/backup' },
{ label: 'Backup Restoration', to: '/backup' },
{ label: 'Backup User Selection', to: '/backup' },
{ label: 'File and Directory Restoration', to: '/backup' },
],
},
{
id: 'clusters',
label: 'Clusters',
icon: GitFork,
items: [
{ label: 'Configuration Cluster', to: '/settings' },
{ label: 'DNS Cluster', to: '/dns' },
{ label: 'Remote Access Key', to: '/security' },
],
},
{
id: 'reboot',
label: 'System Reboot',
icon: RotateCcw,
items: [
{ label: 'Forceful Server Reboot', to: '/system' },
{ label: 'Graceful Server Reboot', to: '/system' },
],
},
{
id: 'server-status',
label: 'Server Status',
icon: Activity,
items: [
{ label: 'Apache Status', to: '/webserver' },
{ label: 'Daily Process Log', to: '/system' },
{ label: 'Server Information',to: '/system' },
{ label: 'Service Status', to: '/services' },
{ label: 'Task Queue Monitor',to: '/system' },
],
},
{
id: 'account-info',
label: 'Account Information',
icon: Users,
items: [
{ label: 'List Accounts', to: '/accounts/list' },
{ label: 'List Parked Domains', to: '/accounts/parked-domains'},
{ label: 'List Subdomains', to: '/accounts/subdomains' },
{ label: 'List Suspended Accounts', to: '/accounts/suspended' },
{ label: 'Show Accounts Over Quota', to: '/accounts/over-quota' },
{ label: 'View Bandwidth Usage', to: '/accounts/bandwidth' },
],
},
{
id: 'account-functions',
label: 'Account Functions',
icon: UserCheck,
items: [
{ label: "Change Site's IP Address", to: '/accounts/change-ip' },
{ label: 'Create a New Account', to: '/accounts/create' },
{ label: 'Email All Users', to: '/accounts/email-users' },
{ label: 'Force Password Change', to: '/accounts/force-password' },
{ label: 'Limit Bandwidth Usage', to: '/accounts/limit-bandwidth' },
{ label: 'Manage Account Suspension', to: '/accounts/suspension' },
{ label: 'Manage Demo Mode', to: '/accounts/demo-mode' },
{ label: 'Manage Shell Access', to: '/accounts/shell-access' },
{ label: 'Modify an Account', to: '/accounts/modify' },
{ label: 'Password Modification', to: '/accounts/password' },
{ label: 'Quota Modification', to: '/accounts/quota' },
{ label: 'Raw Apache Log Download', to: '/accounts/apache-logs' },
{ label: 'Raw NGINX® Log Download', to: '/accounts/nginx-logs' },
{ label: 'Rearrange an Account', to: '/accounts/rearrange' },
{ label: 'Reset Account Bandwidth', to: '/accounts/reset-bandwidth' },
{ label: 'Terminate Accounts', to: '/accounts/terminate' },
{ label: 'Unsuspend Bandwidth Exceeders', to: '/accounts/unsuspend-bandwidth' },
{ label: 'Upgrade/Downgrade an Account', to: '/accounts/upgrade' },
{ label: 'Web Template Editor', to: '/accounts/web-template' },
],
},
{
id: 'multi-account',
label: 'Multi Account Functions',
icon: Users,
items: [
{ label: "Change Multiple Sites' IP Addresses", to: '/accounts/bulk-ip' },
{ label: 'Modify/Upgrade Multiple Accounts', to: '/accounts/bulk-modify' },
],
},
{
id: 'transfers',
label: 'Transfers',
icon: ArrowRightLeft,
items: [
{ label: 'Convert Addon Domain to Account', to: '/accounts' },
{ label: 'Review Transfers and Restores', to: '/accounts' },
{ label: 'Transfer or Restore a cPanel Account', to: '/accounts' },
{ label: 'Transfer Tool', to: '/accounts' },
],
},
{
id: 'themes',
label: 'Themes',
icon: Paintbrush,
items: [
{ label: 'Theme Manager', to: '/settings' },
],
},
{
id: 'packages',
label: 'Packages',
icon: Package,
items: [
{ label: 'Add a Package', to: '/packages' },
{ label: 'Delete a Package', to: '/packages' },
{ label: 'Edit a Package', to: '/packages' },
{ label: 'Feature Manager', to: '/packages' },
],
},
{
id: 'dns',
label: 'DNS Functions',
icon: Globe,
items: [
{ label: 'Add a DNS Zone', to: '/dns' },
{ label: 'Add an A Entry for Hostname', to: '/dns' },
{ label: 'Delete a DNS Zone', to: '/dns' },
{ label: 'DNS Zone Manager', to: '/dns' },
{ label: 'Edit Zone Templates', to: '/dns' },
{ label: 'Email DNS Record Manager', to: '/dns' },
{ label: 'Email Routing Configuration', to: '/email' },
{ label: 'Enable DKIM/SPF Globally', to: '/email' },
{ label: 'Nameserver Record Report', to: '/dns' },
{ label: 'Park a Domain', to: '/dns' },
{ label: 'Perform a DNS Cleanup', to: '/dns' },
{ label: 'Set Zone TTL', to: '/dns' },
{ label: 'Setup/Edit Domain Forwarding',to: '/dns' },
{ label: 'Synchronize DNS Records', to: '/dns' },
],
},
{
id: 'databases',
label: 'Database Services',
icon: Database,
items: [
{ label: 'Change Database Root Password', to: '/settings' },
{ label: 'Change Database User Password', to: '/settings' },
{ label: 'Database Map Tool', to: '/settings' },
{ label: 'Edit Database Configuration', to: '/settings' },
{ label: 'Manage Database Access Hosts', to: '/settings' },
{ label: 'Manage Database Profiles', to: '/settings' },
{ label: 'Manage Database Users', to: '/settings' },
{ label: 'Manage Databases', to: '/settings' },
{ label: 'phpMyAdmin', to: '/settings' },
{ label: 'Repair Databases', to: '/settings' },
{ label: 'Show Database Processes', to: '/settings' },
{ label: 'Upgrade Database Version', to: '/settings' },
],
},
{
id: 'ip',
label: 'IP Functions',
icon: Wifi,
items: [
{ label: 'IPv6 Ranges', to: '/security' },
{ label: 'Add a New IP Address', to: '/security' },
{ label: 'Assign IPv6 Address', to: '/security' },
{ label: 'Configure Remote Service IPs', to: '/security' },
{ label: 'IP Migration Wizard', to: '/security' },
{ label: 'Rebuild the IP Address Pool', to: '/security' },
{ label: 'Show IP Address Usage', to: '/security' },
{ label: 'Show or Delete Current IPs', to: '/security' },
{ label: 'Show/Edit Reserved IPs', to: '/security' },
],
},
{
id: 'software',
label: 'Software',
icon: Box,
items: [
{ label: 'Install a Perl Module', to: '/system' },
{ label: 'Install Distro Packages', to: '/system' },
{ label: 'Module Installers', to: '/system' },
{ label: 'MultiPHP INI Editor', to: '/php' },
{ label: 'MultiPHP Manager', to: '/php' },
{ label: 'NGINX® Manager', to: '/webserver' },
{ label: 'Rebuild RPM Database', to: '/system' },
{ label: 'System Update', to: '/system' },
{ label: 'Update Server Software', to: '/system' },
{ label: 'EasyApache 4', to: '/webserver' },
],
},
{
id: 'email',
label: 'Email',
icon: Mail,
items: [
{ label: 'Email Deliverability', to: '/email' },
{ label: 'Filter Incoming Emails by Country', to: '/email' },
{ label: 'Filter Incoming Emails by Domain', to: '/email' },
{ label: 'Greylisting', to: '/email' },
{ label: 'Mail Delivery Reports', to: '/email' },
{ label: 'Mail Queue Manager', to: '/email' },
{ label: 'Mail Troubleshooter', to: '/email' },
{ label: 'Mailbox Conversion', to: '/email' },
{ label: 'Repair Mailbox Permissions', to: '/email' },
{ label: 'Spamd Startup Configuration', to: '/email' },
{ label: 'View Mail Statistics Summary', to: '/email' },
{ label: 'View Relayers', to: '/email' },
{ label: 'View Sent Summary', to: '/email' },
],
},
{
id: 'system-health',
label: 'System Health',
icon: HeartPulse,
items: [
{ label: 'Background Process Killer', to: '/system' },
{ label: 'Process Manager', to: '/system' },
{ label: 'Show Current Disk Usage', to: '/system' },
{ label: 'Show Current Running Processes', to: '/system' },
],
},
{
id: 'nixpanel',
label: 'NixPanel',
icon: LayoutDashboard,
items: [
{ label: 'Change Log', to: '/settings' },
{ label: 'Customization', to: '/settings' },
{ label: 'Manage Plugins', to: '/settings' },
{ label: 'Modify NixPanel News', to: '/settings' },
{ label: 'Reset a Mailman Password', to: '/email' },
{ label: 'Upgrade to Latest Version', to: '/settings' },
],
},
{
id: 'ssl',
label: 'SSL/TLS',
icon: Lock,
items: [
{ label: 'Generate an SSL Certificate and Signing Request', to: '/ssl' },
{ label: 'Install an SSL Certificate on a Domain', to: '/ssl' },
{ label: 'Manage AutoSSL', to: '/ssl' },
{ label: 'Manage SSL Hosts', to: '/ssl' },
{ label: 'Purchase and Install an SSL Certificate', to: '/ssl' },
{ label: 'SSL Storage Manager', to: '/ssl' },
{ label: 'SSL/TLS Configuration', to: '/ssl' },
],
},
{
id: 'market',
label: 'Market',
icon: ShoppingBag,
items: [
{ label: 'Market Provider Manager', to: '/settings' },
],
},
{
id: 'restart-services',
label: 'Restart Services',
icon: RefreshCw,
items: [
{ label: 'Database Server', to: '/services' },
{ label: 'DNS Server', to: '/services' },
{ label: 'HTTP Server (Apache)', to: '/services' },
{ label: 'IMAP Server', to: '/services' },
{ label: 'Mail Server (Exim)', to: '/services' },
{ label: 'Mailing List Manager (Mailman)', to: '/services' },
{ label: 'PHP-FPM service for Apache', to: '/services' },
{ label: 'SSH Server (OpenSSH)', to: '/services' },
],
},
{
id: 'development',
label: 'Development',
icon: Code2,
items: [
{ label: 'Apps Managed by AppConfig', to: '/settings' },
{ label: 'NixPanel Development Forum', to: '/settings' },
{ label: 'NixPanel Plugin File Generator', to: '/settings' },
{ label: 'Developer Documentation', to: '/settings' },
{ label: 'Manage API Tokens', to: '/settings' },
{ label: 'Manage Hooks', to: '/settings' },
{ label: 'OpenAPI Documentation for NixPanel', to: '/settings' },
{ label: 'OpenAPI Documentation for WHM', to: '/settings' },
],
},
{
id: 'plugins',
label: 'Plugins',
icon: Puzzle,
items: [
{ label: 'Backuply by Softaculous', to: '/backup' },
{ label: 'ImunifyAV', to: '/security' },
{ label: 'Server Monitoring', to: '/system' },
{ label: 'SitePad Website Builder', to: '/settings' },
{ label: 'Softaculous - Instant Installs', to: '/settings' },
{ label: 'WP Toolkit', to: '/settings' },
],
},
] ]
// ─── Helpers ─────────────────────────────────────────────────────────────────
function isItemActive(to: string, pathname: string) {
if (to === '/') return pathname === '/'
return pathname === to || pathname.startsWith(to + '/')
}
// ─── Component ───────────────────────────────────────────────────────────────
export default function Layout() { export default function Layout() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const [sidebarOpen, setSidebarOpen] = useState(true) const [sidebarOpen, setSidebarOpen] = useState(true)
const [search, setSearch] = useState('')
// ── Version / upgrade state ──────────────────────────────────────────────
type VersionInfo = { currentVersion: string; latestVersion: string | null; updateAvailable: boolean }
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null)
const [upgradeState, setUpgradeState] = useState<'idle' | 'confirm' | 'running' | 'done' | 'error'>('idle')
const fetchVersion = useCallback(async () => {
try {
const { data } = await axios.get('/api/nixserver/system/version')
setVersionInfo(data.data)
} catch { /* non-fatal */ }
}, [])
useEffect(() => { fetchVersion() }, [fetchVersion])
const handleUpgrade = async () => {
if (upgradeState === 'confirm') {
setUpgradeState('running')
try {
await axios.post('/api/nixserver/system/upgrade')
setUpgradeState('done')
} catch {
setUpgradeState('error')
}
} else {
setUpgradeState('confirm')
}
}
// ─────────────────────────────────────────────────────────────────────────
const [openCats, setOpenCats] = useState<Set<string>>(() => {
// Auto-open the category that contains the active route on first render
const active = NAV.find(cat =>
cat.items.some(item => isItemActive(item.to, location.pathname))
)
return active ? new Set([active.id]) : new Set()
})
// When the route changes (e.g. user navigates), auto-open the matching category
useEffect(() => {
const active = NAV.find(cat =>
cat.items.some(item => isItemActive(item.to, location.pathname))
)
if (active) {
setOpenCats(prev => prev.has(active.id) ? prev : new Set([...prev, active.id]))
}
}, [location.pathname])
const toggleCat = (id: string) => {
setOpenCats(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
// Filter nav based on search query
const filteredNav = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return NAV
return NAV.map(cat => ({
...cat,
items: cat.items.filter(item => item.label.toLowerCase().includes(q)),
})).filter(cat =>
cat.items.length > 0 || cat.label.toLowerCase().includes(q)
)
}, [search])
// When searching, all matching categories are forced open
const effectiveOpen = (id: string) =>
search.trim() ? filteredNav.some(c => c.id === id) : openCats.has(id)
const handleLogout = async () => { const handleLogout = async () => {
await logout() await logout()
@@ -36,68 +538,222 @@ export default function Layout() {
return ( return (
<div className="flex h-screen overflow-hidden bg-[#0f1117]"> <div className="flex h-screen overflow-hidden bg-[#0f1117]">
{/* Sidebar */}
{/* ── Sidebar ─────────────────────────────────────────────────────── */}
<aside <aside
className={`flex flex-col transition-all duration-200 bg-[#1a1d27] border-r border-[#2a2d3e] ${ className={`flex flex-col transition-all duration-200 bg-[#1a1d27] border-r border-[#2a2d3e] overflow-hidden flex-shrink-0 ${
sidebarOpen ? 'w-56' : 'w-14' sidebarOpen ? 'w-72' : 'w-0'
}`} }`}
> >
{/* Logo */} {/* Branding */}
<div className="flex items-center gap-2 px-4 py-4 border-b border-[#2a2d3e] min-h-[57px]"> <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"> <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> <span className="text-white text-xs font-bold">N</span>
</div> </div>
{sidebarOpen && ( <div className="overflow-hidden">
<div className="overflow-hidden"> <p className="text-white text-sm font-semibold leading-tight whitespace-nowrap">NixServer</p>
<p className="text-white text-sm font-semibold leading-tight">NixServer</p> <p className="text-[#64748b] text-xs whitespace-nowrap">Admin Panel</p>
<p className="text-[#64748b] text-xs">Admin Panel</p> </div>
</div>
{/* Search */}
<div className="px-3 py-2 border-b border-[#2a2d3e] flex-shrink-0">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#4a5568] pointer-events-none" />
<input
type="text"
placeholder="Search..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full bg-[#0f1117] border border-[#2a2d3e] rounded-md text-xs text-[#94a3b8] placeholder-[#4a5568] pl-7 pr-3 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[#4a5568] hover:text-[#94a3b8]"
>
<X size={11} />
</button>
)}
</div>
</div>
{/* Nav */}
<nav className="flex-1 overflow-y-auto py-1 scrollbar-thin">
{/* Dashboard — always at the top */}
<Link
to="/"
className={`flex items-center gap-2.5 px-3 py-1.5 mx-2 my-0.5 rounded-md text-xs transition-colors ${
location.pathname === '/'
? 'bg-indigo-600/20 text-indigo-400'
: 'text-[#94a3b8] hover:bg-[#1e2130] hover:text-white'
}`}
>
<LayoutDashboard size={13} className="flex-shrink-0" />
<span className="truncate">Dashboard</span>
</Link>
{/* Categories */}
{filteredNav.map(cat => {
const isOpen = effectiveOpen(cat.id)
const Icon = cat.icon
const hasActive = cat.items.some(item => isItemActive(item.to, location.pathname))
return (
<div key={cat.id} className="mx-2 my-0.5">
{/* Category header */}
<button
onClick={() => !search.trim() && toggleCat(cat.id)}
className={`flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-xs font-medium transition-colors ${
hasActive && !search.trim()
? 'text-indigo-300'
: 'text-[#64748b] hover:text-[#94a3b8]'
} ${search.trim() ? 'cursor-default' : ''}`}
>
<Icon size={13} className="flex-shrink-0" />
<span className="flex-1 text-left truncate">{cat.label}</span>
{!search.trim() && (
isOpen
? <ChevronDown size={11} className="flex-shrink-0 opacity-60" />
: <ChevronRight size={11} className="flex-shrink-0 opacity-60" />
)}
</button>
{/* Items */}
{isOpen && (
<div className="ml-3 pl-2 border-l border-[#2a2d3e] mt-0.5 mb-1">
{cat.items.map(item => {
const active = isItemActive(item.to, location.pathname)
return (
<Link
key={item.label}
to={item.to}
className={`block px-2 py-1 rounded text-xs transition-colors truncate ${
active
? 'text-indigo-400 bg-indigo-600/10'
: 'text-[#64748b] hover:text-[#94a3b8] hover:bg-[#1e2130]'
}`}
>
{item.label}
</Link>
)
})}
</div>
)}
</div>
)
})}
{/* Empty search state */}
{search.trim() && filteredNav.length === 0 && (
<p className="text-center text-[#4a5568] text-xs py-6">No results</p>
)}
</nav>
{/* Version + upgrade */}
<div className="border-t border-[#2a2d3e] px-3 pt-2.5 pb-1 flex-shrink-0">
{versionInfo ? (
<div className="space-y-1.5">
{/* Version row */}
<div className="flex items-center justify-between px-1">
<div className="flex items-center gap-1.5">
{versionInfo.updateAvailable ? (
<AlertCircle size={11} className="text-amber-400 flex-shrink-0" />
) : (
<CheckCircle2 size={11} className="text-emerald-500 flex-shrink-0" />
)}
<span className="text-[#64748b] text-[10px]">
v{versionInfo.currentVersion}
</span>
{versionInfo.updateAvailable && versionInfo.latestVersion && (
<span className="text-amber-400 text-[10px]">
→ v{versionInfo.latestVersion}
</span>
)}
</div>
<button
onClick={fetchVersion}
title="Check for updates"
className="text-[#4a5568] hover:text-[#94a3b8] transition-colors"
>
<RefreshCcw size={10} />
</button>
</div>
{/* Upgrade button */}
{upgradeState === 'done' ? (
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-emerald-600/10 text-emerald-400 text-[10px]">
<CheckCircle2 size={11} />
<span>Upgrade started — panel restarting…</span>
</div>
) : upgradeState === 'error' ? (
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-red-600/10 text-red-400 text-[10px]">
<AlertCircle size={11} />
<span>Upgrade failed — check logs</span>
</div>
) : (
<div className="flex gap-1.5">
<button
onClick={handleUpgrade}
disabled={upgradeState === 'running'}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[10px] font-medium transition-colors ${
upgradeState === 'confirm'
? 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/30'
: versionInfo.updateAvailable
? 'bg-indigo-600/20 text-indigo-300 hover:bg-indigo-600/30'
: 'bg-[#1e2130] text-[#64748b] hover:text-[#94a3b8]'
}`}
>
{upgradeState === 'running' ? (
<><Loader2 size={10} className="animate-spin" /><span>Upgrading…</span></>
) : upgradeState === 'confirm' ? (
<><ArrowUpCircle size={10} /><span>Confirm upgrade</span></>
) : versionInfo.updateAvailable ? (
<><ArrowUpCircle size={10} /><span>Upgrade available</span></>
) : (
<><ArrowUpCircle size={10} /><span>Upgrade panel</span></>
)}
</button>
{upgradeState === 'confirm' && (
<button
onClick={() => setUpgradeState('idle')}
className="px-2 py-1.5 rounded-md bg-[#1e2130] text-[#64748b] hover:text-[#94a3b8] text-[10px] transition-colors"
>
Cancel
</button>
)}
</div>
)}
</div>
) : (
<div className="flex items-center gap-1.5 px-1 py-1">
<Loader2 size={10} className="animate-spin text-[#4a5568]" />
<span className="text-[#4a5568] text-[10px]">Checking version…</span>
</div> </div>
)} )}
</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>
{/* User + logout */} {/* User + logout */}
<div className="border-t border-[#2a2d3e] p-3"> <div className="border-t border-[#2a2d3e] p-3 flex-shrink-0">
{sidebarOpen && ( <div className="px-1 mb-2">
<div className="px-1 mb-2"> <p className="text-white text-xs font-medium truncate">{user?.username}</p>
<p className="text-white text-xs font-medium truncate">{user?.username}</p> <p className="text-[#64748b] text-xs truncate">{user?.role}</p>
<p className="text-[#64748b] text-xs truncate">{user?.role}</p> </div>
</div>
)}
<button <button
onClick={handleLogout} 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" /> <LogOut size={14} className="flex-shrink-0" />
{sidebarOpen && <span>Logout</span>} <span>Logout</span>
</button> </button>
</div> </div>
</aside> </aside>
{/* Main */} {/* ── Main ────────────────────────────────────────────────────────── */}
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden min-w-0">
{/* Topbar */} {/* Topbar */}
<header className="flex items-center gap-4 px-6 py-3 bg-[#1a1d27] border-b border-[#2a2d3e] min-h-[57px]"> <header className="flex items-center gap-4 px-6 py-3 bg-[#1a1d27] border-b border-[#2a2d3e] min-h-[57px] flex-shrink-0">
<button <button
onClick={() => setSidebarOpen(v => !v)} onClick={() => setSidebarOpen(v => !v)}
className="text-[#64748b] hover:text-white transition-colors" className="text-[#64748b] hover:text-white transition-colors"
+23 -1
View File
@@ -11,6 +11,8 @@ interface User {
interface AuthContextValue { interface AuthContextValue {
user: User | null user: User | null
loading: boolean loading: boolean
setupComplete: boolean
markSetupComplete: () => void
login: (username: string, password: string) => Promise<void> login: (username: string, password: string) => Promise<void>
logout: () => Promise<void> logout: () => Promise<void>
} }
@@ -20,6 +22,7 @@ const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [setupComplete, setSetupComplete] = useState(true)
const fetchMe = useCallback(async () => { const fetchMe = useCallback(async () => {
const token = localStorage.getItem('access_token') const token = localStorage.getItem('access_token')
@@ -32,6 +35,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return return
} }
setUser(data.data) 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 { } catch {
localStorage.clear() localStorage.clear()
} finally { } finally {
@@ -49,6 +58,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
localStorage.setItem('access_token', data.data.accessToken) localStorage.setItem('access_token', data.data.accessToken)
localStorage.setItem('refresh_token', data.data.refreshToken) localStorage.setItem('refresh_token', data.data.refreshToken)
setUser(data.data.user) 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 () => { const logout = async () => {
@@ -56,10 +75,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
try { await api.post('/auth/logout', { refreshToken }) } catch {} try { await api.post('/auth/logout', { refreshToken }) } catch {}
localStorage.clear() localStorage.clear()
setUser(null) setUser(null)
setSetupComplete(true)
} }
const markSetupComplete = () => setSetupComplete(true)
return ( return (
<AuthContext.Provider value={{ user, loading, login, logout }}> <AuthContext.Provider value={{ user, loading, setupComplete, markSetupComplete, login, logout }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
) )
+146 -178
View File
@@ -1,198 +1,166 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Plus, Search, MoreVertical, UserX, UserCheck, Trash2, RefreshCw } from 'lucide-react' 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' import { api } from '../api/client'
interface Account { interface Stats {
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 total: number
page: number active: number
totalPages: number suspended: number
terminated: number
} }
const statusColor: Record<string, string> = { interface FunctionCard {
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', icon: React.ComponentType<{ size?: number | string; className?: string }>
suspended: 'bg-amber-500/10 text-amber-400 border-amber-500/20', label: string
terminated: 'bg-red-500/10 text-red-400 border-red-500/20', description: string
path: string
soon?: boolean
} }
export default function Accounts() { const accountInfo: FunctionCard[] = [
const [data, setData] = useState<PageData | null>(null) { icon: List, label: 'List Accounts', description: 'View and manage all hosting accounts', path: '/accounts/list' },
const [search, setSearch] = useState('') { icon: Globe, label: 'List Parked Domains', description: 'View all parked domains across accounts', path: '/accounts/parked-domains' },
const [status, setStatus] = useState('') { icon: GitBranch, label: 'List Subdomains', description: 'View all subdomains across accounts', path: '/accounts/subdomains' },
const [page, setPage] = useState(1) { icon: UserX, label: 'List Suspended Accounts', description: 'View and manage suspended accounts', path: '/accounts/suspended' },
const [loading, setLoading] = useState(false) { icon: HardDrive, label: 'Show Accounts Over Quota', description: 'Accounts that have exceeded their disk quota', path: '/accounts/over-quota' },
const [actionMenu, setActionMenu] = useState<number | null>(null) { icon: BarChart2, label: 'View Bandwidth Usage', description: 'Bandwidth consumption sorted by usage', path: '/accounts/bandwidth' },
]
const fetch = useCallback(async () => { const accountFunctions: FunctionCard[] = [
setLoading(true) { icon: Network, label: 'Change Site\'s IP Address', description: 'Assign a different IP to an account', path: '/accounts/change-ip' },
try { { icon: Plus, label: 'Create a New Account', description: 'Create a new hosting account', path: '/accounts/create' },
const params = new URLSearchParams({ page: String(page), limit: '25' }) { icon: Mail, label: 'Email All Users', description: 'Send a bulk email to all account holders', path: '/accounts/email-users', soon: true },
if (search) params.set('search', search) { icon: KeyRound, label: 'Force Password Change', description: 'Force users to change password on next login', path: '/accounts/force-password', soon: true },
if (status) params.set('status', status) { icon: Gauge, label: 'Limit Bandwidth Usage', description: 'Set per-account bandwidth caps independent of package', path: '/accounts/limit-bandwidth', soon: true },
const r = await api.get(`/nixserver/accounts?${params}`) { icon: UserCheck, label: 'Manage Account Suspension', description: 'Suspend or unsuspend a hosting account', path: '/accounts/suspension' },
setData(r.data.data) { icon: FlaskConical, label: 'Manage Demo Mode', description: 'Put accounts into read-only demo mode', path: '/accounts/demo-mode', soon: true },
} finally { { icon: Terminal, label: 'Manage Shell Access', description: 'Grant or revoke SSH shell access', path: '/accounts/shell-access' },
setLoading(false) { 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' },
}, [page, search, status]) { 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') => { interface CategoryProps {
setActionMenu(null) title: string
if (act === 'terminate' && !confirm('Terminate this account? This cannot be undone.')) return color: string
try { headerBg: string
if (act === 'suspend') await api.post(`/nixserver/accounts/${id}/suspend`) cards: FunctionCard[]
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')
}
}
function Category({ title, color, headerBg, cards }: CategoryProps) {
const navigate = useNavigate()
return ( return (
<div className="space-y-4"> <div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
<div className="flex items-center justify-between"> <div className={`px-5 py-3 ${headerBg} border-b border-[#2a2d3e]`}>
<div> <h2 className={`text-sm font-semibold ${color}`}>{title}</h2>
<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> </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 */} useEffect(() => {
<div className="flex gap-3"> api.get('/nixserver/accounts/stats')
<div className="relative flex-1 max-w-sm"> .then(r => setStats(r.data.data))
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#64748b]" /> .catch(() => {})
<input }, [])
value={search}
onChange={e => { setSearch(e.target.value); setPage(1) }} return (
placeholder="Search username, domain, email..." <div className="space-y-5">
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" {/* Header */}
/> <div>
</div> <h1 className="text-white text-xl font-semibold">Account Functions</h1>
<select <p className="text-[#64748b] text-sm">Manage hosting accounts, domains, and usage.</p>
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>
</div> </div>
{/* Table */} {/* Stats bar */}
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<table className="w-full text-sm"> {[
<thead> { label: 'Total Accounts', value: stats?.total ?? '—', color: 'text-white' },
<tr className="border-b border-[#2a2d3e]"> { label: 'Active', value: stats?.active ?? '—', color: 'text-emerald-400' },
{['Username', 'Domain', 'Package', 'Status', 'IP', 'Created', ''].map(h => ( { label: 'Suspended', value: stats?.suspended ?? '—', color: 'text-amber-400' },
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th> { label: 'Terminated', value: stats?.terminated ?? '—', color: 'text-red-400' },
))} ].map(s => (
</tr> <div key={s.label} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl px-4 py-3">
</thead> <p className="text-[#64748b] text-xs mb-1">{s.label}</p>
<tbody> <p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
{loading && !data && ( </div>
<tr><td colSpan={7} className="text-center text-[#64748b] py-8">Loading...</td></tr> ))}
)} </div>
{data?.items.map(acc => (
<tr key={acc.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors"> {/* Categories */}
<td className="px-4 py-3"> <Category
<Link to={`/accounts/${acc.id}`} className="text-indigo-400 hover:text-indigo-300 font-medium"> title="Account Information"
{acc.username} color="text-indigo-400"
</Link> headerBg="bg-indigo-600/10"
<p className="text-[#64748b] text-xs">{acc.email}</p> cards={accountInfo}
</td> />
<td className="px-4 py-3 text-[#94a3b8]">{acc.domain}</td> <Category
<td className="px-4 py-3 text-[#94a3b8]">{acc.package?.name ?? '—'}</td> title="Account Functions"
<td className="px-4 py-3"> color="text-violet-400"
<span className={`text-xs px-2 py-0.5 rounded-full border ${statusColor[acc.status] ?? ''}`}> headerBg="bg-violet-600/10"
{acc.status} cards={accountFunctions}
</span> />
</td> <Category
<td className="px-4 py-3 text-[#64748b] font-mono text-xs">{acc.ipAddress ?? '—'}</td> title="Multi Account Functions"
<td className="px-4 py-3 text-[#64748b] text-xs">{new Date(acc.createdAt).toLocaleDateString()}</td> color="text-emerald-400"
<td className="px-4 py-3 relative"> headerBg="bg-emerald-600/10"
<button cards={multiAccountFunctions}
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>
{/* Pagination */} {/* Legend */}
{data && data.totalPages > 1 && ( <div className="flex items-center gap-2 text-xs text-[#64748b]">
<div className="flex items-center justify-between px-4 py-3 border-t border-[#2a2d3e]"> <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 className="text-[#64748b] text-xs"> <span>Feature not yet implemented</span>
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>
</div> </div>
) )
+1 -1
View File
@@ -81,7 +81,7 @@ export default function Login() {
</form> </form>
<p className="text-center text-[#64748b] text-xs mt-4"> <p className="text-center text-[#64748b] text-xs mt-4">
NixServer v0.3.0 · Port 2087 NixServer v0.3.2 · Port 2087
</p> </p>
</div> </div>
</div> </div>
+264
View File
@@ -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>
)
}
+64 -23
View File
@@ -2,54 +2,93 @@ import { useEffect, useState } from 'react'
import { Server, RefreshCw, RotateCcw, CheckCircle, XCircle } from 'lucide-react' import { Server, RefreshCw, RotateCcw, CheckCircle, XCircle } from 'lucide-react'
import { api } from '../api/client' import { api } from '../api/client'
interface WebStatus {
apache: { active: boolean }
php82fpm: { active: boolean }
php83fpm: { active: boolean }
}
export default function WebServer() { export default function WebServer() {
const [status, setStatus] = useState<{ nginx: { active: boolean }; apache: { active: boolean } } | null>(null) const [status, setStatus] = useState<WebStatus | null>(null)
const [, setVhosts] = useState<{ domain: string; path: string }[]>([])
const [logs, setLogs] = useState<{ lines: string[] } | null>(null) const [logs, setLogs] = useState<{ lines: string[] } | null>(null)
const [logType, setLogType] = useState<'access' | 'error'>('access') const [logType, setLogType] = useState<'access' | 'error'>('access')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [testResult, setTestResult] = useState<string | null>(null) const [testResult, setTestResult] = useState<string | null>(null)
const [reloading, setReloading] = useState(false)
const fetch = async () => { const fetchData = async () => {
setLoading(true) setLoading(true)
try { try {
const [s, v, l] = await Promise.all([ const [s, l] = await Promise.all([
api.get('/nixserver/webserver/status'), api.get('/nixserver/webserver/status'),
api.get('/nixserver/webserver/vhosts'),
api.get(`/nixserver/webserver/logs/${logType}`), 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) } } finally { setLoading(false) }
} }
useEffect(() => { fetch() }, [logType]) useEffect(() => { fetchData() }, [logType])
const testConfig = async () => { const testConfig = async () => {
const r = await api.post('/nixserver/webserver/nginx/test') try {
setTestResult(r.data.data.output) 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 () => { const reload = async (service: 'apache' | 'fpm82' | 'fpm83') => {
try { await api.post('/nixserver/webserver/nginx/reload'); fetch() } setReloading(true)
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Reload failed') } 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <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> <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={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button> <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> </div>
{status && ( {status && (
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-3 gap-4">
{[['nginx', status.nginx.active], ['apache2', status.apache.active]].map(([name, active]) => ( {services.map(({ key, label, reload: reloadKey }) => (
<div key={String(name)} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4 flex items-center justify-between"> <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"> <div className="flex items-center gap-3">
<Server size={16} className="text-[#64748b]" /> <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> </div>
{active ? <CheckCircle size={16} className="text-emerald-400" /> : <XCircle size={16} className="text-red-400" />}
</div> </div>
))} ))}
</div> </div>
@@ -57,16 +96,18 @@ export default function WebServer() {
<div className="flex gap-2"> <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={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> </div>
{testResult && ( {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="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-[#2a2d3e]"> <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"> <div className="flex gap-1">
{(['access', 'error'] as const).map(t => ( {(['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> <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> </div>
<div className="max-h-80 overflow-y-auto"> <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...'} {logs?.lines.slice(-100).join('\n') ?? 'Loading...'}
</pre> </pre>
</div> </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} &mdash; {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} &mdash; {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>
)
}
+114
View File
@@ -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>
)
}
+117
View File
@@ -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>
)
}
+35
View File
@@ -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>
)
}
+90
View File
@@ -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>
)
}
+35
View File
@@ -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>
)
}
+207
View File
@@ -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>
)
}
+129
View File
@@ -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'
+112
View File
@@ -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>
)
}
+129
View File
@@ -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>
)
}
+157
View File
@@ -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>
)
}
+108
View File
@@ -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>
)
}
+103
View File
@@ -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>
)
}
+108
View File
@@ -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} &mdash; {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>
)
}
+6004
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nixpanel-server", "name": "nixpanel-server",
"version": "0.3.0", "version": "0.4.3",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"server", "server",
+522 -468
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{ {
"name": "@nixpanel/server", "name": "@nixpanel/server",
"version": "0.3.0", "version": "0.4.3",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -24,6 +24,7 @@
"@fastify/websocket": "^10.0.1", "@fastify/websocket": "^10.0.1",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"mysql2": "^3.11.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"fastify": "^4.28.1", "fastify": "^4.28.1",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
@@ -32,6 +33,7 @@
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/node": "^22.9.0", "@types/node": "^22.9.0",
"@types/node-cron": "^3.0.11",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.2" "typescript": "^5.7.2"
+193 -202
View File
@@ -3,26 +3,26 @@ generator client {
} }
datasource db { datasource db {
provider = "mysql" provider = "sqlite"
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
// ─── Admin / WHM Users ─────────────────────────────────────────────────────── // ─── Admin / WHM Users ───────────────────────────────────────────────────────
model AdminUser { model AdminUser {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
username String @unique @db.VarChar(64) username String @unique
passwordHash String @db.VarChar(255) passwordHash String
email String @unique @db.VarChar(255) email String @unique
twoFactorSecret String? @db.VarChar(64) twoFactorSecret String?
twoFactorEnabled Boolean @default(false) twoFactorEnabled Boolean @default(false)
lastLogin DateTime? lastLogin DateTime?
lastLoginIp String? @db.VarChar(45) lastLoginIp String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
auditLogs AuditLog[] auditLogs AuditLog[]
@@map("admin_users") @@map("admin_users")
} }
@@ -31,25 +31,26 @@ model AdminUser {
model Reseller { model Reseller {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
username String @unique @db.VarChar(64) username String @unique
passwordHash String @db.VarChar(255) passwordHash String
email String @unique @db.VarChar(255) email String @unique
contactName String? @db.VarChar(255) contactName String?
company String? @db.VarChar(255) company String?
maxAccounts Int @default(0) maxAccounts Int @default(0)
diskLimitMb BigInt @default(0) diskLimitMb BigInt @default(0)
bandwidthLimitMb BigInt @default(0) bandwidthLimitMb BigInt @default(0)
diskUsedMb BigInt @default(0) diskUsedMb BigInt @default(0)
bandwidthUsedMb BigInt @default(0) bandwidthUsedMb BigInt @default(0)
status String @default("active") @db.VarChar(20) status String @default("active")
brandName String? @db.VarChar(255) brandName String?
brandLogo String? @db.VarChar(500) brandLogo String?
notes String? @db.Text notes String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
accounts Account[] accounts Account[]
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
auditLogs AuditLog[]
@@map("resellers") @@map("resellers")
} }
@@ -57,70 +58,70 @@ model Reseller {
// ─── Hosting Packages ──────────────────────────────────────────────────────── // ─── Hosting Packages ────────────────────────────────────────────────────────
model Package { model Package {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique @db.VarChar(100) name String @unique
diskMb BigInt @default(1024) diskMb BigInt @default(1024)
bandwidthMb BigInt @default(10240) bandwidthMb BigInt @default(10240)
maxDatabases Int @default(5) maxDatabases Int @default(5)
maxEmailAccounts Int @default(10) maxEmailAccounts Int @default(10)
maxSubdomains Int @default(10) maxSubdomains Int @default(10)
maxAddonDomains Int @default(5) maxAddonDomains Int @default(5)
maxParkedDomains Int @default(5) maxParkedDomains Int @default(5)
maxFtpAccounts Int @default(3) maxFtpAccounts Int @default(3)
maxCronJobs Int @default(5) maxCronJobs Int @default(5)
phpVersion String @default("8.3") @db.VarChar(10) phpVersion String @default("8.3")
sslEnabled Boolean @default(true) sslEnabled Boolean @default(true)
backupEnabled Boolean @default(true) backupEnabled Boolean @default(true)
dedicatedIp Boolean @default(false) dedicatedIp Boolean @default(false)
notes String? @db.Text notes String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
accounts Account[] accounts Account[]
@@map("packages") @@map("packages")
} }
// ─── Hosting Accounts (cPanel users) ───────────────────────────────────────── // ─── Hosting Accounts ────────────────────────────────────────────────────────
model Account { model Account {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
username String @unique @db.VarChar(64) username String @unique
passwordHash String @db.VarChar(255) passwordHash String
email String @db.VarChar(255) email String
domain String @unique @db.VarChar(255) domain String @unique
homedir String @db.VarChar(500) homedir String
shell String @default("/bin/false") @db.VarChar(100) shell String @default("/usr/sbin/nologin")
status String @default("active") @db.VarChar(20) status String @default("active")
diskUsedMb BigInt @default(0) diskUsedMb BigInt @default(0)
bandwidthUsedMb BigInt @default(0) bandwidthUsedMb BigInt @default(0)
ipAddress String? @db.VarChar(45) ipAddress String?
nameservers String? @db.VarChar(500) nameservers String?
notes String? @db.Text notes String?
suspendedAt DateTime? suspendedAt DateTime?
suspendReason String? @db.VarChar(500) suspendReason String?
terminatedAt DateTime? terminatedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
resellerId Int? resellerId Int?
reseller Reseller? @relation(fields: [resellerId], references: [id]) reseller Reseller? @relation(fields: [resellerId], references: [id])
packageId Int? packageId Int?
package Package? @relation(fields: [packageId], references: [id]) package Package? @relation(fields: [packageId], references: [id])
ipId Int? ipId Int?
ip IpAddress? @relation(fields: [ipId], references: [id]) ip IpAddress? @relation(fields: [ipId], references: [id])
dnsZones DnsZone[] dnsZones DnsZone[]
emailAccounts EmailAccount[] emailAccounts EmailAccount[]
emailForwarders EmailForwarder[] emailForwarders EmailForwarder[]
ftpAccounts FtpAccount[] ftpAccounts FtpAccount[]
databases Database[] databases Database[]
subdomains Subdomain[] subdomains Subdomain[]
addonDomains AddonDomain[] addonDomains AddonDomain[]
parkedDomains ParkedDomain[] parkedDomains ParkedDomain[]
sslCertificates SslCertificate[] sslCertificates SslCertificate[]
cronJobs CronJob[] cronJobs CronJob[]
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
@@map("accounts") @@map("accounts")
} }
@@ -128,17 +129,17 @@ model Account {
// ─── IP Addresses ───────────────────────────────────────────────────────────── // ─── IP Addresses ─────────────────────────────────────────────────────────────
model IpAddress { model IpAddress {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
address String @unique @db.VarChar(45) address String @unique
netmask String? @db.VarChar(45) netmask String?
gateway String? @db.VarChar(45) gateway String?
type String @default("shared") @db.VarChar(20) type String @default("shared")
status String @default("available") @db.VarChar(20) status String @default("available")
notes String? @db.Text notes String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
accounts Account[] accounts Account[]
@@map("ip_addresses") @@map("ip_addresses")
} }
@@ -146,27 +147,27 @@ model IpAddress {
// ─── DNS ────────────────────────────────────────────────────────────────────── // ─── DNS ──────────────────────────────────────────────────────────────────────
model DnsZone { model DnsZone {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
domain String @unique @db.VarChar(255) domain String @unique
zoneFile String? @db.VarChar(500) zoneFile String?
serial BigInt @default(1) serial BigInt @default(1)
status String @default("active") @db.VarChar(20) status String @default("active")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
accountId Int accountId Int
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
records DnsRecord[] records DnsRecord[]
@@map("dns_zones") @@map("dns_zones")
} }
model DnsRecord { model DnsRecord {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
type String @db.VarChar(10) type String
name String @db.VarChar(255) name String
value String @db.VarChar(500) value String
ttl Int @default(3600) ttl Int @default(3600)
priority Int? priority Int?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -182,13 +183,13 @@ model DnsRecord {
model EmailAccount { model EmailAccount {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
username String @db.VarChar(64) username String
domain String @db.VarChar(255) domain String
address String @unique @db.VarChar(320) address String @unique
passwordHash String @db.VarChar(255) passwordHash String
quotaMb Int @default(500) quotaMb Int @default(500)
usedMb Int @default(0) usedMb Int @default(0)
status String @default("active") @db.VarChar(20) status String @default("active")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -200,8 +201,8 @@ model EmailAccount {
model EmailForwarder { model EmailForwarder {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
source String @db.VarChar(320) source String
destination String @db.VarChar(320) destination String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
accountId Int accountId Int
@@ -214,11 +215,11 @@ model EmailForwarder {
model FtpAccount { model FtpAccount {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
username String @db.VarChar(64) username String
passwordHash String @db.VarChar(255) passwordHash String
homedir String @db.VarChar(500) homedir String
quotaMb Int @default(0) quotaMb Int @default(0)
status String @default("active") @db.VarChar(20) status String @default("active")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -232,9 +233,9 @@ model FtpAccount {
model Database { model Database {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique @db.VarChar(64) name String @unique
charset String @default("utf8mb4") @db.VarChar(32) charset String @default("utf8mb4")
collation String @default("utf8mb4_unicode_ci") @db.VarChar(64) collation String @default("utf8mb4_unicode_ci")
sizeMb Float @default(0) sizeMb Float @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -249,10 +250,10 @@ model Database {
model DatabaseUser { model DatabaseUser {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
username String @db.VarChar(64) username String
passwordHash String @db.VarChar(255) passwordHash String
host String @default("localhost") @db.VarChar(255) host String @default("localhost")
privileges String @default("ALL") @db.VarChar(255) privileges String @default("ALL")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -265,25 +266,25 @@ model DatabaseUser {
// ─── Subdomains / Addon / Parked Domains ───────────────────────────────────── // ─── Subdomains / Addon / Parked Domains ─────────────────────────────────────
model Subdomain { model Subdomain {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
subdomain String @db.VarChar(255) subdomain String
domain String @db.VarChar(255) domain String
fqdn String @unique @db.VarChar(510) fqdn String @unique
documentRoot String @db.VarChar(500) documentRoot String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
accountId Int accountId Int
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@map("subdomains") @@map("subdomains")
} }
model AddonDomain { model AddonDomain {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
domain String @unique @db.VarChar(255) domain String @unique
subdomain String @db.VarChar(255) subdomain String
documentRoot String @db.VarChar(500) documentRoot String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -295,7 +296,7 @@ model AddonDomain {
model ParkedDomain { model ParkedDomain {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
domain String @unique @db.VarChar(255) domain String @unique
createdAt DateTime @default(now()) createdAt DateTime @default(now())
accountId Int accountId Int
@@ -307,21 +308,21 @@ model ParkedDomain {
// ─── SSL ────────────────────────────────────────────────────────────────────── // ─── SSL ──────────────────────────────────────────────────────────────────────
model SslCertificate { model SslCertificate {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
domain String @db.VarChar(255) domain String
type String @default("letsencrypt") @db.VarChar(30) type String @default("letsencrypt")
certPath String? @db.VarChar(500) certPath String?
keyPath String? @db.VarChar(500) keyPath String?
chainPath String? @db.VarChar(500) chainPath String?
issuedAt DateTime? issuedAt DateTime?
expiresAt DateTime? expiresAt DateTime?
autoRenew Boolean @default(true) autoRenew Boolean @default(true)
status String @default("pending") @db.VarChar(20) status String @default("pending")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
accountId Int accountId Int
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@map("ssl_certificates") @@map("ssl_certificates")
} }
@@ -329,18 +330,18 @@ model SslCertificate {
// ─── Cron Jobs ──────────────────────────────────────────────────────────────── // ─── Cron Jobs ────────────────────────────────────────────────────────────────
model CronJob { model CronJob {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
schedule String @db.VarChar(100) schedule String
command String @db.VarChar(1000) command String
comment String? @db.VarChar(255) comment String?
enabled Boolean @default(true) enabled Boolean @default(true)
lastRun DateTime? lastRun DateTime?
nextRun DateTime? nextRun DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
accountId Int accountId Int
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@map("cron_jobs") @@map("cron_jobs")
} }
@@ -348,32 +349,32 @@ model CronJob {
// ─── Security ───────────────────────────────────────────────────────────────── // ─── Security ─────────────────────────────────────────────────────────────────
model SecurityEvent { model SecurityEvent {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
type String @db.VarChar(50) type String
severity String @default("info") @db.VarChar(20) severity String @default("info")
message String @db.Text message String
sourceIp String? @db.VarChar(45) sourceIp String?
resolved Boolean @default(false) resolved Boolean @default(false)
resolvedAt DateTime? resolvedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@map("security_events") @@map("security_events")
} }
model BlockedIp { model BlockedIp {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
ip String @unique @db.VarChar(45) ip String @unique
reason String? @db.VarChar(500) reason String?
expiresAt DateTime? expiresAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@map("blocked_ips") @@map("blocked_ips")
} }
model WhitelistedIp { model WhitelistedIp {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
ip String @unique @db.VarChar(45) ip String @unique
note String? @db.VarChar(255) note String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@map("whitelisted_ips") @@map("whitelisted_ips")
@@ -382,15 +383,17 @@ model WhitelistedIp {
// ─── Audit Log ──────────────────────────────────────────────────────────────── // ─── Audit Log ────────────────────────────────────────────────────────────────
model AuditLog { model AuditLog {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
action String @db.VarChar(100) action String
target String? @db.VarChar(255) target String?
details String? @db.Text details String?
sourceIp String? @db.VarChar(45) sourceIp String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
adminId Int? adminId Int?
admin AdminUser? @relation(fields: [adminId], references: [id]) admin AdminUser? @relation(fields: [adminId], references: [id])
resellerId Int?
reseller Reseller? @relation(fields: [resellerId], references: [id])
@@map("audit_log") @@map("audit_log")
} }
@@ -398,19 +401,19 @@ model AuditLog {
// ─── Auth ───────────────────────────────────────────────────────────────────── // ─── Auth ─────────────────────────────────────────────────────────────────────
model RefreshToken { model RefreshToken {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
token String @unique @db.VarChar(500) token String @unique
role String @db.VarChar(20) role String
expiresAt DateTime expiresAt DateTime
revokedAt DateTime? revokedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
adminId Int? adminId Int?
admin AdminUser? @relation(fields: [adminId], references: [id]) admin AdminUser? @relation(fields: [adminId], references: [id])
resellerId Int? resellerId Int?
reseller Reseller? @relation(fields: [resellerId], references: [id]) reseller Reseller? @relation(fields: [resellerId], references: [id])
accountId Int? accountId Int?
account Account? @relation(fields: [accountId], references: [id]) account Account? @relation(fields: [accountId], references: [id])
@@map("refresh_tokens") @@map("refresh_tokens")
} }
@@ -418,21 +421,9 @@ model RefreshToken {
// ─── Settings ───────────────────────────────────────────────────────────────── // ─── Settings ─────────────────────────────────────────────────────────────────
model Setting { model Setting {
key String @id @db.VarChar(100) key String @id
value String @db.Text value String
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@map("settings") @@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
View File
@@ -73,7 +73,7 @@ export async function buildApp() {
fastify.get('/api/health', async () => ({ fastify.get('/api/health', async () => ({
status: 'ok', status: 'ok',
version: '0.3.0', version: '0.4.3',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
})) }))
+23 -7
View File
@@ -15,9 +15,8 @@ export const config = {
isDev: optional('NODE_ENV', 'development') === 'development', isDev: optional('NODE_ENV', 'development') === 'development',
server: { server: {
host: optional('HOST', '0.0.0.0'), host: optional('HOST', '127.0.0.1'),
nixserverPort: parseInt(optional('NIXSERVER_PORT', '2087'), 10), port: parseInt(optional('PORT', '4000'), 10),
nixclientPort: parseInt(optional('NIXCLIENT_PORT', '2083'), 10),
}, },
db: { db: {
@@ -39,12 +38,18 @@ export const config = {
paths: { paths: {
homeDir: optional('HOME_DIR', '/home'), 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'), apacheSites: optional('APACHE_SITES_DIR', '/etc/apache2/sites-available'),
apacheEnabled: optional('APACHE_ENABLED_DIR', '/etc/apache2/sites-enabled'), 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'), certbotWebroot: optional('CERTBOT_WEBROOT', '/var/www/letsencrypt'),
letsencryptLive: optional('LETSENCRYPT_LIVE', '/etc/letsencrypt/live'),
}, },
rateLimit: { rateLimit: {
@@ -52,8 +57,19 @@ export const config = {
windowMs: parseInt(optional('RATE_LIMIT_WINDOW_MS', '60000'), 10), 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: { features: {
terminalEnabled: optional('ENABLE_TERMINAL', 'true') === 'true',
wsStatsInterval: parseInt(optional('WEBSOCKET_STATS_INTERVAL', '5000'), 10), wsStatsInterval: parseInt(optional('WEBSOCKET_STATS_INTERVAL', '5000'), 10),
}, },
} as const } as const
+174
View File
@@ -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
View File
@@ -1,4 +1,5 @@
import path from 'path' import path from 'path'
import fs from 'fs/promises'
import { config } from '../config.js' import { config } from '../config.js'
import { exec, writeFile } from './exec.js' import { exec, writeFile } from './exec.js'
@@ -12,9 +13,6 @@ export interface DnsZoneOptions {
ttl?: number ttl?: number
} }
/**
* Generate a BIND9 zone file for a domain.
*/
export function generateZoneFile(opts: DnsZoneOptions): string { export function generateZoneFile(opts: DnsZoneOptions): string {
const { const {
domain, domain,
@@ -22,19 +20,19 @@ export function generateZoneFile(opts: DnsZoneOptions): string {
ns1, ns1,
ns2, ns2,
mail = `mail.${opts.domain}`, mail = `mail.${opts.domain}`,
serial = Math.floor(Date.now() / 1000), serial = generateSerial(),
ttl = 3600, ttl = 3600,
} = opts } = opts
return `; Zone file for ${domain} return `; Zone file for ${domain}
; Generated by NixPanel ; Managed by NixPanel — do not edit manually
$TTL ${ttl} $TTL ${ttl}
@ IN SOA ${ns1}. hostmaster.${domain}. ( @ IN SOA ${ns1}. hostmaster.${domain}. (
${serial} ; Serial ${serial} ; Serial
3600 ; Refresh 3600 ; Refresh
1800 ; Retry 1800 ; Retry
604800 ; Expire 604800 ; Expire
300 ) ; Minimum TTL 300 ) ; Minimum TTL
; Name servers ; Name servers
@ IN NS ${ns1}. @ IN NS ${ns1}.
@@ -44,12 +42,11 @@ $TTL ${ttl}
@ IN A ${ip} @ IN A ${ip}
www IN A ${ip} www IN A ${ip}
mail IN A ${ip} mail IN A ${ip}
ftp IN A ${ip}
nixclient IN A ${ip}
webmail IN A ${ip} webmail IN A ${ip}
cpanel IN A ${ip}
; MX record ; MX record
@ IN MX 10 ${mail}. @ IN MX 10 ${mail}.
; SPF ; SPF
@ IN TXT "v=spf1 a mx ip4:${ip} ~all" @ 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> { 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 writeFile(zoneFile, generateZoneFile(opts))
await registerZoneInBindBackend(opts.domain, zoneFile)
// Add zone declaration to named.conf.local (idempotent) // Tell PowerDNS about the new zone without a full restart
const namedConf = '/etc/bind/named.conf.local' const reload = await exec('pdns_control', ['bind-add-zone', opts.domain, zoneFile])
const zoneDecl = ` if (reload.exitCode !== 0) {
zone "${opts.domain}" { // Fall back to full reload if bind-add-zone not supported
type master; await exec('systemctl', ['reload', 'pdns'])
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
} }
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 return zoneFile
} }
/** /**
* Remove a zone and reload BIND. * Remove a DNS zone and reload PowerDNS.
*/ */
export async function removeZone(domain: string): Promise<void> { export async function removeZone(domain: string): Promise<void> {
const zoneFile = path.join(config.paths.bindZones, `db.${domain}`) const zoneFile = path.join(config.paths.pdnsZones, `db.${domain}`)
await exec('rm', ['-f', zoneFile]) 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' * Update just the serial in an existing zone file to force secondary
const { readFile, writeFile: fsWrite } = await import('fs/promises') * 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 { try {
const content = await readFile(namedConf, 'utf8') content = await fs.readFile(zoneFile, 'utf8')
const cleaned = content.replace( } catch {
new RegExp(`zone\\s+"${domain.replace('.', '\\.')}\"[^}]+\\};\\s*`, 'g'), throw new Error(`Zone file not found for ${domain}`)
'' }
)
await fsWrite(namedConf, cleaned, 'utf8') 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 { } catch {
// best effort // 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
View File
@@ -1,4 +1,4 @@
import { execFile } from 'child_process' import { execFile, spawn } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import { existsSync } from 'fs' import { existsSync } from 'fs'
@@ -7,62 +7,49 @@ const execFileAsync = promisify(execFile)
// Commands allowed to be executed — nothing outside this list runs // Commands allowed to be executed — nothing outside this list runs
const ALLOWED_COMMANDS = new Set([ const ALLOWED_COMMANDS = new Set([
// User management // User management
'useradd', 'userdel', 'usermod', 'passwd', 'chage', 'id', 'groups', 'useradd', 'userdel', 'usermod', 'chage', 'id',
// File operations // File operations
'mkdir', 'rmdir', 'rm', 'cp', 'mv', 'chmod', 'chown', 'ln', 'tar', 'gzip', 'gunzip', 'mkdir', 'rmdir', 'rm', 'cp', 'mv', 'chmod', 'chown', 'ln', 'tar',
'find', 'du', 'df', 'ls', 'stat', 'find', 'du', 'df', 'stat',
// Web servers // Web server
'nginx', 'apache2', 'apache2ctl', 'a2ensite', 'a2dissite', 'a2enmod', 'a2dismod', 'apache2ctl', 'a2ensite', 'a2dissite', 'a2enmod', 'a2dismod',
// DNS // DNS
'named', 'named-checkconf', 'named-checkzone', 'rndc', 'pdns_control', 'pdnsutil',
// Mail // Mail
'postfix', 'postmap', 'postconf', 'dovecot', 'doveadm', 'dovecot', 'openssl',
// SSL // SSL
'certbot', 'openssl', 'certbot',
// FTP
'vsftpd',
// Services // Services
'systemctl', 'service', 'systemctl',
// Network // Network
'ss', 'ip', 'hostname', 'ping', 'traceroute', 'dig', 'nslookup', 'ss', 'ip', 'hostname',
// System // System
'uname', 'uptime', 'free', 'vmstat', 'iostat', 'top', 'ps', 'kill', 'pkill', 'uname', 'free', 'ps',
'cat', 'grep', 'awk', 'sed', 'head', 'tail', 'wc', 'sort', 'uniq', 'cut',
// Package managers
'apt', 'apt-get', 'dpkg',
// MySQL/MariaDB
'mysql', 'mysqladmin', 'mysqlcheck', 'mysqldump',
// PHP // PHP
'php', 'php7.4', 'php8.0', 'php8.1', 'php8.2', 'php8.3', 'update-alternatives', 'php8.2', 'php8.3',
// Misc
'touch', 'echo', 'which', 'whoami', 'env',
// Fail2ban // Fail2ban
'fail2ban-client', 'fail2ban-client',
// UFW // iptables
'ufw', 'iptables', 'netfilter-persistent',
]) ])
// Characters never allowed in arguments (shell injection prevention) // Shell metacharacters never allowed in arguments
const DANGEROUS_CHARS = /[;&|`$<>()\n\r\0]/ 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 = [ const ALLOWED_PATH_PREFIXES = [
'/home/', '/home/',
'/etc/nginx/',
'/etc/apache2/', '/etc/apache2/',
'/etc/bind/',
'/etc/postfix/',
'/etc/dovecot/',
'/etc/vsftpd',
'/etc/php/', '/etc/php/',
'/etc/pdns/',
'/etc/exim4/',
'/etc/dovecot/',
'/etc/letsencrypt/', '/etc/letsencrypt/',
'/etc/fail2ban/', '/etc/fail2ban/',
'/etc/nixpanel/',
'/var/www/', '/var/www/',
'/var/mail/',
'/var/log/', '/var/log/',
'/var/spool/cron/', '/opt/nixpanel/',
'/tmp/',
'/usr/local/nixpanel/',
] ]
function validateArg(arg: string): void { function validateArg(arg: string): void {
@@ -71,8 +58,8 @@ function validateArg(arg: string): void {
} }
} }
function isAllowedPath(path: string): boolean { function isAllowedPath(p: string): boolean {
return ALLOWED_PATH_PREFIXES.some(prefix => path.startsWith(prefix)) return ALLOWED_PATH_PREFIXES.some(prefix => p.startsWith(prefix))
} }
export interface ExecResult { export interface ExecResult {
@@ -83,12 +70,13 @@ export interface ExecResult {
export interface ExecOptions { export interface ExecOptions {
timeout?: number timeout?: number
allowedPaths?: string[] input?: string // data to pipe to stdin
} }
/** /**
* Execute a whitelisted system command safely. * 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( export async function exec(
command: string, command: string,
@@ -103,12 +91,15 @@ export async function exec(
validateArg(arg) 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 { try {
const { stdout, stderr } = await execFileAsync(command, args, { const { stdout, stderr } = await execFileAsync(command, args, {
timeout, timeout: options.timeout ?? 30_000,
maxBuffer: 10 * 1024 * 1024, // 10MB maxBuffer: 10 * 1024 * 1024,
}) })
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 } return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 }
} catch (err: unknown) { } 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> { export async function writeFile(filePath: string, content: string): Promise<void> {
if (!isAllowedPath(path)) { if (!isAllowedPath(filePath)) {
throw new Error(`Path not in allowed list: ${path}`) throw new Error(`Path not in allowed list: ${filePath}`)
} }
const { writeFile: fsWrite } = await import('fs/promises') const { writeFile: fsWrite } = await import('fs/promises')
await fsWrite(path, content, 'utf8') await fsWrite(filePath, content, 'utf8')
} }
/** /**
* Read a file from an allowed path. * Read a file from an allowed path.
*/ */
export async function readFile(path: string): Promise<string> { export async function readFile(filePath: string): Promise<string> {
if (!isAllowedPath(path)) { if (!isAllowedPath(filePath)) {
throw new Error(`Path not in allowed list: ${path}`) throw new Error(`Path not in allowed list: ${filePath}`)
} }
const { readFile: fsRead } = await import('fs/promises') const { readFile: fsRead } = await import('fs/promises')
return fsRead(path, 'utf8') return fsRead(filePath, 'utf8')
} }
/**
* Check if running as root.
*/
export function isRoot(): boolean { export function isRoot(): boolean {
return process.getuid?.() === 0 return process.getuid?.() === 0
} }
/**
* Validate a Linux username.
*/
export function validateUsername(username: string): boolean { export function validateUsername(username: string): boolean {
return /^[a-z][a-z0-9_-]{0,31}$/.test(username) return /^[a-z][a-z0-9_-]{0,31}$/.test(username)
} }
/**
* Validate a domain name.
*/
export function validateDomain(domain: string): boolean { 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) return /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(domain)
} }
/** export function fileExists(filePath: string): boolean {
* File exists check without shell. return existsSync(filePath)
*/
export function fileExists(path: string): boolean {
return existsSync(path)
} }
+99
View File
@@ -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 ''
}
}
+195
View File
@@ -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
}
}
+163
View File
@@ -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(', ')
}
+97
View File
@@ -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}`)
}
}
-138
View File
@@ -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'])
}
+104
View File
@@ -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
}
+70
View File
@@ -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
}
}
+9 -3
View File
@@ -126,7 +126,13 @@ export async function getServiceStatus(name: string): Promise<ServiceStatus> {
} }
export const MANAGED_SERVICES = [ export const MANAGED_SERVICES = [
'nginx', 'apache2', 'mysql', 'mariadb', 'apache2',
'postfix', 'dovecot', 'vsftpd', 'bind9', 'php8.2-fpm',
'fail2ban', 'ufw', 'cron', 'ssh', 'php8.3-fpm',
'exim4',
'dovecot',
'pdns',
'mariadb',
'fail2ban',
'ssh',
] ]
+4 -4
View File
@@ -73,10 +73,10 @@ async function main() {
// Default settings // Default settings
const settings = [ const settings = [
{ key: 'php_default_version', value: '8.3' }, { key: 'php_default_version', value: '8.3' },
{ key: 'mail_server', value: 'postfix' }, { key: 'mail_server', value: 'exim4' },
{ key: 'ftp_server', value: 'vsftpd' }, { key: 'ftp_server', value: 'disabled' },
{ key: 'webserver', value: 'nginx' }, { key: 'webserver', value: 'apache2' },
{ key: 'dns_server', value: 'bind9' }, { key: 'dns_server', value: 'powerdns' },
{ key: 'ssl_provider', value: 'letsencrypt' }, { key: 'ssl_provider', value: 'letsencrypt' },
{ key: 'backup_enabled', value: 'true' }, { key: 'backup_enabled', value: 'true' },
{ key: 'backup_retention_days', value: '7' }, { key: 'backup_retention_days', value: '7' },
+9 -9
View File
@@ -2,32 +2,32 @@ import 'dotenv/config'
import { buildApp } from './app.js' import { buildApp } from './app.js'
import { config } from './config.js' import { config } from './config.js'
import prisma from './db/client.js' import prisma from './db/client.js'
import { startScheduler, stopScheduler } from './scheduler.js'
async function start() { async function start() {
let app let app
try { try {
// Verify DB connection
await prisma.$connect() await prisma.$connect()
console.log('Database connected') console.log('[nixpanel] Database connected')
app = await buildApp() app = await buildApp()
// NixServer on port 2087 await app.listen({ port: config.server.port, host: config.server.host })
await app.listen({ port: config.server.nixserverPort, host: config.server.host }) console.log(`[nixpanel] API running on ${config.server.host}:${config.server.port}`)
console.log(`NixServer running on port ${config.server.nixserverPort}`)
console.log(`NixClient API running on port ${config.server.nixserverPort} (/api/cpanel)`) startScheduler()
} catch (err) { } catch (err) {
console.error('Failed to start server:', err) console.error('[nixpanel] Failed to start:', err)
await prisma.$disconnect() await prisma.$disconnect()
process.exit(1) process.exit(1)
} }
// Graceful shutdown
const shutdown = async (signal: string) => { const shutdown = async (signal: string) => {
console.log(`${signal} received, shutting down...`) console.log(`[nixpanel] ${signal} received, shutting down...`)
await app?.close() await app?.close()
await stopScheduler()
await prisma.$disconnect() await prisma.$disconnect()
process.exit(0) process.exit(0)
} }
+7 -8
View File
@@ -28,7 +28,7 @@ function signTokens(
const refreshToken = fastify.jwt.sign( const refreshToken = fastify.jwt.sign(
{ ...payload, type: 'refresh' }, { ...payload, type: 'refresh' },
{ secret: config.jwt.refreshSecret, expiresIn: config.jwt.refreshExpiresIn } { expiresIn: config.jwt.refreshExpiresIn }
) )
return { accessToken, refreshToken } return { accessToken, refreshToken }
@@ -157,8 +157,7 @@ export default async function authRoutes(fastify: FastifyInstance) {
let payload: JwtPayload & { type?: string } let payload: JwtPayload & { type?: string }
try { try {
payload = fastify.jwt.verify<JwtPayload & { type?: string }>( payload = fastify.jwt.verify<JwtPayload & { type?: string }>(
body.data.refreshToken, body.data.refreshToken
{ secret: config.jwt.refreshSecret }
) )
} catch { } catch {
return reply.code(401).send({ success: false, error: 'Invalid or expired refresh token' }) 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 accessToken = fastify.jwt.sign(newPayload, { expiresIn: config.jwt.expiresIn })
const newRefresh = fastify.jwt.sign( const newRefresh = fastify.jwt.sign(
{ ...newPayload, type: 'refresh' }, { ...newPayload, type: 'refresh' },
{ secret: config.jwt.refreshSecret, expiresIn: config.jwt.refreshExpiresIn } { expiresIn: config.jwt.refreshExpiresIn }
) )
const expiresAt = new Date() const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 7) expiresAt.setDate(expiresAt.getDate() + 7)
const createData: Parameters<typeof prisma.refreshToken.create>[0]['data'] = { const createData = {
token: newRefresh, token: newRefresh,
role: payload.role, role: payload.role,
expiresAt, 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 }) await prisma.refreshToken.create({ data: createData })
+1 -1
View File
@@ -121,7 +121,7 @@ async function writeCrontab(username: string, accountId: number): Promise<void>
`# NixPanel crontab for ${username}`, `# NixPanel crontab for ${username}`,
`# DO NOT EDIT — managed by NixPanel`, `# 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}` : ''}`),
'', '',
] ]
+33 -28
View File
@@ -3,11 +3,15 @@ import bcrypt from 'bcryptjs'
import { z } from 'zod' import { z } from 'zod'
import prisma from '../../db/client.js' import prisma from '../../db/client.js'
import { requireAuth } from '../../middleware/auth.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 { const nameSchema = z.string().min(1).max(32).regex(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores allowed')
return s.replace(/'/g, "\\'").replace(/\\/g, '\\\\')
}
export default async function databaseRoutes(fastify: FastifyInstance) { export default async function databaseRoutes(fastify: FastifyInstance) {
const preHandler = [requireAuth] const preHandler = [requireAuth]
@@ -15,7 +19,9 @@ export default async function databaseRoutes(fastify: FastifyInstance) {
// GET /api/nixclient/databases // GET /api/nixclient/databases
fastify.get('/', { preHandler }, async (request, reply) => { fastify.get('/', { preHandler }, async (request, reply) => {
const user = request.user! 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({ const databases = await prisma.database.findMany({
where: { accountId }, where: { accountId },
@@ -28,10 +34,12 @@ export default async function databaseRoutes(fastify: FastifyInstance) {
// POST /api/nixclient/databases // POST /api/nixclient/databases
fastify.post('/', { preHandler }, async (request, reply) => { fastify.post('/', { preHandler }, async (request, reply) => {
const user = request.user! 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({ const body = z.object({
name: z.string().min(1).max(32).regex(/^[a-zA-Z0-9_]+$/), name: nameSchema,
charset: z.string().default('utf8mb4'), charset: z.string().default('utf8mb4'),
collation: z.string().default('utf8mb4_unicode_ci'), collation: z.string().default('utf8mb4_unicode_ci'),
}).safeParse(request.body) }).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' }) if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
// Check quota
if (account.package && account.package.maxDatabases > 0) { if (account.package && account.package.maxDatabases > 0) {
const count = await prisma.database.count({ where: { accountId } }) const count = await prisma.database.count({ where: { accountId } })
if (count >= account.package.maxDatabases) { 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 dbName = `${account.username}_${body.data.name}`
const exists = await prisma.database.findUnique({ where: { name: dbName } }) const exists = await prisma.database.findUnique({ where: { name: dbName } })
if (exists) return reply.code(409).send({ success: false, error: 'Database already exists' }) if (exists) return reply.code(409).send({ success: false, error: 'Database already exists' })
// Create the actual MariaDB database await createDatabase(dbName, body.data.charset, body.data.collation)
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}` })
}
const db = await prisma.database.create({ const db = await prisma.database.create({
data: { name: dbName, charset: body.data.charset, collation: body.data.collation, accountId }, 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), id: parseInt(id, 10),
...(user.role === 'user' ? { accountId: user.sub } : {}), ...(user.role === 'user' ? { accountId: user.sub } : {}),
}, },
include: { dbUsers: true },
}) })
if (!db) return reply.code(404).send({ success: false, error: 'Database not found' }) 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 } }) await prisma.database.delete({ where: { id: db.id } })
return reply.send({ success: true, data: { message: `Database ${db.name} deleted` } }) 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 user = request.user!
const body = z.object({ const body = z.object({
username: z.string().min(1).max(16).regex(/^[a-zA-Z0-9_]+$/), username: nameSchema,
password: z.string().min(8), password: z.string().min(8),
privileges: z.string().default('ALL'), privileges: z.enum(['ALL', 'SELECT', 'SELECT,INSERT,UPDATE,DELETE']).default('ALL'),
}).safeParse(request.body) }).safeParse(request.body)
if (!body.success) { 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' }) if (!db) return reply.code(404).send({ success: false, error: 'Database not found' })
const dbUsername = `${db.account.username}_${body.data.username}` const dbUsername = `${db.account.username}_${body.data.username}`
const escapedPass = mysqlEscape(body.data.password)
// Create MariaDB user and grant privileges await createDatabaseUser(dbUsername, body.data.password)
await exec('mysql', ['-e', `CREATE USER '${mysqlEscape(dbUsername)}'@'localhost' IDENTIFIED BY '${escapedPass}';`]) await grantPrivileges(db.name, dbUsername, body.data.privileges)
await exec('mysql', ['-e', `GRANT ${mysqlEscape(body.data.privileges)} ON \`${mysqlEscape(db.name)}\`.* TO '${mysqlEscape(dbUsername)}'@'localhost';`])
await exec('mysql', ['-e', 'FLUSH PRIVILEGES;'])
const passwordHash = await bcrypt.hash(body.data.password, 12) const passwordHash = await bcrypt.hash(body.data.password, 12)
const dbUser = await prisma.databaseUser.create({ const dbUser = await prisma.databaseUser.create({
@@ -142,14 +143,18 @@ export default async function databaseRoutes(fastify: FastifyInstance) {
// DELETE /api/nixclient/databases/:id/users/:userId // DELETE /api/nixclient/databases/:id/users/:userId
fastify.delete('/:id/users/:userId', { preHandler }, async (request, reply) => { fastify.delete('/:id/users/:userId', { preHandler }, async (request, reply) => {
const { id, userId } = request.params as { id: string; userId: string } const { id, userId } = request.params as { id: string; userId: string }
const user = request.user!
const dbUser = await prisma.databaseUser.findFirst({ 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' }) 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 dropDatabaseUser(dbUser.username)
await exec('mysql', ['-e', 'FLUSH PRIVILEGES;'])
await prisma.databaseUser.delete({ where: { id: dbUser.id } }) await prisma.databaseUser.delete({ where: { id: dbUser.id } })
return reply.send({ success: true, data: { message: 'Database user deleted' } }) return reply.send({ success: true, data: { message: 'Database user deleted' } })
+6 -6
View File
@@ -3,7 +3,7 @@ import { z } from 'zod'
import prisma from '../../db/client.js' import prisma from '../../db/client.js'
import { requireAuth } from '../../middleware/auth.js' import { requireAuth } from '../../middleware/auth.js'
import { validateDomain, exec } from '../../core/exec.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) { export default async function domainRoutes(fastify: FastifyInstance) {
const preHandler = [requireAuth] const preHandler = [requireAuth]
@@ -48,7 +48,7 @@ export default async function domainRoutes(fastify: FastifyInstance) {
await exec('mkdir', ['-p', docRoot]) await exec('mkdir', ['-p', docRoot])
await exec('chown', [`${account.username}:${account.username}`, docRoot]) await exec('chown', [`${account.username}:${account.username}`, docRoot])
// Create nginx vhost for subdomain // Create Apache vhost for subdomain
try { try {
await enableVhost({ domain: fqdn, username: account.username, documentRoot: docRoot }) await enableVhost({ domain: fqdn, username: account.username, documentRoot: docRoot })
} catch (e) { } 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' }) 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 {} try { await disableVhost(subdomain.fqdn) } catch {}
await prisma.subdomain.delete({ where: { id: subdomain.id } }) 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' }) 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 {} try { await disableVhost(addon.domain) } catch {}
await prisma.addonDomain.delete({ where: { id: addon.id } }) 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 } }) 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' }) 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 { try {
await enableVhost({ await enableVhost({
domain: body.data.domain, 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' }) 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 {} try { await disableVhost(parked.domain) } catch {}
await prisma.parkedDomain.delete({ where: { id: parked.id } }) await prisma.parkedDomain.delete({ where: { id: parked.id } })
+63 -18
View File
@@ -3,9 +3,9 @@ import bcrypt from 'bcryptjs'
import { z } from 'zod' import { z } from 'zod'
import prisma from '../../db/client.js' import prisma from '../../db/client.js'
import { requireAuth } from '../../middleware/auth.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] const preHandler = [requireAuth]
// GET /api/nixclient/email // 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 accountId = user.role === 'user' ? user.sub : (request.body as Record<string, unknown>).accountId as number
const body = z.object({ 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), password: z.string().min(8),
quotaMb: z.number().int().min(0).default(500), quotaMb: z.number().int().min(0).default(500),
}).safeParse(request.body) }).safeParse(request.body)
@@ -50,33 +50,72 @@ export default async function cpanelEmailRoutes(fastify: FastifyInstance) {
const exists = await prisma.emailAccount.findUnique({ where: { address } }) const exists = await prisma.emailAccount.findUnique({ where: { address } })
if (exists) return reply.code(409).send({ success: false, error: 'Email address already exists' }) 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) 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({ 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 const { passwordHash: _, ...safe } = emailAccount
return reply.code(201).send({ success: true, data: safe }) 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 // DELETE /api/nixclient/email/:id
fastify.delete('/:id', { preHandler }, async (request, reply) => { fastify.delete('/:id', { preHandler }, async (request, reply) => {
const user = request.user! const user = request.user!
const accountId = user.role === 'user' ? user.sub : 0
const { id } = request.params as { id: string } 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 (!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 } }) await prisma.emailAccount.delete({ where: { id: emailAccount.id } })
return reply.send({ success: true, data: { message: 'Email account deleted' } }) 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 }) 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 } }) const forwarder = await prisma.emailForwarder.create({ data: { ...body.data, accountId } })
return reply.code(201).send({ success: true, data: forwarder }) 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 // DELETE /api/nixclient/email/forwarders/:id
fastify.delete('/forwarders/:id', { preHandler }, async (request, reply) => { fastify.delete('/forwarders/:id', { preHandler }, async (request, reply) => {
const user = request.user! const user = request.user!
const accountId = user.role === 'user' ? user.sub : 0
const { id } = request.params as { id: string } 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 (!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 } }) await prisma.emailForwarder.delete({ where: { id: forwarder.id } })
return reply.send({ success: true, data: { message: 'Forwarder deleted' } }) return reply.send({ success: true, data: { message: 'Forwarder deleted' } })
}) })
+2
View File
@@ -6,6 +6,7 @@ import domainRoutes from './domains.js'
import ftpRoutes from './ftp.js' import ftpRoutes from './ftp.js'
import cronRoutes from './cron.js' import cronRoutes from './cron.js'
import statsRoutes from './stats.js' import statsRoutes from './stats.js'
import sslRoutes from './ssl.js'
export default async function nixclientRoutes(fastify: FastifyInstance) { export default async function nixclientRoutes(fastify: FastifyInstance) {
fastify.register(fileRoutes, { prefix: '/files' }) fastify.register(fileRoutes, { prefix: '/files' })
@@ -15,4 +16,5 @@ export default async function nixclientRoutes(fastify: FastifyInstance) {
fastify.register(ftpRoutes, { prefix: '/ftp' }) fastify.register(ftpRoutes, { prefix: '/ftp' })
fastify.register(cronRoutes, { prefix: '/cron' }) fastify.register(cronRoutes, { prefix: '/cron' })
fastify.register(statsRoutes, { prefix: '/stats' }) fastify.register(statsRoutes, { prefix: '/stats' })
fastify.register(sslRoutes, { prefix: '/ssl' })
} }
+46
View File
@@ -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 })
})
}
+263 -10
View File
@@ -4,7 +4,7 @@ import { z } from 'zod'
import prisma from '../../db/client.js' import prisma from '../../db/client.js'
import { requireAdminOrReseller } from '../../middleware/auth.js' import { requireAdminOrReseller } from '../../middleware/auth.js'
import { exec, validateUsername, validateDomain } from '../../core/exec.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 { createZone, removeZone } from '../../core/dns.js'
import { config } from '../../config.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 } }) 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 // GET /api/nixserver/accounts/:id
fastify.get('/:id', { preHandler }, async (request, reply) => { fastify.get('/:id', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
@@ -139,7 +351,7 @@ export default async function accountRoutes(fastify: FastifyInstance) {
// Create system user // Create system user
const userAdd = await exec('useradd', [ 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) { if (userAdd.exitCode !== 0 && !config.isDev) {
return reply.code(500).send({ success: false, error: `Failed to create system user: ${userAdd.stderr}` }) 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 { try {
await enableVhost({ await enablePhpFpmPool(username, phpVersion)
domain, await enableVhost({ domain, username, documentRoot: `${homedir}/public_html`, phpVersion })
username,
documentRoot: `${homedir}/public_html`,
})
} catch (e) { } catch (e) {
console.warn(`Nginx vhost creation failed for ${domain}:`, e) console.warn(`Apache/PHP-FPM setup failed for ${domain}:`, e)
} }
// Audit log // 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' }) if (!account) return reply.code(404).send({ success: false, error: 'Active account not found' })
await exec('usermod', ['-L', account.username]) 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({ await prisma.account.update({
where: { id: account.id }, 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' }) if (!account) return reply.code(404).send({ success: false, error: 'Suspended account not found' })
await exec('usermod', ['-U', account.username]) 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({ await prisma.account.update({
where: { id: account.id }, 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) } }) const account = await prisma.account.findUnique({ where: { id: parseInt(id, 10) } })
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' }) if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
// Remove nginx vhost // Remove Apache vhost + PHP-FPM pool
try { try {
await disableVhost(account.domain) await disableVhost(account.domain)
} catch (e) { } catch (e) {
console.warn(`Failed to remove vhost for ${account.domain}:`, 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 // Remove DNS zone
try { try {
@@ -364,6 +590,33 @@ export default async function accountRoutes(fastify: FastifyInstance) {
return reply.send({ success: true, data: { message: `Account ${account.username} terminated` } }) 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 // POST /api/nixserver/accounts/bulk
fastify.post('/bulk', { preHandler }, async (request, reply) => { fastify.post('/bulk', { preHandler }, async (request, reply) => {
const body = z.object({ const body = z.object({
+2
View File
@@ -11,8 +11,10 @@ import systemRoutes from './system.js'
import securityRoutes from './security.js' import securityRoutes from './security.js'
import backupRoutes from './backup.js' import backupRoutes from './backup.js'
import webserverRoutes from './webserver.js' import webserverRoutes from './webserver.js'
import setupRoutes from './setup.js'
export default async function nixserverRoutes(fastify: FastifyInstance) { export default async function nixserverRoutes(fastify: FastifyInstance) {
fastify.register(setupRoutes, { prefix: '/setup' })
fastify.register(accountRoutes, { prefix: '/accounts' }) fastify.register(accountRoutes, { prefix: '/accounts' })
fastify.register(packageRoutes, { prefix: '/packages' }) fastify.register(packageRoutes, { prefix: '/packages' })
fastify.register(resellerRoutes, { prefix: '/resellers' }) fastify.register(resellerRoutes, { prefix: '/resellers' })
+1 -1
View File
@@ -1,7 +1,7 @@
import type { FastifyInstance } from 'fastify' import type { FastifyInstance } from 'fastify'
import { z } from 'zod' import { z } from 'zod'
import { requireAdmin } from '../../middleware/auth.js' 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' import { getServiceStatus, MANAGED_SERVICES } from '../../core/system.js'
const ServiceActionSchema = z.object({ const ServiceActionSchema = z.object({
+83
View File
@@ -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' } })
})
}
+7 -13
View File
@@ -1,11 +1,10 @@
import type { FastifyInstance } from 'fastify' import type { FastifyInstance } from 'fastify'
import { z } from 'zod' import { z } from 'zod'
import path from 'path'
import prisma from '../../db/client.js' import prisma from '../../db/client.js'
import { requireAdminOrReseller } from '../../middleware/auth.js' import { requireAdminOrReseller } from '../../middleware/auth.js'
import { exec } from '../../core/exec.js' import { exec } from '../../core/exec.js'
import { enableVhost } from '../../core/nginx.js' import { enableVhost } from '../../core/apache.js'
import { config } from '../../config.js' import { getCertPaths } from '../../core/ssl.js'
export default async function sslRoutes(fastify: FastifyInstance) { export default async function sslRoutes(fastify: FastifyInstance) {
const preHandler = [requireAdminOrReseller] const preHandler = [requireAdminOrReseller]
@@ -47,23 +46,19 @@ export default async function sslRoutes(fastify: FastifyInstance) {
if (includeWww) domains.push(`www.${domain}`) if (includeWww) domains.push(`www.${domain}`)
const certbotArgs = [ const certbotArgs = [
'certonly', '--webroot', 'certonly', '--apache',
'-w', config.paths.certbotWebroot,
'--non-interactive', '--non-interactive',
'--agree-tos', '--agree-tos',
'--email', account.email, '--email', account.email,
...domains.flatMap(d => ['-d', d]), ...domains.flatMap(d => ['-d', d]),
] ]
const result = await exec('certbot', certbotArgs) const result = await exec('certbot', certbotArgs, { timeout: 120_000 })
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
return reply.code(500).send({ success: false, error: `certbot failed: ${result.stderr}` }) return reply.code(500).send({ success: false, error: `certbot failed: ${result.stderr}` })
} }
const certDir = `/etc/letsencrypt/live/${domain}` const { certPath, keyPath, chainPath } = getCertPaths(domain)
const certPath = `${certDir}/fullchain.pem`
const keyPath = `${certDir}/privkey.pem`
const chainPath = `${certDir}/chain.pem`
const issuedAt = new Date() const issuedAt = new Date()
const expiresAt = 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 { try {
await enableVhost({ await enableVhost({
domain, domain,
@@ -91,10 +86,9 @@ export default async function sslRoutes(fastify: FastifyInstance) {
sslEnabled: true, sslEnabled: true,
certPath, certPath,
keyPath, keyPath,
chainPath,
}) })
} catch (e) { } 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 }) return reply.send({ success: true, data: cert })
+55
View File
@@ -1,10 +1,13 @@
import type { FastifyInstance } from 'fastify' import type { FastifyInstance } from 'fastify'
import { spawn } from 'child_process'
import prisma from '../../db/client.js' import prisma from '../../db/client.js'
import { requireAdmin } from '../../middleware/auth.js' import { requireAdmin } from '../../middleware/auth.js'
import { getSystemStats } from '../../core/system.js' import { getSystemStats } from '../../core/system.js'
import { exec } from '../../core/exec.js' import { exec } from '../../core/exec.js'
import { readFile } from 'fs/promises' import { readFile } from 'fs/promises'
const INSTALL_DIR = process.env.INSTALL_DIR ?? '/opt/nixpanel'
export default async function systemRoutes(fastify: FastifyInstance) { export default async function systemRoutes(fastify: FastifyInstance) {
// GET /api/nixserver/system/stats // GET /api/nixserver/system/stats
fastify.get('/stats', { preHandler: [requireAdmin] }, async (request, reply) => { fastify.get('/stats', { preHandler: [requireAdmin] }, async (request, reply) => {
@@ -121,4 +124,56 @@ export default async function systemRoutes(fastify: FastifyInstance) {
return reply.send({ success: true, data: { message: 'Settings updated' } }) return reply.send({ success: true, data: { message: 'Settings updated' } })
}) })
// GET /api/nixserver/system/version
// Returns the installed version and checks GitHub for the latest available version.
fastify.get('/version', { preHandler: [requireAdmin] }, async (_request, reply) => {
// Read installed version from package.json
let currentVersion = '0.0.0'
try {
const pkg = JSON.parse(await readFile(`${INSTALL_DIR}/package.json`, 'utf8'))
currentVersion = pkg.version ?? '0.0.0'
} catch { /* running in dev or non-standard path */ }
// Check GitHub for latest version (5 s timeout, non-fatal)
let latestVersion: string | null = null
try {
const resp = await fetch(
'https://raw.githubusercontent.com/NixReaper/nixpanel-server/main/package.json',
{ signal: AbortSignal.timeout(5_000) }
)
if (resp.ok) {
const json = await resp.json() as { version?: string }
latestVersion = json.version ?? null
}
} catch { /* offline or rate-limited */ }
return reply.send({
success: true,
data: {
currentVersion,
latestVersion,
updateAvailable: latestVersion !== null && latestVersion !== currentVersion,
},
})
})
// POST /api/nixserver/system/upgrade
// Fires the update script in the background and returns immediately.
// The service will restart ~60 s later once the build finishes.
fastify.post('/upgrade', { preHandler: [requireAdmin] }, async (_request, reply) => {
reply.send({
success: true,
data: { message: 'Upgrade started. The panel will be back online in about 60 seconds.' },
})
// Delay slightly to ensure the HTTP response is flushed before the service restarts
setTimeout(() => {
const child = spawn('bash', [`${INSTALL_DIR}/scripts/update.sh`], {
detached: true,
stdio: 'ignore',
})
child.unref()
}, 800)
})
} }
+52 -20
View File
@@ -1,34 +1,42 @@
import type { FastifyInstance } from 'fastify' import type { FastifyInstance } from 'fastify'
import { z } from 'zod'
import { requireAdmin } from '../../middleware/auth.js' import { requireAdmin } from '../../middleware/auth.js'
import { exec } from '../../core/exec.js' import { exec } from '../../core/exec.js'
import { readFile } from 'fs/promises' import { readFile } from 'fs/promises'
import { readdir } from 'fs/promises'
import path from 'path'
export default async function webserverRoutes(fastify: FastifyInstance) { export default async function webserverRoutes(fastify: FastifyInstance) {
// GET /api/nixserver/webserver/status // GET /api/nixserver/webserver/status
fastify.get('/status', { preHandler: [requireAdmin] }, async (request, reply) => { fastify.get('/status', { preHandler: [requireAdmin] }, async (request, reply) => {
const [nginx, apache] = await Promise.all([ const [apache, fpm82, fpm83] = await Promise.all([
exec('systemctl', ['is-active', 'nginx']),
exec('systemctl', ['is-active', 'apache2']), exec('systemctl', ['is-active', 'apache2']),
exec('systemctl', ['is-active', 'php8.2-fpm']),
exec('systemctl', ['is-active', 'php8.3-fpm']),
]) ])
return reply.send({ return reply.send({
success: true, success: true,
data: { data: {
nginx: { active: nginx.stdout.trim() === 'active' },
apache: { active: apache.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 // GET /api/nixserver/webserver/vhosts
fastify.get('/vhosts', { preHandler: [requireAdmin] }, async (request, reply) => { fastify.get('/vhosts', { preHandler: [requireAdmin] }, async (request, reply) => {
const result = await exec('find', ['/etc/nginx/sites-enabled', '-name', '*.conf', '-type', 'f']) try {
const files = result.stdout.split('\n').filter(Boolean) const files = await readdir('/etc/apache2/sites-enabled')
const vhosts = files.map(f => ({ const vhosts = files
path: f, .filter(f => f.endsWith('.conf'))
domain: f.split('/').pop()?.replace('.conf', '') ?? '', .map(f => ({
})) path: `/etc/apache2/sites-enabled/${f}`,
return reply.send({ success: true, data: vhosts }) domain: f.replace('.conf', ''),
}))
return reply.send({ success: true, data: vhosts })
} catch {
return reply.send({ success: true, data: [] })
}
}) })
// GET /api/nixserver/webserver/vhosts/:domain // 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' }) return reply.code(400).send({ success: false, error: 'Invalid domain' })
} }
try { 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 } }) return reply.send({ success: true, data: { domain, config: content } })
} catch { } catch {
return reply.code(404).send({ success: false, error: 'Vhost not found' }) return reply.code(404).send({ success: false, error: 'Vhost not found' })
} }
}) })
// POST /api/nixserver/webserver/nginx/test // POST /api/nixserver/webserver/apache/test
fastify.post('/nginx/test', { preHandler: [requireAdmin] }, async (request, reply) => { fastify.post('/apache/test', { preHandler: [requireAdmin] }, async (request, reply) => {
const result = await exec('nginx', ['-t']) const result = await exec('apache2ctl', ['configtest'])
return reply.send({ return reply.send({
success: result.exitCode === 0, success: result.exitCode === 0,
data: { output: result.stdout + result.stderr }, data: { output: result.stdout + result.stderr },
}) })
}) })
// POST /api/nixserver/webserver/nginx/reload // POST /api/nixserver/webserver/apache/reload
fastify.post('/nginx/reload', { preHandler: [requireAdmin] }, async (request, reply) => { fastify.post('/apache/reload', { preHandler: [requireAdmin] }, async (request, reply) => {
const test = await exec('nginx', ['-t']) const test = await exec('apache2ctl', ['configtest'])
if (test.exitCode !== 0) { if (test.exitCode !== 0) {
return reply.code(500).send({ success: false, error: `Config test failed: ${test.stderr}` }) 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 } }) return reply.send({ success: result.exitCode === 0, data: { output: result.stdout } })
}) })
// GET /api/nixserver/webserver/logs/:type — access or error log (last 200 lines) // GET /api/nixserver/webserver/logs/:type — access or error log (last 200 lines)
fastify.get('/logs/:type', { preHandler: [requireAdmin] }, async (request, reply) => { fastify.get('/logs/:type', { preHandler: [requireAdmin] }, async (request, reply) => {
const { type } = request.params as { type: string } 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]) const result = await exec('tail', ['-n', '200', logFile])
return reply.send({ success: true, data: { lines: result.stdout.split('\n').filter(Boolean) } }) return reply.send({ success: true, data: { lines: result.stdout.split('\n').filter(Boolean) } })
}) })
+65
View File
@@ -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 -4
View File
@@ -6,15 +6,17 @@ export interface JwtPayload {
sub: number sub: number
role: Role role: Role
username: string username: string
type?: string
iat?: number iat?: number
exp?: number exp?: number
} }
// ─── Fastify type augmentation ──────────────────────────────────────────────── // ─── @fastify/jwt type augmentation ───────────────────────────────────────────
declare module 'fastify' { declare module '@fastify/jwt' {
interface FastifyRequest { interface FastifyJWT {
user?: JwtPayload payload: JwtPayload
user: JwtPayload
} }
} }