From 8ff00c544ccbcb14e4ce96d6aef49c233cb6472b Mon Sep 17 00:00:00 2001 From: NixReaper Date: Sat, 11 Apr 2026 05:17:49 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20v0.4.0=20=E2=80=94=20Apache=20service?= =?UTF-8?q?=20stack,=20shell=20access,=20log=20viewer,=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 116 + nixclient/package.json | 2 +- nixclient/src/pages/Login.tsx | 2 +- nixserver/package.json | 2 +- nixserver/src/App.tsx | 116 + nixserver/src/pages/Accounts.tsx | 324 +- nixserver/src/pages/Login.tsx | 2 +- nixserver/src/pages/Setup.tsx | 2 +- nixserver/src/pages/WebServer.tsx | 87 +- .../src/pages/accounts/AccountPicker.tsx | 133 + nixserver/src/pages/accounts/ApacheLogs.tsx | 114 + nixserver/src/pages/accounts/Bandwidth.tsx | 117 + nixserver/src/pages/accounts/BulkIP.tsx | 35 + nixserver/src/pages/accounts/BulkModify.tsx | 35 + nixserver/src/pages/accounts/ChangeIP.tsx | 90 + nixserver/src/pages/accounts/DemoMode.tsx | 35 + nixserver/src/pages/accounts/EmailUsers.tsx | 35 + .../src/pages/accounts/ForcePassword.tsx | 35 + .../src/pages/accounts/LimitBandwidth.tsx | 35 + nixserver/src/pages/accounts/List.tsx | 207 + nixserver/src/pages/accounts/Modify.tsx | 129 + nixserver/src/pages/accounts/NginxLogs.tsx | 2 + nixserver/src/pages/accounts/OverQuota.tsx | 112 + .../src/pages/accounts/ParkedDomains.tsx | 92 + nixserver/src/pages/accounts/PasswordMod.tsx | 102 + nixserver/src/pages/accounts/QuotaMod.tsx | 129 + nixserver/src/pages/accounts/Rearrange.tsx | 35 + .../src/pages/accounts/ResetBandwidth.tsx | 136 + nixserver/src/pages/accounts/ShellAccess.tsx | 112 + nixserver/src/pages/accounts/Subdomains.tsx | 95 + nixserver/src/pages/accounts/Suspended.tsx | 157 + nixserver/src/pages/accounts/Suspension.tsx | 108 + nixserver/src/pages/accounts/Terminate.tsx | 103 + .../src/pages/accounts/UnsuspendBandwidth.tsx | 142 + nixserver/src/pages/accounts/Upgrade.tsx | 108 + nixserver/src/pages/accounts/WebTemplate.tsx | 35 + package-lock.json | 6004 +++++++++++++++++ package.json | 2 +- scripts/install.sh | 969 +-- server/.env.example | 17 + server/package.json | 4 +- server/prisma/schema.prisma | 395 +- server/src/app.ts | 2 +- server/src/config.ts | 25 +- server/src/core/apache.ts | 174 + server/src/core/dns.ts | 142 +- server/src/core/exec.ts | 138 +- server/src/core/license.ts | 99 + server/src/core/mail.ts | 195 + server/src/core/mariadb.ts | 163 + server/src/core/monitor.ts | 97 + server/src/core/nginx.ts | 138 - server/src/core/ssl.ts | 104 + server/src/core/stats.ts | 70 + server/src/core/system.ts | 12 +- server/src/index.ts | 17 +- server/src/routes/auth/index.ts | 8 +- server/src/routes/nixclient/cron.ts | 2 +- server/src/routes/nixclient/databases.ts | 61 +- server/src/routes/nixclient/domains.ts | 12 +- server/src/routes/nixclient/email.ts | 81 +- server/src/routes/nixserver/accounts.ts | 273 +- server/src/routes/nixserver/ssl.ts | 20 +- server/src/routes/nixserver/webserver.ts | 72 +- server/src/scheduler.ts | 65 + 65 files changed, 11223 insertions(+), 1259 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 nixserver/src/pages/accounts/AccountPicker.tsx create mode 100644 nixserver/src/pages/accounts/ApacheLogs.tsx create mode 100644 nixserver/src/pages/accounts/Bandwidth.tsx create mode 100644 nixserver/src/pages/accounts/BulkIP.tsx create mode 100644 nixserver/src/pages/accounts/BulkModify.tsx create mode 100644 nixserver/src/pages/accounts/ChangeIP.tsx create mode 100644 nixserver/src/pages/accounts/DemoMode.tsx create mode 100644 nixserver/src/pages/accounts/EmailUsers.tsx create mode 100644 nixserver/src/pages/accounts/ForcePassword.tsx create mode 100644 nixserver/src/pages/accounts/LimitBandwidth.tsx create mode 100644 nixserver/src/pages/accounts/List.tsx create mode 100644 nixserver/src/pages/accounts/Modify.tsx create mode 100644 nixserver/src/pages/accounts/NginxLogs.tsx create mode 100644 nixserver/src/pages/accounts/OverQuota.tsx create mode 100644 nixserver/src/pages/accounts/ParkedDomains.tsx create mode 100644 nixserver/src/pages/accounts/PasswordMod.tsx create mode 100644 nixserver/src/pages/accounts/QuotaMod.tsx create mode 100644 nixserver/src/pages/accounts/Rearrange.tsx create mode 100644 nixserver/src/pages/accounts/ResetBandwidth.tsx create mode 100644 nixserver/src/pages/accounts/ShellAccess.tsx create mode 100644 nixserver/src/pages/accounts/Subdomains.tsx create mode 100644 nixserver/src/pages/accounts/Suspended.tsx create mode 100644 nixserver/src/pages/accounts/Suspension.tsx create mode 100644 nixserver/src/pages/accounts/Terminate.tsx create mode 100644 nixserver/src/pages/accounts/UnsuspendBandwidth.tsx create mode 100644 nixserver/src/pages/accounts/Upgrade.tsx create mode 100644 nixserver/src/pages/accounts/WebTemplate.tsx create mode 100644 package-lock.json create mode 100644 server/.env.example create mode 100644 server/src/core/apache.ts create mode 100644 server/src/core/license.ts create mode 100644 server/src/core/mail.ts create mode 100644 server/src/core/mariadb.ts create mode 100644 server/src/core/monitor.ts delete mode 100644 server/src/core/nginx.ts create mode 100644 server/src/core/ssl.ts create mode 100644 server/src/core/stats.ts create mode 100644 server/src/scheduler.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3124a07 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,116 @@ +# NixPanel Changelog + +All notable changes to NixPanel are documented here. +Versioning follows [Semantic Versioning](https://semver.org/): MAJOR.MINOR.PATCH. +Pre-1.0 versions treat MINOR as feature releases and PATCH as bug fixes. + +--- + +## [0.4.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` 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. diff --git a/nixclient/package.json b/nixclient/package.json index 95fc701..83a1ea3 100644 --- a/nixclient/package.json +++ b/nixclient/package.json @@ -1,6 +1,6 @@ { "name": "@nixpanel/cpanel", - "version": "0.3.1", + "version": "0.4.0", "private": true, "type": "module", "scripts": { diff --git a/nixclient/src/pages/Login.tsx b/nixclient/src/pages/Login.tsx index 6fbf57a..703269c 100644 --- a/nixclient/src/pages/Login.tsx +++ b/nixclient/src/pages/Login.tsx @@ -49,7 +49,7 @@ export default function Login() { -

NixClient v0.3.1 · Port 2083

+

NixClient v0.3.2 · Port 2083

) diff --git a/nixserver/package.json b/nixserver/package.json index 3036bb4..1b6f035 100644 --- a/nixserver/package.json +++ b/nixserver/package.json @@ -1,6 +1,6 @@ { "name": "@nixpanel/whm", - "version": "0.3.1", + "version": "0.4.0", "private": true, "type": "module", "scripts": { diff --git a/nixserver/src/App.tsx b/nixserver/src/App.tsx index 6c31cd9..715b32f 100644 --- a/nixserver/src/App.tsx +++ b/nixserver/src/App.tsx @@ -22,6 +22,34 @@ const System = React.lazy(() => import('./pages/System')) const WebServer = React.lazy(() => import('./pages/WebServer')) const Settings = React.lazy(() => import('./pages/Settings')) +// Accounts sub-pages +const AccountList = React.lazy(() => import('./pages/accounts/List')) +const AccountParkedDomains = React.lazy(() => import('./pages/accounts/ParkedDomains')) +const AccountSubdomains = React.lazy(() => import('./pages/accounts/Subdomains')) +const AccountSuspended = React.lazy(() => import('./pages/accounts/Suspended')) +const AccountOverQuota = React.lazy(() => import('./pages/accounts/OverQuota')) +const AccountBandwidth = React.lazy(() => import('./pages/accounts/Bandwidth')) +const AccountChangeIP = React.lazy(() => import('./pages/accounts/ChangeIP')) +const AccountEmailUsers = React.lazy(() => import('./pages/accounts/EmailUsers')) +const AccountForcePassword = React.lazy(() => import('./pages/accounts/ForcePassword')) +const AccountLimitBandwidth = React.lazy(() => import('./pages/accounts/LimitBandwidth')) +const AccountSuspension = React.lazy(() => import('./pages/accounts/Suspension')) +const AccountDemoMode = React.lazy(() => import('./pages/accounts/DemoMode')) +const AccountShellAccess = React.lazy(() => import('./pages/accounts/ShellAccess')) +const AccountModify = React.lazy(() => import('./pages/accounts/Modify')) +const AccountPasswordMod = React.lazy(() => import('./pages/accounts/PasswordMod')) +const AccountQuotaMod = React.lazy(() => import('./pages/accounts/QuotaMod')) +const AccountApacheLogs = React.lazy(() => import('./pages/accounts/ApacheLogs')) +const AccountNginxLogs = React.lazy(() => import('./pages/accounts/NginxLogs')) +const AccountRearrange = React.lazy(() => import('./pages/accounts/Rearrange')) +const AccountResetBandwidth = React.lazy(() => import('./pages/accounts/ResetBandwidth')) +const AccountTerminate = React.lazy(() => import('./pages/accounts/Terminate')) +const AccountUnsuspendBW = React.lazy(() => import('./pages/accounts/UnsuspendBandwidth')) +const AccountUpgrade = React.lazy(() => import('./pages/accounts/Upgrade')) +const AccountWebTemplate = React.lazy(() => import('./pages/accounts/WebTemplate')) +const AccountBulkIP = React.lazy(() => import('./pages/accounts/BulkIP')) +const AccountBulkModify = React.lazy(() => import('./pages/accounts/BulkModify')) + function ProtectedRoute({ children }: { children: React.ReactNode }) { const { user, loading, setupComplete } = useAuth() @@ -63,8 +91,96 @@ function AppRoutes() { }> } /> + + {/* Accounts hub */} } /> } /> + + {/* Account Information */} + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + + {/* Account Functions */} + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + }> + } /> + + {/* Multi Account Functions */} + }> + } /> + }> + } /> + + {/* Other sections */} } /> }> diff --git a/nixserver/src/pages/Accounts.tsx b/nixserver/src/pages/Accounts.tsx index 3664c46..2675531 100644 --- a/nixserver/src/pages/Accounts.tsx +++ b/nixserver/src/pages/Accounts.tsx @@ -1,198 +1,166 @@ -import { useEffect, useState, useCallback } from 'react' -import { Link } from 'react-router-dom' -import { Plus, Search, MoreVertical, UserX, UserCheck, Trash2, RefreshCw } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { + List, Globe, GitBranch, UserX, HardDrive, BarChart2, + Network, Plus, Mail, KeyRound, Gauge, UserCheck, FlaskConical, + Terminal, Edit, Lock, FileQuestion, FileText, ArrowLeftRight, + RotateCcw, Trash2, TrendingDown, ArrowUpDown, Code2, Copy, Layers +} from 'lucide-react' import { api } from '../api/client' -interface Account { - id: number - username: string - domain: string - email: string - status: string - diskUsedMb: number - bandwidthUsedMb: number - ipAddress: string | null - createdAt: string - package?: { name: string; diskMb: number } -} - -interface PageData { - items: Account[] +interface Stats { total: number - page: number - totalPages: number + active: number + suspended: number + terminated: number } -const statusColor: Record = { - active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', - suspended: 'bg-amber-500/10 text-amber-400 border-amber-500/20', - terminated: 'bg-red-500/10 text-red-400 border-red-500/20', +interface FunctionCard { + icon: React.ComponentType<{ size?: number | string; className?: string }> + label: string + description: string + path: string + soon?: boolean } -export default function Accounts() { - const [data, setData] = useState(null) - const [search, setSearch] = useState('') - const [status, setStatus] = useState('') - const [page, setPage] = useState(1) - const [loading, setLoading] = useState(false) - const [actionMenu, setActionMenu] = useState(null) +const accountInfo: FunctionCard[] = [ + { icon: List, label: 'List Accounts', description: 'View and manage all hosting accounts', path: '/accounts/list' }, + { icon: Globe, label: 'List Parked Domains', description: 'View all parked domains across accounts', path: '/accounts/parked-domains' }, + { icon: GitBranch, label: 'List Subdomains', description: 'View all subdomains across accounts', path: '/accounts/subdomains' }, + { icon: UserX, label: 'List Suspended Accounts', description: 'View and manage suspended accounts', path: '/accounts/suspended' }, + { icon: HardDrive, label: 'Show Accounts Over Quota', description: 'Accounts that have exceeded their disk quota', path: '/accounts/over-quota' }, + { icon: BarChart2, label: 'View Bandwidth Usage', description: 'Bandwidth consumption sorted by usage', path: '/accounts/bandwidth' }, +] - const fetch = useCallback(async () => { - setLoading(true) - try { - const params = new URLSearchParams({ page: String(page), limit: '25' }) - if (search) params.set('search', search) - if (status) params.set('status', status) - const r = await api.get(`/nixserver/accounts?${params}`) - setData(r.data.data) - } finally { - setLoading(false) - } - }, [page, search, status]) +const accountFunctions: FunctionCard[] = [ + { icon: Network, label: 'Change Site\'s IP Address', description: 'Assign a different IP to an account', path: '/accounts/change-ip' }, + { icon: Plus, label: 'Create a New Account', description: 'Create a new hosting account', path: '/accounts/create' }, + { icon: Mail, label: 'Email All Users', description: 'Send a bulk email to all account holders', path: '/accounts/email-users', soon: true }, + { icon: KeyRound, label: 'Force Password Change', description: 'Force users to change password on next login', path: '/accounts/force-password', soon: true }, + { icon: Gauge, label: 'Limit Bandwidth Usage', description: 'Set per-account bandwidth caps independent of package', path: '/accounts/limit-bandwidth', soon: true }, + { icon: UserCheck, label: 'Manage Account Suspension', description: 'Suspend or unsuspend a hosting account', path: '/accounts/suspension' }, + { icon: FlaskConical, label: 'Manage Demo Mode', description: 'Put accounts into read-only demo mode', path: '/accounts/demo-mode', soon: true }, + { icon: Terminal, label: 'Manage Shell Access', description: 'Grant or revoke SSH shell access', path: '/accounts/shell-access' }, + { icon: Edit, label: 'Modify an Account', description: 'Edit account email, notes, or package', path: '/accounts/modify' }, + { icon: Lock, label: 'Password Modification', description: 'Change the password for a hosting account', path: '/accounts/password' }, + { icon: FileQuestion, label: 'Quota Modification', description: 'Adjust disk and bandwidth quotas', path: '/accounts/quota' }, + { icon: FileText, label: 'Apache Log Viewer', description: 'View Apache access and error logs per account', path: '/accounts/apache-logs' }, + { icon: ArrowLeftRight,label: 'Rearrange an Account', description: 'Move an account between resellers', path: '/accounts/rearrange', soon: true }, + { icon: RotateCcw, label: 'Reset Account Bandwidth', description: 'Reset bandwidth counters for accounts', path: '/accounts/reset-bandwidth' }, + { icon: Trash2, label: 'Terminate Accounts', description: 'Permanently terminate a hosting account', path: '/accounts/terminate' }, + { icon: TrendingDown, label: 'Unsuspend Bandwidth Exceeders',description: 'Unsuspend accounts suspended for bandwidth', path: '/accounts/unsuspend-bandwidth' }, + { icon: ArrowUpDown, label: 'Upgrade / Downgrade an Account',description: 'Change the hosting package for an account', path: '/accounts/upgrade' }, + { icon: Code2, label: 'Web Template Editor', description: 'Edit default HTML templates for new accounts', path: '/accounts/web-template', soon: true }, +] - useEffect(() => { fetch() }, [fetch]) +const multiAccountFunctions: FunctionCard[] = [ + { icon: Copy, label: 'Change Multiple Sites\' IP Addresses', description: 'Batch-change IP addresses across multiple accounts', path: '/accounts/bulk-ip', soon: true }, + { icon: Layers, label: 'Modify / Upgrade Multiple Accounts', description: 'Apply changes to multiple accounts simultaneously', path: '/accounts/bulk-modify', soon: true }, +] - const action = async (id: number, act: 'suspend' | 'unsuspend' | 'terminate') => { - setActionMenu(null) - if (act === 'terminate' && !confirm('Terminate this account? This cannot be undone.')) return - try { - if (act === 'suspend') await api.post(`/nixserver/accounts/${id}/suspend`) - else if (act === 'unsuspend') await api.post(`/nixserver/accounts/${id}/unsuspend`) - else await api.delete(`/nixserver/accounts/${id}`) - fetch() - } catch (e: unknown) { - alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed') - } - } +interface CategoryProps { + title: string + color: string + headerBg: string + cards: FunctionCard[] +} +function Category({ title, color, headerBg, cards }: CategoryProps) { + const navigate = useNavigate() return ( -
-
-
-

Accounts

-

{data?.total ?? 0} total

-
- - Create Account - +
+
+

{title}

+
+ {cards.map(card => { + const Icon = card.icon + return ( + + ) + })} +
+
+ ) +} + +export default function Accounts() { + const [stats, setStats] = useState(null) - {/* Filters */} -
-
- - { 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" - /> -
- - + useEffect(() => { + api.get('/nixserver/accounts/stats') + .then(r => setStats(r.data.data)) + .catch(() => {}) + }, []) + + return ( +
+ {/* Header */} +
+

Account Functions

+

Manage hosting accounts, domains, and usage.

- {/* Table */} -
- - - - {['Username', 'Domain', 'Package', 'Status', 'IP', 'Created', ''].map(h => ( - - ))} - - - - {loading && !data && ( - - )} - {data?.items.map(acc => ( - - - - - - - - - - ))} - {data?.items.length === 0 && ( - - )} - -
{h}
Loading...
- - {acc.username} - -

{acc.email}

-
{acc.domain}{acc.package?.name ?? '—'} - - {acc.status} - - {acc.ipAddress ?? '—'}{new Date(acc.createdAt).toLocaleDateString()} - - {actionMenu === acc.id && ( -
- {acc.status === 'active' ? ( - - ) : acc.status === 'suspended' ? ( - - ) : null} - -
- )} -
No accounts found
+ {/* Stats bar */} +
+ {[ + { label: 'Total Accounts', value: stats?.total ?? '—', color: 'text-white' }, + { label: 'Active', value: stats?.active ?? '—', color: 'text-emerald-400' }, + { label: 'Suspended', value: stats?.suspended ?? '—', color: 'text-amber-400' }, + { label: 'Terminated', value: stats?.terminated ?? '—', color: 'text-red-400' }, + ].map(s => ( +
+

{s.label}

+

{s.value}

+
+ ))} +
+ + {/* Categories */} + + + - {/* Pagination */} - {data && data.totalPages > 1 && ( -
- - Page {data.page} of {data.totalPages} ({data.total} total) - -
- - -
-
- )} + {/* Legend */} +
+ Soon + Feature not yet implemented
) diff --git a/nixserver/src/pages/Login.tsx b/nixserver/src/pages/Login.tsx index d2d54cf..e8c08ad 100644 --- a/nixserver/src/pages/Login.tsx +++ b/nixserver/src/pages/Login.tsx @@ -81,7 +81,7 @@ export default function Login() {

- NixServer v0.3.1 · Port 2087 + NixServer v0.3.2 · Port 2087

diff --git a/nixserver/src/pages/Setup.tsx b/nixserver/src/pages/Setup.tsx index 09cd201..5bcffe1 100644 --- a/nixserver/src/pages/Setup.tsx +++ b/nixserver/src/pages/Setup.tsx @@ -220,7 +220,7 @@ export default function Setup() {

- NixServer v0.3.1 · You can revisit these settings anytime under Settings + NixServer v0.3.2 · You can revisit these settings anytime under Settings

diff --git a/nixserver/src/pages/WebServer.tsx b/nixserver/src/pages/WebServer.tsx index 2a07076..7dda391 100644 --- a/nixserver/src/pages/WebServer.tsx +++ b/nixserver/src/pages/WebServer.tsx @@ -2,54 +2,93 @@ import { useEffect, useState } from 'react' import { Server, RefreshCw, RotateCcw, CheckCircle, XCircle } from 'lucide-react' import { api } from '../api/client' +interface WebStatus { + apache: { active: boolean } + php82fpm: { active: boolean } + php83fpm: { active: boolean } +} + export default function WebServer() { - const [status, setStatus] = useState<{ nginx: { active: boolean }; apache: { active: boolean } } | null>(null) - const [, setVhosts] = useState<{ domain: string; path: string }[]>([]) + const [status, setStatus] = useState(null) const [logs, setLogs] = useState<{ lines: string[] } | null>(null) const [logType, setLogType] = useState<'access' | 'error'>('access') const [loading, setLoading] = useState(false) const [testResult, setTestResult] = useState(null) + const [reloading, setReloading] = useState(false) - const fetch = async () => { + const fetchData = async () => { setLoading(true) try { - const [s, v, l] = await Promise.all([ + const [s, l] = await Promise.all([ api.get('/nixserver/webserver/status'), - api.get('/nixserver/webserver/vhosts'), api.get(`/nixserver/webserver/logs/${logType}`), ]) - setStatus(s.data.data); setVhosts(v.data.data); setLogs(l.data.data) + setStatus(s.data.data) + setLogs(l.data.data) } finally { setLoading(false) } } - useEffect(() => { fetch() }, [logType]) + useEffect(() => { fetchData() }, [logType]) const testConfig = async () => { - const r = await api.post('/nixserver/webserver/nginx/test') - setTestResult(r.data.data.output) + try { + const r = await api.post('/nixserver/webserver/apache/test') + setTestResult(r.data.data.output) + } catch (err: unknown) { + setTestResult((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Test failed') + } } - const reload = async () => { - try { await api.post('/nixserver/webserver/nginx/reload'); fetch() } - catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Reload failed') } + const reload = async (service: 'apache' | 'fpm82' | 'fpm83') => { + setReloading(true) + try { + if (service === 'apache') { + await api.post('/nixserver/webserver/apache/reload') + } else { + await api.post('/nixserver/webserver/fpm/reload', { version: service === 'fpm82' ? '8.2' : '8.3' }) + } + fetchData() + } catch (err: unknown) { + alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Reload failed') + } finally { setReloading(false) } } + const services: { key: keyof WebStatus; label: string; reload?: 'apache' | 'fpm82' | 'fpm83' }[] = [ + { key: 'apache', label: 'apache2', reload: 'apache' }, + { key: 'php82fpm', label: 'php8.2-fpm', reload: 'fpm82' }, + { key: 'php83fpm', label: 'php8.3-fpm', reload: 'fpm83' }, + ] + return (
-

Web Server

Nginx and Apache management

- +

Web Server

Apache and PHP-FPM management

+
{status && ( -
- {[['nginx', status.nginx.active], ['apache2', status.apache.active]].map(([name, active]) => ( -
+
+ {services.map(({ key, label, reload: reloadKey }) => ( +
- {name} + {label} +
+
+ {status[key].active + ? + : } + {reloadKey && ( + + )}
- {active ? : }
))}
@@ -57,16 +96,18 @@ export default function WebServer() {
- +
{testResult && ( -
{testResult}
+
{testResult}
)}
-

Logs

+

Apache Logs

{(['access', 'error'] as const).map(t => ( @@ -74,7 +115,7 @@ export default function WebServer() {
-
+          
             {logs?.lines.slice(-100).join('\n') ?? 'Loading...'}
           
diff --git a/nixserver/src/pages/accounts/AccountPicker.tsx b/nixserver/src/pages/accounts/AccountPicker.tsx new file mode 100644 index 0000000..698ed03 --- /dev/null +++ b/nixserver/src/pages/accounts/AccountPicker.tsx @@ -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 = { + 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([]) + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + const timerRef = useRef | null>(null) + const containerRef = useRef(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 ( +
+
+ + {loading && ( +
+ )} + 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 && ( +
+ {results.map(acc => ( + + ))} +
+ )} + {open && !loading && results.length === 0 && search.trim() && ( +
+

No accounts found

+
+ )} +
+ + {selected && ( +
+
+ +
+
+

{selected.username}

+

{selected.domain} — {selected.email}

+ {selected.package && ( +

Package: {selected.package.name}

+ )} +
+ + {selected.status} + +
+ )} +
+ ) +} diff --git a/nixserver/src/pages/accounts/ApacheLogs.tsx b/nixserver/src/pages/accounts/ApacheLogs.tsx new file mode 100644 index 0000000..0711849 --- /dev/null +++ b/nixserver/src/pages/accounts/ApacheLogs.tsx @@ -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(null) + const [logType, setLogType] = useState<'access' | 'error'>('access') + const [lines, setLines] = useState([]) + 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 ( +
+
+ + Accounts + + / + Apache Log Viewer +
+ +
+
+

Apache Log Viewer

+

View Apache access and error logs per hosting account.

+
+ {selected && ( + + )} +
+ +
+ + + {selected && ( +
+
+
+ + /home/{selected.username}/logs/ +
+
+ {(['access', 'error'] as const).map(t => ( + + ))} +
+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ {loading ? ( +
Loading logs...
+ ) : lines.length === 0 ? ( +
No log entries found
+ ) : ( +
+                    {lines.join('\n')}
+                  
+ )} +
+ {lines.length > 0 && ( +
+ Showing last {lines.length} lines +
+ )} +
+
+ )} +
+
+ ) +} diff --git a/nixserver/src/pages/accounts/Bandwidth.tsx b/nixserver/src/pages/accounts/Bandwidth.tsx new file mode 100644 index 0000000..d0ddb7f --- /dev/null +++ b/nixserver/src/pages/accounts/Bandwidth.tsx @@ -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([]) + 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 = { + 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 ( +
+
+ + Accounts + + / + View Bandwidth Usage +
+ +
+
+

View Bandwidth Usage

+

{items.length} account{items.length !== 1 ? 's' : ''}, sorted by usage

+
+ +
+ +
+ + + + {['Username', 'Domain', 'Bandwidth Used', 'Bandwidth Limit', '% Used', 'Status'].map(h => ( + + ))} + + + + {loading && ( + + )} + {!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 ( + + + + + + + + + ) + })} + {!loading && items.length === 0 && ( + + )} + +
{h}
Loading...
+ + {acc.username} + + {acc.domain}{acc.bandwidthUsedMb.toLocaleString()} MB + {limit > 0 ? `${limit.toLocaleString()} MB` : '—'} + + {limit > 0 ? ( +
+
+
+
+ {pct}% +
+ ) : ( + No limit + )} +
+ + {acc.status} + +
No accounts found
+
+
+ ) +} diff --git a/nixserver/src/pages/accounts/BulkIP.tsx b/nixserver/src/pages/accounts/BulkIP.tsx new file mode 100644 index 0000000..22a8eaa --- /dev/null +++ b/nixserver/src/pages/accounts/BulkIP.tsx @@ -0,0 +1,35 @@ +import { Link } from 'react-router-dom' +import { ChevronLeft, Network, Clock } from 'lucide-react' + +export default function BulkIP() { + return ( +
+
+ + Accounts + + / + Change Multiple Sites' IP Addresses +
+
+

Change Multiple Sites' IP Addresses

+

Batch-change IP addresses across multiple accounts.

+
+
+
+ +
+
+
+ + Coming Soon +
+

Change Multiple Sites' IP Addresses

+

+ This feature will allow you to batch-reassign IP addresses across multiple hosting accounts simultaneously, useful for IP migrations. +

+
+
+
+ ) +} diff --git a/nixserver/src/pages/accounts/BulkModify.tsx b/nixserver/src/pages/accounts/BulkModify.tsx new file mode 100644 index 0000000..1cd44a5 --- /dev/null +++ b/nixserver/src/pages/accounts/BulkModify.tsx @@ -0,0 +1,35 @@ +import { Link } from 'react-router-dom' +import { ChevronLeft, Users, Clock } from 'lucide-react' + +export default function BulkModify() { + return ( +
+
+ + Accounts + + / + Modify / Upgrade Multiple Accounts +
+
+

Modify / Upgrade Multiple Accounts

+

Apply package upgrades or setting changes to multiple accounts simultaneously.

+
+
+
+ +
+
+
+ + Coming Soon +
+

Modify / Upgrade Multiple Accounts

+

+ This feature will allow you to apply package upgrades, setting changes, or bulk modifications to multiple hosting accounts at once. +

+
+
+
+ ) +} diff --git a/nixserver/src/pages/accounts/ChangeIP.tsx b/nixserver/src/pages/accounts/ChangeIP.tsx new file mode 100644 index 0000000..bdb6273 --- /dev/null +++ b/nixserver/src/pages/accounts/ChangeIP.tsx @@ -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(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 ( +
+
+ + Accounts + + / + Change Site's IP Address +
+ +
+

Change Site's IP Address

+

Assign a different IP address to a hosting account.

+
+ +
+ + + {selected && ( +
+
+

Current IP Address

+

{selected.ipAddress ?? 'Not assigned'}

+
+ +
+ + 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" + /> +
+ + +
+ )} + + {message && ( +

{message}

+ )} + {error && ( +

{error}

+ )} +
+
+ ) +} diff --git a/nixserver/src/pages/accounts/DemoMode.tsx b/nixserver/src/pages/accounts/DemoMode.tsx new file mode 100644 index 0000000..e83c0f1 --- /dev/null +++ b/nixserver/src/pages/accounts/DemoMode.tsx @@ -0,0 +1,35 @@ +import { Link } from 'react-router-dom' +import { ChevronLeft, FlaskConical, Clock } from 'lucide-react' + +export default function DemoMode() { + return ( +
+
+ + Accounts + + / + Manage Demo Mode +
+
+

Manage Demo Mode

+

Put accounts into read-only demo mode.

+
+
+
+ +
+
+
+ + Coming Soon +
+

Manage Demo Mode

+

+ This feature will allow you to put hosting accounts into a read-only demo mode, preventing any changes or file modifications. +

+
+
+
+ ) +} diff --git a/nixserver/src/pages/accounts/EmailUsers.tsx b/nixserver/src/pages/accounts/EmailUsers.tsx new file mode 100644 index 0000000..46fb26a --- /dev/null +++ b/nixserver/src/pages/accounts/EmailUsers.tsx @@ -0,0 +1,35 @@ +import { Link } from 'react-router-dom' +import { ChevronLeft, Mail, Clock } from 'lucide-react' + +export default function EmailUsers() { + return ( +
+
+ + Accounts + + / + Email All Users +
+
+

Email All Users

+

Send a bulk email to all account holders.

+
+
+
+ +
+
+
+ + Coming Soon +
+

Email All Users

+

+ This feature will allow you to compose and send a bulk email to all hosting account holders simultaneously. +

+
+
+
+ ) +} diff --git a/nixserver/src/pages/accounts/ForcePassword.tsx b/nixserver/src/pages/accounts/ForcePassword.tsx new file mode 100644 index 0000000..797da35 --- /dev/null +++ b/nixserver/src/pages/accounts/ForcePassword.tsx @@ -0,0 +1,35 @@ +import { Link } from 'react-router-dom' +import { ChevronLeft, KeyRound, Clock } from 'lucide-react' + +export default function ForcePassword() { + return ( +
+
+ + Accounts + + / + Force Password Change +
+
+

Force Password Change

+

Force users to change their password on next login.

+
+
+
+ +
+
+
+ + Coming Soon +
+

Force Password Change

+

+ This feature will allow you to flag one or more accounts to require a password change on their next login. +

+
+
+
+ ) +} diff --git a/nixserver/src/pages/accounts/LimitBandwidth.tsx b/nixserver/src/pages/accounts/LimitBandwidth.tsx new file mode 100644 index 0000000..b437fca --- /dev/null +++ b/nixserver/src/pages/accounts/LimitBandwidth.tsx @@ -0,0 +1,35 @@ +import { Link } from 'react-router-dom' +import { ChevronLeft, Gauge, Clock } from 'lucide-react' + +export default function LimitBandwidth() { + return ( +
+
+ + Accounts + + / + Limit Bandwidth Usage +
+
+

Limit Bandwidth Usage

+

Set per-account bandwidth caps independent of package.

+
+
+
+ +
+
+
+ + Coming Soon +
+

Limit Bandwidth Usage

+

+ This feature will allow you to set custom per-account bandwidth caps that override the package-level limits. +

+
+
+
+ ) +} diff --git a/nixserver/src/pages/accounts/List.tsx b/nixserver/src/pages/accounts/List.tsx new file mode 100644 index 0000000..bfbf333 --- /dev/null +++ b/nixserver/src/pages/accounts/List.tsx @@ -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 = { + 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(null) + const [search, setSearch] = useState('') + const [status, setStatus] = useState('') + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(false) + const [actionMenu, setActionMenu] = useState(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 ( +
+ {/* Breadcrumb */} +
+ + Accounts + + / + List Accounts +
+ +
+
+

List Accounts

+

{data?.total ?? 0} total accounts

+
+ + Create Account + +
+ + {/* Filters */} +
+
+ + { 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" + /> +
+ + +
+ + {/* Table */} +
+ + + + {['Username', 'Domain', 'Package', 'Status', 'IP', 'Created', ''].map(h => ( + + ))} + + + + {loading && !data && ( + + )} + {data?.items.map(acc => ( + + + + + + + + + + ))} + {data?.items.length === 0 && ( + + )} + +
{h}
Loading...
+ + {acc.username} + +

{acc.email}

+
{acc.domain}{acc.package?.name ?? '—'} + + {acc.status} + + {acc.ipAddress ?? '—'}{new Date(acc.createdAt).toLocaleDateString()} + + {actionMenu === acc.id && ( +
+ {acc.status === 'active' ? ( + + ) : acc.status === 'suspended' ? ( + + ) : null} + +
+ )} +
No accounts found
+ + {data && data.totalPages > 1 && ( +
+ + Page {data.page} of {data.totalPages} ({data.total} total) + +
+ + +
+
+ )} +
+
+ ) +} diff --git a/nixserver/src/pages/accounts/Modify.tsx b/nixserver/src/pages/accounts/Modify.tsx new file mode 100644 index 0000000..2cc000b --- /dev/null +++ b/nixserver/src/pages/accounts/Modify.tsx @@ -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(null) + const [email, setEmail] = useState('') + const [notes, setNotes] = useState('') + const [packageId, setPackageId] = useState('') + const [packages, setPackages] = useState([]) + 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 = {} + 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 ( +
+
+ + Accounts + + / + Modify an Account +
+ +
+

Modify an Account

+

Update account settings such as email, notes, or package.

+
+ +
+ + + {selected && ( +
+
+ + 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" + /> +
+ +
+ + +
+ +
+ +