feat: v0.3.0-alpha — DNS management, email accounts, suspend/terminate, password change
Backend: - routes/dns.rs: GET/POST/DELETE /api/dns/:domain (pdnsutil CRUD, SOA hidden, auto-rectify) - routes/email.rs: GET/POST/DELETE email accounts with Maildir provisioning - routes/me.rs: POST /api/me/password (verify current, hash new, update users+accounts) - routes/accounts.rs: GET /api/accounts/:username, suspend, unsuspend, terminate (deprovision+userdel) - main.rs: register all new routes; import axum::routing::delete - Cargo.toml: version bump 0.2.0-alpha -> 0.3.0-alpha NixServer: - Zone Manager page: lists all PowerDNS zones, click -> DNS editor - DNS Records page: add/delete records, colour-coded type badges, zone switcher - Service Manager page: dedicated page with refresh + running/stopped pills - List Accounts: Suspend/Unsuspend/Terminate buttons with confirmation modal - Domain cell links to live site NixClient: - Domains page: primary domain info, document root, quota, bandwidth - Email Accounts page: create/list/delete mailboxes with quota display - Change Password page: Argon2id verify + update server-side - AccountSidebar: fetches real account data (domain, package, quota) - api.ts: listMyEmailAccounts, createEmailAccount, deleteEmailAccount, changePassword, getMyAccount(username) CHANGELOG.md: v0.3.0-alpha entry
This commit is contained in:
+36
-23
@@ -8,28 +8,7 @@ Versions follow [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- `nixpanel-accounts` section binary — full account provisioner
|
||||
- Creates Apache vhost with PHP-FPM proxy
|
||||
- Creates PHP-FPM pool (per-user socket, open_basedir isolation)
|
||||
- Creates PowerDNS zone with A, www, mail, ftp, MX, SPF records
|
||||
- Builds home directory structure: `public_html/`, `logs/`, `tmp/`, `mail/`, `backups/`
|
||||
- Writes default `index.html` for new domains
|
||||
- Sets correct ownership and permissions
|
||||
- `deprovision` action removes vhost, FPM pool, DNS zone
|
||||
- NixClient real JWT authentication — login now calls `/api/auth/login`
|
||||
- Session persists across refresh via `localStorage`
|
||||
- Admin accounts redirected to NixServer (port 2087) instead of being allowed in
|
||||
|
||||
### Fixed
|
||||
- NixClient login accepted any username/password (was using fake `setTimeout` bypass)
|
||||
- NixClient Sign out button now clears JWT and session correctly
|
||||
- `CreateAccount` `FormField` component moved to module level — fixes focus jumping to next field after one keystroke (React remount bug from component defined inside render function)
|
||||
- `BIGINT UNSIGNED` columns changed to `BIGINT` in DB schema — fixes `i64` type mismatch error on login
|
||||
|
||||
### Planned
|
||||
- DNS zone editor (CRUD records via PowerDNS API)
|
||||
- Email account management (Dovecot mailbox provisioning)
|
||||
- SSL certificate management (Let's Encrypt via Certbot)
|
||||
- File manager
|
||||
- MySQL/MariaDB database manager (per-account)
|
||||
@@ -37,7 +16,40 @@ Versions follow [Semantic Versioning](https://semver.org/).
|
||||
- Backup / restore system
|
||||
- Firewall rule editor (CSF/iptables)
|
||||
- Multi-server support (Agency plan)
|
||||
- NixClient pages: domains, email, databases, FTP, security, stats
|
||||
- NixClient pages: databases, FTP, subdomains, redirects, PHP version selector, stats
|
||||
|
||||
---
|
||||
|
||||
## [0.3.0-alpha] — 2026-04-25
|
||||
|
||||
### Added
|
||||
- **DNS Management** — full CRUD via pdnsutil
|
||||
- `GET /api/dns` — list all PowerDNS zones
|
||||
- `GET /api/dns/:domain` — list records (A, AAAA, MX, TXT, CNAME, NS, SRV, CAA)
|
||||
- `POST /api/dns/:domain` — add record (name, type, TTL, content)
|
||||
- `DELETE /api/dns/:domain` — delete an RRset (name + type)
|
||||
- SOA records hidden; zone auto-rectified after each change
|
||||
- **NixServer — Zone Manager page** (`domains`): lists all PowerDNS zones, click → DNS editor
|
||||
- **NixServer — DNS Records page** (`dns`): record table with inline add/delete, zone switcher dropdown, colour-coded type badges
|
||||
- **NixServer — Service Manager page** (`services`): dedicated page with refresh button and running/stopped summary pills
|
||||
- **NixServer — Account actions**: Suspend / Unsuspend / Terminate buttons with confirmation modal
|
||||
- `POST /api/accounts/:username/suspend` / `unsuspend`
|
||||
- `DELETE /api/accounts/:username` — deprovisions vhost, FPM, DNS; removes system user; cascades DB records
|
||||
- **Email account management**
|
||||
- `GET /api/email/:username` — list email accounts for a hosting account
|
||||
- `POST /api/email` — create mailbox (Maildir provisioned under `~/mail/`)
|
||||
- `DELETE /api/email/id/:id` — delete email account
|
||||
- **NixClient — Email Accounts page** — create, list, delete mailboxes with quota display
|
||||
- **NixClient — Domains page** — shows primary domain, document root, quota, bandwidth
|
||||
- **NixClient — Change Password page** — verifies current password, hashes and updates both `users` and `accounts` tables
|
||||
- **NixClient — AccountSidebar** now fetches real account data (domain, package, quota) from `GET /api/accounts/:username`
|
||||
- **Password change API** — `POST /api/me/password` (verifies old password via Argon2id, updates `users` + `accounts`)
|
||||
- **Single account lookup** — `GET /api/accounts/:username`
|
||||
|
||||
### Changed
|
||||
- NixServer footer / login version bump: `v0.2.0-alpha` → `v0.3.0-alpha`
|
||||
- NixClient footer / login version bump: `v0.2.0-alpha` → `v0.3.0-alpha`
|
||||
- List Accounts: domain cell links to site, suspend/terminate replace placeholder Edit button
|
||||
|
||||
---
|
||||
|
||||
@@ -118,6 +130,7 @@ Versions follow [Semantic Versioning](https://semver.org/).
|
||||
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/NixReaper/nixpanel/compare/v0.2.0-alpha...HEAD
|
||||
[Unreleased]: https://github.com/NixReaper/nixpanel/compare/v0.3.0-alpha...HEAD
|
||||
[0.3.0-alpha]: https://github.com/NixReaper/nixpanel/compare/v0.2.0-alpha...v0.3.0-alpha
|
||||
[0.2.0-alpha]: https://github.com/NixReaper/nixpanel/compare/v0.1.0-alpha...v0.2.0-alpha
|
||||
[0.1.0-alpha]: https://github.com/NixReaper/nixpanel/releases/tag/v0.1.0-alpha
|
||||
|
||||
+399
-242
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import * as api from './api'
|
||||
import type { AccountInfo, EmailAccount } from './api'
|
||||
|
||||
/* ── Types ──────────────────────────────────────────────────────────────── */
|
||||
/* ── Types ───────────────────────────────────────────────────────────── */
|
||||
type Page =
|
||||
| 'home' | 'domains' | 'subdomains' | 'redirects'
|
||||
| 'email' | 'emailAccounts' | 'webmail' | 'spamFilter'
|
||||
@@ -11,84 +12,70 @@ type Page =
|
||||
| 'software' | 'php' | 'wordpress'
|
||||
| 'stats' | 'logs'
|
||||
|
||||
/* ── Feature groups (cPanel icon grid style) ────────────────────────────── */
|
||||
/* ── Feature groups ──────────────────────────────────────────────────── */
|
||||
type Feature = { id: Page; label: string; icon: string; desc: string }
|
||||
type FeatureGroup = { label: string; color: string; features: Feature[] }
|
||||
|
||||
const FEATURE_GROUPS: FeatureGroup[] = [
|
||||
{
|
||||
label: 'Domains',
|
||||
color: 'blue',
|
||||
features: [
|
||||
{ id: 'domains', label: 'Domains', icon: '🌐', desc: 'Addon & parked domains' },
|
||||
{ id: 'subdomains', label: 'Subdomains', icon: '🔗', desc: 'Create subdomains' },
|
||||
{ id: 'redirects', label: 'Redirects', icon: '↪️', desc: '301 / 302 redirects' },
|
||||
label: 'Domains', color: 'blue', features: [
|
||||
{ id: 'domains', label: 'Domains', icon: '🌐', desc: 'Addon & parked domains' },
|
||||
{ id: 'subdomains', label: 'Subdomains', icon: '🔗', desc: 'Create subdomains' },
|
||||
{ id: 'redirects', label: 'Redirects', icon: '↪️', desc: '301 / 302 redirects' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Email',
|
||||
color: 'emerald',
|
||||
features: [
|
||||
label: 'Email', color: 'emerald', features: [
|
||||
{ id: 'emailAccounts', label: 'Email Accounts', icon: '✉️', desc: 'Create & manage mailboxes' },
|
||||
{ id: 'webmail', label: 'Webmail', icon: '📬', desc: 'Open Roundcube webmail' },
|
||||
{ id: 'spamFilter', label: 'Spam Filters', icon: '🚫', desc: 'SpamAssassin rules' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Databases',
|
||||
color: 'purple',
|
||||
features: [
|
||||
label: 'Databases', color: 'purple', features: [
|
||||
{ id: 'mysql', label: 'MySQL Databases', icon: '🗄️', desc: 'Create & manage DBs' },
|
||||
{ id: 'phpmyadmin', label: 'phpMyAdmin', icon: '🖥️', desc: 'Database admin GUI' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Files',
|
||||
color: 'orange',
|
||||
features: [
|
||||
label: 'Files', color: 'orange', features: [
|
||||
{ id: 'fileManager', label: 'File Manager', icon: '📁', desc: 'Browse & edit files' },
|
||||
{ id: 'ftp', label: 'FTP Accounts', icon: '📡', desc: 'Manage FTP users' },
|
||||
{ id: 'backups', label: 'Backups', icon: '💾', desc: 'Download & restore' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Security',
|
||||
color: 'red',
|
||||
features: [
|
||||
{ id: 'ssl', label: 'SSL / TLS', icon: '🔒', desc: "Let's Encrypt certs" },
|
||||
{ id: 'passwords', label: 'Password', icon: '🔑', desc: 'Change cPanel password' },
|
||||
{ id: 'ssh', label: 'SSH Keys', icon: '🗝️', desc: 'Manage SSH key pairs' },
|
||||
label: 'Security', color: 'red', features: [
|
||||
{ id: 'ssl', label: 'SSL / TLS', icon: '🔒', desc: "Let's Encrypt certs" },
|
||||
{ id: 'passwords', label: 'Password', icon: '🔑', desc: 'Change cPanel password' },
|
||||
{ id: 'ssh', label: 'SSH Keys', icon: '🗝️', desc: 'Manage SSH key pairs' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Software',
|
||||
color: 'cyan',
|
||||
features: [
|
||||
{ id: 'php', label: 'PHP Version', icon: '🐘', desc: 'Select PHP 8.x' },
|
||||
{ id: 'wordpress', label: 'WordPress', icon: '🌀', desc: 'Auto-install WP' },
|
||||
label: 'Software', color: 'cyan', features: [
|
||||
{ id: 'php', label: 'PHP Version', icon: '🐘', desc: 'Select PHP 8.x' },
|
||||
{ id: 'wordpress', label: 'WordPress', icon: '🌀', desc: 'Auto-install WP' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Metrics',
|
||||
color: 'yellow',
|
||||
features: [
|
||||
label: 'Metrics', color: 'yellow', features: [
|
||||
{ id: 'stats', label: 'Statistics', icon: '📊', desc: 'Bandwidth & visitors' },
|
||||
{ id: 'logs', label: 'Logs', icon: '📜', desc: 'Error & access logs' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const COLOR_MAP: Record<string, { tile: string; icon: string; badge: string }> = {
|
||||
blue: { tile: 'hover:border-blue-500/40 hover:bg-blue-500/5', icon: 'bg-blue-500/15 text-blue-400', badge: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
|
||||
emerald: { tile: 'hover:border-emerald-500/40 hover:bg-emerald-500/5', icon: 'bg-emerald-500/15 text-emerald-400', badge: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' },
|
||||
purple: { tile: 'hover:border-purple-500/40 hover:bg-purple-500/5', icon: 'bg-purple-500/15 text-purple-400', badge: 'bg-purple-500/20 text-purple-400 border-purple-500/30' },
|
||||
orange: { tile: 'hover:border-orange-500/40 hover:bg-orange-500/5', icon: 'bg-orange-500/15 text-orange-400', badge: 'bg-orange-500/20 text-orange-400 border-orange-500/30' },
|
||||
red: { tile: 'hover:border-red-500/40 hover:bg-red-500/5', icon: 'bg-red-500/15 text-red-400', badge: 'bg-red-500/20 text-red-400 border-red-500/30' },
|
||||
cyan: { tile: 'hover:border-cyan-500/40 hover:bg-cyan-500/5', icon: 'bg-cyan-500/15 text-cyan-400', badge: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30' },
|
||||
yellow: { tile: 'hover:border-yellow-500/40 hover:bg-yellow-500/5', icon: 'bg-yellow-500/15 text-yellow-400', badge: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' },
|
||||
const COLOR_MAP: Record<string, { tile: string; icon: string }> = {
|
||||
blue: { tile: 'hover:border-blue-500/40 hover:bg-blue-500/5', icon: 'bg-blue-500/15 text-blue-400' },
|
||||
emerald: { tile: 'hover:border-emerald-500/40 hover:bg-emerald-500/5', icon: 'bg-emerald-500/15 text-emerald-400' },
|
||||
purple: { tile: 'hover:border-purple-500/40 hover:bg-purple-500/5', icon: 'bg-purple-500/15 text-purple-400' },
|
||||
orange: { tile: 'hover:border-orange-500/40 hover:bg-orange-500/5', icon: 'bg-orange-500/15 text-orange-400' },
|
||||
red: { tile: 'hover:border-red-500/40 hover:bg-red-500/5', icon: 'bg-red-500/15 text-red-400' },
|
||||
cyan: { tile: 'hover:border-cyan-500/40 hover:bg-cyan-500/5', icon: 'bg-cyan-500/15 text-cyan-400' },
|
||||
yellow: { tile: 'hover:border-yellow-500/40 hover:bg-yellow-500/5', icon: 'bg-yellow-500/15 text-yellow-400' },
|
||||
}
|
||||
|
||||
/* ── Usage bar ──────────────────────────────────────────────────────────── */
|
||||
/* ── Usage bar ───────────────────────────────────────────────────────── */
|
||||
function UsageBar({ label, used, total, unit, color }: { label: string; used: number; total: number; unit: string; color: string }) {
|
||||
const pct = total > 0 ? Math.min((used / total) * 100, 100) : 0
|
||||
const barColor = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : color
|
||||
@@ -105,101 +92,81 @@ function UsageBar({ label, used, total, unit, color }: { label: string; used: nu
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Account info sidebar ───────────────────────────────────────────────── */
|
||||
function AccountSidebar({ domain }: { domain: string }) {
|
||||
/* ── Account sidebar ─────────────────────────────────────────────────── */
|
||||
function AccountSidebar({ account, username, onNav }: {
|
||||
account: AccountInfo | null; username: string; onNav: (p: Page) => void
|
||||
}) {
|
||||
const domain = account?.domain ?? username
|
||||
return (
|
||||
<aside className="w-64 flex-shrink-0 space-y-4">
|
||||
{/* Account card */}
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-xl overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-blue-700 to-blue-600 px-4 py-4">
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center text-white text-lg font-black mb-2">
|
||||
{domain.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<p className="text-white font-bold text-sm leading-tight">{domain}</p>
|
||||
<p className="text-blue-200 text-xs mt-0.5">Hosting Account</p>
|
||||
<p className="text-blue-200 text-xs mt-0.5">{account?.package_name ?? 'Hosting'} Account</p>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<UsageBar label="Disk Space" used={0} total={0} unit="MB" color="bg-blue-500" />
|
||||
<UsageBar label="Bandwidth" used={0} total={0} unit="MB" color="bg-emerald-500" />
|
||||
<UsageBar label="Email" used={0} total={0} unit="" color="bg-purple-500" />
|
||||
<UsageBar label="Databases" used={0} total={0} unit="" color="bg-orange-500" />
|
||||
<UsageBar label="Disk Space" used={0} total={account?.disk_quota_mb ?? 0} unit="MB" color="bg-blue-500" />
|
||||
<UsageBar label="Bandwidth" used={0} total={account?.bandwidth_mb ?? 0} unit="MB" color="bg-emerald-500" />
|
||||
<UsageBar label="Email" used={0} total={0} unit="" color="bg-purple-500" />
|
||||
<UsageBar label="Databases" used={0} total={0} unit="" color="bg-orange-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-xl p-4 space-y-2">
|
||||
<p className="text-[10px] font-bold text-gray-600 uppercase tracking-widest mb-3">Account Info</p>
|
||||
{[
|
||||
{ label: 'PHP Version', value: '8.3' },
|
||||
{ label: 'Server', value: 'Apache 2.4' },
|
||||
{ label: 'IP Address', value: '—' },
|
||||
{ label: 'Home Dir', value: '/home/user' },
|
||||
{ label: 'Domain', value: account?.domain ?? '—' },
|
||||
{ label: 'Username', value: account?.username ?? username },
|
||||
{ label: 'Package', value: account?.package_name ?? '—' },
|
||||
{ label: 'Home Dir', value: `/home/${account?.username ?? username}` },
|
||||
{ label: 'Status', value: account?.status ?? '—' },
|
||||
].map(r => (
|
||||
<div key={r.label} className="flex justify-between items-center text-xs py-1 border-b border-[#252b3d] last:border-0">
|
||||
<span className="text-gray-600">{r.label}</span>
|
||||
<span className="text-gray-300 font-medium">{r.value}</span>
|
||||
<span className="text-gray-300 font-medium truncate max-w-[120px]">{r.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-xl overflow-hidden">
|
||||
<p className="px-4 py-2.5 text-[10px] font-bold text-gray-600 uppercase tracking-widest border-b border-[#2a3044]">
|
||||
Quick Links
|
||||
</p>
|
||||
<p className="px-4 py-2.5 text-[10px] font-bold text-gray-600 uppercase tracking-widest border-b border-[#2a3044]">Quick Links</p>
|
||||
{[
|
||||
{ label: '🌐 Visit Website', href: '#' },
|
||||
{ label: '📬 Open Webmail', href: '#' },
|
||||
{ label: '🖥️ phpMyAdmin', href: '#' },
|
||||
{ label: '📊 View Statistics', href: '#' },
|
||||
{ label: '🌐 Visit Website', action: () => account?.domain && window.open(`http://${account.domain}`, '_blank') },
|
||||
{ label: '✉️ Email Accounts', action: () => onNav('emailAccounts') },
|
||||
{ label: '🔑 Change Password', action: () => onNav('passwords') },
|
||||
].map(l => (
|
||||
<a
|
||||
key={l.label}
|
||||
href={l.href}
|
||||
className="flex items-center px-4 py-2.5 text-xs text-gray-400 hover:bg-[#252b3d] hover:text-white transition-colors border-b border-[#252b3d] last:border-0"
|
||||
>
|
||||
<button key={l.label} onClick={l.action}
|
||||
className="w-full flex items-center px-4 py-2.5 text-xs text-gray-400 hover:bg-[#252b3d] hover:text-white transition-colors border-b border-[#252b3d] last:border-0 text-left">
|
||||
{l.label}
|
||||
</a>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Home / feature grid ────────────────────────────────────────────────── */
|
||||
/* ── Home / feature grid ─────────────────────────────────────────────── */
|
||||
function Home({ onNav }: { onNav: (p: Page) => void }) {
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const allFeatures = FEATURE_GROUPS.flatMap(g =>
|
||||
g.features.map(f => ({ ...f, groupColor: g.color, groupLabel: g.label }))
|
||||
)
|
||||
|
||||
const allFeatures = FEATURE_GROUPS.flatMap(g => g.features.map(f => ({ ...f, groupColor: g.color })))
|
||||
const filtered = search.trim()
|
||||
? allFeatures.filter(
|
||||
f =>
|
||||
f.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
f.desc.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
? allFeatures.filter(f => f.label.toLowerCase().includes(search.toLowerCase()) || f.desc.toLowerCase().includes(search.toLowerCase()))
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Search bar */}
|
||||
<div className="relative">
|
||||
<span className="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-600 text-sm">🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search features…"
|
||||
className="w-full bg-[#1a1f2e] border border-[#2a3044] focus:border-blue-500/60 text-white rounded-xl pl-9 pr-4 py-3 text-sm placeholder-gray-600 focus:outline-none focus:ring-1 focus:ring-blue-500/20 transition-all"
|
||||
/>
|
||||
<input type="text" value={search} onChange={e => setSearch(e.target.value)} placeholder="Search features…"
|
||||
className="w-full bg-[#1a1f2e] border border-[#2a3044] focus:border-blue-500/60 text-white rounded-xl pl-9 pr-4 py-3 text-sm placeholder-gray-600 focus:outline-none focus:ring-1 focus:ring-blue-500/20 transition-all" />
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-600 hover:text-gray-400 text-xs">✕</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search results */}
|
||||
{filtered ? (
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 mb-3">{filtered.length} result{filtered.length !== 1 ? 's' : ''} for "{search}"</p>
|
||||
@@ -207,26 +174,18 @@ function Home({ onNav }: { onNav: (p: Page) => void }) {
|
||||
{filtered.map(f => {
|
||||
const c = COLOR_MAP[f.groupColor]
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => onNav(f.id)}
|
||||
className={`flex flex-col items-center text-center p-4 bg-[#1a1f2e] border border-[#2a3044] rounded-xl transition-all group ${c.tile}`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-xl mb-2.5 ${c.icon}`}>
|
||||
{f.icon}
|
||||
</div>
|
||||
<button key={f.id} onClick={() => onNav(f.id)}
|
||||
className={`flex flex-col items-center text-center p-4 bg-[#1a1f2e] border border-[#2a3044] rounded-xl transition-all group ${c.tile}`}>
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-xl mb-2.5 ${c.icon}`}>{f.icon}</div>
|
||||
<span className="text-xs font-semibold text-gray-300 group-hover:text-white leading-tight">{f.label}</span>
|
||||
<span className="text-[10px] text-gray-600 group-hover:text-gray-400 mt-0.5 leading-tight">{f.desc}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-600 text-sm">No features found for "{search}"</div>
|
||||
)}
|
||||
{filtered.length === 0 && <div className="text-center py-12 text-gray-600 text-sm">No features found for "{search}"</div>}
|
||||
</div>
|
||||
) : (
|
||||
/* Normal grouped view */
|
||||
<div className="space-y-6">
|
||||
{FEATURE_GROUPS.map(group => {
|
||||
const c = COLOR_MAP[group.color]
|
||||
@@ -238,14 +197,9 @@ function Home({ onNav }: { onNav: (p: Page) => void }) {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{group.features.map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => onNav(f.id)}
|
||||
className={`flex flex-col items-center text-center p-4 bg-[#1a1f2e] border border-[#2a3044] rounded-xl transition-all group ${c.tile}`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-xl mb-2.5 ${c.icon}`}>
|
||||
{f.icon}
|
||||
</div>
|
||||
<button key={f.id} onClick={() => onNav(f.id)}
|
||||
className={`flex flex-col items-center text-center p-4 bg-[#1a1f2e] border border-[#2a3044] rounded-xl transition-all group ${c.tile}`}>
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-xl mb-2.5 ${c.icon}`}>{f.icon}</div>
|
||||
<span className="text-xs font-semibold text-gray-300 group-hover:text-white leading-tight">{f.label}</span>
|
||||
<span className="text-[10px] text-gray-600 group-hover:text-gray-400 mt-1 leading-tight">{f.desc}</span>
|
||||
</button>
|
||||
@@ -260,60 +214,322 @@ function Home({ onNav }: { onNav: (p: Page) => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Placeholder ────────────────────────────────────────────────────────── */
|
||||
function Placeholder({ title, icon }: { title: string; icon: string }) {
|
||||
/* ── Domains page ─────────────────────────────────────────────────────── */
|
||||
function Domains({ account }: { account: AccountInfo | null }) {
|
||||
if (!account) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-center">
|
||||
<div className="text-gray-600 text-sm">Loading domain info…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-72 text-center">
|
||||
<div className="text-5xl mb-4">{icon}</div>
|
||||
<h2 className="text-lg font-bold text-white mb-2">{title}</h2>
|
||||
<p className="text-gray-600 text-sm max-w-sm">
|
||||
This module is under active development and will be available in an upcoming release.
|
||||
</p>
|
||||
<div className="mt-4 px-3 py-1.5 bg-blue-500/10 border border-blue-500/20 rounded text-xs text-blue-400 font-semibold">
|
||||
Coming Soon
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-black text-white">Domains</h1>
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[#2a3044] bg-[#0f1520]">
|
||||
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-widest">Primary Domain</h3>
|
||||
</div>
|
||||
<div className="p-5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-lg font-bold text-white">{account.domain}</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Main domain · {account.package_name} package</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-emerald-500/20 border border-emerald-500/30 text-emerald-400 text-xs rounded-full font-semibold">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
Active
|
||||
</span>
|
||||
<a href={`http://${account.domain}`} target="_blank" rel="noopener"
|
||||
className="px-3 py-1.5 bg-[#2a3044] hover:bg-blue-500/20 hover:text-blue-400 text-gray-400 text-xs rounded-lg transition-colors">
|
||||
Visit Site ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 pb-5 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: 'Document Root', value: `/home/${account.username}/public_html` },
|
||||
{ label: 'Home Dir', value: `/home/${account.username}` },
|
||||
{ label: 'Disk Quota', value: account.disk_quota_mb === 0 ? 'Unlimited' : `${account.disk_quota_mb} MB` },
|
||||
{ label: 'Bandwidth', value: account.bandwidth_mb === 0 ? 'Unlimited' : `${account.bandwidth_mb} MB/mo` },
|
||||
].map(i => (
|
||||
<div key={i.label} className="bg-[#0f1520] border border-[#2a3044] rounded-lg p-3">
|
||||
<p className="text-[10px] font-bold text-gray-600 uppercase tracking-wider mb-1">{i.label}</p>
|
||||
<p className="text-xs text-gray-300 font-mono break-all">{i.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-bold text-white">Addon Domains</h3>
|
||||
<div className="px-3 py-1 bg-blue-500/10 border border-blue-500/20 text-blue-400 text-[11px] rounded font-semibold">Coming Soon</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">Addon domain management will be available in the next release.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Page meta ──────────────────────────────────────────────────────────── */
|
||||
const PAGE_META: Record<Page, { title: string; icon: string }> = {
|
||||
home: { title: 'My Control Panel', icon: '▦' },
|
||||
domains: { title: 'Domains', icon: '🌐' },
|
||||
subdomains: { title: 'Subdomains', icon: '🔗' },
|
||||
redirects: { title: 'Redirects', icon: '↪️' },
|
||||
email: { title: 'Email', icon: '✉️' },
|
||||
emailAccounts:{ title: 'Email Accounts', icon: '✉️' },
|
||||
webmail: { title: 'Webmail', icon: '📬' },
|
||||
spamFilter: { title: 'Spam Filters', icon: '🚫' },
|
||||
databases: { title: 'Databases', icon: '🗄️' },
|
||||
mysql: { title: 'MySQL Databases', icon: '🗄️' },
|
||||
phpmyadmin: { title: 'phpMyAdmin', icon: '🖥️' },
|
||||
files: { title: 'Files', icon: '📁' },
|
||||
fileManager: { title: 'File Manager', icon: '📁' },
|
||||
ftp: { title: 'FTP Accounts', icon: '📡' },
|
||||
backups: { title: 'Backups', icon: '💾' },
|
||||
security: { title: 'Security', icon: '🔒' },
|
||||
ssl: { title: 'SSL / TLS', icon: '🔒' },
|
||||
passwords: { title: 'Password', icon: '🔑' },
|
||||
ssh: { title: 'SSH Keys', icon: '🗝️' },
|
||||
software: { title: 'Software', icon: '🐘' },
|
||||
php: { title: 'PHP Version', icon: '🐘' },
|
||||
wordpress: { title: 'WordPress', icon: '🌀' },
|
||||
stats: { title: 'Statistics', icon: '📊' },
|
||||
logs: { title: 'Logs', icon: '📜' },
|
||||
/* ── Email Accounts page ─────────────────────────────────────────────── */
|
||||
function EmailAccounts({ account }: { account: AccountInfo | null }) {
|
||||
const [emails, setEmails] = useState<EmailAccount[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [form, setForm] = useState({ username: '', password: '', quota_mb: 1024 })
|
||||
|
||||
const username = account?.username ?? localStorage.getItem('nixclient_username') ?? ''
|
||||
const domain = account?.domain ?? ''
|
||||
|
||||
const flash = (m: string) => { setMsg(m); setTimeout(() => setMsg(''), 4000) }
|
||||
|
||||
const refresh = () => {
|
||||
if (!username) return
|
||||
setLoading(true)
|
||||
api.listMyEmailAccounts(username).then(setEmails).catch(console.error).finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => { refresh() }, [username])
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.username || !form.password || !domain) {
|
||||
flash('Username, password, and domain are required')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.createEmailAccount({ username: form.username, domain, password: form.password, quota_mb: form.quota_mb })
|
||||
flash(`Created ${form.username}@${domain}`)
|
||||
setShowAdd(false)
|
||||
setForm({ username: '', password: '', quota_mb: 1024 })
|
||||
refresh()
|
||||
} catch (e: any) {
|
||||
flash(`Error: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number, address: string) => {
|
||||
if (!confirm(`Delete ${address}? This cannot be undone.`)) return
|
||||
try {
|
||||
await api.deleteEmailAccount(id)
|
||||
flash(`Deleted ${address}`)
|
||||
refresh()
|
||||
} catch (e: any) {
|
||||
flash(`Error: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-black text-white">Email Accounts</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{domain ? `Manage mailboxes for ${domain}` : 'Loading…'}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{msg && <span className="text-xs text-blue-400 bg-blue-500/10 border border-blue-500/20 px-3 py-1.5 rounded-lg">{msg}</span>}
|
||||
<button onClick={() => setShowAdd(!showAdd)}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded transition-colors">
|
||||
+ Create Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
{showAdd && (
|
||||
<form onSubmit={handleCreate} className="bg-[#1a1f2e] border border-blue-500/30 rounded-lg p-5 space-y-4">
|
||||
<h3 className="text-sm font-bold text-blue-400">New Email Account</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Username</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<input value={form.username} onChange={e => setForm(f => ({ ...f, username: e.target.value }))}
|
||||
placeholder="info"
|
||||
className="flex-1 bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-500/60" />
|
||||
<span className="text-gray-500 text-sm">@{domain || '…'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Password</label>
|
||||
<input type="password" value={form.password} onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder="••••••••"
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-500/60" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Quota (MB)</label>
|
||||
<input type="number" value={form.quota_mb} onChange={e => setForm(f => ({ ...f, quota_mb: Number(e.target.value) }))}
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-500/60" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="submit" className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-bold rounded transition-colors">Create Account</button>
|
||||
<button type="button" onClick={() => setShowAdd(false)} className="px-4 py-2 border border-[#2a3044] text-gray-400 hover:text-white text-sm rounded transition-colors">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Email list */}
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-gray-600 text-sm">
|
||||
<span className="inline-block w-4 h-4 border-2 border-gray-700 border-t-blue-500 rounded-full animate-spin mr-2" />Loading…
|
||||
</div>
|
||||
) : emails.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<div className="text-4xl mb-3">✉️</div>
|
||||
<p className="text-gray-500 text-sm">No email accounts yet.</p>
|
||||
<button onClick={() => setShowAdd(true)} className="mt-3 text-blue-400 hover:underline text-sm">Create your first mailbox →</button>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#2a3044] bg-[#0f1520]">
|
||||
{['Address', 'Quota', 'Created', 'Actions'].map(h => (
|
||||
<th key={h} className="text-left px-4 py-2.5 text-[10px] font-bold text-gray-500 uppercase tracking-wider">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{emails.map(em => (
|
||||
<tr key={em.id} className="border-b border-[#252b3d] hover:bg-[#252b3d]/50 transition-colors">
|
||||
<td className="px-4 py-3 text-gray-300 font-medium">{em.address}</td>
|
||||
<td className="px-4 py-3 text-gray-500 text-xs">{em.quota_mb} MB</td>
|
||||
<td className="px-4 py-3 text-gray-600 text-xs">{new Date(em.created_at).toLocaleDateString()}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button onClick={() => handleDelete(em.id, em.address)}
|
||||
className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:text-red-400 text-gray-400 transition-colors">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Client panel shell ─────────────────────────────────────────────────── */
|
||||
function ClientPanel({ onLogout }: { onLogout: () => void }) {
|
||||
const [page, setPage] = useState<Page>('home')
|
||||
/* ── Change Password page ────────────────────────────────────────────── */
|
||||
function ChangePassword() {
|
||||
const [form, setForm] = useState({ current: '', newPw: '', confirm: '' })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (form.newPw !== form.confirm) { setError('New passwords do not match.'); return }
|
||||
if (form.newPw.length < 8) { setError('Password must be at least 8 characters.'); return }
|
||||
setLoading(true); setError(''); setSuccess('')
|
||||
try {
|
||||
await api.changePassword(form.current, form.newPw)
|
||||
setSuccess('Password changed successfully.')
|
||||
setForm({ current: '', newPw: '', confirm: '' })
|
||||
} catch (e: any) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md space-y-5">
|
||||
<div>
|
||||
<h1 className="text-lg font-black text-white">Change Password</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Update your NixClient account password</p>
|
||||
</div>
|
||||
{error && <div className="px-4 py-3 bg-red-950/60 border border-red-800/50 rounded-lg text-red-400 text-sm">⚠️ {error}</div>}
|
||||
{success && <div className="px-4 py-3 bg-emerald-950/60 border border-emerald-800/50 rounded-lg text-emerald-400 text-sm">✓ {success}</div>}
|
||||
<form onSubmit={handleSubmit} className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg divide-y divide-[#2a3044]">
|
||||
<div className="p-5 space-y-4">
|
||||
{[
|
||||
{ label: 'Current Password', key: 'current' as const, placeholder: 'Enter current password' },
|
||||
{ label: 'New Password', key: 'newPw' as const, placeholder: 'At least 8 characters' },
|
||||
{ label: 'Confirm New Password', key: 'confirm' as const, placeholder: 'Repeat new password' },
|
||||
].map(f => (
|
||||
<div key={f.key}>
|
||||
<label className="block text-xs font-semibold text-gray-400 mb-1.5">{f.label}</label>
|
||||
<input type="password" value={form[f.key]} onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))}
|
||||
placeholder={f.placeholder} required
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm placeholder-gray-600 focus:outline-none focus:border-blue-500/60" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<button type="submit" disabled={loading}
|
||||
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white text-sm font-bold rounded transition-colors flex items-center gap-2">
|
||||
{loading && <span className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />}
|
||||
{loading ? 'Updating…' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Placeholder ──────────────────────────────────────────────────────── */
|
||||
function Placeholder({ title, icon }: { title: string; icon: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-72 text-center">
|
||||
<div className="text-5xl mb-4">{icon}</div>
|
||||
<h2 className="text-lg font-bold text-white mb-2">{title}</h2>
|
||||
<p className="text-gray-600 text-sm max-w-sm">This module is under active development and will be available in an upcoming release.</p>
|
||||
<div className="mt-4 px-3 py-1.5 bg-blue-500/10 border border-blue-500/20 rounded text-xs text-blue-400 font-semibold">Coming Soon</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PAGE_META: Record<Page, { title: string; icon: string }> = {
|
||||
home: { title: 'My Control Panel', icon: '▦' },
|
||||
domains: { title: 'Domains', icon: '🌐' },
|
||||
subdomains: { title: 'Subdomains', icon: '🔗' },
|
||||
redirects: { title: 'Redirects', icon: '↪️' },
|
||||
email: { title: 'Email', icon: '✉️' },
|
||||
emailAccounts: { title: 'Email Accounts', icon: '✉️' },
|
||||
webmail: { title: 'Webmail', icon: '📬' },
|
||||
spamFilter: { title: 'Spam Filters', icon: '🚫' },
|
||||
databases: { title: 'Databases', icon: '🗄️' },
|
||||
mysql: { title: 'MySQL Databases', icon: '🗄️' },
|
||||
phpmyadmin: { title: 'phpMyAdmin', icon: '🖥️' },
|
||||
files: { title: 'Files', icon: '📁' },
|
||||
fileManager: { title: 'File Manager', icon: '📁' },
|
||||
ftp: { title: 'FTP Accounts', icon: '📡' },
|
||||
backups: { title: 'Backups', icon: '💾' },
|
||||
security: { title: 'Security', icon: '🔒' },
|
||||
ssl: { title: 'SSL / TLS', icon: '🔒' },
|
||||
passwords: { title: 'Change Password', icon: '🔑' },
|
||||
ssh: { title: 'SSH Keys', icon: '🗝️' },
|
||||
software: { title: 'Software', icon: '🐘' },
|
||||
php: { title: 'PHP Version', icon: '🐘' },
|
||||
wordpress: { title: 'WordPress', icon: '🌀' },
|
||||
stats: { title: 'Statistics', icon: '📊' },
|
||||
logs: { title: 'Logs', icon: '📜' },
|
||||
}
|
||||
|
||||
/* ── Client panel shell ───────────────────────────────────────────────── */
|
||||
function ClientPanel({ username, onLogout }: { username: string; onLogout: () => void }) {
|
||||
const [page, setPage] = useState<Page>('home')
|
||||
const [account, setAccount] = useState<AccountInfo | null>(null)
|
||||
const meta = PAGE_META[page]
|
||||
const username = localStorage.getItem('nixclient_username') ?? 'user'
|
||||
const domain = username // domain matches account username for now
|
||||
|
||||
// Fetch account info on mount
|
||||
useEffect(() => {
|
||||
api.getMyAccount(username).then(setAccount).catch(console.error)
|
||||
}, [username])
|
||||
|
||||
function renderPage() {
|
||||
if (page === 'home') return <Home onNav={setPage} />
|
||||
return <Placeholder title={meta.title} icon={meta.icon} />
|
||||
switch (page) {
|
||||
case 'home': return <Home onNav={setPage} />
|
||||
case 'domains': return <Domains account={account} />
|
||||
case 'emailAccounts': return <EmailAccounts account={account} />
|
||||
case 'email': return <EmailAccounts account={account} />
|
||||
case 'passwords': return <ChangePassword />
|
||||
default: return <Placeholder title={meta.title} icon={meta.icon} />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -321,55 +537,42 @@ function ClientPanel({ onLogout }: { onLogout: () => void }) {
|
||||
{/* Top nav */}
|
||||
<header className="bg-[#1a1f2e] border-b border-[#2a3044] sticky top-0 z-40 flex-shrink-0">
|
||||
<div className="h-14 max-w-screen-2xl mx-auto px-6 flex items-center justify-between gap-4">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<div className="w-7 h-7 rounded-lg bg-blue-600 flex items-center justify-center text-white text-sm font-black">N</div>
|
||||
<span className="text-base font-black text-white hidden sm:block">
|
||||
Nix<span className="text-blue-400">Client</span>
|
||||
</span>
|
||||
<span className="text-base font-black text-white hidden sm:block">Nix<span className="text-blue-400">Client</span></span>
|
||||
</div>
|
||||
|
||||
{/* Center nav */}
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{[
|
||||
{ id: 'home' as Page, label: 'Home' },
|
||||
{ id: 'email' as Page, label: 'Email' },
|
||||
{ id: 'mysql' as Page, label: 'Databases' },
|
||||
{ id: 'ssl' as Page, label: 'Security' },
|
||||
{ id: 'stats' as Page, label: 'Stats' },
|
||||
{ id: 'home' as Page, label: 'Home' },
|
||||
{ id: 'emailAccounts' as Page, label: 'Email' },
|
||||
{ id: 'mysql' as Page, label: 'Databases' },
|
||||
{ id: 'ssl' as Page, label: 'Security' },
|
||||
{ id: 'stats' as Page, label: 'Stats' },
|
||||
].map(l => (
|
||||
<button
|
||||
key={l.id}
|
||||
onClick={() => setPage(l.id)}
|
||||
<button key={l.id} onClick={() => setPage(l.id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
page === l.id
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-[#252b3d]'
|
||||
}`}
|
||||
>
|
||||
page === l.id ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white hover:bg-[#252b3d]'
|
||||
}`}>
|
||||
{l.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Right: account / logout */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 bg-[#0f1520] border border-[#2a3044] rounded-lg">
|
||||
<span className="w-5 h-5 rounded-full bg-blue-600 flex items-center justify-center text-white text-[10px] font-bold">
|
||||
{domain.charAt(0).toUpperCase()}
|
||||
{(account?.domain ?? username).charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="text-xs text-gray-300 font-medium max-w-[120px] truncate">{domain}</span>
|
||||
<span className="text-xs text-gray-300 font-medium max-w-[120px] truncate">{account?.domain ?? username}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="px-3 py-1.5 rounded-lg border border-[#2a3044] text-gray-400 hover:border-gray-600 hover:text-white transition-colors text-xs font-medium"
|
||||
>
|
||||
<button onClick={onLogout}
|
||||
className="px-3 py-1.5 rounded-lg border border-[#2a3044] text-gray-400 hover:border-gray-600 hover:text-white transition-colors text-xs font-medium">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
{page !== 'home' && (
|
||||
<div className="border-t border-[#252b3d] px-6 py-1.5 flex items-center gap-1.5 text-[11px] text-gray-600 max-w-screen-2xl mx-auto">
|
||||
<button onClick={() => setPage('home')} className="hover:text-blue-400 transition-colors">Home</button>
|
||||
@@ -379,34 +582,26 @@ function ClientPanel({ onLogout }: { onLogout: () => void }) {
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 max-w-screen-2xl mx-auto w-full px-6 py-6 flex gap-6">
|
||||
{/* Sidebar */}
|
||||
<AccountSidebar domain={domain} />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0">
|
||||
{renderPage()}
|
||||
</main>
|
||||
<AccountSidebar account={account} username={username} onNav={setPage} />
|
||||
<main className="flex-1 min-w-0">{renderPage()}</main>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-[#2a3044] px-6 py-3 text-center text-[10px] text-gray-700">
|
||||
NixPanel Client v0.2.0-alpha · {domain} · Powered by NixServer
|
||||
NixPanel Client v0.3.0-alpha · {account?.domain ?? username} · Powered by NixServer
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Login ──────────────────────────────────────────────────────────────── */
|
||||
/* ── Login ────────────────────────────────────────────────────────────── */
|
||||
export default function App() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loggedInAs, setLoggedInAs] = useState<string | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Restore session on load
|
||||
useEffect(() => {
|
||||
if (api.isLoggedIn()) {
|
||||
const stored = localStorage.getItem('nixclient_username')
|
||||
@@ -416,15 +611,10 @@ export default function App() {
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!username || !password) {
|
||||
setError('Please enter your username and password.')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError('')
|
||||
if (!username || !password) { setError('Please enter your username and password.'); return }
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const result = await api.login(username, password)
|
||||
// Only allow non-admin users into NixClient
|
||||
if (result.user.role === 'admin') {
|
||||
api.clearToken()
|
||||
setError('Admin accounts must use NixServer (port 2087).')
|
||||
@@ -446,15 +636,12 @@ export default function App() {
|
||||
setUsername(''); setPassword('')
|
||||
}
|
||||
|
||||
if (loggedInAs) return <ClientPanel onLogout={handleLogout} />
|
||||
if (loggedInAs) return <ClientPanel username={loggedInAs} onLogout={handleLogout} />
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#0f1520] relative overflow-hidden">
|
||||
{/* Background radial */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-blue-950/40 via-[#0f1520] to-[#0f1520]" />
|
||||
|
||||
<div className="relative w-full max-w-sm px-4">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center gap-3 mb-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-600 flex items-center justify-center text-white text-2xl font-black shadow-lg shadow-blue-900/50">N</div>
|
||||
@@ -465,9 +652,7 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-2xl shadow-2xl overflow-hidden">
|
||||
{/* Header band */}
|
||||
<div className="bg-gradient-to-r from-blue-700 to-blue-600 px-6 py-4">
|
||||
<h2 className="text-white font-bold text-base">Sign in to your account</h2>
|
||||
<p className="text-blue-200 text-xs mt-0.5">NixClient Hosting Control Panel</p>
|
||||
@@ -479,52 +664,24 @@ export default function App() {
|
||||
<span>⚠️</span> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1.5">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
placeholder="username"
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-700 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-all"
|
||||
/>
|
||||
<input type="text" value={username} onChange={e => setUsername(e.target.value)} required autoFocus autoComplete="username" placeholder="username"
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-700 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-all" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-700 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-all"
|
||||
/>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="current-password" placeholder="••••••••"
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-700 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-all" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 rounded-lg bg-blue-600 hover:bg-blue-700 active:bg-blue-800 disabled:opacity-60 text-white font-bold text-sm transition-all shadow-lg shadow-blue-900/40 flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Signing in…
|
||||
</>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
<button type="submit" disabled={loading}
|
||||
className="w-full py-2.5 rounded-lg bg-blue-600 hover:bg-blue-700 active:bg-blue-800 disabled:opacity-60 text-white font-bold text-sm transition-all shadow-lg shadow-blue-900/40 flex items-center justify-center gap-2">
|
||||
{loading ? (<><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />Signing in…</>) : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="border-t border-[#2a3044] px-6 py-3 bg-[#0f1520] text-[10px] text-gray-700 text-center">
|
||||
NixPanel Client v0.2.0-alpha · Manage your hosting account
|
||||
NixPanel Client v0.3.0-alpha · Manage your hosting account
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+43
-4
@@ -37,6 +37,7 @@ async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T>
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/* ── Auth ────────────────────────────────────────────────────────────── */
|
||||
export interface LoginResult {
|
||||
token: string
|
||||
user: { id: number; username: string; role: string }
|
||||
@@ -50,7 +51,9 @@ export async function login(username: string, password: string): Promise<LoginRe
|
||||
return result
|
||||
}
|
||||
|
||||
/* ── Account info ────────────────────────────────────────────────────── */
|
||||
export interface AccountInfo {
|
||||
id: number
|
||||
username: string
|
||||
domain: string
|
||||
email: string
|
||||
@@ -58,13 +61,49 @@ export interface AccountInfo {
|
||||
disk_quota_mb: number
|
||||
bandwidth_mb: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
export async function getMyAccount(): Promise<AccountInfo | null> {
|
||||
export async function getMyAccount(username: string): Promise<AccountInfo | null> {
|
||||
try {
|
||||
// Returns the first account matching the logged-in user's username
|
||||
const accounts = await apiFetch<AccountInfo[]>('/accounts')
|
||||
return accounts[0] ?? null
|
||||
return await apiFetch<AccountInfo>(`/accounts/${username}`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Email accounts ──────────────────────────────────────────────────── */
|
||||
export interface EmailAccount {
|
||||
id: number
|
||||
account_id: number
|
||||
address: string
|
||||
quota_mb: number
|
||||
created_at: string
|
||||
}
|
||||
export async function listMyEmailAccounts(username: string): Promise<EmailAccount[]> {
|
||||
return apiFetch(`/email/${username}`)
|
||||
}
|
||||
export async function createEmailAccount(data: {
|
||||
username: string
|
||||
domain: string
|
||||
password: string
|
||||
quota_mb?: number
|
||||
}): Promise<EmailAccount> {
|
||||
return apiFetch('/email', { method: 'POST', body: JSON.stringify(data) })
|
||||
}
|
||||
export async function deleteEmailAccount(id: number): Promise<void> {
|
||||
await apiFetch(`/email/id/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/* ── Password change ─────────────────────────────────────────────────── */
|
||||
export async function changePassword(
|
||||
currentPassword: string,
|
||||
newPassword: string
|
||||
): Promise<void> {
|
||||
await apiFetch('/me/password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "nixpanel"
|
||||
version = "0.2.0-alpha"
|
||||
version = "0.3.0-alpha"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::sync::Arc;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
routing::{delete, get, post},
|
||||
Router,
|
||||
};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
@@ -110,11 +110,32 @@ fn build_router(state: AppState) -> Router {
|
||||
// Dashboard
|
||||
.route("/api/dashboard", get(routes::dashboard::get_dashboard))
|
||||
// Services
|
||||
.route("/api/services", get(routes::services::list_services))
|
||||
.route("/api/services/:name/:action", post(routes::services::service_action))
|
||||
// Accounts
|
||||
.route("/api/accounts", get(routes::accounts::list_accounts)
|
||||
.post(routes::accounts::create_account))
|
||||
.route("/api/services", get(routes::services::list_services))
|
||||
.route("/api/services/:name/:action", post(routes::services::service_action))
|
||||
// Accounts — list / create
|
||||
.route("/api/accounts",
|
||||
get(routes::accounts::list_accounts)
|
||||
.post(routes::accounts::create_account))
|
||||
// Accounts — per-account actions
|
||||
.route("/api/accounts/:username",
|
||||
get(routes::accounts::get_account)
|
||||
.delete(routes::accounts::terminate_account))
|
||||
.route("/api/accounts/:username/suspend",
|
||||
post(routes::accounts::suspend_account))
|
||||
.route("/api/accounts/:username/unsuspend",
|
||||
post(routes::accounts::unsuspend_account))
|
||||
// DNS
|
||||
.route("/api/dns", get(routes::dns::list_zones))
|
||||
.route("/api/dns/:domain",
|
||||
get(routes::dns::list_zone_records)
|
||||
.post(routes::dns::add_record)
|
||||
.delete(routes::dns::delete_record))
|
||||
// Email accounts
|
||||
.route("/api/email", post(routes::email::create_email_account))
|
||||
.route("/api/email/:username", get(routes::email::list_email_accounts))
|
||||
.route("/api/email/id/:id", delete(routes::email::delete_email_account))
|
||||
// Profile / password
|
||||
.route("/api/me/password", post(routes::me::change_password))
|
||||
.with_state(state)
|
||||
.layer(cors)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use axum::{extract::State, Json};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -133,3 +136,117 @@ pub async fn create_account(
|
||||
|
||||
Ok(Json(account))
|
||||
}
|
||||
|
||||
/* ── Suspend / Unsuspend ─────────────────────────────────────────────── */
|
||||
|
||||
pub async fn suspend_account(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Path(username): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let rows = sqlx::query(
|
||||
"UPDATE accounts SET status = 'suspended' WHERE username = ?",
|
||||
)
|
||||
.bind(&username)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if rows == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "ok": true, "status": "suspended" })))
|
||||
}
|
||||
|
||||
pub async fn unsuspend_account(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Path(username): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let rows = sqlx::query(
|
||||
"UPDATE accounts SET status = 'active' WHERE username = ?",
|
||||
)
|
||||
.bind(&username)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if rows == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "ok": true, "status": "active" })))
|
||||
}
|
||||
|
||||
/* ── Terminate (delete + deprovision) ────────────────────────────────── */
|
||||
|
||||
pub async fn terminate_account(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Path(username): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
// Fetch domain so we can deprovision DNS
|
||||
let domain: Option<String> = sqlx::query_scalar(
|
||||
"SELECT domain FROM accounts WHERE username = ?",
|
||||
)
|
||||
.bind(&username)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
if domain.is_none() {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
let domain = domain.unwrap();
|
||||
|
||||
// Run nixpanel-accounts deprovision (best effort)
|
||||
let result = crate::section::call(
|
||||
&state.config.bin_dir,
|
||||
"nixpanel-accounts",
|
||||
"deprovision",
|
||||
serde_json::json!({ "username": username, "domain": domain }),
|
||||
);
|
||||
if let Err(ref e) = result {
|
||||
tracing::warn!("Deprovision error for {}: {}", username, e);
|
||||
}
|
||||
|
||||
// Remove system user (keep home dir for safety — admin can wipe manually)
|
||||
let _ = std::process::Command::new("userdel")
|
||||
.arg(&username)
|
||||
.output();
|
||||
|
||||
// Remove DB records (cascades to email, ftp, db, ssl, etc.)
|
||||
sqlx::query("DELETE FROM accounts WHERE username = ?")
|
||||
.bind(&username)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
// Also remove panel login user
|
||||
sqlx::query("DELETE FROM users WHERE username = ? AND role = 'user'")
|
||||
.bind(&username)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"message": format!("Account {} terminated", username)
|
||||
})))
|
||||
}
|
||||
|
||||
/* ── Single account lookup ───────────────────────────────────────────── */
|
||||
|
||||
pub async fn get_account(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Path(username): Path<String>,
|
||||
) -> Result<Json<Account>, AppError> {
|
||||
let account = sqlx::query_as::<_, Account>(
|
||||
"SELECT id, username, domain, email, package_name, disk_quota_mb, bandwidth_mb, status, created_at
|
||||
FROM accounts WHERE username = ?",
|
||||
)
|
||||
.bind(&username)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
Ok(Json(account))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::{auth::Claims, error::AppError, state::AppState};
|
||||
|
||||
/* ── Types ───────────────────────────────────────────────────────────── */
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct DnsRecord {
|
||||
pub name: String,
|
||||
pub ttl: u32,
|
||||
pub rtype: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AddRecordRequest {
|
||||
pub name: String,
|
||||
pub rtype: String,
|
||||
pub ttl: Option<u32>,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteRecordRequest {
|
||||
pub name: String,
|
||||
pub rtype: String,
|
||||
}
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/// Parse `pdnsutil list-zone <domain>` tab-separated output
|
||||
fn parse_zone(domain: &str, output: &str) -> Vec<DnsRecord> {
|
||||
let mut records = Vec::new();
|
||||
for line in output.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
// Columns: name\tttl\tIN\ttype\tcontent
|
||||
let cols: Vec<&str> = line.splitn(5, '\t').collect();
|
||||
if cols.len() < 5 {
|
||||
continue;
|
||||
}
|
||||
let raw_name = cols[0].trim().trim_end_matches('.');
|
||||
let ttl: u32 = cols[1].trim().parse().unwrap_or(3600);
|
||||
// cols[2] == "IN"
|
||||
let rtype = cols[3].trim().to_uppercase();
|
||||
let content = cols[4].trim().to_string();
|
||||
|
||||
if rtype == "SOA" {
|
||||
continue; // skip internal records
|
||||
}
|
||||
|
||||
// Normalise name: FQDN → relative label (@=apex)
|
||||
let name = if raw_name == domain || raw_name.is_empty() {
|
||||
"@".to_string()
|
||||
} else {
|
||||
let suffix = format!(".{}", domain);
|
||||
if raw_name.ends_with(&suffix) {
|
||||
raw_name[..raw_name.len() - suffix.len()].to_string()
|
||||
} else {
|
||||
raw_name.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
records.push(DnsRecord { name, ttl, rtype, content });
|
||||
}
|
||||
records
|
||||
}
|
||||
|
||||
fn pdnsutil(args: &[&str]) -> Result<String, String> {
|
||||
let out = Command::new("pdnsutil")
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run pdnsutil: {}", e))?;
|
||||
if out.status.success() {
|
||||
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Handlers ────────────────────────────────────────────────────────── */
|
||||
|
||||
/// List all DNS zones managed by PowerDNS
|
||||
pub async fn list_zones(
|
||||
_claims: Claims,
|
||||
State(_state): State<AppState>,
|
||||
) -> Result<Json<Vec<String>>, AppError> {
|
||||
let out = Command::new("pdnsutil")
|
||||
.args(["list-all-zones"])
|
||||
.output()
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("pdnsutil: {}", e)))?;
|
||||
|
||||
let zones: Vec<String> = String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.map(|l| l.trim().trim_end_matches('.').to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect();
|
||||
|
||||
Ok(Json(zones))
|
||||
}
|
||||
|
||||
/// List all records in a DNS zone
|
||||
pub async fn list_zone_records(
|
||||
_claims: Claims,
|
||||
State(_state): State<AppState>,
|
||||
Path(domain): Path<String>,
|
||||
) -> Result<Json<Vec<DnsRecord>>, AppError> {
|
||||
let out = Command::new("pdnsutil")
|
||||
.args(["list-zone", &domain])
|
||||
.output()
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("pdnsutil: {}", e)))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
Ok(Json(parse_zone(&domain, &stdout)))
|
||||
}
|
||||
|
||||
/// Add a DNS record to a zone
|
||||
pub async fn add_record(
|
||||
_claims: Claims,
|
||||
State(_state): State<AppState>,
|
||||
Path(domain): Path<String>,
|
||||
Json(body): Json<AddRecordRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
if body.name.is_empty() || body.rtype.is_empty() || body.content.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"name, rtype, and content are required".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let ttl = body.ttl.unwrap_or(3600).to_string();
|
||||
pdnsutil(&["add-record", &domain, &body.name, &body.rtype, &ttl, &body.content])
|
||||
.map_err(|e| AppError::BadRequest(format!("DNS error: {}", e)))?;
|
||||
|
||||
let _ = pdnsutil(&["rectify-zone", &domain]);
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// Delete an RRset (all records of a type at a name) from a zone
|
||||
pub async fn delete_record(
|
||||
_claims: Claims,
|
||||
State(_state): State<AppState>,
|
||||
Path(domain): Path<String>,
|
||||
Json(body): Json<DeleteRecordRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
if body.name.is_empty() || body.rtype.is_empty() {
|
||||
return Err(AppError::BadRequest("name and rtype are required".into()));
|
||||
}
|
||||
|
||||
pdnsutil(&["delete-rrset", &domain, &body.name, &body.rtype])
|
||||
.map_err(|e| AppError::BadRequest(format!("DNS error: {}", e)))?;
|
||||
|
||||
let _ = pdnsutil(&["rectify-zone", &domain]);
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{auth::Claims, error::AppError, state::AppState};
|
||||
|
||||
/* ── Types ───────────────────────────────────────────────────────────── */
|
||||
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
pub struct EmailAccount {
|
||||
pub id: i64,
|
||||
pub account_id: i64,
|
||||
pub address: String,
|
||||
pub quota_mb: i32,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateEmailRequest {
|
||||
pub username: String, // local part (before @)
|
||||
pub domain: String, // domain
|
||||
pub password: String,
|
||||
pub quota_mb: Option<i32>,
|
||||
}
|
||||
|
||||
/* ── Handlers ────────────────────────────────────────────────────────── */
|
||||
|
||||
/// List all email accounts for a hosting account (by account username).
|
||||
/// Admin can pass any username; a regular user's JWT sub is used automatically.
|
||||
pub async fn list_email_accounts(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Path(account_username): Path<String>,
|
||||
) -> Result<Json<Vec<EmailAccount>>, AppError> {
|
||||
let emails = sqlx::query_as::<_, EmailAccount>(
|
||||
"SELECT ea.id, ea.account_id, ea.address, ea.quota_mb, ea.created_at
|
||||
FROM email_accounts ea
|
||||
JOIN accounts a ON a.id = ea.account_id
|
||||
WHERE a.username = ?
|
||||
ORDER BY ea.created_at DESC",
|
||||
)
|
||||
.bind(&account_username)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(emails))
|
||||
}
|
||||
|
||||
/// Create a new email account under a domain.
|
||||
pub async fn create_email_account(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<CreateEmailRequest>,
|
||||
) -> Result<Json<EmailAccount>, AppError> {
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
|
||||
if body.username.is_empty() || body.domain.is_empty() || body.password.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"username, domain, and password are required".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate local part (no @, spaces, etc.)
|
||||
if body.username.contains('@') || body.username.contains(' ') {
|
||||
return Err(AppError::BadRequest("Invalid email username".into()));
|
||||
}
|
||||
|
||||
let address = format!("{}@{}", body.username, body.domain);
|
||||
|
||||
// Look up account by domain
|
||||
let row: Option<(i64, String)> = sqlx::query_as(
|
||||
"SELECT id, username FROM accounts WHERE domain = ?",
|
||||
)
|
||||
.bind(&body.domain)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (account_id, acct_username) = row.ok_or_else(|| {
|
||||
AppError::BadRequest(format!("No account for domain {}", body.domain))
|
||||
})?;
|
||||
|
||||
// Hash password
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hash = Argon2::default()
|
||||
.hash_password(body.password.as_bytes(), &salt)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("argon2: {}", e)))?
|
||||
.to_string();
|
||||
|
||||
let quota_mb: i32 = body.quota_mb.unwrap_or(1024);
|
||||
|
||||
let insert_id: u64 = sqlx::query(
|
||||
"INSERT INTO email_accounts (account_id, address, password_hash, quota_mb)
|
||||
VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(&address)
|
||||
.bind(&hash)
|
||||
.bind(quota_mb)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.last_insert_id();
|
||||
|
||||
// Provision Maildir structure under the account's home directory
|
||||
let mail_root = format!("/home/{}/mail/{}", acct_username, body.username);
|
||||
for sub in ["new", "cur", "tmp"] {
|
||||
let _ = std::fs::create_dir_all(format!("{}/{}", mail_root, sub));
|
||||
}
|
||||
let _ = std::process::Command::new("chown")
|
||||
.args([
|
||||
"-R",
|
||||
&format!("{}:{}", acct_username, acct_username),
|
||||
&mail_root,
|
||||
])
|
||||
.output();
|
||||
|
||||
let email = sqlx::query_as::<_, EmailAccount>(
|
||||
"SELECT id, account_id, address, quota_mb, created_at
|
||||
FROM email_accounts WHERE id = ?",
|
||||
)
|
||||
.bind(insert_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(email))
|
||||
}
|
||||
|
||||
/// Delete an email account by ID.
|
||||
pub async fn delete_email_account(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
sqlx::query("DELETE FROM email_accounts WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{auth::Claims, error::AppError, state::AppState};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ChangePasswordRequest {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
/// Change the password of the currently logged-in user.
|
||||
pub async fn change_password(
|
||||
claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<ChangePasswordRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2, PasswordHash,
|
||||
};
|
||||
|
||||
if body.new_password.len() < 8 {
|
||||
return Err(AppError::BadRequest(
|
||||
"New password must be at least 8 characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Fetch current hash from users table
|
||||
let hash: Option<String> = sqlx::query_scalar(
|
||||
"SELECT password_hash FROM users WHERE id = ?",
|
||||
)
|
||||
.bind(claims.user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let hash = hash.ok_or(AppError::NotFound)?;
|
||||
|
||||
// Verify current password
|
||||
let parsed = PasswordHash::new(&hash)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("hash parse: {}", e)))?;
|
||||
|
||||
Argon2::default()
|
||||
.verify_password(body.current_password.as_bytes(), &parsed)
|
||||
.map_err(|_| AppError::BadRequest("Current password is incorrect".into()))?;
|
||||
|
||||
// Hash new password
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let new_hash = Argon2::default()
|
||||
.hash_password(body.new_password.as_bytes(), &salt)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("argon2: {}", e)))?
|
||||
.to_string();
|
||||
|
||||
// Update users table
|
||||
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||
.bind(&new_hash)
|
||||
.bind(claims.user_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
// Also update accounts table if there's a matching account (for panel login)
|
||||
sqlx::query(
|
||||
"UPDATE accounts SET password_hash = ? WHERE username = ?",
|
||||
)
|
||||
.bind(&new_hash)
|
||||
.bind(&claims.sub)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.ok(); // not an error if no matching account
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
pub mod auth;
|
||||
pub mod accounts;
|
||||
pub mod dashboard;
|
||||
pub mod dns;
|
||||
pub mod email;
|
||||
pub mod me;
|
||||
pub mod services;
|
||||
|
||||
+475
-157
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import * as api from './api'
|
||||
import type { DashboardData, ServiceInfo, Account } from './api'
|
||||
import type { DashboardData, ServiceInfo, Account, DnsRecord, EmailAccount } from './api'
|
||||
|
||||
/* ── Types ──────────────────────────────────────────────────────────────── */
|
||||
/* ── Types ───────────────────────────────────────────────────────────── */
|
||||
type Page =
|
||||
| 'dashboard' | 'accounts' | 'createAccount' | 'listAccounts'
|
||||
| 'domains' | 'dns' | 'databases' | 'email'
|
||||
@@ -10,37 +10,45 @@ type Page =
|
||||
| 'apache' | 'php' | 'services'
|
||||
| 'settings' | 'logs' | 'packages'
|
||||
|
||||
/* ── Nav ─────────────────────────────────────────────────────────────────── */
|
||||
/* ── Nav ──────────────────────────────────────────────────────────────── */
|
||||
type NavGroup = { label: string; items: { id: Page; label: string; icon: string }[] }
|
||||
|
||||
const NAV_GROUPS: NavGroup[] = [
|
||||
{ label: 'Overview', items: [{ id: 'dashboard', label: 'Dashboard', icon: '▦' }] },
|
||||
{ label: 'Account Management',items: [
|
||||
{ id: 'listAccounts', label: 'List Accounts', icon: '📋' },
|
||||
{ id: 'createAccount',label: 'Create Account', icon: '➕' },
|
||||
{ id: 'packages', label: 'Feature Packages', icon: '📦' },
|
||||
]},
|
||||
{ label: 'Domains & DNS', items: [
|
||||
{ id: 'domains', label: 'Zone Manager', icon: '🌐' },
|
||||
{ id: 'dns', label: 'DNS Records', icon: '🔗' },
|
||||
]},
|
||||
{ label: 'Databases', items: [{ id: 'databases', label: 'MySQL / MariaDB', icon: '🗄️' }] },
|
||||
{ label: 'Email', items: [{ id: 'email', label: 'Email Routing', icon: '✉️' }] },
|
||||
{ label: 'Security', items: [
|
||||
{ id: 'ssl', label: 'SSL / TLS', icon: '🔒' },
|
||||
{ id: 'firewall', label: 'Firewall (CSF)', icon: '🛡️' },
|
||||
]},
|
||||
{ label: 'Server', items: [
|
||||
{ id: 'apache', label: 'Apache Config', icon: '⚙️' },
|
||||
{ id: 'php', label: 'PHP Manager', icon: '🐘' },
|
||||
{ id: 'services', label: 'Service Manager', icon: '🔧' },
|
||||
{ id: 'backups', label: 'Backup Manager', icon: '💾' },
|
||||
{ id: 'logs', label: 'System Logs', icon: '📜' },
|
||||
{ id: 'settings', label: 'Server Config', icon: '🔩' },
|
||||
]},
|
||||
{ label: 'Overview', items: [{ id: 'dashboard', label: 'Dashboard', icon: '▦' }] },
|
||||
{
|
||||
label: 'Account Management', items: [
|
||||
{ id: 'listAccounts', label: 'List Accounts', icon: '📋' },
|
||||
{ id: 'createAccount', label: 'Create Account', icon: '➕' },
|
||||
{ id: 'packages', label: 'Feature Packages', icon: '📦' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Domains & DNS', items: [
|
||||
{ id: 'domains', label: 'Zone Manager', icon: '🌐' },
|
||||
{ id: 'dns', label: 'DNS Records', icon: '🔗' },
|
||||
],
|
||||
},
|
||||
{ label: 'Databases', items: [{ id: 'databases', label: 'MySQL / MariaDB', icon: '🗄️' }] },
|
||||
{ label: 'Email', items: [{ id: 'email', label: 'Email Routing', icon: '✉️' }] },
|
||||
{
|
||||
label: 'Security', items: [
|
||||
{ id: 'ssl', label: 'SSL / TLS', icon: '🔒' },
|
||||
{ id: 'firewall', label: 'Firewall (CSF)', icon: '🛡️' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Server', items: [
|
||||
{ id: 'apache', label: 'Apache Config', icon: '⚙️' },
|
||||
{ id: 'php', label: 'PHP Manager', icon: '🐘' },
|
||||
{ id: 'services', label: 'Service Manager', icon: '🔧' },
|
||||
{ id: 'backups', label: 'Backup Manager', icon: '💾' },
|
||||
{ id: 'logs', label: 'System Logs', icon: '📜' },
|
||||
{ id: 'settings', label: 'Server Config', icon: '🔩' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/* ── Sidebar ────────────────────────────────────────────────────────────── */
|
||||
/* ── Sidebar ──────────────────────────────────────────────────────────── */
|
||||
function Sidebar({ current, onNav, onLogout, collapsed, onToggle, username }: {
|
||||
current: Page; onNav: (p: Page) => void; onLogout: () => void
|
||||
collapsed: boolean; onToggle: () => void; username: string
|
||||
@@ -107,31 +115,21 @@ function Sidebar({ current, onNav, onLogout, collapsed, onToggle, username }: {
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Stat bar ────────────────────────────────────────────────────────────── */
|
||||
/* ── Stat bar ─────────────────────────────────────────────────────────── */
|
||||
function StatBar({ data }: { data: DashboardData | null }) {
|
||||
const s = data?.stats
|
||||
const s = data?.stats
|
||||
const si = data?.sysinfo
|
||||
|
||||
const ramPct = si && si.ram_total_mb > 0
|
||||
? Math.round((si.ram_used_mb / si.ram_total_mb) * 100)
|
||||
: null
|
||||
const diskPct = si && si.disk_total_gb > 0
|
||||
? Math.round((si.disk_used_gb / si.disk_total_gb) * 100)
|
||||
: null
|
||||
|
||||
function barColor(pct: number) {
|
||||
return pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-emerald-500'
|
||||
}
|
||||
|
||||
const ramPct = si && si.ram_total_mb > 0 ? Math.round((si.ram_used_mb / si.ram_total_mb) * 100) : null
|
||||
const diskPct = si && si.disk_total_gb > 0 ? Math.round((si.disk_used_gb / si.disk_total_gb) * 100) : null
|
||||
function barColor(pct: number) { return pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-emerald-500' }
|
||||
const stats = [
|
||||
{ label: 'Hosting Accounts', value: s?.accounts ?? '—', unit: '', color: 'text-orange-400', bar: null },
|
||||
{ label: 'Domains', value: s?.domains ?? '—', unit: '', color: 'text-blue-400', bar: null },
|
||||
{ label: 'Databases', value: s?.databases ?? '—', unit: '', color: 'text-purple-400', bar: null },
|
||||
{ label: 'Email Accounts', value: s?.email_accounts ?? '—', unit: '', color: 'text-cyan-400', bar: null },
|
||||
{ label: 'Hosting Accounts', value: s?.accounts ?? '—', unit: '', color: 'text-orange-400', bar: null },
|
||||
{ label: 'Domains', value: s?.domains ?? '—', unit: '', color: 'text-blue-400', bar: null },
|
||||
{ label: 'Databases', value: s?.databases ?? '—', unit: '', color: 'text-purple-400', bar: null },
|
||||
{ label: 'Email Accounts', value: s?.email_accounts ?? '—', unit: '', color: 'text-cyan-400', bar: null },
|
||||
{ label: 'Disk Used', value: si ? `${si.disk_used_gb}` : '—', unit: 'GB', color: diskPct && diskPct > 80 ? 'text-red-400' : 'text-emerald-400', bar: diskPct },
|
||||
{ label: 'RAM Used', value: si ? `${Math.round(si.ram_used_mb / 1024 * 10) / 10}` : '—', unit: 'GB', color: ramPct && ramPct > 80 ? 'text-red-400' : 'text-yellow-400', bar: ramPct },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 lg:grid-cols-6 gap-0 border border-[#2a3044] rounded-lg overflow-hidden">
|
||||
{stats.map((s, i) => (
|
||||
@@ -151,7 +149,7 @@ function StatBar({ data }: { data: DashboardData | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Service table ───────────────────────────────────────────────────────── */
|
||||
/* ── Service table ────────────────────────────────────────────────────── */
|
||||
function ServiceTable({ services, loading, onAction }: {
|
||||
services: ServiceInfo[]
|
||||
loading: boolean
|
||||
@@ -163,7 +161,6 @@ function ServiceTable({ services, loading, onAction }: {
|
||||
warning: 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30',
|
||||
}
|
||||
const running = services.filter(s => s.status === 'running').length
|
||||
|
||||
return (
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[#2a3044] flex items-center justify-between">
|
||||
@@ -177,22 +174,17 @@ function ServiceTable({ services, loading, onAction }: {
|
||||
<thead>
|
||||
<tr className="border-b border-[#2a3044]">
|
||||
{['Service', 'Status', 'PID', 'Uptime', 'Actions'].map(h => (
|
||||
<th key={h} className="text-left px-4 py-2 text-[10px] font-bold text-gray-500 uppercase tracking-wider hidden-last-two md:table-cell first:table-cell">{h}</th>
|
||||
<th key={h} className="text-left px-4 py-2 text-[10px] font-bold text-gray-500 uppercase tracking-wider">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-600 text-sm">
|
||||
<span className="inline-block w-4 h-4 border-2 border-gray-700 border-t-orange-500 rounded-full animate-spin mr-2" />
|
||||
Loading services…
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-600 text-sm">
|
||||
<span className="inline-block w-4 h-4 border-2 border-gray-700 border-t-orange-500 rounded-full animate-spin mr-2" />Loading services…
|
||||
</td></tr>
|
||||
) : services.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-600 text-sm">No service data available</td>
|
||||
</tr>
|
||||
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-600 text-sm">No service data</td></tr>
|
||||
) : services.map(svc => (
|
||||
<tr key={svc.name} className="border-b border-[#252b3d] hover:bg-[#252b3d]/50 transition-colors">
|
||||
<td className="px-4 py-2.5 text-gray-300 font-medium">{svc.display}</td>
|
||||
@@ -202,12 +194,12 @@ function ServiceTable({ services, loading, onAction }: {
|
||||
{svc.status.charAt(0).toUpperCase() + svc.status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 text-xs hidden md:table-cell">{svc.pid ?? '—'}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 text-xs hidden md:table-cell">{svc.uptime ?? '—'}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 text-xs">{svc.pid ?? '—'}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 text-xs">{svc.uptime ?? '—'}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex gap-1.5">
|
||||
<button onClick={() => onAction(svc.name, 'start')} className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:bg-emerald-500/20 hover:text-emerald-400 text-gray-400 transition-colors">Start</button>
|
||||
<button onClick={() => onAction(svc.name, 'stop')} className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:bg-red-500/20 hover:text-red-400 text-gray-400 transition-colors">Stop</button>
|
||||
<button onClick={() => onAction(svc.name, 'start')} className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:bg-emerald-500/20 hover:text-emerald-400 text-gray-400 transition-colors">Start</button>
|
||||
<button onClick={() => onAction(svc.name, 'stop')} className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:bg-red-500/20 hover:text-red-400 text-gray-400 transition-colors">Stop</button>
|
||||
<button onClick={() => onAction(svc.name, 'restart')} className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:bg-orange-500/20 hover:text-orange-400 text-gray-400 transition-colors">Restart</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -219,7 +211,7 @@ function ServiceTable({ services, loading, onAction }: {
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Quick action grid ───────────────────────────────────────────────────── */
|
||||
/* ── Quick action grid ────────────────────────────────────────────────── */
|
||||
function QuickActions({ onNav }: { onNav: (p: Page) => void }) {
|
||||
const actions = [
|
||||
{ label: 'Create Account', icon: '👤', page: 'createAccount' as Page, desc: 'Add hosting account' },
|
||||
@@ -251,7 +243,7 @@ function QuickActions({ onNav }: { onNav: (p: Page) => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Server info bar ─────────────────────────────────────────────────────── */
|
||||
/* ── Server info bar ──────────────────────────────────────────────────── */
|
||||
function ServerInfoBar({ data }: { data: DashboardData | null }) {
|
||||
const si = data?.sysinfo
|
||||
const v = data?.versions
|
||||
@@ -282,17 +274,14 @@ function ServerInfoBar({ data }: { data: DashboardData | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Dashboard page ──────────────────────────────────────────────────────── */
|
||||
/* ── Dashboard ────────────────────────────────────────────────────────── */
|
||||
function Dashboard({ onNav }: { onNav: (p: Page) => void }) {
|
||||
const [dashData, setDashData] = useState<DashboardData | null>(null)
|
||||
const [services, setServices] = useState<ServiceInfo[]>([])
|
||||
const [dashData, setDashData] = useState<DashboardData | null>(null)
|
||||
const [services, setServices] = useState<ServiceInfo[]>([])
|
||||
const [svcLoading, setSvcLoading] = useState(true)
|
||||
const [actionMsg, setActionMsg] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api.getDashboard().then(setDashData).catch(console.error)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { api.getDashboard().then(setDashData).catch(console.error) }, [])
|
||||
useEffect(() => {
|
||||
api.getServices().then(s => { setServices(s); setSvcLoading(false) }).catch(() => setSvcLoading(false))
|
||||
}, [])
|
||||
@@ -301,7 +290,6 @@ function Dashboard({ onNav }: { onNav: (p: Page) => void }) {
|
||||
setActionMsg(`${action}ing ${name}…`)
|
||||
try {
|
||||
await api.serviceAction(name, action)
|
||||
// Refresh services
|
||||
const updated = await api.getServices()
|
||||
setServices(updated)
|
||||
setActionMsg(`${name} ${action}ed`)
|
||||
@@ -317,16 +305,12 @@ function Dashboard({ onNav }: { onNav: (p: Page) => void }) {
|
||||
<div>
|
||||
<h1 className="text-lg font-black text-white">Server Overview</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{dashData?.sysinfo
|
||||
? `${dashData.sysinfo.hostname} · ${dashData.sysinfo.ip}`
|
||||
: 'Welcome back, admin — NixServer WHM'}
|
||||
{dashData?.sysinfo ? `${dashData.sysinfo.hostname} · ${dashData.sysinfo.ip}` : 'Welcome back, admin — NixServer WHM'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{actionMsg && (
|
||||
<span className="text-xs text-orange-400 bg-orange-500/10 border border-orange-500/20 px-3 py-1.5 rounded-full">
|
||||
{actionMsg}
|
||||
</span>
|
||||
<span className="text-xs text-orange-400 bg-orange-500/10 border border-orange-500/20 px-3 py-1.5 rounded-full">{actionMsg}</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1.5 text-xs text-emerald-400 font-semibold bg-emerald-500/10 border border-emerald-500/20 px-3 py-1.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
@@ -334,10 +318,8 @@ function Dashboard({ onNav }: { onNav: (p: Page) => void }) {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatBar data={dashData} />
|
||||
<ServerInfoBar data={dashData} />
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-5">
|
||||
<ServiceTable services={services} loading={svcLoading} onAction={handleServiceAction} />
|
||||
<QuickActions onNav={onNav} />
|
||||
@@ -346,30 +328,142 @@ function Dashboard({ onNav }: { onNav: (p: Page) => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
/* ── List accounts ───────────────────────────────────────────────────────── */
|
||||
function ListAccounts({ onNav }: { onNav: (p: Page) => void }) {
|
||||
const [accounts, setAccounts] = useState<Account[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
/* ── Service Manager (dedicated page) ────────────────────────────────── */
|
||||
function ServiceManager() {
|
||||
const [services, setServices] = useState<ServiceInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionMsg, setActionMsg] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api.getAccounts()
|
||||
.then(setAccounts)
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true)
|
||||
api.getServices().then(s => { setServices(s); setLoading(false) }).catch(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const filtered = accounts.filter(a =>
|
||||
a.username.includes(search) || a.domain.includes(search)
|
||||
)
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
const handleAction = useCallback(async (name: string, action: 'start' | 'stop' | 'restart') => {
|
||||
setActionMsg(`${action}ing ${name}…`)
|
||||
try {
|
||||
await api.serviceAction(name, action)
|
||||
refresh()
|
||||
setActionMsg(`${name} ${action}ed successfully`)
|
||||
} catch (e: any) {
|
||||
setActionMsg(`Error: ${e.message}`)
|
||||
}
|
||||
setTimeout(() => setActionMsg(''), 4000)
|
||||
}, [refresh])
|
||||
|
||||
const running = services.filter(s => s.status === 'running').length
|
||||
const stopped = services.filter(s => s.status !== 'running').length
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-black text-white">Service Manager</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Control all system services from one place</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{actionMsg && (
|
||||
<span className="text-xs text-orange-400 bg-orange-500/10 border border-orange-500/20 px-3 py-1.5 rounded-lg">{actionMsg}</span>
|
||||
)}
|
||||
<button onClick={refresh} className="px-3 py-1.5 bg-[#1a1f2e] border border-[#2a3044] hover:border-orange-500/40 text-gray-400 hover:text-white text-xs rounded-lg transition-colors">
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary pills */}
|
||||
<div className="flex gap-3">
|
||||
<span className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-500/10 border border-emerald-500/20 rounded-full text-xs text-emerald-400 font-semibold">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
{running} running
|
||||
</span>
|
||||
{stopped > 0 && (
|
||||
<span className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500/10 border border-red-500/20 rounded-full text-xs text-red-400 font-semibold">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
|
||||
{stopped} stopped
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ServiceTable services={services} loading={loading} onAction={handleAction} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── List accounts ────────────────────────────────────────────────────── */
|
||||
function ListAccounts({ onNav }: { onNav: (p: Page) => void }) {
|
||||
const [accounts, setAccounts] = useState<Account[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [actionMsg, setActionMsg] = useState('')
|
||||
const [confirm, setConfirm] = useState<{ action: string; username: string } | null>(null)
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
api.getAccounts().then(setAccounts).catch(console.error).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
const filtered = accounts.filter(a =>
|
||||
a.username.toLowerCase().includes(search.toLowerCase()) ||
|
||||
a.domain.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
const doAction = async (action: string, username: string) => {
|
||||
setActionMsg(`${action}ing ${username}…`)
|
||||
try {
|
||||
if (action === 'suspend') await api.suspendAccount(username)
|
||||
if (action === 'unsuspend') await api.unsuspendAccount(username)
|
||||
if (action === 'terminate') await api.terminateAccount(username)
|
||||
setActionMsg(`${username} ${action}d`)
|
||||
refresh()
|
||||
} catch (e: any) {
|
||||
setActionMsg(`Error: ${e.message}`)
|
||||
}
|
||||
setTimeout(() => setActionMsg(''), 4000)
|
||||
setConfirm(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Confirm dialog */}
|
||||
{confirm && (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-xl p-6 max-w-sm w-full shadow-2xl">
|
||||
<h3 className="text-base font-bold text-white mb-2">
|
||||
{confirm.action === 'terminate' ? '⚠️ Terminate Account' : confirm.action === 'suspend' ? 'Suspend Account' : 'Unsuspend Account'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 mb-5">
|
||||
{confirm.action === 'terminate'
|
||||
? <>This will permanently delete <span className="text-white font-semibold">{confirm.username}</span> and remove all vhost, DNS, and FPM config. This cannot be undone.</>
|
||||
: confirm.action === 'suspend'
|
||||
? <>Suspend hosting account <span className="text-white font-semibold">{confirm.username}</span>? Their site will go offline.</>
|
||||
: <>Restore access for <span className="text-white font-semibold">{confirm.username}</span>?</>
|
||||
}
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button onClick={() => setConfirm(null)} className="px-4 py-2 rounded-lg border border-[#2a3044] text-gray-400 hover:text-white text-sm transition-colors">Cancel</button>
|
||||
<button
|
||||
onClick={() => doAction(confirm.action, confirm.username)}
|
||||
className={`px-4 py-2 rounded-lg text-white text-sm font-semibold transition-colors ${confirm.action === 'terminate' ? 'bg-red-600 hover:bg-red-700' : confirm.action === 'suspend' ? 'bg-yellow-600 hover:bg-yellow-700' : 'bg-emerald-600 hover:bg-emerald-700'}`}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-black text-white">Hosting Accounts</h1>
|
||||
<button onClick={() => onNav('createAccount')} className="px-3 py-1.5 bg-orange-500 hover:bg-orange-600 text-white text-sm font-semibold rounded transition-colors">
|
||||
+ Create Account
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{actionMsg && <span className="text-xs text-orange-400 bg-orange-500/10 border border-orange-500/20 px-3 py-1.5 rounded-lg">{actionMsg}</span>}
|
||||
<button onClick={() => onNav('createAccount')} className="px-3 py-1.5 bg-orange-500 hover:bg-orange-600 text-white text-sm font-semibold rounded transition-colors">
|
||||
+ Create Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg p-3 flex gap-3">
|
||||
@@ -386,7 +480,7 @@ function ListAccounts({ onNav }: { onNav: (p: Page) => void }) {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#2a3044] bg-[#0f1520]">
|
||||
{['Username', 'Domain', 'Package', 'Disk Quota', 'Status', 'Created', 'Actions'].map(h => (
|
||||
{['Username', 'Domain', 'Package', 'Disk', 'Status', 'Created', 'Actions'].map(h => (
|
||||
<th key={h} className="text-left px-4 py-2.5 text-[10px] font-bold text-gray-500 uppercase tracking-wider">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -400,30 +494,35 @@ function ListAccounts({ onNav }: { onNav: (p: Page) => void }) {
|
||||
<tr><td colSpan={7} className="px-4 py-16 text-center">
|
||||
<div className="text-3xl mb-3">👤</div>
|
||||
<p className="text-gray-500 text-sm">
|
||||
{search ? 'No accounts match your search.' : <>No accounts yet. <button onClick={() => onNav('createAccount')} className="text-orange-400 hover:underline">Create your first account →</button></>}
|
||||
{search ? 'No accounts match your search.' : <><button onClick={() => onNav('createAccount')} className="text-orange-400 hover:underline">Create your first account →</button></>}
|
||||
</p>
|
||||
</td></tr>
|
||||
) : filtered.map(acc => (
|
||||
<tr key={acc.id} className="border-b border-[#252b3d] hover:bg-[#252b3d]/50 transition-colors">
|
||||
<td className="px-4 py-2.5 text-gray-300 font-medium">{acc.username}</td>
|
||||
<td className="px-4 py-2.5 text-gray-400">{acc.domain}</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 text-xs">
|
||||
<a href={`http://${acc.domain}`} target="_blank" rel="noopener" className="hover:text-blue-400 transition-colors">{acc.domain} ↗</a>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 text-xs">{acc.package_name}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 text-xs">{acc.disk_quota_mb === 0 ? '∞' : `${acc.disk_quota_mb} MB`}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-semibold ${
|
||||
acc.status === 'active'
|
||||
? 'bg-emerald-500/20 text-emerald-400'
|
||||
: 'bg-red-500/20 text-red-400'
|
||||
acc.status === 'active' ? 'bg-emerald-500/20 text-emerald-400' :
|
||||
acc.status === 'suspended' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
'bg-red-500/20 text-red-400'
|
||||
}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${acc.status === 'active' ? 'bg-emerald-400' : 'bg-red-400'}`} />
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${acc.status === 'active' ? 'bg-emerald-400' : acc.status === 'suspended' ? 'bg-yellow-400' : 'bg-red-400'}`} />
|
||||
{acc.status.charAt(0).toUpperCase() + acc.status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-600 text-xs">{new Date(acc.created_at).toLocaleDateString()}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex gap-1.5">
|
||||
<button className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:text-orange-400 text-gray-400 transition-colors">Edit</button>
|
||||
<button className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:text-red-400 text-gray-400 transition-colors">Suspend</button>
|
||||
{acc.status === 'suspended'
|
||||
? <button onClick={() => setConfirm({ action: 'unsuspend', username: acc.username })} className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:text-emerald-400 text-gray-400 transition-colors">Unsuspend</button>
|
||||
: <button onClick={() => setConfirm({ action: 'suspend', username: acc.username })} className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:text-yellow-400 text-gray-400 transition-colors">Suspend</button>
|
||||
}
|
||||
<button onClick={() => setConfirm({ action: 'terminate', username: acc.username })} className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:text-red-400 text-gray-400 transition-colors">Terminate</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -435,7 +534,7 @@ function ListAccounts({ onNav }: { onNav: (p: Page) => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Reusable form field — defined at module level to avoid remount bug ───── */
|
||||
/* ── Reusable form field ──────────────────────────────────────────────── */
|
||||
function FormField({ label, type = 'text', placeholder = '', value, onChange }: {
|
||||
label: string; type?: string; placeholder?: string
|
||||
value: string; onChange: (v: string) => void
|
||||
@@ -444,17 +543,14 @@ function FormField({ label, type = 'text', placeholder = '', value, onChange }:
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 mb-1.5">{label}</label>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
type={type} value={value} onChange={e => onChange(e.target.value)} placeholder={placeholder}
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm placeholder-gray-600 focus:outline-none focus:border-orange-500/60"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Create account form ─────────────────────────────────────────────────── */
|
||||
/* ── Create account form ──────────────────────────────────────────────── */
|
||||
function CreateAccount({ onNav }: { onNav: (p: Page) => void }) {
|
||||
const [form, setForm] = useState({
|
||||
username: '', domain: '', email: '', password: '',
|
||||
@@ -463,18 +559,13 @@ function CreateAccount({ onNav }: { onNav: (p: Page) => void }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
const set = (k: string, v: string | number) => setForm(f => ({ ...f, [k]: v }))
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true); setError(''); setSuccess('')
|
||||
try {
|
||||
const acc = await api.createAccount({
|
||||
...form,
|
||||
disk_quota_mb: Number(form.disk_quota_mb),
|
||||
bandwidth_mb: Number(form.bandwidth_mb),
|
||||
})
|
||||
const acc = await api.createAccount({ ...form, disk_quota_mb: Number(form.disk_quota_mb), bandwidth_mb: Number(form.bandwidth_mb) })
|
||||
setSuccess(`Account "${acc.username}" created for ${acc.domain}`)
|
||||
setTimeout(() => onNav('listAccounts'), 1500)
|
||||
} catch (e: any) {
|
||||
@@ -487,16 +578,13 @@ function CreateAccount({ onNav }: { onNav: (p: Page) => void }) {
|
||||
return (
|
||||
<div className="max-w-2xl space-y-5">
|
||||
<h1 className="text-lg font-black text-white">Create Hosting Account</h1>
|
||||
|
||||
{error && <div className="px-4 py-3 bg-red-950/60 border border-red-800/50 rounded-lg text-red-400 text-sm">⚠️ {error}</div>}
|
||||
{success && <div className="px-4 py-3 bg-emerald-950/60 border border-emerald-800/50 rounded-lg text-emerald-400 text-sm">✓ {success}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg divide-y divide-[#2a3044]">
|
||||
<div className="p-5 space-y-4">
|
||||
<h2 className="text-xs font-bold text-orange-400 uppercase tracking-widest">Domain Information</h2>
|
||||
<FormField label="Domain Name" value={form.domain} onChange={v => set('domain', v)} placeholder="example.com" />
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
<h2 className="text-xs font-bold text-orange-400 uppercase tracking-widest">Account Credentials</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -507,7 +595,6 @@ function CreateAccount({ onNav }: { onNav: (p: Page) => void }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
<h2 className="text-xs font-bold text-orange-400 uppercase tracking-widest">Resource Limits</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -515,27 +602,21 @@ function CreateAccount({ onNav }: { onNav: (p: Page) => void }) {
|
||||
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Package</label>
|
||||
<select value={form.package_name} onChange={e => set('package_name', e.target.value)}
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/60">
|
||||
<option>Default</option>
|
||||
<option>Starter</option>
|
||||
<option>Professional</option>
|
||||
<option>Enterprise</option>
|
||||
<option>Default</option><option>Starter</option><option>Professional</option><option>Enterprise</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Disk Quota (MB)</label>
|
||||
<input type="number" value={form.disk_quota_mb} onChange={e => set('disk_quota_mb', e.target.value)}
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/60"
|
||||
/>
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/60" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Bandwidth (MB/mo, 0=∞)</label>
|
||||
<input type="number" value={form.bandwidth_mb} onChange={e => set('bandwidth_mb', e.target.value)}
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/60"
|
||||
/>
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/60" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 flex gap-3">
|
||||
<button type="submit" disabled={loading}
|
||||
className="px-5 py-2 bg-orange-500 hover:bg-orange-600 disabled:opacity-60 text-white text-sm font-bold rounded transition-colors flex items-center gap-2">
|
||||
@@ -552,7 +633,253 @@ function CreateAccount({ onNav }: { onNav: (p: Page) => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Placeholder ─────────────────────────────────────────────────────────── */
|
||||
/* ── Zone Manager (list all zones) ───────────────────────────────────── */
|
||||
function ZoneManager({ onNav }: { onNav: (p: Page) => void; onSelectZone?: (z: string) => void }) {
|
||||
const [zones, setZones] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api.listZones().then(setZones).catch(console.error).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const filtered = zones.filter(z => z.toLowerCase().includes(search.toLowerCase()))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-black text-white">Zone Manager</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">PowerDNS zones — click a domain to view DNS records</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg p-3">
|
||||
<input type="text" value={search} onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Filter domains…"
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm placeholder-gray-600 focus:outline-none focus:border-orange-500/50" />
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-gray-600 text-sm">
|
||||
<span className="inline-block w-4 h-4 border-2 border-gray-700 border-t-orange-500 rounded-full animate-spin mr-2" />Loading zones…
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<div className="text-4xl mb-3">🌐</div>
|
||||
<p className="text-gray-500 text-sm">
|
||||
{search ? 'No zones match your search.' : 'No DNS zones found. Create an account to generate a zone.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#2a3044] bg-[#0f1520]">
|
||||
{['Domain', 'Type', 'Actions'].map(h => (
|
||||
<th key={h} className="text-left px-4 py-2.5 text-[10px] font-bold text-gray-500 uppercase tracking-wider">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(zone => (
|
||||
<tr key={zone} className="border-b border-[#252b3d] hover:bg-[#252b3d]/50 transition-colors">
|
||||
<td className="px-4 py-3 text-gray-300 font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-blue-400 text-xs">🌐</span>
|
||||
{zone}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-block px-2 py-0.5 bg-blue-500/10 border border-blue-500/20 text-blue-400 text-[11px] rounded font-semibold">Native</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.setItem('nixpanel_dns_zone', zone)
|
||||
onNav('dns')
|
||||
}}
|
||||
className="px-3 py-1 rounded text-xs bg-[#2a3044] hover:bg-blue-500/20 hover:text-blue-400 text-gray-400 transition-colors"
|
||||
>
|
||||
Edit Records →
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── DNS Records editor ───────────────────────────────────────────────── */
|
||||
const RTYPE_OPTIONS = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA']
|
||||
|
||||
function DnsRecords({ onNav }: { onNav: (p: Page) => void }) {
|
||||
const [zone, setZone] = useState(localStorage.getItem('nixpanel_dns_zone') ?? '')
|
||||
const [zones, setZones] = useState<string[]>([])
|
||||
const [records, setRecords] = useState<DnsRecord[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [newRec, setNewRec] = useState({ name: '@', rtype: 'A', ttl: 3600, content: '' })
|
||||
|
||||
useEffect(() => {
|
||||
api.listZones().then(zs => { setZones(zs); if (!zone && zs[0]) setZone(zs[0]) }).catch(console.error)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!zone) return
|
||||
setLoading(true)
|
||||
api.listZoneRecords(zone).then(setRecords).catch(console.error).finally(() => setLoading(false))
|
||||
}, [zone])
|
||||
|
||||
const flash = (m: string) => { setMsg(m); setTimeout(() => setMsg(''), 4000) }
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await api.addDnsRecord(zone, newRec)
|
||||
flash(`Added ${newRec.rtype} record`)
|
||||
setShowAdd(false)
|
||||
setNewRec({ name: '@', rtype: 'A', ttl: 3600, content: '' })
|
||||
const updated = await api.listZoneRecords(zone)
|
||||
setRecords(updated)
|
||||
} catch (e: any) {
|
||||
flash(`Error: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (rec: DnsRecord) => {
|
||||
if (!confirm(`Delete ${rec.rtype} record for "${rec.name}"?`)) return
|
||||
try {
|
||||
await api.deleteDnsRecord(zone, rec.name, rec.rtype)
|
||||
flash(`Deleted ${rec.rtype} record`)
|
||||
const updated = await api.listZoneRecords(zone)
|
||||
setRecords(updated)
|
||||
} catch (e: any) {
|
||||
flash(`Error: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-black text-white">DNS Records</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
<button onClick={() => onNav('domains')} className="hover:text-orange-400 transition-colors">Zone Manager</button>
|
||||
{zone && <> / <span className="text-gray-400">{zone}</span></>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{msg && <span className="text-xs text-orange-400 bg-orange-500/10 border border-orange-500/20 px-3 py-1.5 rounded-lg">{msg}</span>}
|
||||
<button onClick={() => setShowAdd(!showAdd)}
|
||||
className="px-3 py-1.5 bg-orange-500 hover:bg-orange-600 text-white text-sm font-semibold rounded transition-colors">
|
||||
+ Add Record
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zone selector */}
|
||||
{zones.length > 1 && (
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg p-3">
|
||||
<select value={zone} onChange={e => setZone(e.target.value)}
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/50">
|
||||
{zones.map(z => <option key={z} value={z}>{z}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add record form */}
|
||||
{showAdd && (
|
||||
<form onSubmit={handleAdd} className="bg-[#1a1f2e] border border-orange-500/30 rounded-lg p-5 space-y-4">
|
||||
<h3 className="text-sm font-bold text-orange-400">Add DNS Record</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Name</label>
|
||||
<input value={newRec.name} onChange={e => setNewRec(r => ({ ...r, name: e.target.value }))}
|
||||
placeholder="@ or subdomain"
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/60" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Type</label>
|
||||
<select value={newRec.rtype} onChange={e => setNewRec(r => ({ ...r, rtype: e.target.value }))}
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/60">
|
||||
{RTYPE_OPTIONS.map(t => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">TTL</label>
|
||||
<input type="number" value={newRec.ttl} onChange={e => setNewRec(r => ({ ...r, ttl: Number(e.target.value) }))}
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/60" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Content</label>
|
||||
<input value={newRec.content} onChange={e => setNewRec(r => ({ ...r, content: e.target.value }))}
|
||||
placeholder="1.2.3.4 or hostname"
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/60" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="submit" className="px-4 py-2 bg-orange-500 hover:bg-orange-600 text-white text-sm font-bold rounded transition-colors">Add Record</button>
|
||||
<button type="button" onClick={() => setShowAdd(false)} className="px-4 py-2 border border-[#2a3044] text-gray-400 hover:text-white text-sm rounded transition-colors">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Records table */}
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg overflow-hidden">
|
||||
{!zone ? (
|
||||
<div className="py-16 text-center text-gray-600 text-sm">
|
||||
Select a zone above or <button onClick={() => onNav('domains')} className="text-orange-400 hover:underline">go to Zone Manager</button>.
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="py-12 text-center text-gray-600 text-sm">
|
||||
<span className="inline-block w-4 h-4 border-2 border-gray-700 border-t-orange-500 rounded-full animate-spin mr-2" />Loading records for {zone}…
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#2a3044] bg-[#0f1520]">
|
||||
{['Name', 'Type', 'TTL', 'Content', 'Actions'].map(h => (
|
||||
<th key={h} className="text-left px-4 py-2.5 text-[10px] font-bold text-gray-500 uppercase tracking-wider">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-600 text-sm">No records found for {zone}</td></tr>
|
||||
) : records.map((rec, i) => (
|
||||
<tr key={i} className="border-b border-[#252b3d] hover:bg-[#252b3d]/50 transition-colors">
|
||||
<td className="px-4 py-2.5 text-gray-300 font-medium font-mono text-xs">{rec.name}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-[11px] font-bold ${
|
||||
rec.rtype === 'A' ? 'bg-blue-500/20 text-blue-400' :
|
||||
rec.rtype === 'MX' ? 'bg-purple-500/20 text-purple-400' :
|
||||
rec.rtype === 'TXT' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
rec.rtype === 'CNAME' ? 'bg-cyan-500/20 text-cyan-400' :
|
||||
'bg-gray-500/20 text-gray-400'
|
||||
}`}>{rec.rtype}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 text-xs">{rec.ttl}s</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 text-xs font-mono max-w-xs truncate">{rec.content}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<button onClick={() => handleDelete(rec)} className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:text-red-400 text-gray-400 transition-colors">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Placeholder ──────────────────────────────────────────────────────── */
|
||||
function Placeholder({ title, icon }: { title: string; icon: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-72 text-center">
|
||||
@@ -584,9 +911,9 @@ const PAGE_META: Record<Page, { title: string; icon: string }> = {
|
||||
logs: { title: 'System Logs', icon: '📜' },
|
||||
}
|
||||
|
||||
/* ── Admin panel shell ───────────────────────────────────────────────────── */
|
||||
/* ── Admin panel shell ────────────────────────────────────────────────── */
|
||||
function AdminPanel({ username, onLogout }: { username: string; onLogout: () => void }) {
|
||||
const [page, setPage] = useState<Page>('dashboard')
|
||||
const [page, setPage] = useState<Page>('dashboard')
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const meta = PAGE_META[page]
|
||||
|
||||
@@ -595,6 +922,9 @@ function AdminPanel({ username, onLogout }: { username: string; onLogout: () =>
|
||||
case 'dashboard': return <Dashboard onNav={setPage} />
|
||||
case 'listAccounts': return <ListAccounts onNav={setPage} />
|
||||
case 'createAccount': return <CreateAccount onNav={setPage} />
|
||||
case 'domains': return <ZoneManager onNav={setPage} />
|
||||
case 'dns': return <DnsRecords onNav={setPage} />
|
||||
case 'services': return <ServiceManager />
|
||||
default: return <Placeholder title={meta.title} icon={meta.icon} />
|
||||
}
|
||||
}
|
||||
@@ -602,7 +932,6 @@ function AdminPanel({ username, onLogout }: { username: string; onLogout: () =>
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#0f1520] text-white">
|
||||
<Sidebar current={page} onNav={setPage} onLogout={onLogout} collapsed={collapsed} onToggle={() => setCollapsed(c => !c)} username={username} />
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<header className="h-14 border-b border-[#2a3044] bg-[#1a1f2e] flex items-center px-6 justify-between gap-4 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
@@ -619,7 +948,6 @@ function AdminPanel({ username, onLogout }: { username: string; onLogout: () =>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{page !== 'dashboard' && (
|
||||
<div className="px-6 py-2 border-b border-[#2a3044] bg-[#0f1520] flex items-center gap-1.5 text-xs text-gray-600">
|
||||
<button onClick={() => setPage('dashboard')} className="hover:text-orange-400 transition-colors">Dashboard</button>
|
||||
@@ -627,11 +955,9 @@ function AdminPanel({ username, onLogout }: { username: string; onLogout: () =>
|
||||
<span className="text-gray-400">{meta.title}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="flex-1 p-6 overflow-auto">{renderPage()}</main>
|
||||
|
||||
<footer className="border-t border-[#2a3044] px-6 py-2 flex items-center justify-between text-[10px] text-gray-700">
|
||||
<span>NixPanel WHM v0.2.0-alpha · Ubuntu 24.04 LTS</span>
|
||||
<span>NixPanel WHM v0.3.0-alpha · Ubuntu 24.04 LTS</span>
|
||||
<span>© 2025 NixPanel</span>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -639,15 +965,14 @@ function AdminPanel({ username, onLogout }: { username: string; onLogout: () =>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Login ───────────────────────────────────────────────────────────────── */
|
||||
/* ── Login ────────────────────────────────────────────────────────────── */
|
||||
export default function App() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loggedInAs, setLoggedInAs] = useState<string | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Restore session from localStorage
|
||||
useEffect(() => {
|
||||
if (api.isLoggedIn()) {
|
||||
const stored = localStorage.getItem('nixpanel_username')
|
||||
@@ -708,7 +1033,7 @@ export default function App() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-700">NixPanel WHM v0.2.0-alpha · Secured by Fail2ban</p>
|
||||
<p className="text-[10px] text-gray-700">NixPanel WHM v0.3.0-alpha · Secured by Fail2ban</p>
|
||||
</div>
|
||||
|
||||
{/* Login form */}
|
||||
@@ -718,13 +1043,11 @@ export default function App() {
|
||||
<span className="text-3xl font-black text-white">Nix<span className="text-orange-400">Server</span></span>
|
||||
<p className="text-xs text-gray-600 mt-1">WHM Control Panel</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-xl shadow-2xl overflow-hidden">
|
||||
<div className="bg-orange-500 px-6 py-4">
|
||||
<h1 className="text-white font-bold text-base">Administrator Login</h1>
|
||||
<p className="text-orange-200 text-xs mt-0.5">NixServer WHM · Root Access</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="px-4 py-3 bg-red-950/60 border border-red-800/50 rounded-lg text-red-400 text-sm flex items-center gap-2">
|
||||
@@ -734,26 +1057,21 @@ export default function App() {
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1.5">Username</label>
|
||||
<input type="text" value={username} onChange={e => setUsername(e.target.value)} required autoFocus autoComplete="username" placeholder="admin"
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-700 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-all"
|
||||
/>
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-700 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-all" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1.5">Password</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="current-password" placeholder="••••••••••••"
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-700 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-all"
|
||||
/>
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-700 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-all" />
|
||||
</div>
|
||||
<button type="submit" disabled={loading}
|
||||
className="w-full py-2.5 rounded-lg bg-orange-500 hover:bg-orange-600 active:bg-orange-700 disabled:opacity-60 text-white font-bold text-sm transition-all shadow-lg shadow-orange-900/40 flex items-center justify-center gap-2">
|
||||
{loading ? (
|
||||
<><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />Authenticating…</>
|
||||
) : 'Sign in to WHM'}
|
||||
{loading ? (<><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />Authenticating…</>) : 'Sign in to WHM'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="border-t border-[#2a3044] px-6 py-3 bg-[#0f1520] flex items-center justify-between text-[10px] text-gray-700">
|
||||
<span>Secured by Fail2ban</span>
|
||||
<span>NixPanel v0.2.0-alpha</span>
|
||||
<span>NixPanel v0.3.0-alpha</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,6 +124,9 @@ export interface Account {
|
||||
export async function getAccounts(): Promise<Account[]> {
|
||||
return apiFetch('/accounts')
|
||||
}
|
||||
export async function getAccount(username: string): Promise<Account> {
|
||||
return apiFetch(`/accounts/${username}`)
|
||||
}
|
||||
export async function createAccount(data: {
|
||||
username: string
|
||||
domain: string
|
||||
@@ -135,3 +138,65 @@ export async function createAccount(data: {
|
||||
}): Promise<Account> {
|
||||
return apiFetch('/accounts', { method: 'POST', body: JSON.stringify(data) })
|
||||
}
|
||||
export async function suspendAccount(username: string): Promise<void> {
|
||||
await apiFetch(`/accounts/${username}/suspend`, { method: 'POST' })
|
||||
}
|
||||
export async function unsuspendAccount(username: string): Promise<void> {
|
||||
await apiFetch(`/accounts/${username}/unsuspend`, { method: 'POST' })
|
||||
}
|
||||
export async function terminateAccount(username: string): Promise<void> {
|
||||
await apiFetch(`/accounts/${username}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/* ── DNS ─────────────────────────────────────────────────────────────── */
|
||||
export interface DnsRecord {
|
||||
name: string
|
||||
ttl: number
|
||||
rtype: string
|
||||
content: string
|
||||
}
|
||||
export async function listZones(): Promise<string[]> {
|
||||
return apiFetch('/dns')
|
||||
}
|
||||
export async function listZoneRecords(domain: string): Promise<DnsRecord[]> {
|
||||
return apiFetch(`/dns/${domain}`)
|
||||
}
|
||||
export async function addDnsRecord(
|
||||
domain: string,
|
||||
record: { name: string; rtype: string; ttl?: number; content: string }
|
||||
): Promise<void> {
|
||||
await apiFetch(`/dns/${domain}`, { method: 'POST', body: JSON.stringify(record) })
|
||||
}
|
||||
export async function deleteDnsRecord(
|
||||
domain: string,
|
||||
name: string,
|
||||
rtype: string
|
||||
): Promise<void> {
|
||||
await apiFetch(`/dns/${domain}`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ name, rtype }),
|
||||
})
|
||||
}
|
||||
|
||||
/* ── Email accounts ──────────────────────────────────────────────────── */
|
||||
export interface EmailAccount {
|
||||
id: number
|
||||
account_id: number
|
||||
address: string
|
||||
quota_mb: number
|
||||
created_at: string
|
||||
}
|
||||
export async function listEmailAccounts(username: string): Promise<EmailAccount[]> {
|
||||
return apiFetch(`/email/${username}`)
|
||||
}
|
||||
export async function createEmailAccount(data: {
|
||||
username: string
|
||||
domain: string
|
||||
password: string
|
||||
quota_mb?: number
|
||||
}): Promise<EmailAccount> {
|
||||
return apiFetch('/email', { method: 'POST', body: JSON.stringify(data) })
|
||||
}
|
||||
export async function deleteEmailAccount(id: number): Promise<void> {
|
||||
await apiFetch(`/email/id/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user