35 Commits
Author SHA1 Message Date
NixReaper 501f94b7e8 fix(install): read from /dev/tty for curl|bash, bump v0.6.2 2026-04-12 04:40:04 -05:00
NixReaper 3902935f60 fix(install): read from /dev/tty so curl | bash confirmation prompt works, bump v0.5.2
set -euo pipefail causes the script to exit when read gets EOF from
the curl pipe instead of an interactive terminal. Fix by appending
'< /dev/tty || CONFIRM=Y' so the prompt reads from the actual terminal
regardless of how the script was invoked.
2026-04-12 04:39:14 -05:00
NixReaperandClaude Sonnet 4.6 5c98ac0ee6 docs: add CLAUDE.md development standards, bump v0.6.1
Adds CLAUDE.md which Claude Code reads automatically before every
session. Covers version bumping, changelog format, commit conventions,
file size limits, section/page architecture, and API standards.

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

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

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-04-11 18:07:57 -05:00
NixReaper da55367039 fix(install): remove --silent from build steps, clean node_modules before install, bump v0.5.1 2026-04-11 16:15:53 -05:00
NixReaper 55786bfa0e fix: never commit package-lock.json, clean install in update.sh, bump v0.5.0
package-lock.json generated on Windows includes win32 rollup binary but
not linux-x64-gnu, breaking the frontend build on the server.

- Add package-lock.json to .gitignore and remove it from git tracking
- update.sh: rm -rf node_modules + package-lock.json before npm install
  so the server always generates a platform-correct lock file from scratch
- update.sh: remove --silent from build steps so errors are visible
- .gitignore: fix stale whm/cpanel dist paths → nixserver/nixclient
2026-04-11 15:32:29 -05:00
NixReaper 70908745bd fix(update): discard local package-lock.json before git pull, bump v0.4.9
npm install modifies package-lock.json on the server, causing
'git pull --ff-only' to abort with 'your local changes would be
overwritten'. Add 'git checkout -- package-lock.json' before the pull
so the generated lock file never blocks updates.
2026-04-11 15:03:46 -05:00
NixReaper 0362810681 fix(login): fetch version from /api/health instead of hardcoding, bump v0.4.8
Login page had 'NixServer v0.3.2 · Port 2087' hardcoded and subtitle
said 'Web Host Manager'. Now fetches version from GET /api/health on
mount (no auth needed) and shows it dynamically. Changed subtitle to
'Admin Panel'.
2026-04-11 15:02:18 -05:00
NixReaper 9ec39c9120 fix: regenerate package-lock.json after @nixpanel/whm → @nixpanel/nixserver rename, bump v0.4.7
The old package-lock.json still referenced @nixpanel/whm as a workspace.
After the package rename in v0.4.6, npm install failed to resolve the
workspace correctly, causing the nixserver frontend build to fail and
breaking login. Deleted and regenerated package-lock.json cleanly.
2026-04-11 15:01:46 -05:00
NixReaper b7eb76d91a chore: remove WHM trademark references, rename package, bump v0.4.6
WHM is a registered trademark of cPanel, L.L.C. Replace all occurrences:
- Sidebar: 'Basic WebHost Manager® Setup' → 'Basic NixServer Setup'
- Sidebar: 'WHM Marketplace' → 'NixServer Marketplace'
- Sidebar: 'OpenAPI Documentation for WHM' → 'OpenAPI Documentation for NixServer'
- nixserver/package.json: @nixpanel/whm → @nixpanel/nixserver
- server/src/app.ts: whmDist variable → nixserverDist
2026-04-11 14:45:50 -05:00
NixReaper 5af2762634 fix(version): cache-bust refresh + clear stale state on manual check, bump to v0.4.5
The refresh/retry button called fetchVersion() without clearing
versionInfo first, so the old value stayed displayed with no feedback.
On manual refresh it now immediately sets versionInfo to null (shows
spinner) then fetches with ?_t=Date.now() to bypass any browser cache
of the API response.

Bump version to 0.4.5 to include all fixes since 0.4.4 (day/night
toggle, uptime/kill allowlist, auto-retry on version error).
2026-04-11 14:27:19 -05:00
NixReaper ddb51a8b4d feat: day/night mode toggle with theme context
Add ThemeContext (dark/light, persisted to localStorage, applied as
data-theme on <html>). Theme toggle is a sun/moon flip switch at the
right end of the dashboard info bar.

CSS overrides in index.css target every hardcoded Tailwind arbitrary
colour class used across all pages via [data-theme="light"] selectors,
so the entire panel switches without touching individual page files.

Light palette: page #f0f4f8, cards/sidebar #ffffff, hover #f1f5f9,
borders #e2e8f0, text #0f172a. Chart tooltip is made theme-aware via
inline-style interpolation since Tailwind CSS overrides cannot reach
Recharts inline styles.
2026-04-11 14:24:51 -05:00
NixReaper 4de2a7fa8e fix: auto-retry version fetch + add uptime/kill to exec allowlist
Layout.tsx: when versionError is true, automatically retry fetchVersion
after 5 s. This handles the case where the initial fetch runs during
the service-restart window after an upgrade (server not yet up), which
caused the sidebar footer to permanently show 'Version unavailable'
even though the server came back online shortly after.

exec.ts: add 'uptime' and 'kill' to ALLOWED_COMMANDS. 'uptime' was
missing, causing exec('uptime', ['-p']) in GET /api/nixserver/system/info
to throw 'Command not allowed', which rejected the entire Promise.all
and returned 500 — leaving Hostname and OS as '...' on the dashboard.
2026-04-11 14:19:24 -05:00
NixReaper 90dbf94c18 fix(layout): use authenticated api client for version/upgrade calls
Plain axios was called without the Authorization header, so the
requireAdmin middleware rejected every request with 401. The catch
block set versionError but the error state was also not yet deployed,
leaving the spinner stuck on 'Checking version…' forever.

Switch both axios.get('/api/nixserver/system/version') and
axios.post('/api/nixserver/system/upgrade') to the api client from
../api/client, which automatically attaches Bearer token from
localStorage on every request.
2026-04-11 14:15:48 -05:00
NixReaper 146c17abd8 feat(dashboard): add cPanel-style server info bar and bump to v0.4.4
Adds a compact info strip at the top of the NixPanel dashboard showing
Username, Hostname, OS, NixPanel Version, Load Averages (live via
WebSocket), and Server Monitoring alert count. Alert count is derived
from GET /api/nixserver/services — green "All Clear" when all services
are running, amber "N Alerts" for any stopped/failed service.

Also fixes the version footer error state in Layout.tsx (versionError
→ "Version unavailable" fallback with retry button) so the spinner no
longer hangs indefinitely when the version API is unreachable.
2026-04-11 14:13:43 -05:00
NixReaper 47ce924a90 fix(update): set web-readable permissions on dist dirs after rebuild 2026-04-11 12:09:31 -05:00
NixReaper 82b9ac30e9 fix(nixserver): widen Lucide icon size prop type to number | string 2026-04-11 12:06:58 -05:00
NixReaper c6f13f2e07 fix(update): use prisma db push instead of migrate deploy 2026-04-11 12:05:30 -05:00
NixReaper 945da97c97 fix(update): remove unsupported --silent flag from prisma generate 2026-04-11 12:04:30 -05:00
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
109 changed files with 8350 additions and 1843 deletions
+9 -4
View File
@@ -6,15 +6,20 @@ dist/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# package-lock.json is platform-specific (rollup native binaries differ between
# Windows dev machines and Linux servers). Never commit it — the server generates
# its own on each install.
package-lock.json
# Prisma # Prisma
server/prisma/dev.db server/prisma/dev.db
server/prisma/dev.db-journal server/prisma/dev.db-journal
# Build outputs # Build outputs
server/dist/ server/dist/
whm/dist/ nixserver/dist/
cpanel/dist/ nixclient/dist/
# Vite # Vite
whm/.vite/ nixserver/.vite/
cpanel/.vite/ nixclient/.vite/
+243
View File
@@ -0,0 +1,243 @@
# 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.6.2] — 2026-04-12
### Bug Fixes
- **`curl | bash` aborts immediately at confirmation prompt** — `set -euo pipefail` caused the script to exit when `read` received EOF from the pipe instead of a terminal. Fixed by reading from `/dev/tty` directly so the prompt works correctly whether the script is piped or run from a file.
---
## [0.6.1] — 2026-04-11
### Changes
- **`CLAUDE.md` added** — development standards file auto-read by Claude Code before every session. Documents version numbering rules, changelog format, commit message conventions, file size limits, section/page architecture patterns, API conventions, and project structure.
---
## [0.6.0] — 2026-04-11
### Features
- **Basic NixServer Setup page** — full WHM-equivalent settings page at `/server-configuration/basic-setup` with two tabs (Contact Information, Basic Config) and a Nameservers section.
- **Server Configuration section page** — new section index at `/server-configuration` showing all 13 tools as a card grid.
- **`GET /api/nixserver/system/network-interfaces`** — new endpoint returning all non-loopback interfaces, used to populate the Ethernet Device dropdown.
### Refactors
- **File size limit enforced** — `Layout.tsx`, `Settings.tsx`, `accounts.ts` each split into focused sub-files under 300 lines.
- **`NavCategory` gains optional `to` field** — links sidebar category headers to section index pages.
- **Shared components added** — `SectionCard.tsx` and `ComingSoon.tsx`.
- **`/settings` redirected** to `/server-configuration/basic-setup` for backwards compatibility.
---
## [0.5.1] — 2026-04-11
### Bug Fixes
- **`install.sh` build steps used `--silent`** — errors during `npm install` and frontend/server builds were suppressed, making failures invisible. Removed `--silent` from all build commands. Also added `rm -rf node_modules package-lock.json` before `npm install` to ensure a clean platform-correct install, consistent with `update.sh`.
---
## [0.5.0] — 2026-04-11
### Bug Fixes
- **Rollup native binary missing on Linux** — `package-lock.json` was generated on Windows and committed to git. It included the Windows rollup binary (`@rollup/rollup-win32-x64-msvc`) but not the Linux one (`@rollup/rollup-linux-x64-gnu`), causing the frontend build to fail on the server with `Cannot find module @rollup/rollup-linux-x64-gnu`.
- **`package-lock.json` added to `.gitignore`** — lock files are platform-specific and must never be committed. The server now generates its own fresh lock file on every update.
- **`update.sh` now does a clean install** — deletes `node_modules/` and `package-lock.json` before `npm install` on every update run to guarantee platform-correct native binaries. Also drops `--silent` from build commands so errors are visible.
- **`.gitignore` corrected** — removed stale `whm/` and `cpanel/` dist paths, added correct `nixserver/` and `nixclient/` paths.
---
## [0.4.9] — 2026-04-11
### Bug Fixes
- **`update.sh` fails when `package-lock.json` has local changes** — `git pull --ff-only` aborted with "Your local changes would be overwritten by merge" whenever npm had modified the lock file on the server. Fixed by running `git checkout -- package-lock.json` before the pull to discard local changes to the generated lock file.
---
## [0.4.8] — 2026-04-11
### Bug Fixes
- **Login page showing stale hardcoded version** — "NixServer v0.3.2 · Port 2087" was hardcoded. Now fetches version live from `GET /api/health` (no auth required) on mount.
- **Login page subtitle** — "Web Host Manager" replaced with "Admin Panel" to remove any WHM trademark association.
---
## [0.4.7] — 2026-04-11
### Bug Fixes
- **Login broken after v0.4.6 update** — `package-lock.json` still mapped the workspace as `@nixpanel/whm`. After renaming the package to `@nixpanel/nixserver`, npm workspace resolution failed during `npm install`, causing the frontend build to break and leaving the panel unresponsive. Fixed by regenerating `package-lock.json` with the correct package name.
---
## [0.4.6] — 2026-04-11
### Changes
- **Removed all WHM trademark references** — "WHM" is a registered trademark of cPanel, L.L.C. All occurrences replaced with "NixServer":
- Sidebar menu item "Basic WebHost Manager® Setup" → "Basic NixServer Setup"
- Sidebar menu item "WHM Marketplace" → "NixServer Marketplace"
- Sidebar menu item "OpenAPI Documentation for WHM" → "OpenAPI Documentation for NixServer"
- Internal npm package name `@nixpanel/whm` → `@nixpanel/nixserver`
---
## [0.4.5] — 2026-04-11
### Bug Fixes
- **Sidebar version refresh not updating** — clicking the refresh icon now clears the stale version immediately (shows spinner) and adds a `?_t=` cache-bust parameter so the browser never serves a cached response. Same fix applied to the "Retry" button in the error state.
- **`uptime` and `kill` missing from exec allowlist** — caused `GET /api/nixserver/system/info` to throw "Command not allowed", returning 500 and leaving Hostname/OS as `…` on the dashboard.
- **Version fetch auto-retry** — when the version API fails (e.g. during service restart after upgrade), the sidebar now automatically retries every 5 s until it succeeds instead of permanently showing "Version unavailable".
### New Features
- **Day/Night mode toggle** — sun/moon flip switch at the right end of the dashboard info bar. Light palette: page `#f0f4f8`, cards `#ffffff`. Preference persisted to `localStorage`. All pages switch via seven `[data-theme="light"]` CSS overrides in `index.css` without touching individual page files.
---
## [0.4.4] — 2026-04-11
### New Features
- **Server info bar on Dashboard** — cPanel/WHM-style info strip at the top of the NixPanel home page. Shows Username, Hostname, OS, NixPanel Version, Load Averages (live from WebSocket), and Server Monitoring alert count. Alerts are colour-coded: green "All Clear" when all managed services are running; amber "N Alerts" with a count of stopped/failed services.
---
## [0.4.3] — 2026-04-11
### New Features
- **Version display in sidebar** — the sidebar footer shows the currently installed version (e.g. `v0.4.3`), fetched live from the API. A green checkmark indicates up to date; an amber alert indicates an update is available.
- **One-click panel upgrade** — "Upgrade panel" button in the sidebar footer. When a newer version is available it highlights amber and shows the target version. Clicking requires a confirmation step before running. Calls `POST /api/nixserver/system/upgrade` which fires `scripts/update.sh` in the background (git pull → npm install → prisma migrate → build frontends + server → restart service). The panel comes back online automatically after ~60 s.
- **Check for updates button** — small refresh icon next to the version badge re-fetches version info on demand.
- **Backend: `GET /api/nixserver/system/version`** — returns `currentVersion` (read from `/opt/nixpanel/package.json`), `latestVersion` (fetched from GitHub raw content with a 5 s timeout), and `updateAvailable` boolean.
- **Backend: `POST /api/nixserver/system/upgrade`** — admin-only; responds immediately then spawns `scripts/update.sh` detached so the HTTP response is flushed before the service restarts.
---
## [0.4.2] — 2026-04-11
### Changes
- **NixServer sidebar redesigned** — replaced the flat 14-item nav with a full WHM-style collapsible category sidebar (29 categories, ~210 menu items). Categories fold/unfold on click; the active route's category auto-expands on navigation. A live search box at the top of the sidebar filters items across all categories and auto-expands matching results. Sidebar can be hidden entirely via the topbar toggle. All existing routes are wired to their correct menu items; future feature items route to the closest related hub page.
---
## [0.4.1] — 2026-04-11
### Bug Fixes (Installer)
- **SpamAssassin service name**: On Ubuntu 24.04 the service unit is `spamd`, not `spamassassin`. The old `systemctl enable spamassassin` caused the install to abort due to `set -euo pipefail`.
- **PowerDNS port 53 conflict**: `systemd-resolved` stub listener now disabled in a dedicated step that runs *before* package installation. Previously it ran after apt had already tried (and failed) to restart `pdns` during post-install because port 53 was still held by `systemd-resolved`.
- **Git clone into pre-existing directory**: The directory structure step (`mkdir -p`) creates `$INSTALL_DIR` before the clone step, causing `git clone` to fail with "destination path already exists". Fixed by cloning to a temp directory and copying contents into place (preserving any existing `data/` and `logs/` subdirectories).
- **Apache 403 on frontend assets**: Frontend dist files were created with root-only permissions. Added a post-build step that sets 755/644 on all dist directories and files so Apache (`www-data`) can read them.
- **Apache 500 on all API requests**: `mod_proxy_http` was not enabled. Without it, `ProxyPass http://...` directives silently fail and Apache returns 500 for every API call. Also enabled `mod_proxy_wstunnel` (required for the `ws://` WebSocket reverse proxy).
---
## [0.4.0] — 2026-04-11
### New Features
- **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.
+165
View File
@@ -0,0 +1,165 @@
# NixPanel Development Standards
This file is read automatically by Claude Code before every session.
All rules below are **mandatory** — follow them without being asked.
---
## 1. Version Numbering
Versioning follows [Semantic Versioning](https://semver.org/). Pre-1.0 rules:
| Change type | Bump | Example |
|---|---|---|
| New feature, new page, new section | **MINOR** | 0.5.1 → 0.6.0 |
| Bug fix, refactor, chore, docs | **PATCH** | 0.5.1 → 0.5.2 |
| Breaking API/schema change | **MINOR** | 0.5.x → 0.6.0 |
**Always** bump the version in `package.json` before committing any user-visible change.
---
## 2. Changelog
File: `CHANGELOG.md` at the repo root.
### Format
```markdown
## [X.Y.Z] — YYYY-MM-DD
### Features ← new functionality
### Changes ← non-breaking behaviour changes / refactors
### Bug Fixes ← defect corrections
### Refactors ← internal restructuring with no behaviour change
```
### Rules
- Add a new entry **at the top** of the file (below the header block) for every commit.
- One entry per version — if multiple changes share a version, group them under the same header.
- Bullet points must be descriptive enough that a developer can understand what changed and why without reading the diff.
- Use bold for the affected component/file: `- **BasicSetup.tsx** — description`.
- Today's date format: `YYYY-MM-DD`.
---
## 3. Commit Messages
Format: `type(scope): short imperative summary`
| Type | When to use |
|---|---|
| `feat` | New feature or page |
| `fix` | Bug fix |
| `refactor` | Code restructuring, no behaviour change |
| `chore` | Version bump, changelog, config, tooling |
| `docs` | Documentation only |
Rules:
- Summary line ≤ 72 characters.
- Always include `Co-Authored-By: Claude Sonnet 4.6 <[email protected]>` trailer.
- Use a multiline HEREDOC — never single-line for complex messages.
### Workflow for every change
1. Make code changes.
2. Bump version in `package.json`.
3. Add entry to `CHANGELOG.md`.
4. Stage all changed files including `package.json` and `CHANGELOG.md`.
5. Commit everything together in **one commit**.
6. Push to `origin main`.
> Do **not** make a separate `chore: bump version` commit — version + changelog belong in the same commit as the code change.
---
## 4. File Size Limit
**No source file should exceed 300 lines.**
When a file approaches this limit, split it by responsibility:
- React pages → extract sub-components into a `pages/<section>/` subfolder.
- Fastify route files → split into sub-plugins under `routes/<section>/` with an `index.ts` registering them.
- Shared UI primitives → `components/` folder.
---
## 5. Page / Section Architecture
### Frontend
Each nav category in the sidebar maps to a **section folder**:
```
pages/<section-name>/
index.tsx ← card-grid landing page (always create this first)
ToolName.tsx ← one file per tool/feature
```
Unimplemented tools use `<ComingSoon title="..." description="..." />` from `components/ComingSoon.tsx`.
Section landing pages use `<SectionCard>` from `components/SectionCard.tsx`.
### Backend
Route files live under `routes/nixserver/<section>/`:
```
routes/nixserver/<section>/
index.ts ← registers sub-plugins, no route logic here
list.ts ← read-only GET routes
create.ts ← POST / creation route
modify.ts ← PUT / PATCH routes
status.ts ← suspend / unsuspend / delete / bulk
```
---
## 6. Project Structure (key paths)
```
nixpanel-server/
package.json ← root version number lives here
CHANGELOG.md ← always update before committing
CLAUDE.md ← this file
nixserver/src/
components/
navData.ts ← NAV array + NavCategory/NavItem types
Sidebar.tsx ← sidebar UI
Layout.tsx ← thin shell (topbar + Outlet)
SectionCard.tsx ← reusable section card
ComingSoon.tsx ← placeholder for unimplemented tools
pages/
server-configuration/ ← example of section folder pattern
index.tsx
BasicSetup.tsx
ChangeRoot.tsx
...
server/src/
routes/nixserver/
accounts/ ← example of split route folder
index.ts
list.ts
create.ts
modify.ts
status.ts
```
---
## 7. API Conventions
- All responses: `{ success: true, data: ... }` or `{ success: false, error: "..." }`.
- All admin routes use `preHandler: [requireAdmin]` or `preHandler: [requireAdminOrReseller]`.
- Settings stored as key-value pairs in the `Setting` model (`key String @id`, `value String`).
- New settings keys use UPPERCASE (matching WHM convention): `CONTACTEMAIL`, `TTL`, etc.
---
## 8. Git Remote
```
origin https://github.com/NixReaper/nixpanel-server.git (fetch/push)
branch main
```
Always push to `origin main` after committing.
+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') }
} }
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@nixpanel/whm", "name": "@nixpanel/nixserver",
"version": "0.3.0", "version": "0.4.6",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+217 -16
View File
@@ -1,51 +1,249 @@
import React from 'react' import React from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './contexts/AuthContext' import { AuthProvider, useAuth } from './contexts/AuthContext'
import { ThemeProvider } from './contexts/ThemeContext'
import Layout from './components/Layout' import Layout from './components/Layout'
import Login from './pages/Login' import Login from './pages/Login'
import Dashboard from './pages/Dashboard' 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'))
// Server Configuration section
const ServerConfiguration = React.lazy(() => import('./pages/server-configuration/index'))
const SCBasicSetup = React.lazy(() => import('./pages/server-configuration/BasicSetup'))
const SCChangeRoot = React.lazy(() => import('./pages/server-configuration/ChangeRoot'))
const SCAnalytics = React.lazy(() => import('./pages/server-configuration/Analytics'))
const SCCronJobs = React.lazy(() => import('./pages/server-configuration/CronJobs'))
const SCQuotaSetup = React.lazy(() => import('./pages/server-configuration/QuotaSetup'))
const SCLinkNodes = React.lazy(() => import('./pages/server-configuration/LinkNodes'))
const SCServerProfile = React.lazy(() => import('./pages/server-configuration/ServerProfile'))
const SCServerTime = React.lazy(() => import('./pages/server-configuration/ServerTime'))
const SCStatisticsSoftware = React.lazy(() => import('./pages/server-configuration/StatisticsSoftware'))
const SCTerminal = React.lazy(() => import('./pages/server-configuration/Terminal'))
const SCTweakSettings = React.lazy(() => import('./pages/server-configuration/TweakSettings'))
const SCUpdatePreferences = React.lazy(() => import('./pages/server-configuration/UpdatePreferences'))
const SCMarketplace = React.lazy(() => import('./pages/server-configuration/Marketplace'))
// Accounts sub-pages
const AccountList = React.lazy(() => import('./pages/accounts/List'))
const AccountParkedDomains = React.lazy(() => import('./pages/accounts/ParkedDomains'))
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>
} />
{/* Server Configuration section */}
<Route path="server-configuration" element={
<React.Suspense fallback={<PageLoader />}><ServerConfiguration /></React.Suspense>
} />
<Route path="server-configuration/basic-setup" element={
<React.Suspense fallback={<PageLoader />}><SCBasicSetup /></React.Suspense>
} />
<Route path="server-configuration/change-root" element={
<React.Suspense fallback={<PageLoader />}><SCChangeRoot /></React.Suspense>
} />
<Route path="server-configuration/analytics" element={
<React.Suspense fallback={<PageLoader />}><SCAnalytics /></React.Suspense>
} />
<Route path="server-configuration/cron-jobs" element={
<React.Suspense fallback={<PageLoader />}><SCCronJobs /></React.Suspense>
} />
<Route path="server-configuration/quota-setup" element={
<React.Suspense fallback={<PageLoader />}><SCQuotaSetup /></React.Suspense>
} />
<Route path="server-configuration/link-nodes" element={
<React.Suspense fallback={<PageLoader />}><SCLinkNodes /></React.Suspense>
} />
<Route path="server-configuration/server-profile" element={
<React.Suspense fallback={<PageLoader />}><SCServerProfile /></React.Suspense>
} />
<Route path="server-configuration/server-time" element={
<React.Suspense fallback={<PageLoader />}><SCServerTime /></React.Suspense>
} />
<Route path="server-configuration/statistics-software" element={
<React.Suspense fallback={<PageLoader />}><SCStatisticsSoftware /></React.Suspense>
} />
<Route path="server-configuration/terminal" element={
<React.Suspense fallback={<PageLoader />}><SCTerminal /></React.Suspense>
} />
<Route path="server-configuration/tweak-settings" element={
<React.Suspense fallback={<PageLoader />}><SCTweakSettings /></React.Suspense>
} />
<Route path="server-configuration/update-preferences" element={
<React.Suspense fallback={<PageLoader />}><SCUpdatePreferences /></React.Suspense>
} />
<Route path="server-configuration/marketplace" element={
<React.Suspense fallback={<PageLoader />}><SCMarketplace /></React.Suspense>
} />
{/* Redirect old /settings to new canonical route */}
<Route path="settings" element={<Navigate to="/server-configuration/basic-setup" replace />} />
{/* Other sections */}
<Route path="packages" element={<Packages />} /> <Route path="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 +279,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>
) )
@@ -97,9 +296,11 @@ function PageLoader() {
export default function App() { export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<AuthProvider> <ThemeProvider>
<AppRoutes /> <AuthProvider>
</AuthProvider> <AppRoutes />
</AuthProvider>
</ThemeProvider>
</BrowserRouter> </BrowserRouter>
) )
} }
+26
View File
@@ -0,0 +1,26 @@
import { Wrench } from 'lucide-react'
interface ComingSoonProps {
title: string
description?: string
}
export default function ComingSoon({ title, description }: ComingSoonProps) {
return (
<div className="space-y-4 max-w-2xl">
<div>
<h1 className="text-white text-xl font-semibold">{title}</h1>
{description && <p className="text-[#64748b] text-sm mt-0.5">{description}</p>}
</div>
<div className="flex flex-col items-center justify-center py-20 bg-[#1a1d27] border border-[#2a2d3e] rounded-xl text-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-[#0f1117] border border-[#2a2d3e] flex items-center justify-center">
<Wrench size={24} className="text-[#475569]" />
</div>
<div>
<p className="text-[#94a3b8] font-medium">Coming Soon</p>
<p className="text-[#475569] text-sm mt-1">This feature is under development.</p>
</div>
</div>
</div>
)
}
+7 -92
View File
@@ -1,103 +1,18 @@
import { useState } from 'react' import { useState } from 'react'
import { Outlet, NavLink, useNavigate } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import { import { Menu, X } from 'lucide-react'
LayoutDashboard, Users, Package, UserCog, Globe, Mail, Shield, import Sidebar from './Sidebar'
Server, HardDrive, Settings, LogOut, Menu, X,
Activity, Lock, BarChart3, Cpu
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
const nav = [
{ label: 'Dashboard', to: '/', icon: LayoutDashboard },
{ label: 'Accounts', to: '/accounts', icon: Users },
{ label: 'Packages', to: '/packages', icon: Package },
{ label: 'Resellers', to: '/resellers', icon: UserCog },
{ label: 'DNS', to: '/dns', icon: Globe },
{ label: 'Email', to: '/email', icon: Mail },
{ label: 'SSL', to: '/ssl', icon: Lock },
{ label: 'PHP', to: '/php', icon: Cpu },
{ label: 'Web Server', to: '/webserver', icon: Server },
{ label: 'Services', to: '/services', icon: Activity },
{ label: 'Backup', to: '/backup', icon: HardDrive },
{ label: 'Security', to: '/security', icon: Shield },
{ label: 'System', to: '/system', icon: BarChart3 },
{ label: 'Settings', to: '/settings', icon: Settings },
]
export default function Layout() { export default function Layout() {
const { user, logout } = useAuth()
const navigate = useNavigate()
const [sidebarOpen, setSidebarOpen] = useState(true) const [sidebarOpen, setSidebarOpen] = useState(true)
const handleLogout = async () => {
await logout()
navigate('/login')
}
return ( return (
<div className="flex h-screen overflow-hidden bg-[#0f1117]"> <div className="flex h-screen overflow-hidden bg-[#0f1117]">
{/* Sidebar */} <Sidebar open={sidebarOpen} onToggle={() => setSidebarOpen(v => !v)} />
<aside
className={`flex flex-col transition-all duration-200 bg-[#1a1d27] border-r border-[#2a2d3e] ${
sidebarOpen ? 'w-56' : 'w-14'
}`}
>
{/* Logo */}
<div className="flex items-center gap-2 px-4 py-4 border-b border-[#2a2d3e] min-h-[57px]">
<div className="w-7 h-7 rounded-lg bg-indigo-600 flex items-center justify-center flex-shrink-0">
<span className="text-white text-xs font-bold">N</span>
</div>
{sidebarOpen && (
<div className="overflow-hidden">
<p className="text-white text-sm font-semibold leading-tight">NixServer</p>
<p className="text-[#64748b] text-xs">Admin Panel</p>
</div>
)}
</div>
{/* Nav */} <div className="flex-1 flex flex-col overflow-hidden min-w-0">
<nav className="flex-1 overflow-y-auto py-2">
{nav.map(({ label, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-2 mx-2 my-0.5 rounded-lg text-sm transition-colors ${
isActive
? 'bg-indigo-600/20 text-indigo-400'
: 'text-[#94a3b8] hover:bg-[#1e2130] hover:text-white'
}`
}
>
<Icon size={16} className="flex-shrink-0" />
{sidebarOpen && <span className="truncate">{label}</span>}
</NavLink>
))}
</nav>
{/* User + logout */}
<div className="border-t border-[#2a2d3e] p-3">
{sidebarOpen && (
<div className="px-1 mb-2">
<p className="text-white text-xs font-medium truncate">{user?.username}</p>
<p className="text-[#64748b] text-xs truncate">{user?.role}</p>
</div>
)}
<button
onClick={handleLogout}
className="flex items-center gap-2 px-3 py-2 w-full rounded-lg text-[#94a3b8] hover:bg-[#1e2130] hover:text-red-400 transition-colors text-sm"
>
<LogOut size={16} className="flex-shrink-0" />
{sidebarOpen && <span>Logout</span>}
</button>
</div>
</aside>
{/* Main */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* 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"
@@ -110,7 +25,7 @@ export default function Layout() {
</span> </span>
</header> </header>
{/* Content */} {/* Page content */}
<main className="flex-1 overflow-y-auto p-6"> <main className="flex-1 overflow-y-auto p-6">
<Outlet /> <Outlet />
</main> </main>
+46
View File
@@ -0,0 +1,46 @@
import { Link } from 'react-router-dom'
import type { ComponentType } from 'react'
interface SectionCardProps {
label: string
description?: string
to: string
icon?: ComponentType<{ size?: number; className?: string }>
comingSoon?: boolean
}
export default function SectionCard({ label, description, to, icon: Icon, comingSoon }: SectionCardProps) {
if (comingSoon) {
return (
<div className="flex items-start gap-3 p-4 rounded-xl border border-[#2a2d3e] bg-[#0f1117] opacity-50 cursor-not-allowed select-none">
{Icon && (
<div className="w-8 h-8 rounded-lg bg-[#1e2130] flex items-center justify-center flex-shrink-0 mt-0.5">
<Icon size={15} className="text-[#475569]" />
</div>
)}
<div className="min-w-0">
<p className="text-[#64748b] text-sm font-medium truncate">{label}</p>
{description && <p className="text-[#374151] text-xs mt-0.5 line-clamp-2">{description}</p>}
<span className="inline-block mt-1 text-[10px] text-[#374151] border border-[#2a2d3e] rounded px-1.5 py-0.5">Coming soon</span>
</div>
</div>
)
}
return (
<Link
to={to}
className="flex items-start gap-3 p-4 rounded-xl border border-[#2a2d3e] bg-[#0f1117] hover:bg-[#1a1d27] hover:border-indigo-500/40 transition-colors group"
>
{Icon && (
<div className="w-8 h-8 rounded-lg bg-indigo-600/10 flex items-center justify-center flex-shrink-0 mt-0.5 group-hover:bg-indigo-600/20 transition-colors">
<Icon size={15} className="text-indigo-400" />
</div>
)}
<div className="min-w-0">
<p className="text-[#cbd5e1] text-sm font-medium truncate group-hover:text-white transition-colors">{label}</p>
{description && <p className="text-[#475569] text-xs mt-0.5 line-clamp-2">{description}</p>}
</div>
</Link>
)
}
+340
View File
@@ -0,0 +1,340 @@
import { useState, useMemo, useEffect, useCallback } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import {
LayoutDashboard, LogOut, Menu, X,
ChevronDown, ChevronRight, Search, RefreshCcw,
ArrowUpCircle, CheckCircle2, AlertCircle, Loader2, ExternalLink,
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { api } from '../api/client'
import { NAV, isItemActive } from './navData'
type VersionInfo = {
currentVersion: string
latestVersion: string | null
updateAvailable: boolean
}
type UpgradeState = 'idle' | 'confirm' | 'running' | 'done' | 'error'
interface SidebarProps {
open: boolean
onToggle: () => void
}
export default function Sidebar({ open, onToggle }: SidebarProps) {
const { user, logout } = useAuth()
const location = useLocation()
const navigate = useNavigate()
const [search, setSearch] = useState('')
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null)
const [versionError, setVersionError] = useState(false)
const [upgradeState, setUpgradeState] = useState<UpgradeState>('idle')
// ── Version checking ───────────────────────────────────────────────────────
const fetchVersion = useCallback(async (manual = false) => {
setVersionError(false)
if (manual) setVersionInfo(null)
try {
const { data } = await api.get('/nixserver/system/version', {
timeout: 10_000,
params: { _t: Date.now() },
})
setVersionInfo(data.data)
} catch {
setVersionError(true)
}
}, [])
useEffect(() => { fetchVersion() }, [fetchVersion])
useEffect(() => {
if (!versionError) return
const t = setTimeout(fetchVersion, 5_000)
return () => clearTimeout(t)
}, [versionError, fetchVersion])
const handleUpgrade = async () => {
if (upgradeState === 'confirm') {
setUpgradeState('running')
try {
await api.post('/nixserver/system/upgrade')
setUpgradeState('done')
} catch {
setUpgradeState('error')
}
} else {
setUpgradeState('confirm')
}
}
// ── Category open/close ────────────────────────────────────────────────────
const [openCats, setOpenCats] = useState<Set<string>>(() => {
const active = NAV.find(cat =>
cat.items.some(item => isItemActive(item.to, location.pathname))
)
return active ? new Set([active.id]) : new Set()
})
useEffect(() => {
const active = NAV.find(cat =>
cat.items.some(item => isItemActive(item.to, location.pathname))
)
if (active) {
setOpenCats(prev => prev.has(active.id) ? prev : new Set([...prev, active.id]))
}
}, [location.pathname])
const toggleCat = (id: string) => {
setOpenCats(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
// ── Search filter ──────────────────────────────────────────────────────────
const filteredNav = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return NAV
return NAV.map(cat => ({
...cat,
items: cat.items.filter(item => item.label.toLowerCase().includes(q)),
})).filter(cat => cat.items.length > 0 || cat.label.toLowerCase().includes(q))
}, [search])
const effectiveOpen = (id: string) =>
search.trim() ? filteredNav.some(c => c.id === id) : openCats.has(id)
const handleLogout = async () => {
await logout()
navigate('/login')
}
// ── Render ─────────────────────────────────────────────────────────────────
return (
<aside
className={`flex flex-col transition-all duration-200 bg-[#1a1d27] border-r border-[#2a2d3e] overflow-hidden flex-shrink-0 ${
open ? 'w-72' : 'w-0'
}`}
>
{/* Branding + toggle */}
<div className="flex items-center gap-2 px-4 py-4 border-b border-[#2a2d3e] min-h-[57px] flex-shrink-0">
<button onClick={onToggle} className="text-[#64748b] hover:text-white transition-colors mr-1">
{open ? <X size={16} /> : <Menu size={16} />}
</button>
<div className="w-7 h-7 rounded-lg bg-indigo-600 flex items-center justify-center flex-shrink-0">
<span className="text-white text-xs font-bold">N</span>
</div>
<div className="overflow-hidden">
<p className="text-white text-sm font-semibold leading-tight whitespace-nowrap">NixServer</p>
<p className="text-[#64748b] text-xs whitespace-nowrap">Admin Panel</p>
</div>
</div>
{/* Search */}
<div className="px-3 py-2 border-b border-[#2a2d3e] flex-shrink-0">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#4a5568] pointer-events-none" />
<input
type="text"
placeholder="Search..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full bg-[#0f1117] border border-[#2a2d3e] rounded-md text-xs text-[#94a3b8] placeholder-[#4a5568] pl-7 pr-3 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[#4a5568] hover:text-[#94a3b8]"
>
<X size={11} />
</button>
)}
</div>
</div>
{/* Nav */}
<nav className="flex-1 overflow-y-auto py-1 scrollbar-thin">
<Link
to="/"
className={`flex items-center gap-2.5 px-3 py-1.5 mx-2 my-0.5 rounded-md text-xs transition-colors ${
location.pathname === '/'
? 'bg-indigo-600/20 text-indigo-400'
: 'text-[#94a3b8] hover:bg-[#1e2130] hover:text-white'
}`}
>
<LayoutDashboard size={13} className="flex-shrink-0" />
<span className="truncate">Dashboard</span>
</Link>
{filteredNav.map(cat => {
const isOpen = effectiveOpen(cat.id)
const Icon = cat.icon
const hasActive = cat.items.some(item => isItemActive(item.to, location.pathname))
return (
<div key={cat.id} className="mx-2 my-0.5">
<div className="flex items-center gap-1">
<button
onClick={() => !search.trim() && toggleCat(cat.id)}
className={`flex items-center gap-2 flex-1 px-2 py-1.5 rounded-md text-xs font-medium transition-colors ${
hasActive && !search.trim()
? 'text-indigo-300'
: 'text-[#64748b] hover:text-[#94a3b8]'
} ${search.trim() ? 'cursor-default' : ''}`}
>
<Icon size={13} className="flex-shrink-0" />
<span className="flex-1 text-left truncate">{cat.label}</span>
{!search.trim() && (
isOpen
? <ChevronDown size={11} className="flex-shrink-0 opacity-60" />
: <ChevronRight size={11} className="flex-shrink-0 opacity-60" />
)}
</button>
{cat.to && (
<Link
to={cat.to}
title={`${cat.label} overview`}
className="p-1 text-[#374151] hover:text-indigo-400 transition-colors flex-shrink-0"
>
<ExternalLink size={10} />
</Link>
)}
</div>
{isOpen && (
<div className="ml-3 pl-2 border-l border-[#2a2d3e] mt-0.5 mb-1">
{cat.items.map(item => {
const active = isItemActive(item.to, location.pathname)
return (
<Link
key={item.label}
to={item.to}
className={`block px-2 py-1 rounded text-xs transition-colors truncate ${
active
? 'text-indigo-400 bg-indigo-600/10'
: 'text-[#64748b] hover:text-[#94a3b8] hover:bg-[#1e2130]'
}`}
>
{item.label}
</Link>
)
})}
</div>
)}
</div>
)
})}
{search.trim() && filteredNav.length === 0 && (
<p className="text-center text-[#4a5568] text-xs py-6">No results</p>
)}
</nav>
{/* Version + upgrade */}
<div className="border-t border-[#2a2d3e] px-3 pt-2.5 pb-1 flex-shrink-0">
{versionInfo ? (
<div className="space-y-1.5">
<div className="flex items-center justify-between px-1">
<div className="flex items-center gap-1.5">
{versionInfo.updateAvailable ? (
<AlertCircle size={11} className="text-amber-400 flex-shrink-0" />
) : (
<CheckCircle2 size={11} className="text-emerald-500 flex-shrink-0" />
)}
<span className="text-[#64748b] text-[10px]">v{versionInfo.currentVersion}</span>
{versionInfo.updateAvailable && versionInfo.latestVersion && (
<span className="text-amber-400 text-[10px]">→ v{versionInfo.latestVersion}</span>
)}
</div>
<button
onClick={() => fetchVersion(true)}
title="Check for updates"
className="text-[#4a5568] hover:text-[#94a3b8] transition-colors"
>
<RefreshCcw size={10} />
</button>
</div>
{upgradeState === 'done' ? (
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-emerald-600/10 text-emerald-400 text-[10px]">
<CheckCircle2 size={11} />
<span>Upgrade started — panel restarting…</span>
</div>
) : upgradeState === 'error' ? (
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-red-600/10 text-red-400 text-[10px]">
<AlertCircle size={11} />
<span>Upgrade failed — check logs</span>
</div>
) : (
<div className="flex gap-1.5">
<button
onClick={handleUpgrade}
disabled={upgradeState === 'running'}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[10px] font-medium transition-colors ${
upgradeState === 'confirm'
? 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/30'
: versionInfo.updateAvailable
? 'bg-indigo-600/20 text-indigo-300 hover:bg-indigo-600/30'
: 'bg-[#1e2130] text-[#64748b] hover:text-[#94a3b8]'
}`}
>
{upgradeState === 'running' ? (
<><Loader2 size={10} className="animate-spin" /><span>Upgrading…</span></>
) : upgradeState === 'confirm' ? (
<><ArrowUpCircle size={10} /><span>Confirm upgrade</span></>
) : versionInfo.updateAvailable ? (
<><ArrowUpCircle size={10} /><span>Upgrade available</span></>
) : (
<><ArrowUpCircle size={10} /><span>Upgrade panel</span></>
)}
</button>
{upgradeState === 'confirm' && (
<button
onClick={() => setUpgradeState('idle')}
className="px-2 py-1.5 rounded-md bg-[#1e2130] text-[#64748b] hover:text-[#94a3b8] text-[10px] transition-colors"
>
Cancel
</button>
)}
</div>
)}
</div>
) : versionError ? (
<div className="flex items-center justify-between px-1 py-1">
<span className="text-[#4a5568] text-[10px]">Version unavailable</span>
<button onClick={() => fetchVersion(true)} title="Retry" className="text-[#4a5568] hover:text-[#94a3b8] transition-colors">
<RefreshCcw size={10} />
</button>
</div>
) : (
<div className="flex items-center gap-1.5 px-1 py-1">
<Loader2 size={10} className="animate-spin text-[#4a5568]" />
<span className="text-[#4a5568] text-[10px]">Checking version…</span>
</div>
)}
</div>
{/* User + logout */}
<div className="border-t border-[#2a2d3e] p-3 flex-shrink-0">
<div className="px-1 mb-2">
<p className="text-white text-xs font-medium truncate">{user?.username}</p>
<p className="text-[#64748b] text-xs truncate">{user?.role}</p>
</div>
<button
onClick={handleLogout}
className="flex items-center gap-2 px-3 py-2 w-full rounded-lg text-[#94a3b8] hover:bg-[#1e2130] hover:text-red-400 transition-colors text-xs"
>
<LogOut size={14} className="flex-shrink-0" />
<span>Logout</span>
</button>
</div>
</aside>
)
}
+443
View File
@@ -0,0 +1,443 @@
import type { ComponentType } from 'react'
import {
LayoutDashboard, Settings, HelpCircle, Network, BookUser, UserCog,
Wrench, Languages, HardDrive, GitFork, RefreshCw, Activity,
Users, UserCheck, ArrowRightLeft, Paintbrush, Package,
Globe, Database, Wifi, Box, Mail, HeartPulse, Lock,
ShoppingBag, Code2, Puzzle, RotateCcw,
} from 'lucide-react'
export type NavItem = { label: string; to: string }
export type NavCategory = {
id: string
label: string
icon: ComponentType<{ size?: number | string; className?: string }>
to?: string // optional link to a section index page
items: NavItem[]
}
export function isItemActive(to: string, pathname: string): boolean {
if (to === '/') return pathname === '/'
return pathname === to || pathname.startsWith(to + '/')
}
export const NAV: NavCategory[] = [
{
id: 'server-config',
label: 'Server Configuration',
icon: Settings,
to: '/server-configuration',
items: [
{ label: 'Basic NixServer Setup', to: '/server-configuration/basic-setup' },
{ label: 'Change Root Password', to: '/server-configuration/change-root' },
{ label: 'Configure Analytics', to: '/server-configuration/analytics' },
{ label: 'Configure Cron Jobs', to: '/server-configuration/cron-jobs' },
{ label: 'Initial Quota Setup', to: '/server-configuration/quota-setup' },
{ label: 'Link Server Nodes', to: '/server-configuration/link-nodes' },
{ label: 'Server Profile', to: '/server-configuration/server-profile' },
{ label: 'Server Time', to: '/server-configuration/server-time' },
{ label: 'Statistics Software', to: '/server-configuration/statistics-software' },
{ label: 'Terminal', to: '/server-configuration/terminal' },
{ label: 'Tweak Settings', to: '/server-configuration/tweak-settings' },
{ label: 'Update Preferences', to: '/server-configuration/update-preferences' },
{ label: 'NixServer Marketplace', to: '/server-configuration/marketplace' },
],
},
{
id: 'support',
label: 'Support',
icon: HelpCircle,
items: [
{ label: 'Grant NixPanel Support Access', to: '/security' },
{ label: 'Support Center', to: '/settings' },
],
},
{
id: 'networking',
label: 'Networking Setup',
icon: Network,
items: [
{ label: 'Change Hostname', to: '/security' },
{ label: 'Resolver Configuration', to: '/security' },
{ label: 'Security Center', to: '/security' },
{ label: 'Apache mod_userdir Tweak', to: '/webserver' },
{ label: 'SMTP Restrictions', to: '/email' },
{ label: 'Compiler Access', to: '/security' },
{ label: 'Configure Security Policies', to: '/security' },
{ label: 'mXSTOP Brute Force Protection', to: '/security' },
{ label: 'Host Access Control', to: '/security' },
{ label: 'Manage External Authentications', to: '/security' },
{ label: "Manage root's SSH Keys", to: '/security' },
{ label: 'Manage Wheel Group Users', to: '/security' },
{ label: 'ModSecurity™ Configuration', to: '/security' },
{ label: 'ModSecurity™ Tools', to: '/security' },
{ label: 'ModSecurity™ Vendors', to: '/security' },
{ label: 'Password Strength Configuration', to: '/security' },
{ label: 'Security Advisor', to: '/security' },
{ label: 'Security Questions', to: '/security' },
{ label: 'Shell Fork Bomb Protection', to: '/security' },
{ label: 'SSH Password Authorization Tweak', to: '/security' },
{ label: 'Traceroute Enable/Disable', to: '/security' },
{ label: 'Two-Factor Authentication', to: '/security' },
],
},
{
id: 'contacts',
label: 'Server Contacts',
icon: BookUser,
items: [
{ label: 'Contact Manager', to: '/settings' },
{ label: 'Edit System Mail Preferences', to: '/email' },
],
},
{
id: 'resellers',
label: 'Resellers',
icon: UserCog,
items: [
{ label: 'Change Ownership of an Account', to: '/resellers' },
{ label: 'Change Ownership of Multiple Accounts', to: '/resellers' },
{ label: 'Edit Reseller Nameservers and Privileges', to: '/resellers' },
{ label: 'Email All Resellers', to: '/resellers' },
{ label: "Manage Reseller's IP Delegation", to: '/resellers' },
{ label: "Manage Reseller's Shared IP", to: '/resellers' },
{ label: 'Reseller Center', to: '/resellers' },
{ label: 'Reset Resellers', to: '/resellers' },
{ label: 'Show Reseller Accounts', to: '/resellers' },
{ label: 'View Reseller Usage', to: '/resellers' },
],
},
{
id: 'service-config',
label: 'Service Configuration',
icon: Wrench,
items: [
{ label: 'Apache Configuration', to: '/webserver' },
{ label: 'Log Rotation Configuration', to: '/settings' },
{ label: 'Web Disk Configuration', to: '/settings' },
{ label: 'Web Services Configuration', to: '/settings' },
{ label: 'Exim Configuration Manager', to: '/email' },
{ label: 'FTP Server Selection', to: '/settings' },
{ label: 'Mailserver Configuration', to: '/email' },
{ label: 'Manage Service SSL Certificates', to: '/ssl' },
{ label: 'Nameserver Selection', to: '/dns' },
{ label: 'Service Manager', to: '/services' },
],
},
{
id: 'locales',
label: 'Locales',
icon: Languages,
items: [
{ label: 'Configure Application Locales', to: '/settings' },
{ label: 'Copy a Locale', to: '/settings' },
{ label: 'Delete a Locale', to: '/settings' },
{ label: 'Edit a Locale', to: '/settings' },
{ label: 'Locale XML Download', to: '/settings' },
{ label: 'Locale XML Upload', to: '/settings' },
{ label: 'View Available Locales', to: '/settings' },
],
},
{
id: 'backup',
label: 'Backup',
icon: HardDrive,
items: [
{ label: 'Backup Configuration', to: '/backup' },
{ label: 'Backup Restoration', to: '/backup' },
{ label: 'Backup User Selection', to: '/backup' },
{ label: 'File and Directory Restoration', to: '/backup' },
],
},
{
id: 'clusters',
label: 'Clusters',
icon: GitFork,
items: [
{ label: 'Configuration Cluster', to: '/settings' },
{ label: 'DNS Cluster', to: '/dns' },
{ label: 'Remote Access Key', to: '/security' },
],
},
{
id: 'reboot',
label: 'System Reboot',
icon: RotateCcw,
items: [
{ label: 'Forceful Server Reboot', to: '/system' },
{ label: 'Graceful Server Reboot', to: '/system' },
],
},
{
id: 'server-status',
label: 'Server Status',
icon: Activity,
items: [
{ label: 'Apache Status', to: '/webserver' },
{ label: 'Daily Process Log', to: '/system' },
{ label: 'Server Information', to: '/system' },
{ label: 'Service Status', to: '/services' },
{ label: 'Task Queue Monitor', to: '/system' },
],
},
{
id: 'account-info',
label: 'Account Information',
icon: Users,
items: [
{ label: 'List Accounts', to: '/accounts/list' },
{ label: 'List Parked Domains', to: '/accounts/parked-domains' },
{ label: 'List Subdomains', to: '/accounts/subdomains' },
{ label: 'List Suspended Accounts', to: '/accounts/suspended' },
{ label: 'Show Accounts Over Quota', to: '/accounts/over-quota' },
{ label: 'View Bandwidth Usage', to: '/accounts/bandwidth' },
],
},
{
id: 'account-functions',
label: 'Account Functions',
icon: UserCheck,
items: [
{ label: "Change Site's IP Address", to: '/accounts/change-ip' },
{ label: 'Create a New Account', to: '/accounts/create' },
{ label: 'Email All Users', to: '/accounts/email-users' },
{ label: 'Force Password Change', to: '/accounts/force-password' },
{ label: 'Limit Bandwidth Usage', to: '/accounts/limit-bandwidth' },
{ label: 'Manage Account Suspension', to: '/accounts/suspension' },
{ label: 'Manage Demo Mode', to: '/accounts/demo-mode' },
{ label: 'Manage Shell Access', to: '/accounts/shell-access' },
{ label: 'Modify an Account', to: '/accounts/modify' },
{ label: 'Password Modification', to: '/accounts/password' },
{ label: 'Quota Modification', to: '/accounts/quota' },
{ label: 'Raw Apache Log Download', to: '/accounts/apache-logs' },
{ label: 'Raw NGINX® Log Download', to: '/accounts/nginx-logs' },
{ label: 'Rearrange an Account', to: '/accounts/rearrange' },
{ label: 'Reset Account Bandwidth', to: '/accounts/reset-bandwidth' },
{ label: 'Terminate Accounts', to: '/accounts/terminate' },
{ label: 'Unsuspend Bandwidth Exceeders',to: '/accounts/unsuspend-bandwidth'},
{ label: 'Upgrade/Downgrade an Account', to: '/accounts/upgrade' },
{ label: 'Web Template Editor', to: '/accounts/web-template' },
],
},
{
id: 'multi-account',
label: 'Multi Account Functions',
icon: Users,
items: [
{ label: "Change Multiple Sites' IP Addresses", to: '/accounts/bulk-ip' },
{ label: 'Modify/Upgrade Multiple Accounts', to: '/accounts/bulk-modify' },
],
},
{
id: 'transfers',
label: 'Transfers',
icon: ArrowRightLeft,
items: [
{ label: 'Convert Addon Domain to Account', to: '/accounts' },
{ label: 'Review Transfers and Restores', to: '/accounts' },
{ label: 'Transfer or Restore a cPanel Account', to: '/accounts' },
{ label: 'Transfer Tool', to: '/accounts' },
],
},
{
id: 'themes',
label: 'Themes',
icon: Paintbrush,
items: [
{ label: 'Theme Manager', to: '/settings' },
],
},
{
id: 'packages',
label: 'Packages',
icon: Package,
items: [
{ label: 'Add a Package', to: '/packages' },
{ label: 'Delete a Package', to: '/packages' },
{ label: 'Edit a Package', to: '/packages' },
{ label: 'Feature Manager', to: '/packages' },
],
},
{
id: 'dns',
label: 'DNS Functions',
icon: Globe,
items: [
{ label: 'Add a DNS Zone', to: '/dns' },
{ label: 'Add an A Entry for Hostname', to: '/dns' },
{ label: 'Delete a DNS Zone', to: '/dns' },
{ label: 'DNS Zone Manager', to: '/dns' },
{ label: 'Edit Zone Templates', to: '/dns' },
{ label: 'Email DNS Record Manager', to: '/dns' },
{ label: 'Email Routing Configuration', to: '/email' },
{ label: 'Enable DKIM/SPF Globally', to: '/email' },
{ label: 'Nameserver Record Report', to: '/dns' },
{ label: 'Park a Domain', to: '/dns' },
{ label: 'Perform a DNS Cleanup', to: '/dns' },
{ label: 'Set Zone TTL', to: '/dns' },
{ label: 'Setup/Edit Domain Forwarding', to: '/dns' },
{ label: 'Synchronize DNS Records', to: '/dns' },
],
},
{
id: 'databases',
label: 'Database Services',
icon: Database,
items: [
{ label: 'Change Database Root Password', to: '/settings' },
{ label: 'Change Database User Password', to: '/settings' },
{ label: 'Database Map Tool', to: '/settings' },
{ label: 'Edit Database Configuration', to: '/settings' },
{ label: 'Manage Database Access Hosts', to: '/settings' },
{ label: 'Manage Database Profiles', to: '/settings' },
{ label: 'Manage Database Users', to: '/settings' },
{ label: 'Manage Databases', to: '/settings' },
{ label: 'phpMyAdmin', to: '/settings' },
{ label: 'Repair Databases', to: '/settings' },
{ label: 'Show Database Processes', to: '/settings' },
{ label: 'Upgrade Database Version', to: '/settings' },
],
},
{
id: 'ip',
label: 'IP Functions',
icon: Wifi,
items: [
{ label: 'IPv6 Ranges', to: '/security' },
{ label: 'Add a New IP Address', to: '/security' },
{ label: 'Assign IPv6 Address', to: '/security' },
{ label: 'Configure Remote Service IPs', to: '/security' },
{ label: 'IP Migration Wizard', to: '/security' },
{ label: 'Rebuild the IP Address Pool', to: '/security' },
{ label: 'Show IP Address Usage', to: '/security' },
{ label: 'Show or Delete Current IPs', to: '/security' },
{ label: 'Show/Edit Reserved IPs', to: '/security' },
],
},
{
id: 'software',
label: 'Software',
icon: Box,
items: [
{ label: 'Install a Perl Module', to: '/system' },
{ label: 'Install Distro Packages', to: '/system' },
{ label: 'Module Installers', to: '/system' },
{ label: 'MultiPHP INI Editor', to: '/php' },
{ label: 'MultiPHP Manager', to: '/php' },
{ label: 'NGINX® Manager', to: '/webserver' },
{ label: 'Rebuild RPM Database', to: '/system' },
{ label: 'System Update', to: '/system' },
{ label: 'Update Server Software', to: '/system' },
{ label: 'EasyApache 4', to: '/webserver' },
],
},
{
id: 'email',
label: 'Email',
icon: Mail,
items: [
{ label: 'Email Deliverability', to: '/email' },
{ label: 'Filter Incoming Emails by Country', to: '/email' },
{ label: 'Filter Incoming Emails by Domain', to: '/email' },
{ label: 'Greylisting', to: '/email' },
{ label: 'Mail Delivery Reports', to: '/email' },
{ label: 'Mail Queue Manager', to: '/email' },
{ label: 'Mail Troubleshooter', to: '/email' },
{ label: 'Mailbox Conversion', to: '/email' },
{ label: 'Repair Mailbox Permissions', to: '/email' },
{ label: 'Spamd Startup Configuration', to: '/email' },
{ label: 'View Mail Statistics Summary', to: '/email' },
{ label: 'View Relayers', to: '/email' },
{ label: 'View Sent Summary', to: '/email' },
],
},
{
id: 'system-health',
label: 'System Health',
icon: HeartPulse,
items: [
{ label: 'Background Process Killer', to: '/system' },
{ label: 'Process Manager', to: '/system' },
{ label: 'Show Current Disk Usage', to: '/system' },
{ label: 'Show Current Running Processes', to: '/system' },
],
},
{
id: 'nixpanel',
label: 'NixPanel',
icon: LayoutDashboard,
items: [
{ label: 'Change Log', to: '/settings' },
{ label: 'Customization', to: '/settings' },
{ label: 'Manage Plugins', to: '/settings' },
{ label: 'Modify NixPanel News', to: '/settings' },
{ label: 'Reset a Mailman Password', to: '/email' },
{ label: 'Upgrade to Latest Version',to: '/settings' },
],
},
{
id: 'ssl',
label: 'SSL/TLS',
icon: Lock,
items: [
{ label: 'Generate an SSL Certificate and Signing Request', to: '/ssl' },
{ label: 'Install an SSL Certificate on a Domain', to: '/ssl' },
{ label: 'Manage AutoSSL', to: '/ssl' },
{ label: 'Manage SSL Hosts', to: '/ssl' },
{ label: 'Purchase and Install an SSL Certificate', to: '/ssl' },
{ label: 'SSL Storage Manager', to: '/ssl' },
{ label: 'SSL/TLS Configuration', to: '/ssl' },
],
},
{
id: 'market',
label: 'Market',
icon: ShoppingBag,
items: [
{ label: 'Market Provider Manager', to: '/settings' },
],
},
{
id: 'restart-services',
label: 'Restart Services',
icon: RefreshCw,
items: [
{ label: 'Database Server', to: '/services' },
{ label: 'DNS Server', to: '/services' },
{ label: 'HTTP Server (Apache)', to: '/services' },
{ label: 'IMAP Server', to: '/services' },
{ label: 'Mail Server (Exim)', to: '/services' },
{ label: 'Mailing List Manager (Mailman)', to: '/services' },
{ label: 'PHP-FPM service for Apache', to: '/services' },
{ label: 'SSH Server (OpenSSH)', to: '/services' },
],
},
{
id: 'development',
label: 'Development',
icon: Code2,
items: [
{ label: 'Apps Managed by AppConfig', to: '/settings' },
{ label: 'NixPanel Development Forum', to: '/settings' },
{ label: 'NixPanel Plugin File Generator', to: '/settings' },
{ label: 'Developer Documentation', to: '/settings' },
{ label: 'Manage API Tokens', to: '/settings' },
{ label: 'Manage Hooks', to: '/settings' },
{ label: 'OpenAPI Documentation for NixPanel', to: '/settings' },
{ label: 'OpenAPI Documentation for NixServer', to: '/settings' },
],
},
{
id: 'plugins',
label: 'Plugins',
icon: Puzzle,
items: [
{ label: 'Backuply by Softaculous', to: '/backup' },
{ label: 'ImunifyAV', to: '/security' },
{ label: 'Server Monitoring', to: '/system' },
{ label: 'SitePad Website Builder', to: '/settings' },
{ label: 'Softaculous - Instant Installs', to: '/settings' },
{ label: 'WP Toolkit', to: '/settings' },
],
},
]
+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>
) )
+35
View File
@@ -0,0 +1,35 @@
import { createContext, useContext, useEffect, useState } from 'react'
type Theme = 'dark' | 'light'
interface ThemeContextValue {
theme: Theme
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() =>
(localStorage.getItem('nixpanel-theme') as Theme) ?? 'dark'
)
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('nixpanel-theme', theme)
}, [theme])
const toggleTheme = () => setTheme(t => (t === 'dark' ? 'light' : 'dark'))
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used inside ThemeProvider')
return ctx
}
+42
View File
@@ -45,3 +45,45 @@ body {
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #3a3d4e; background: #3a3d4e;
} }
/* ── Light theme ─────────────────────────────────────────────────────────── */
[data-theme="light"] {
--bg-primary: #f0f4f8;
--bg-secondary: #ffffff;
--bg-card: #f1f5f9;
--border: #e2e8f0;
--text-primary: #0f172a;
--text-muted: #64748b;
}
[data-theme="light"] body {
background-color: #f0f4f8;
color: #0f172a;
}
/* Scrollbar */
[data-theme="light"] ::-webkit-scrollbar-track { background: #f1f5f9; }
[data-theme="light"] ::-webkit-scrollbar-thumb { background: #cbd5e1; }
[data-theme="light"] ::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
/* ── Background overrides ──────────────────────────────────────────────── */
/* Page background (darkest) */
[data-theme="light"] .bg-\[\#0f1117\] { background-color: #f0f4f8 !important; }
/* Cards / sidebar */
[data-theme="light"] .bg-\[\#1a1d27\] { background-color: #ffffff !important; }
/* Hover / alternate rows */
[data-theme="light"] .bg-\[\#1e2130\] { background-color: #f1f5f9 !important; }
/* ── Border overrides ──────────────────────────────────────────────────── */
[data-theme="light"] .border-\[\#2a2d3e\] { border-color: #e2e8f0 !important; }
/* ── Text overrides ────────────────────────────────────────────────────── */
[data-theme="light"] .text-white { color: #0f172a !important; }
[data-theme="light"] .text-\[\#94a3b8\] { color: #475569 !important; }
[data-theme="light"] .text-\[\#64748b\] { color: #64748b !important; }
[data-theme="light"] .text-\[\#4a5568\] { color: #94a3b8 !important; }
/* ── Input fields ──────────────────────────────────────────────────────── */
[data-theme="light"] input { color: #0f172a !important; }
[data-theme="light"] input::placeholder { color: #94a3b8 !important; }
+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>
) )
+123 -12
View File
@@ -1,7 +1,9 @@
import React, { useEffect, useState, useRef } from 'react' import React, { useEffect, useState, useRef } from 'react'
import { Users, Server, HardDrive, Activity } from 'lucide-react' import { Users, Server, HardDrive, Activity, AlertTriangle, CheckCircle2, Sun, Moon } from 'lucide-react'
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts' import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'
import { api } from '../api/client' import { api } from '../api/client'
import { useAuth } from '../contexts/AuthContext'
import { useTheme } from '../contexts/ThemeContext'
interface SystemStats { interface SystemStats {
cpu: number cpu: number
@@ -18,6 +20,17 @@ interface AccountStats {
terminated: number terminated: number
} }
interface SystemInfo {
hostname: string
os: string
}
interface ServiceStatus {
name: string
status: 'running' | 'stopped' | 'failed' | 'unknown'
enabled: boolean
}
interface StatCard { interface StatCard {
label: string label: string
value: string | number value: string | number
@@ -31,15 +44,52 @@ const formatBytes = (bytes: number) => {
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / 1024 / 1024).toFixed(0)} MB` return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / 1024 / 1024).toFixed(0)} MB`
} }
// ─── Server Info Bar ─────────────────────────────────────────────────────────
interface InfoCellProps {
label: string
value: React.ReactNode
}
function InfoCell({ label, value }: InfoCellProps) {
return (
<div className="flex flex-col min-w-0">
<span className="text-[#4a5568] text-[10px] font-medium uppercase tracking-wide leading-none mb-0.5">
{label}
</span>
<span className="text-[#94a3b8] text-xs font-medium leading-none truncate">
{value}
</span>
</div>
)
}
function Divider() {
return <div className="w-px h-8 bg-[#2a2d3e] flex-shrink-0" />
}
// ─── Component ───────────────────────────────────────────────────────────────
export default function Dashboard() { export default function Dashboard() {
const { user } = useAuth()
const { theme, toggleTheme } = useTheme()
const [stats, setStats] = useState<SystemStats | null>(null) const [stats, setStats] = useState<SystemStats | null>(null)
const [accountStats, setAccountStats] = useState<AccountStats | null>(null) const [accountStats, setAccountStats] = useState<AccountStats | null>(null)
const [cpuHistory, setCpuHistory] = useState<{ t: string; cpu: number; mem: number }[]>([]) const [cpuHistory, setCpuHistory] = useState<{ t: string; cpu: number; mem: number }[]>([])
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
const [version, setVersion] = useState<string | null>(null)
const [alertCount, setAlertCount] = useState<number | null>(null)
const wsRef = useRef<WebSocket | null>(null) const wsRef = useRef<WebSocket | null>(null)
useEffect(() => { useEffect(() => {
api.get('/nixserver/accounts/stats').then(r => setAccountStats(r.data.data)).catch(() => {}) api.get('/nixserver/accounts/stats').then(r => setAccountStats(r.data.data)).catch(() => {})
api.get('/nixserver/system/stats').then(r => setStats(r.data.data)).catch(() => {}) api.get('/nixserver/system/stats').then(r => setStats(r.data.data)).catch(() => {})
api.get('/nixserver/system/info').then(r => setSystemInfo(r.data.data)).catch(() => {})
api.get('/nixserver/system/version').then(r => setVersion(r.data.data?.currentVersion ?? null)).catch(() => {})
api.get('/nixserver/services').then(r => {
const services: ServiceStatus[] = r.data.data ?? []
setAlertCount(services.filter(s => s.status !== 'running').length)
}).catch(() => { setAlertCount(0) })
// WebSocket live stats // WebSocket live stats
const token = localStorage.getItem('access_token') const token = localStorage.getItem('access_token')
@@ -92,14 +142,69 @@ export default function Dashboard() {
}, },
] ]
const loadStr = stats
? `${stats.load[0].toFixed(2)}\u00a0\u00a0${stats.load[1].toFixed(2)}\u00a0\u00a0${stats.load[2].toFixed(2)}`
: '…'
const monitoringNode =
alertCount === null ? (
<span className="text-[#4a5568]">…</span>
) : alertCount === 0 ? (
<span className="flex items-center gap-1 text-emerald-400">
<CheckCircle2 size={11} />
All Clear
</span>
) : (
<span className="flex items-center gap-1 text-amber-400">
<AlertTriangle size={11} />
{alertCount} Alert{alertCount !== 1 ? 's' : ''}
</span>
)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div>
<h1 className="text-white text-xl font-semibold">Dashboard</h1> {/* ── cPanel-style server info bar ─────────────────────────────────── */}
<p className="text-[#64748b] text-sm mt-0.5">Server overview and real-time metrics</p> <div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl px-5 py-3.5 flex flex-wrap items-center gap-x-5 gap-y-3">
<InfoCell label="Username" value={user?.username ?? '…'} />
<Divider />
<InfoCell label="Hostname" value={systemInfo?.hostname ?? '…'} />
<Divider />
<InfoCell label="OS" value={systemInfo?.os ?? '…'} />
<Divider />
<InfoCell label="NixPanel Version" value={version ? `v${version}` : '…'} />
<Divider />
<InfoCell label="Load Averages" value={loadStr} />
<Divider />
<InfoCell label="Server Monitoring" value={monitoringNode} />
{/* ── Day / Night toggle — far right ── */}
<div className="ml-auto flex items-center gap-2 flex-shrink-0">
<Moon
size={13}
className={theme === 'dark' ? 'text-indigo-400' : 'text-[#94a3b8]'}
/>
<button
onClick={toggleTheme}
title={theme === 'dark' ? 'Switch to day mode' : 'Switch to night mode'}
className={`relative flex items-center w-10 h-5 rounded-full transition-colors cursor-pointer flex-shrink-0 ${
theme === 'light' ? 'bg-amber-400' : 'bg-[#2a2d3e]'
}`}
>
<span
className={`absolute h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
theme === 'light' ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
<Sun
size={13}
className={theme === 'light' ? 'text-amber-500' : 'text-[#4a5568]'}
/>
</div>
</div> </div>
{/* Stat cards */} {/* ── Stat cards ───────────────────────────────────────────────────── */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
{cards.map(card => ( {cards.map(card => (
<div key={card.label} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4"> <div key={card.label} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
@@ -113,7 +218,7 @@ export default function Dashboard() {
))} ))}
</div> </div>
{/* CPU / Memory chart */} {/* ── CPU / Memory chart ───────────────────────────────────────────── */}
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4"> <div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
<h2 className="text-white text-sm font-medium mb-4">CPU & Memory — Live</h2> <h2 className="text-white text-sm font-medium mb-4">CPU & Memory — Live</h2>
{cpuHistory.length > 0 ? ( {cpuHistory.length > 0 ? (
@@ -132,8 +237,14 @@ export default function Dashboard() {
<XAxis dataKey="t" tick={{ fill: '#64748b', fontSize: 10 }} tickLine={false} axisLine={false} /> <XAxis dataKey="t" tick={{ fill: '#64748b', fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis domain={[0, 100]} tick={{ fill: '#64748b', fontSize: 10 }} tickLine={false} axisLine={false} /> <YAxis domain={[0, 100]} tick={{ fill: '#64748b', fontSize: 10 }} tickLine={false} axisLine={false} />
<Tooltip <Tooltip
contentStyle={{ background: '#1e2130', border: '1px solid #2a2d3e', borderRadius: 8, fontSize: 12 }} contentStyle={{
labelStyle={{ color: '#94a3b8' }} background: theme === 'light' ? '#ffffff' : '#1e2130',
border: `1px solid ${theme === 'light' ? '#e2e8f0' : '#2a2d3e'}`,
borderRadius: 8,
fontSize: 12,
color: theme === 'light' ? '#0f172a' : '#e2e8f0',
}}
labelStyle={{ color: theme === 'light' ? '#64748b' : '#94a3b8' }}
/> />
<Area type="monotone" dataKey="cpu" stroke="#6366f1" fill="url(#cpu)" strokeWidth={2} name="CPU %" /> <Area type="monotone" dataKey="cpu" stroke="#6366f1" fill="url(#cpu)" strokeWidth={2} name="CPU %" />
<Area type="monotone" dataKey="mem" stroke="#10b981" fill="url(#mem)" strokeWidth={2} name="Mem %" /> <Area type="monotone" dataKey="mem" stroke="#10b981" fill="url(#mem)" strokeWidth={2} name="Mem %" />
@@ -146,15 +257,15 @@ export default function Dashboard() {
)} )}
</div> </div>
{/* Account status breakdown */} {/* ── Account status breakdown ─────────────────────────────────────── */}
{accountStats && ( {accountStats && (
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4"> <div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
<h2 className="text-white text-sm font-medium mb-4">Account Status</h2> <h2 className="text-white text-sm font-medium mb-4">Account Status</h2>
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
{[ {[
{ label: 'Active', value: accountStats.active, color: 'text-emerald-400' }, { label: 'Active', value: accountStats.active, color: 'text-emerald-400' },
{ label: 'Suspended', value: accountStats.suspended, color: 'text-amber-400' }, { label: 'Suspended', value: accountStats.suspended, color: 'text-amber-400' },
{ label: 'Terminated', value: accountStats.terminated, color: 'text-red-400' }, { label: 'Terminated', value: accountStats.terminated, color: 'text-red-400' },
].map(s => ( ].map(s => (
<div key={s.label} className="text-center"> <div key={s.label} className="text-center">
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p> <p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
+11 -3
View File
@@ -1,7 +1,8 @@
import React, { useState } from 'react' import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { Shield } from 'lucide-react' import { Shield } from 'lucide-react'
import axios from 'axios'
export default function Login() { export default function Login() {
const { login } = useAuth() const { login } = useAuth()
@@ -10,6 +11,13 @@ export default function Login() {
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [version, setVersion] = useState<string | null>(null)
useEffect(() => {
axios.get('/api/health', { timeout: 5_000 })
.then(r => setVersion(r.data.version ?? null))
.catch(() => {})
}, [])
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -35,7 +43,7 @@ export default function Login() {
<Shield size={24} className="text-white" /> <Shield size={24} className="text-white" />
</div> </div>
<h1 className="text-white text-xl font-bold">NixServer</h1> <h1 className="text-white text-xl font-bold">NixServer</h1>
<p className="text-[#64748b] text-sm mt-1">Web Host Manager</p> <p className="text-[#64748b] text-sm mt-1">Admin Panel</p>
</div> </div>
{/* Form */} {/* Form */}
@@ -81,7 +89,7 @@ export default function Login() {
</form> </form>
<p className="text-center text-[#64748b] text-xs mt-4"> <p className="text-center text-[#64748b] text-xs mt-4">
NixServer v0.3.0 · Port 2087 NixServer{version ? ` v${version}` : ''} · Port 2087
</p> </p>
</div> </div>
</div> </div>
+101 -25
View File
@@ -1,49 +1,125 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Save, RefreshCw } from 'lucide-react' import { Save, RefreshCw, AlertTriangle } from 'lucide-react'
import { api } from '../api/client' import { api } from '../api/client'
import ContactTab from './settings/ContactTab'
import BasicTab from './settings/BasicTab'
import NameserversSection from './settings/NameserversSection'
type Tab = 'contact' | 'basic'
const TABS: { id: Tab; label: string }[] = [
{ id: 'contact', label: 'Contact Information' },
{ id: 'basic', label: 'Basic Config' },
]
export default function Settings() { export default function Settings() {
const [settings, setSettings] = useState<Record<string, string>>({}) const [settings, setSettings] = useState<Record<string, string>>({})
const [interfaces, setInterfaces] = useState<string[]>([])
const [tab, setTab] = useState<Tab>('contact')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [error, setError] = useState('')
const fetch = async () => { const load = async () => {
setLoading(true) setLoading(true)
try { const r = await api.get('/nixserver/system/settings'); setSettings(r.data.data) } setError('')
finally { setLoading(false) } try {
const [settingsRes, ifaceRes] = await Promise.allSettled([
api.get('/nixserver/system/settings'),
api.get('/nixserver/system/network-interfaces'),
])
if (settingsRes.status === 'fulfilled') setSettings(settingsRes.value.data.data)
if (ifaceRes.status === 'fulfilled') setInterfaces(ifaceRes.value.data.data ?? [])
} catch {
setError('Failed to load settings')
} finally {
setLoading(false)
}
} }
useEffect(() => { fetch() }, []) useEffect(() => { load() }, [])
const handleChange = (key: string, value: string) =>
setSettings(s => ({ ...s, [key]: value }))
const save = async (e: React.FormEvent) => { const save = async (e: React.FormEvent) => {
e.preventDefault(); setSaving(true) e.preventDefault()
try { await api.put('/nixserver/system/settings', settings); setSaved(true); setTimeout(() => setSaved(false), 2000) } setSaving(true)
finally { setSaving(false) } setError('')
try {
await api.put('/nixserver/system/settings', settings)
setSaved(true)
setTimeout(() => setSaved(false), 2500)
} catch {
setError('Failed to save settings')
} finally {
setSaving(false)
}
} }
return ( return (
<div className="space-y-4 max-w-2xl"> <div className="space-y-6 max-w-4xl">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div><h1 className="text-white text-xl font-semibold">Settings</h1><p className="text-[#64748b] text-sm">System-wide configuration</p></div> <div>
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button> <h1 className="text-white text-xl font-semibold">Basic NixServer Setup</h1>
<p className="text-[#64748b] text-sm mt-0.5">Configure server-wide defaults</p>
</div>
<button
type="button"
onClick={load}
className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors"
>
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
</button>
</div> </div>
<form onSubmit={save} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-6 space-y-3"> {error && (
{Object.entries(settings).map(([key, value]) => ( <div className="flex items-center gap-2 bg-red-900/20 border border-red-700/40 rounded-lg px-4 py-3 text-red-400 text-sm">
<div key={key} className="flex items-center gap-4"> <AlertTriangle size={14} className="flex-shrink-0" />
<label className="text-[#94a3b8] text-xs font-mono w-56 flex-shrink-0">{key}</label> {error}
<input </div>
value={value} )}
onChange={e => setSettings(s => ({ ...s, [key]: e.target.value }))}
className="flex-1 bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-indigo-500" <form onSubmit={save}>
/> {/* Tab bar */}
</div> <div className="flex border-b border-[#2a2d3e]">
))} {TABS.map(t => (
<div className="pt-4"> <button
<button type="submit" disabled={saving} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm px-4 py-2 rounded-lg"> key={t.id}
<Save size={14} /> {saving ? 'Saving...' : saved ? 'Saved!' : 'Save Settings'} type="button"
onClick={() => setTab(t.id)}
className={[
'px-5 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
tab === t.id
? 'border-indigo-500 text-white'
: 'border-transparent text-[#64748b] hover:text-[#94a3b8]',
].join(' ')}
>
{t.label}
</button>
))}
</div>
<div className="bg-[#1a1d27] border border-[#2a2d3e] border-t-0 rounded-b-xl px-6 py-2">
{tab === 'contact'
? <ContactTab settings={settings} onChange={handleChange} />
: <BasicTab settings={settings} onChange={handleChange} interfaces={interfaces} />
}
</div>
<NameserversSection settings={settings} onChange={handleChange} />
<div className="flex items-center gap-4 pt-4">
<button
type="submit"
disabled={saving || loading}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-medium px-5 py-2.5 rounded-lg transition-colors"
>
<Save size={14} />
{saving ? 'Saving…' : saved ? 'Saved!' : 'Save Changes'}
</button> </button>
{saved && <span className="text-green-400 text-sm">Settings saved successfully.</span>}
</div> </div>
</form> </form>
</div> </div>
+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>
)
}
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function Analytics() {
return (
<ComingSoon
title="Configure Analytics"
description="Enable and configure server-side analytics and statistics collection."
/>
)
}
@@ -0,0 +1,3 @@
// Basic NixServer Setup — full implementation lives in the settings/ sub-components.
// This file is the route entry point at /server-configuration/basic-setup.
export { default } from '../Settings'
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function ChangeRoot() {
return (
<ComingSoon
title="Change Root Password"
description="Update the root account password for this server."
/>
)
}
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function CronJobs() {
return (
<ComingSoon
title="Configure Cron Jobs"
description="Schedule recurring system-level tasks and maintenance scripts."
/>
)
}
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function LinkNodes() {
return (
<ComingSoon
title="Link Server Nodes"
description="Connect additional server nodes for clustering or load distribution."
/>
)
}
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function Marketplace() {
return (
<ComingSoon
title="NixServer Marketplace"
description="Browse and install official extensions, plugins, and integrations."
/>
)
}
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function QuotaSetup() {
return (
<ComingSoon
title="Initial Quota Setup"
description="Configure disk quota enforcement and default account limits."
/>
)
}
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function ServerProfile() {
return (
<ComingSoon
title="Server Profile"
description="Set server-wide identity, locale, and branding preferences."
/>
)
}
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function ServerTime() {
return (
<ComingSoon
title="Server Time"
description="Configure the system clock, timezone, and NTP synchronization."
/>
)
}
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function StatisticsSoftware() {
return (
<ComingSoon
title="Statistics Software"
description="Install and configure AWStats, Webalizer, or other log analysis tools."
/>
)
}
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function Terminal() {
return (
<ComingSoon
title="Terminal"
description="Open a browser-based terminal session with server access."
/>
)
}
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function TweakSettings() {
return (
<ComingSoon
title="Tweak Settings"
description="Fine-tune advanced server behaviours and feature toggles."
/>
)
}
@@ -0,0 +1,10 @@
import ComingSoon from '../../components/ComingSoon'
export default function UpdatePreferences() {
return (
<ComingSoon
title="Update Preferences"
description="Control automatic update behaviour, channels, and scheduling."
/>
)
}
@@ -0,0 +1,116 @@
import {
Settings, Lock, BarChart2, Clock, HardDrive, Link2,
User, Timer, PieChart, Terminal, Sliders, RefreshCw, ShoppingBag,
} from 'lucide-react'
import SectionCard from '../../components/SectionCard'
const TOOLS = [
{
label: 'Basic NixServer Setup',
description: 'Configure contact info, shared IP, nameservers, DNS TTL, home directory, and Apache log style.',
to: '/server-configuration/basic-setup',
icon: Settings,
comingSoon: false,
},
{
label: 'Change Root Password',
description: 'Update the root account password for this server.',
to: '/server-configuration/change-root',
icon: Lock,
comingSoon: false,
},
{
label: 'Configure Analytics',
description: 'Enable and configure server-side analytics and statistics collection.',
to: '/server-configuration/analytics',
icon: BarChart2,
comingSoon: true,
},
{
label: 'Configure Cron Jobs',
description: 'Schedule recurring system-level tasks and maintenance scripts.',
to: '/server-configuration/cron-jobs',
icon: Clock,
comingSoon: true,
},
{
label: 'Initial Quota Setup',
description: 'Configure disk quota enforcement and default account limits.',
to: '/server-configuration/quota-setup',
icon: HardDrive,
comingSoon: true,
},
{
label: 'Link Server Nodes',
description: 'Connect additional server nodes for clustering or load distribution.',
to: '/server-configuration/link-nodes',
icon: Link2,
comingSoon: true,
},
{
label: 'Server Profile',
description: 'Set server-wide identity, locale, and branding preferences.',
to: '/server-configuration/server-profile',
icon: User,
comingSoon: true,
},
{
label: 'Server Time',
description: 'Configure the system clock, timezone, and NTP synchronization.',
to: '/server-configuration/server-time',
icon: Timer,
comingSoon: true,
},
{
label: 'Statistics Software',
description: 'Install and configure AWStats, Webalizer, or other log analysis tools.',
to: '/server-configuration/statistics-software',
icon: PieChart,
comingSoon: true,
},
{
label: 'Terminal',
description: 'Open a browser-based terminal session with server access.',
to: '/server-configuration/terminal',
icon: Terminal,
comingSoon: true,
},
{
label: 'Tweak Settings',
description: 'Fine-tune advanced server behaviours and feature toggles.',
to: '/server-configuration/tweak-settings',
icon: Sliders,
comingSoon: true,
},
{
label: 'Update Preferences',
description: 'Control automatic update behaviour, channels, and scheduling.',
to: '/server-configuration/update-preferences',
icon: RefreshCw,
comingSoon: true,
},
{
label: 'NixServer Marketplace',
description: 'Browse and install official extensions, plugins, and integrations.',
to: '/server-configuration/marketplace',
icon: ShoppingBag,
comingSoon: true,
},
]
export default function ServerConfiguration() {
return (
<div className="space-y-6">
<div>
<h1 className="text-white text-xl font-semibold">Server Configuration</h1>
<p className="text-[#64748b] text-sm mt-0.5">Server-wide settings and system configuration tools</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
{TOOLS.map(tool => (
<SectionCard key={tool.to} {...tool} />
))}
</div>
</div>
)
}
+130
View File
@@ -0,0 +1,130 @@
import { AlertTriangle } from 'lucide-react'
import { Field, TextInput, RadioGroup, get } from './FormControls'
interface Props {
settings: Record<string, string>
onChange: (key: string, value: string) => void
interfaces: string[]
}
export default function BasicTab({ settings, onChange, interfaces }: Props) {
const ethdev = get(settings, 'ETHDEV')
const isOther = ethdev !== '' && !interfaces.includes(ethdev)
return (
<div>
<Field label="Shared IPv4 Address" hint="ADDR — Main server IPv4 address">
<TextInput
value={get(settings, 'ADDR')}
onChange={v => onChange('ADDR', v)}
placeholder="192.168.1.100"
/>
</Field>
<Field label="Shared IPv6 Address" hint="ADDR6 — Leave blank if not using IPv6">
<TextInput
value={get(settings, 'ADDR6')}
onChange={v => onChange('ADDR6', v)}
placeholder="2001:db8::1"
/>
</Field>
<Field label="Ethernet Device" hint="ETHDEV — Network interface for IP configuration">
<div className="flex gap-2">
<select
value={isOther ? '__other__' : ethdev}
onChange={e => {
if (e.target.value !== '__other__') onChange('ETHDEV', e.target.value)
else onChange('ETHDEV', '')
}}
className="bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
>
<option value="">— select —</option>
{interfaces.map(iface => (
<option key={iface} value={iface}>{iface}</option>
))}
<option value="__other__">Other…</option>
</select>
{isOther && (
<TextInput
value={ethdev}
onChange={v => onChange('ETHDEV', v)}
placeholder="eth0"
/>
)}
</div>
</Field>
<Field label="Home Directory" hint="HOMEDIR — Default: /home">
<TextInput
value={get(settings, 'HOMEDIR')}
onChange={v => onChange('HOMEDIR', v)}
placeholder="/home"
/>
</Field>
<Field
label="Home Directory Prefix Match"
hint="HOMEMATCH — Must be present in home path. Default: home"
>
<TextInput
value={get(settings, 'HOMEMATCH')}
onChange={v => onChange('HOMEMATCH', v)}
placeholder="home"
/>
</Field>
<Field
label="Auto-Create CGI-bin Alias"
hint="SCRIPTALIAS — Automatically create a cgi-bin for new accounts"
>
<RadioGroup
value={get(settings, 'SCRIPTALIAS')}
onChange={v => onChange('SCRIPTALIAS', v)}
options={[
{ value: 'yes', label: 'Yes' },
{ value: 'no', label: 'No' },
]}
/>
</Field>
<Field label="DNS TTL" hint="TTL — Time-to-live for DNS records (seconds). Default: 14400">
<TextInput
value={get(settings, 'TTL')}
onChange={v => onChange('TTL', v)}
placeholder="14400"
/>
</Field>
<Field label="Nameserver TTL" hint="NSTTL — Time-to-live for NS records (seconds). Default: 86400">
<TextInput
value={get(settings, 'NSTTL')}
onChange={v => onChange('NSTTL', v)}
placeholder="86400"
/>
</Field>
<Field label="Apache Log Style" hint="LOGSTYLE — Log format used in Apache vhost configs">
<div className="space-y-2">
<RadioGroup
value={get(settings, 'LOGSTYLE')}
onChange={v => onChange('LOGSTYLE', v)}
options={[
{ value: 'combined', label: 'Combined' },
{ value: 'common', label: 'Common' },
]}
/>
{get(settings, 'LOGSTYLE') === 'common' && (
<div className="flex items-start gap-2 bg-yellow-900/20 border border-yellow-700/40 rounded-lg px-3 py-2 text-yellow-400 text-xs">
<AlertTriangle size={14} className="mt-0.5 flex-shrink-0" />
<span>
<strong>Warning:</strong> "Common" log format omits the referrer and user-agent
fields. "Combined" is recommended for most setups.
</span>
</div>
)}
</div>
</Field>
</div>
)
}
+107
View File
@@ -0,0 +1,107 @@
import { Field, TextInput, get } from './FormControls'
interface Props {
settings: Record<string, string>
onChange: (key: string, value: string) => void
}
export default function ContactTab({ settings, onChange }: Props) {
return (
<div>
<Field
label="Contact Email Address(es)"
hint="Separate multiple addresses with spaces. Used for system notifications."
>
<TextInput
value={get(settings, 'CONTACTEMAIL')}
onChange={v => onChange('CONTACTEMAIL', v)}
placeholder="[email protected]"
type="email"
/>
<p className="text-[#475569] text-xs mt-1">
Enter the email address(es) to which you would like server notification messages to be
sent. Separate multiple addresses with spaces.
</p>
</Field>
<Field label="Email Sender Name" hint="EMAILFROMNAME — Name shown in From: header">
<TextInput
value={get(settings, 'EMAILFROMNAME')}
onChange={v => onChange('EMAILFROMNAME', v)}
placeholder="NixPanel"
/>
</Field>
<Field label="Reply-To Email Address" hint="EMAILREPLYTO — Optional reply-to header">
<TextInput
value={get(settings, 'EMAILREPLYTO')}
onChange={v => onChange('EMAILREPLYTO', v)}
placeholder="[email protected]"
type="email"
/>
</Field>
<Field label="Pager / Cellular Email" hint="CONTACTPAGER — For critical alerts (e.g. SMS gateway)">
<TextInput
value={get(settings, 'CONTACTPAGER')}
onChange={v => onChange('CONTACTPAGER', v)}
placeholder="[email protected]"
/>
</Field>
<Field label="Pushbullet API Token" hint="CONTACTPUSHBULLET — Leave blank to disable">
<TextInput
value={get(settings, 'CONTACTPUSHBULLET')}
onChange={v => onChange('CONTACTPUSHBULLET', v)}
placeholder="o.xxxxxxxxxxxxxxxxxxxxxx"
type="password"
/>
</Field>
<Field label="Slack Webhook URL(s)" hint="CONTACTSLACK — One URL per line">
<textarea
value={get(settings, 'CONTACTSLACK')}
onChange={e => onChange('CONTACTSLACK', e.target.value)}
placeholder="https://hooks.slack.com/services/..."
rows={3}
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500 placeholder-[#374151] resize-y"
/>
</Field>
<Field label="POST Notification URL(s)" hint="CONTACTPOSTURL — One URL per line">
<textarea
value={get(settings, 'CONTACTPOSTURL')}
onChange={e => onChange('CONTACTPOSTURL', e.target.value)}
placeholder="https://example.com/webhook"
rows={3}
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500 placeholder-[#374151] resize-y"
/>
</Field>
<Field label="ICQ User ID" hint="ICQUSER">
<TextInput
value={get(settings, 'ICQUSER')}
onChange={v => onChange('ICQUSER', v)}
placeholder="123456789"
/>
</Field>
<Field label="ICQ Password" hint="ICQPASS">
<TextInput
value={get(settings, 'ICQPASS')}
onChange={v => onChange('ICQPASS', v)}
placeholder="••••••••"
type="password"
/>
</Field>
<Field label="ICQ Contact UIDs" hint="CONTACTUIN — UIDs to notify (space-separated)">
<TextInput
value={get(settings, 'CONTACTUIN')}
onChange={v => onChange('CONTACTUIN', v)}
placeholder="123456789 987654321"
/>
</Field>
</div>
)
}
@@ -0,0 +1,83 @@
// Shared form primitives used across Settings tabs
export function Field({
label,
hint,
children,
}: {
label: string
hint?: string
children: React.ReactNode
}) {
return (
<div className="grid grid-cols-[280px_1fr] gap-4 items-start py-3 border-b border-[#1e2130] last:border-0">
<div>
<div className="text-[#cbd5e1] text-sm font-medium">{label}</div>
{hint && <div className="text-[#475569] text-xs mt-0.5">{hint}</div>}
</div>
<div>{children}</div>
</div>
)
}
export function TextInput({
value,
onChange,
placeholder,
type = 'text',
}: {
value: string
onChange: (v: string) => void
placeholder?: string
type?: string
}) {
return (
<input
type={type}
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500 placeholder-[#374151]"
/>
)
}
export function RadioGroup({
value,
onChange,
options,
}: {
value: string
onChange: (v: string) => void
options: { value: string; label: string }[]
}) {
return (
<div className="flex gap-6">
{options.map(opt => (
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
value={opt.value}
checked={value === opt.value}
onChange={() => onChange(opt.value)}
className="accent-indigo-500"
/>
<span className="text-[#cbd5e1] text-sm">{opt.label}</span>
</label>
))}
</div>
)
}
export const FIELD_DEFAULTS: Record<string, string> = {
HOMEDIR: '/home',
HOMEMATCH: 'home',
TTL: '14400',
NSTTL: '86400',
SCRIPTALIAS: 'yes',
LOGSTYLE: 'combined',
}
export function get(settings: Record<string, string>, key: string): string {
return settings[key] ?? FIELD_DEFAULTS[key] ?? ''
}
@@ -0,0 +1,57 @@
import { Server } from 'lucide-react'
import { TextInput, get } from './FormControls'
const NS_KEYS = ['NS', 'NS2', 'NS3', 'NS4'] as const
const NS_LABELS: Record<string, string> = {
NS: 'Nameserver 1',
NS2: 'Nameserver 2',
NS3: 'Nameserver 3 (optional)',
NS4: 'Nameserver 4 (optional)',
}
const NS_PLACEHOLDERS: Record<string, string> = {
NS: 'ns1.example.com',
NS2: 'ns2.example.com',
NS3: '',
NS4: '',
}
interface Props {
settings: Record<string, string>
onChange: (key: string, value: string) => void
}
export default function NameserversSection({ settings, onChange }: Props) {
return (
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden mt-6">
<div className="flex items-center gap-3 px-6 py-4 border-b border-[#2a2d3e]">
<Server size={16} className="text-indigo-400" />
<h2 className="text-white font-semibold">Nameservers</h2>
</div>
<div className="px-6 py-4">
{NS_KEYS.map(key => (
<div
key={key}
className="grid grid-cols-[280px_1fr_auto] gap-4 items-center py-3 border-b border-[#1e2130] last:border-0"
>
<div className="text-[#cbd5e1] text-sm font-medium">{NS_LABELS[key]}</div>
<TextInput
value={get(settings, key)}
onChange={v => onChange(key, v)}
placeholder={NS_PLACEHOLDERS[key]}
/>
<button
type="button"
onClick={() => {
const ns = get(settings, key)
if (ns) alert(`Configure A record for ${ns} — DNS management coming soon.`)
}}
className="text-xs text-indigo-400 hover:text-indigo-300 border border-indigo-500/30 hover:border-indigo-400 rounded px-3 py-1.5 whitespace-nowrap transition-colors"
>
Configure Address Records
</button>
</div>
))}
</div>
</div>
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nixpanel-server", "name": "nixpanel-server",
"version": "0.3.0", "version": "0.6.2",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"server", "server",
+527 -470
View File
File diff suppressed because it is too large Load Diff
+19 -6
View File
@@ -14,29 +14,42 @@ INSTALL_DIR="${1:-/opt/nixpanel}"
[[ -d "$INSTALL_DIR/.git" ]] || error "NixPanel not found at $INSTALL_DIR" [[ -d "$INSTALL_DIR/.git" ]] || error "NixPanel not found at $INSTALL_DIR"
step "Pulling latest code" step "Pulling latest code"
# package-lock.json is not tracked (platform-specific), but discard any other
# locally modified tracked files that could block a fast-forward pull
git -C "$INSTALL_DIR" checkout -- . 2>/dev/null || true
git -C "$INSTALL_DIR" pull --ff-only git -C "$INSTALL_DIR" pull --ff-only
success "Code updated" success "Code updated"
step "Installing dependencies" step "Installing dependencies"
cd "$INSTALL_DIR" cd "$INSTALL_DIR"
npm install --workspaces --include-workspace-root --silent # Delete node_modules and any stale lock file so npm regenerates them fresh
# for this platform (avoids the rollup native binary mismatch between
# Windows dev machines and Linux servers)
rm -rf node_modules package-lock.json
npm install --workspaces --include-workspace-root
success "Dependencies ready" success "Dependencies ready"
step "Running migrations" step "Running migrations"
cd "$INSTALL_DIR/server" cd "$INSTALL_DIR/server"
npx prisma generate --silent npx prisma generate
npx prisma migrate deploy npx prisma db push
success "Migrations applied" success "Migrations applied"
step "Rebuilding frontends" step "Rebuilding frontends"
cd "$INSTALL_DIR" cd "$INSTALL_DIR"
npm run build --workspace=nixserver --silent npm run build --workspace=nixserver
npm run build --workspace=nixclient --silent npm run build --workspace=nixclient
# Ensure Apache (www-data) can read the rebuilt assets
chmod 755 "$INSTALL_DIR/nixserver" "$INSTALL_DIR/nixclient"
find "$INSTALL_DIR/nixserver/dist" -type d -exec chmod 755 {} \;
find "$INSTALL_DIR/nixserver/dist" -type f -exec chmod 644 {} \;
find "$INSTALL_DIR/nixclient/dist" -type d -exec chmod 755 {} \;
find "$INSTALL_DIR/nixclient/dist" -type f -exec chmod 644 {} \;
success "Frontends rebuilt" success "Frontends rebuilt"
step "Rebuilding server" step "Rebuilding server"
cd "$INSTALL_DIR/server" cd "$INSTALL_DIR/server"
npm run build --silent 2>/dev/null || npx tsc 2>/dev/null || true npm run build 2>/dev/null || npx tsc 2>/dev/null || true
success "Server rebuilt" success "Server rebuilt"
step "Restarting service" step "Restarting service"
+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")
}
+2 -2
View File
@@ -60,7 +60,7 @@ export async function buildApp() {
// ─── Static files ──────────────────────────────────────────────────────────── // ─── Static files ────────────────────────────────────────────────────────────
// NixServer frontend // NixServer frontend
const whmDist = path.resolve(__dirname, '../../nixserver/dist') const nixserverDist = path.resolve(__dirname, '../../nixserver/dist')
const cpanelDist = path.resolve(__dirname, '../../nixclient/dist') const cpanelDist = path.resolve(__dirname, '../../nixclient/dist')
// ─── API Routes ────────────────────────────────────────────────────────────── // ─── API Routes ──────────────────────────────────────────────────────────────
@@ -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.6.2',
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', 'uptime', 'free', 'ps', 'kill',
'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 })
})
}
-418
View File
@@ -1,418 +0,0 @@
import type { FastifyInstance } from 'fastify'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import prisma from '../../db/client.js'
import { requireAdminOrReseller } from '../../middleware/auth.js'
import { exec, validateUsername, validateDomain } from '../../core/exec.js'
import { enableVhost, disableVhost } from '../../core/nginx.js'
import { createZone, removeZone } from '../../core/dns.js'
import { config } from '../../config.js'
const CreateAccountSchema = z.object({
username: z.string().min(1).max(32),
password: z.string().min(8),
email: z.string().email(),
domain: z.string().min(4),
packageId: z.number().int().positive().optional(),
resellerId: z.number().int().positive().optional(),
ipId: z.number().int().positive().optional(),
})
const UpdateAccountSchema = z.object({
email: z.string().email().optional(),
packageId: z.number().int().positive().optional(),
password: z.string().min(8).optional(),
notes: z.string().max(500).optional(),
})
export default async function accountRoutes(fastify: FastifyInstance) {
const preHandler = [requireAdminOrReseller]
// GET /api/nixserver/accounts
fastify.get('/', { preHandler }, async (request, reply) => {
const q = request.query as Record<string, string>
const page = Math.max(1, parseInt(q.page ?? '1', 10))
const limit = Math.min(100, Math.max(1, parseInt(q.limit ?? '25', 10)))
const skip = (page - 1) * limit
const where: Record<string, unknown> = {}
if (q.search) {
where.OR = [
{ username: { contains: q.search } },
{ domain: { contains: q.search } },
{ email: { contains: q.search } },
]
}
if (q.status) where.status = q.status
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
const [items, total] = await Promise.all([
prisma.account.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true, username: true, domain: true, email: true, status: true,
diskUsedMb: true, bandwidthUsedMb: true, ipAddress: true,
createdAt: true, suspendedAt: true, suspendReason: true,
package: { select: { name: true, diskMb: true, bandwidthMb: true } },
reseller: { select: { username: true } },
},
}),
prisma.account.count({ where }),
])
return reply.send({
success: true,
data: { items, total, page, limit, totalPages: Math.ceil(total / limit) },
})
})
// GET /api/nixserver/accounts/stats
fastify.get('/stats', { preHandler }, async (request, reply) => {
const where = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
const [total, active, suspended, terminated] = await Promise.all([
prisma.account.count({ where }),
prisma.account.count({ where: { ...where, status: 'active' } }),
prisma.account.count({ where: { ...where, status: 'suspended' } }),
prisma.account.count({ where: { ...where, status: 'terminated' } }),
])
return reply.send({ success: true, data: { total, active, suspended, terminated } })
})
// GET /api/nixserver/accounts/:id
fastify.get('/:id', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const where: Record<string, unknown> = { id: parseInt(id, 10) }
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
const account = await prisma.account.findFirst({
where,
include: {
package: true,
reseller: { select: { username: true, company: true } },
ip: true,
dnsZones: { select: { id: true, domain: true, status: true } },
_count: {
select: {
emailAccounts: true, databases: true, ftpAccounts: true,
subdomains: true, addonDomains: true, sslCertificates: true,
},
},
},
})
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
const { passwordHash, ...safe } = account
return reply.send({ success: true, data: safe })
})
// POST /api/nixserver/accounts
fastify.post('/', { preHandler }, async (request, reply) => {
const body = CreateAccountSchema.safeParse(request.body)
if (!body.success) {
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
}
const { username, password, email, domain, packageId, resellerId, ipId } = body.data
if (!validateUsername(username)) {
return reply.code(400).send({ success: false, error: 'Invalid username format' })
}
if (!validateDomain(domain)) {
return reply.code(400).send({ success: false, error: 'Invalid domain format' })
}
const exists = await prisma.account.findFirst({
where: { OR: [{ username }, { domain }] },
})
if (exists) {
return reply.code(409).send({ success: false, error: 'Username or domain already in use' })
}
const passwordHash = await bcrypt.hash(password, 12)
const homedir = `${config.paths.homeDir}/${username}`
// Create system user
const userAdd = await exec('useradd', [
'-m', '-d', homedir, '-s', '/bin/false', username,
])
if (userAdd.exitCode !== 0 && !config.isDev) {
return reply.code(500).send({ success: false, error: `Failed to create system user: ${userAdd.stderr}` })
}
// Create directory structure
const dirs = [
`${homedir}/public_html`,
`${homedir}/logs`,
`${homedir}/tmp`,
`${homedir}/mail`,
`${homedir}/.ssh`,
]
for (const dir of dirs) {
await exec('mkdir', ['-p', dir])
}
await exec('chown', ['-R', `${username}:${username}`, homedir])
await exec('chmod', ['750', homedir])
await exec('chmod', ['755', `${homedir}/public_html`])
// Resolve IP for vhost
let ipAddress: string | undefined
if (ipId) {
const ip = await prisma.ipAddress.findUnique({ where: { id: ipId } })
ipAddress = ip?.address
}
// Create account in DB
const account = await prisma.account.create({
data: {
username,
passwordHash,
email,
domain,
homedir,
status: 'active',
packageId: packageId ?? null,
resellerId: resellerId ?? (request.user!.role === 'reseller' ? request.user!.sub : null),
ipId: ipId ?? null,
ipAddress: ipAddress ?? null,
},
})
// Create DNS zone
if (ipAddress) {
const serverHostname = (await exec('hostname', ['-f'])).stdout || 'ns1.server.com'
try {
const zoneFile = await createZone({
domain,
ip: ipAddress,
ns1: `ns1.${domain}`,
ns2: `ns2.${domain}`,
})
await prisma.dnsZone.create({
data: { domain, zoneFile, accountId: account.id },
})
} catch (e) {
// Non-fatal — log and continue
console.warn(`DNS zone creation failed for ${domain}:`, e)
}
}
// Create nginx vhost
try {
await enableVhost({
domain,
username,
documentRoot: `${homedir}/public_html`,
})
} catch (e) {
console.warn(`Nginx vhost creation failed for ${domain}:`, e)
}
// Audit log
await prisma.auditLog.create({
data: {
action: 'account.create',
target: username,
details: `Created hosting account for domain ${domain}`,
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
const { passwordHash: _, ...safe } = account
return reply.code(201).send({ success: true, data: safe })
})
// PUT /api/nixserver/accounts/:id
fastify.put('/:id', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const body = UpdateAccountSchema.safeParse(request.body)
if (!body.success) {
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
}
const where: Record<string, unknown> = { id: parseInt(id, 10) }
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
const existing = await prisma.account.findFirst({ where })
if (!existing) return reply.code(404).send({ success: false, error: 'Account not found' })
const updateData: Record<string, unknown> = {}
if (body.data.email) updateData.email = body.data.email
if (body.data.packageId) updateData.packageId = body.data.packageId
if (body.data.notes !== undefined) updateData.notes = body.data.notes
if (body.data.password) {
updateData.passwordHash = await bcrypt.hash(body.data.password, 12)
// Update system user password
await exec('passwd', ['--stdin', existing.username])
}
const updated = await prisma.account.update({
where: { id: parseInt(id, 10) },
data: updateData,
})
const { passwordHash: _, ...safe } = updated
return reply.send({ success: true, data: safe })
})
// POST /api/nixserver/accounts/:id/suspend
fastify.post('/:id/suspend', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const { reason } = (request.body as Record<string, string>) ?? {}
const account = await prisma.account.findFirst({
where: { id: parseInt(id, 10), status: 'active' },
})
if (!account) return reply.code(404).send({ success: false, error: 'Active account not found' })
await exec('usermod', ['-L', account.username])
await prisma.account.update({
where: { id: account.id },
data: { status: 'suspended', suspendedAt: new Date(), suspendReason: reason ?? null },
})
await prisma.auditLog.create({
data: {
action: 'account.suspend',
target: account.username,
details: reason ?? 'No reason given',
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
return reply.send({ success: true, data: { message: `Account ${account.username} suspended` } })
})
// POST /api/nixserver/accounts/:id/unsuspend
fastify.post('/:id/unsuspend', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const account = await prisma.account.findFirst({
where: { id: parseInt(id, 10), status: 'suspended' },
})
if (!account) return reply.code(404).send({ success: false, error: 'Suspended account not found' })
await exec('usermod', ['-U', account.username])
await prisma.account.update({
where: { id: account.id },
data: { status: 'active', suspendedAt: null, suspendReason: null },
})
await prisma.auditLog.create({
data: {
action: 'account.unsuspend',
target: account.username,
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
return reply.send({ success: true, data: { message: `Account ${account.username} unsuspended` } })
})
// DELETE /api/nixserver/accounts/:id
fastify.delete('/:id', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const { removeFiles } = (request.body as Record<string, boolean>) ?? {}
const account = await prisma.account.findUnique({ where: { id: parseInt(id, 10) } })
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
// Remove nginx vhost
try {
await disableVhost(account.domain)
} catch (e) {
console.warn(`Failed to remove vhost for ${account.domain}:`, e)
}
// Remove DNS zone
try {
await removeZone(account.domain)
} catch (e) {
console.warn(`Failed to remove DNS zone for ${account.domain}:`, e)
}
// Remove system user
const delArgs = removeFiles ? ['-r', account.username] : [account.username]
await exec('userdel', delArgs)
// Mark as terminated in DB (cascade deletes related records)
await prisma.account.update({
where: { id: account.id },
data: { status: 'terminated', terminatedAt: new Date() },
})
await prisma.auditLog.create({
data: {
action: 'account.terminate',
target: account.username,
details: removeFiles ? 'Files removed' : 'Files retained',
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
return reply.send({ success: true, data: { message: `Account ${account.username} terminated` } })
})
// POST /api/nixserver/accounts/bulk
fastify.post('/bulk', { preHandler }, async (request, reply) => {
const body = z.object({
action: z.enum(['suspend', 'unsuspend', 'terminate']),
ids: z.array(z.number().int().positive()).min(1).max(100),
reason: z.string().optional(),
}).safeParse(request.body)
if (!body.success) {
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
}
const { action, ids, reason } = body.data
const results: { id: number; success: boolean; error?: string }[] = []
for (const id of ids) {
try {
const account = await prisma.account.findUnique({ where: { id } })
if (!account) {
results.push({ id, success: false, error: 'Not found' })
continue
}
if (action === 'suspend') {
await exec('usermod', ['-L', account.username])
await prisma.account.update({
where: { id },
data: { status: 'suspended', suspendedAt: new Date(), suspendReason: reason ?? null },
})
} else if (action === 'unsuspend') {
await exec('usermod', ['-U', account.username])
await prisma.account.update({
where: { id },
data: { status: 'active', suspendedAt: null, suspendReason: null },
})
} else if (action === 'terminate') {
await exec('userdel', [account.username])
await prisma.account.update({
where: { id },
data: { status: 'terminated', terminatedAt: new Date() },
})
}
results.push({ id, success: true })
} catch (e) {
results.push({ id, success: false, error: String(e) })
}
}
return reply.send({ success: true, data: results })
})
}
@@ -0,0 +1,110 @@
import type { FastifyInstance } from 'fastify'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import prisma from '../../../db/client.js'
import { requireAdminOrReseller } from '../../../middleware/auth.js'
import { exec, validateUsername, validateDomain } from '../../../core/exec.js'
import { enableVhost, enablePhpFpmPool } from '../../../core/apache.js'
import { createZone } from '../../../core/dns.js'
import { config } from '../../../config.js'
const CreateAccountSchema = z.object({
username: z.string().min(1).max(32),
password: z.string().min(8),
email: z.string().email(),
domain: z.string().min(4),
packageId: z.number().int().positive().optional(),
resellerId: z.number().int().positive().optional(),
ipId: z.number().int().positive().optional(),
})
export default async function createRoute(fastify: FastifyInstance) {
// POST /api/nixserver/accounts
fastify.post('/', { preHandler: [requireAdminOrReseller] }, async (request, reply) => {
const body = CreateAccountSchema.safeParse(request.body)
if (!body.success) {
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
}
const { username, password, email, domain, packageId, resellerId, ipId } = body.data
if (!validateUsername(username)) {
return reply.code(400).send({ success: false, error: 'Invalid username format' })
}
if (!validateDomain(domain)) {
return reply.code(400).send({ success: false, error: 'Invalid domain format' })
}
const exists = await prisma.account.findFirst({ where: { OR: [{ username }, { domain }] } })
if (exists) {
return reply.code(409).send({ success: false, error: 'Username or domain already in use' })
}
const passwordHash = await bcrypt.hash(password, 12)
const homedir = `${config.paths.homeDir}/${username}`
const userAdd = await exec('useradd', ['-m', '-d', homedir, '-s', '/usr/sbin/nologin', username])
if (userAdd.exitCode !== 0 && !config.isDev) {
return reply.code(500).send({ success: false, error: `Failed to create system user: ${userAdd.stderr}` })
}
const dirs = [
`${homedir}/public_html`,
`${homedir}/logs`,
`${homedir}/tmp`,
`${homedir}/mail`,
`${homedir}/.ssh`,
]
for (const dir of dirs) await exec('mkdir', ['-p', dir])
await exec('chown', ['-R', `${username}:${username}`, homedir])
await exec('chmod', ['750', homedir])
await exec('chmod', ['755', `${homedir}/public_html`])
let ipAddress: string | undefined
if (ipId) {
const ip = await prisma.ipAddress.findUnique({ where: { id: ipId } })
ipAddress = ip?.address
}
const account = await prisma.account.create({
data: {
username, passwordHash, email, domain, homedir,
status: 'active',
packageId: packageId ?? null,
resellerId: resellerId ?? (request.user!.role === 'reseller' ? request.user!.sub : null),
ipId: ipId ?? null,
ipAddress: ipAddress ?? null,
},
})
if (ipAddress) {
try {
const zoneFile = await createZone({ domain, ip: ipAddress, ns1: `ns1.${domain}`, ns2: `ns2.${domain}` })
await prisma.dnsZone.create({ data: { domain, zoneFile, accountId: account.id } })
} catch (e) {
console.warn(`DNS zone creation failed for ${domain}:`, e)
}
}
const phpVersion = (await prisma.package.findUnique({ where: { id: packageId ?? 0 } }))?.phpVersion ?? '8.3'
try {
await enablePhpFpmPool(username, phpVersion)
await enableVhost({ domain, username, documentRoot: `${homedir}/public_html`, phpVersion })
} catch (e) {
console.warn(`Apache/PHP-FPM setup failed for ${domain}:`, e)
}
await prisma.auditLog.create({
data: {
action: 'account.create',
target: username,
details: `Created hosting account for domain ${domain}`,
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
const { passwordHash: _, ...safe } = account
return reply.code(201).send({ success: true, data: safe })
})
}
@@ -0,0 +1,12 @@
import type { FastifyInstance } from 'fastify'
import listRoutes from './list.js'
import createRoute from './create.js'
import modifyRoutes from './modify.js'
import statusRoutes from './status.js'
export default async function accountRoutes(fastify: FastifyInstance) {
fastify.register(listRoutes)
fastify.register(createRoute)
fastify.register(modifyRoutes)
fastify.register(statusRoutes)
}
@@ -0,0 +1,147 @@
import type { FastifyInstance } from 'fastify'
import prisma from '../../../db/client.js'
import { requireAdminOrReseller } from '../../../middleware/auth.js'
const preHandler = [requireAdminOrReseller]
export default async function listRoutes(fastify: FastifyInstance) {
// GET /api/nixserver/accounts
fastify.get('/', { preHandler }, async (request, reply) => {
const q = request.query as Record<string, string>
const page = Math.max(1, parseInt(q.page ?? '1', 10))
const limit = Math.min(100, Math.max(1, parseInt(q.limit ?? '25', 10)))
const skip = (page - 1) * limit
const where: Record<string, unknown> = {}
if (q.search) {
where.OR = [
{ username: { contains: q.search } },
{ domain: { contains: q.search } },
{ email: { contains: q.search } },
]
}
if (q.status) where.status = q.status
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
const [items, total] = await Promise.all([
prisma.account.findMany({
where, skip, take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true, username: true, domain: true, email: true, status: true,
diskUsedMb: true, bandwidthUsedMb: true, ipAddress: true,
createdAt: true, suspendedAt: true, suspendReason: true,
package: { select: { name: true, diskMb: true, bandwidthMb: true } },
reseller: { select: { username: true } },
},
}),
prisma.account.count({ where }),
])
return reply.send({
success: true,
data: { items, total, page, limit, totalPages: Math.ceil(total / limit) },
})
})
// GET /api/nixserver/accounts/stats
fastify.get('/stats', { preHandler }, async (request, reply) => {
const where = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
const [total, active, suspended, terminated] = await Promise.all([
prisma.account.count({ where }),
prisma.account.count({ where: { ...where, status: 'active' } }),
prisma.account.count({ where: { ...where, status: 'suspended' } }),
prisma.account.count({ where: { ...where, status: 'terminated' } }),
])
return reply.send({ success: true, data: { total, active, suspended, terminated } })
})
// GET /api/nixserver/accounts/parked-domains
fastify.get('/parked-domains', { preHandler }, async (request, reply) => {
const where = request.user!.role === 'reseller'
? { account: { resellerId: request.user!.sub } } : {}
const items = await prisma.parkedDomain.findMany({
where,
orderBy: { createdAt: 'desc' },
include: { account: { select: { username: true, domain: true } } },
})
return reply.send({ success: true, data: items })
})
// GET /api/nixserver/accounts/subdomains
fastify.get('/subdomains', { preHandler }, async (request, reply) => {
const where = request.user!.role === 'reseller'
? { account: { resellerId: request.user!.sub } } : {}
const items = await prisma.subdomain.findMany({
where,
orderBy: { createdAt: 'desc' },
include: { account: { select: { username: true } } },
})
return reply.send({ success: true, data: items })
})
// GET /api/nixserver/accounts/over-quota
fastify.get('/over-quota', { preHandler }, async (request, reply) => {
const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
const accounts = await prisma.account.findMany({
where: { ...resellerWhere, package: { isNot: null } },
select: {
id: true, username: true, domain: true, status: true, diskUsedMb: true,
package: { select: { name: true, diskMb: true, bandwidthMb: true } },
},
})
const overQuota = accounts.filter(
(a: typeof accounts[number]) => a.package && a.diskUsedMb > a.package.diskMb
)
return reply.send({ success: true, data: overQuota })
})
// GET /api/nixserver/accounts/bandwidth
fastify.get('/bandwidth', { preHandler }, async (request, reply) => {
const resellerWhere = request.user!.role === 'reseller' ? { resellerId: request.user!.sub } : {}
const items = await prisma.account.findMany({
where: resellerWhere,
orderBy: { bandwidthUsedMb: 'desc' },
select: {
id: true, username: true, domain: true, status: true,
bandwidthUsedMb: true, suspendReason: true,
package: { select: { name: true, bandwidthMb: true } },
},
})
return reply.send({ success: true, data: items })
})
// GET /api/nixserver/accounts/:id
fastify.get('/:id', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const where: Record<string, unknown> = { id: parseInt(id, 10) }
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
const account = await prisma.account.findFirst({
where,
include: {
package: true,
reseller: { select: { username: true, company: true } },
ip: true,
dnsZones: { select: { id: true, domain: true, status: true } },
_count: {
select: {
emailAccounts: true, databases: true, ftpAccounts: true,
subdomains: true, addonDomains: true, sslCertificates: true,
},
},
},
})
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
const { passwordHash, ...safe } = account
return reply.send({ success: true, data: safe })
})
}
@@ -0,0 +1,171 @@
import type { FastifyInstance } from 'fastify'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import prisma from '../../../db/client.js'
import { requireAdminOrReseller } from '../../../middleware/auth.js'
import { exec } from '../../../core/exec.js'
const UpdateAccountSchema = z.object({
email: z.string().email().optional(),
packageId: z.number().int().positive().optional(),
password: z.string().min(8).optional(),
notes: z.string().max(500).optional(),
})
const preHandler = [requireAdminOrReseller]
export default async function modifyRoutes(fastify: FastifyInstance) {
// PUT /api/nixserver/accounts/:id
fastify.put('/:id', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const body = UpdateAccountSchema.safeParse(request.body)
if (!body.success) {
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
}
const where: Record<string, unknown> = { id: parseInt(id, 10) }
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
const existing = await prisma.account.findFirst({ where })
if (!existing) return reply.code(404).send({ success: false, error: 'Account not found' })
const updateData: Record<string, unknown> = {}
if (body.data.email) updateData.email = body.data.email
if (body.data.packageId) updateData.packageId = body.data.packageId
if (body.data.notes !== undefined) updateData.notes = body.data.notes
if (body.data.password) {
updateData.passwordHash = await bcrypt.hash(body.data.password, 12)
await exec('passwd', ['--stdin', existing.username])
}
const updated = await prisma.account.update({
where: { id: parseInt(id, 10) },
data: updateData,
})
const { passwordHash: _, ...safe } = updated
return reply.send({ success: true, data: safe })
})
// PUT /api/nixserver/accounts/:id/quota
fastify.put('/:id/quota', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const body = z.object({
diskMb: z.number().int().positive(),
bandwidthMb: z.number().int().positive(),
}).safeParse(request.body)
if (!body.success) {
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
}
const where: Record<string, unknown> = { id: parseInt(id, 10) }
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
const account = await prisma.account.findFirst({ where, include: { package: true } })
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
if (account.packageId) {
await prisma.package.update({
where: { id: account.packageId },
data: { diskMb: body.data.diskMb, bandwidthMb: body.data.bandwidthMb },
})
}
await prisma.auditLog.create({
data: {
action: 'account.quota-modification',
target: account.username,
details: `diskMb=${body.data.diskMb}, bandwidthMb=${body.data.bandwidthMb}`,
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
return reply.send({ success: true, data: { message: 'Quota updated', ...body.data } })
})
// POST /api/nixserver/accounts/:id/change-ip
fastify.post('/:id/change-ip', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const body = z.object({ ipAddress: z.string().min(1) }).safeParse(request.body)
if (!body.success) {
return reply.code(400).send({ success: false, error: body.error.issues[0]?.message })
}
const where: Record<string, unknown> = { id: parseInt(id, 10) }
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
const account = await prisma.account.findFirst({ where })
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
const updated = await prisma.account.update({
where: { id: account.id },
data: { ipAddress: body.data.ipAddress },
})
await prisma.auditLog.create({
data: {
action: 'account.change-ip',
target: account.username,
details: `IP changed to ${body.data.ipAddress}`,
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
const { passwordHash: _, ...safe } = updated
return reply.send({ success: true, data: safe })
})
// POST /api/nixserver/accounts/:id/reset-bandwidth
fastify.post('/:id/reset-bandwidth', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const where: Record<string, unknown> = { id: parseInt(id, 10) }
if (request.user!.role === 'reseller') where.resellerId = request.user!.sub
const account = await prisma.account.findFirst({ where })
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
await prisma.account.update({ where: { id: account.id }, data: { bandwidthUsedMb: 0 } })
await prisma.auditLog.create({
data: {
action: 'account.reset-bandwidth',
target: account.username,
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
return reply.send({ success: true, data: { message: `Bandwidth reset for ${account.username}` } })
})
// PUT /api/nixserver/accounts/:id/shell
fastify.put('/:id/shell', { preHandler }, async (request, reply) => {
const { id } = request.params as { id: string }
const { enabled } = (request.body ?? {}) as { enabled?: boolean }
if (typeof enabled !== 'boolean') {
return reply.code(400).send({ success: false, error: '"enabled" boolean required' })
}
const account = await prisma.account.findUnique({ where: { id: parseInt(id, 10) } })
if (!account) return reply.code(404).send({ success: false, error: 'Account not found' })
const shell = enabled ? '/bin/bash' : '/usr/sbin/nologin'
await exec('usermod', ['-s', shell, account.username])
await prisma.account.update({ where: { id: account.id }, data: { shell } })
await prisma.auditLog.create({
data: {
action: enabled ? 'account.shell.enable' : 'account.shell.disable',
target: account.username,
sourceIp: request.ip,
adminId: request.user!.role === 'admin' ? request.user!.sub : null,
},
})
return reply.send({ success: true, data: { shell } })
})
}

Some files were not shown because too many files have changed in this diff Show More