From 079170b43a8fd0e682ab18327e47a8b0bdecea32 Mon Sep 17 00:00:00 2001 From: NixReaper Date: Sun, 26 Apr 2026 05:23:55 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20v0.3.0-alpha=20=E2=80=94=20DNS=20manage?= =?UTF-8?q?ment,=20email=20accounts,=20suspend/terminate,=20password=20cha?= =?UTF-8?q?nge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 59 ++- nixclient/src/App.tsx | 641 +++++++++++++++++----------- nixclient/src/api.ts | 47 +- nixpanel-api/Cargo.toml | 2 +- nixpanel-api/src/main.rs | 33 +- nixpanel-api/src/routes/accounts.rs | 119 +++++- nixpanel-api/src/routes/dns.rs | 163 +++++++ nixpanel-api/src/routes/email.rs | 145 +++++++ nixpanel-api/src/routes/me.rs | 72 ++++ nixpanel-api/src/routes/mod.rs | 3 + nixserver/src/App.tsx | 632 ++++++++++++++++++++------- nixserver/src/api.ts | 65 +++ 12 files changed, 1547 insertions(+), 434 deletions(-) create mode 100644 nixpanel-api/src/routes/dns.rs create mode 100644 nixpanel-api/src/routes/email.rs create mode 100644 nixpanel-api/src/routes/me.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ce65b0..9d62175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/nixclient/src/App.tsx b/nixclient/src/App.tsx index 75a2f9e..0825a61 100644 --- a/nixclient/src/App.tsx +++ b/nixclient/src/App.tsx @@ -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 = { - 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 = { + 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 ( ) } -/* ── 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 (
- {/* Search bar */}
🔍 - 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" - /> + 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 && ( )}
- {/* Search results */} {filtered ? (

{filtered.length} result{filtered.length !== 1 ? 's' : ''} for "{search}"

@@ -207,26 +174,18 @@ function Home({ onNav }: { onNav: (p: Page) => void }) { {filtered.map(f => { const c = COLOR_MAP[f.groupColor] return ( - ) })}
- {filtered.length === 0 && ( -
No features found for "{search}"
- )} + {filtered.length === 0 &&
No features found for "{search}"
}
) : ( - /* Normal grouped view */
{FEATURE_GROUPS.map(group => { const c = COLOR_MAP[group.color] @@ -238,14 +197,9 @@ function Home({ onNav }: { onNav: (p: Page) => void }) {
{group.features.map(f => ( - @@ -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 ( +
+
Loading domain info…
+
+ ) + } return ( -
-
{icon}
-

{title}

-

- This module is under active development and will be available in an upcoming release. -

-
- Coming Soon +
+

Domains

+
+
+

Primary Domain

+
+
+
+

{account.domain}

+

Main domain · {account.package_name} package

+
+
+ + + Active + + + Visit Site ↗ + +
+
+
+ {[ + { 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 => ( +
+

{i.label}

+

{i.value}

+
+ ))} +
+
+ +
+
+

Addon Domains

+
Coming Soon
+
+

Addon domain management will be available in the next release.

) } -/* ── Page meta ──────────────────────────────────────────────────────────── */ -const PAGE_META: Record = { - 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([]) + 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 ( +
+
+
+

Email Accounts

+

{domain ? `Manage mailboxes for ${domain}` : 'Loading…'}

+
+
+ {msg && {msg}} + +
+
+ + {/* Create form */} + {showAdd && ( +
+

New Email Account

+
+
+ +
+ 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" /> + @{domain || '…'} +
+
+
+ + 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" /> +
+
+ + 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" /> +
+
+
+ + +
+
+ )} + + {/* Email list */} +
+ {loading ? ( +
+ Loading… +
+ ) : emails.length === 0 ? ( +
+
✉️
+

No email accounts yet.

+ +
+ ) : ( + + + + {['Address', 'Quota', 'Created', 'Actions'].map(h => ( + + ))} + + + + {emails.map(em => ( + + + + + + + ))} + +
{h}
{em.address}{em.quota_mb} MB{new Date(em.created_at).toLocaleDateString()} + +
+ )} +
+
+ ) } -/* ── Client panel shell ─────────────────────────────────────────────────── */ -function ClientPanel({ onLogout }: { onLogout: () => void }) { - const [page, setPage] = useState('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 ( +
+
+

Change Password

+

Update your NixClient account password

+
+ {error &&
⚠️ {error}
} + {success &&
✓ {success}
} +
+
+ {[ + { 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 => ( +
+ + 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" /> +
+ ))} +
+
+ +
+
+
+ ) +} + +/* ── Placeholder ──────────────────────────────────────────────────────── */ +function Placeholder({ title, icon }: { title: string; icon: string }) { + return ( +
+
{icon}
+

{title}

+

This module is under active development and will be available in an upcoming release.

+
Coming Soon
+
+ ) +} + +const PAGE_META: Record = { + 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('home') + const [account, setAccount] = useState(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 - return + switch (page) { + case 'home': return + case 'domains': return + case 'emailAccounts': return + case 'email': return + case 'passwords': return + default: return + } } return ( @@ -321,55 +537,42 @@ function ClientPanel({ onLogout }: { onLogout: () => void }) { {/* Top nav */}
- {/* Logo */}
N
- - NixClient - + NixClient
- {/* Center nav */} - {/* Right: account / logout */}
- {domain.charAt(0).toUpperCase()} + {(account?.domain ?? username).charAt(0).toUpperCase()} - {domain} + {account?.domain ?? username}
-
- {/* Breadcrumb */} {page !== 'home' && (
@@ -379,34 +582,26 @@ function ClientPanel({ onLogout }: { onLogout: () => void }) { )}
- {/* Body */}
- {/* Sidebar */} - - - {/* Main content */} -
- {renderPage()} -
+ +
{renderPage()}
- {/* Footer */}
- NixPanel Client v0.2.0-alpha · {domain} · Powered by NixServer + NixPanel Client v0.3.0-alpha · {account?.domain ?? username} · Powered by NixServer
) } -/* ── 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(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 + if (loggedInAs) return return (
- {/* Background radial */}
-
- {/* Logo */}
N
@@ -465,9 +652,7 @@ export default function App() {
- {/* Card */}
- {/* Header band */}

Sign in to your account

NixClient Hosting Control Panel

@@ -479,52 +664,24 @@ export default function App() { ⚠️ {error}
)} -
- 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" - /> + 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" />
-
- 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" - /> + 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" />
- -
- NixPanel Client v0.2.0-alpha · Manage your hosting account + NixPanel Client v0.3.0-alpha · Manage your hosting account
diff --git a/nixclient/src/api.ts b/nixclient/src/api.ts index beac6a2..1e3088f 100644 --- a/nixclient/src/api.ts +++ b/nixclient/src/api.ts @@ -37,6 +37,7 @@ async function apiFetch(path: string, options: RequestInit = {}): Promise 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 { +export async function getMyAccount(username: string): Promise { try { - // Returns the first account matching the logged-in user's username - const accounts = await apiFetch('/accounts') - return accounts[0] ?? null + return await apiFetch(`/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 { + return apiFetch(`/email/${username}`) +} +export async function createEmailAccount(data: { + username: string + domain: string + password: string + quota_mb?: number +}): Promise { + return apiFetch('/email', { method: 'POST', body: JSON.stringify(data) }) +} +export async function deleteEmailAccount(id: number): Promise { + await apiFetch(`/email/id/${id}`, { method: 'DELETE' }) +} + +/* ── Password change ─────────────────────────────────────────────────── */ +export async function changePassword( + currentPassword: string, + newPassword: string +): Promise { + await apiFetch('/me/password', { + method: 'POST', + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword, + }), + }) +} diff --git a/nixpanel-api/Cargo.toml b/nixpanel-api/Cargo.toml index 6648191..bd833d0 100644 --- a/nixpanel-api/Cargo.toml +++ b/nixpanel-api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nixpanel" -version = "0.2.0-alpha" +version = "0.3.0-alpha" edition = "2021" [[bin]] diff --git a/nixpanel-api/src/main.rs b/nixpanel-api/src/main.rs index 07a8717..d708c19 100644 --- a/nixpanel-api/src/main.rs +++ b/nixpanel-api/src/main.rs @@ -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) } diff --git a/nixpanel-api/src/routes/accounts.rs b/nixpanel-api/src/routes/accounts.rs index 9d24f4c..fe684a7 100644 --- a/nixpanel-api/src/routes/accounts.rs +++ b/nixpanel-api/src/routes/accounts.rs @@ -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, + Path(username): Path, +) -> Result, 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, + Path(username): Path, +) -> Result, 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, + Path(username): Path, +) -> Result, AppError> { + // Fetch domain so we can deprovision DNS + let domain: Option = 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, + Path(username): Path, +) -> Result, 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)) +} diff --git a/nixpanel-api/src/routes/dns.rs b/nixpanel-api/src/routes/dns.rs new file mode 100644 index 0000000..fde682a --- /dev/null +++ b/nixpanel-api/src/routes/dns.rs @@ -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, + pub content: String, +} + +#[derive(Deserialize)] +pub struct DeleteRecordRequest { + pub name: String, + pub rtype: String, +} + +/* ── Helpers ─────────────────────────────────────────────────────────── */ + +/// Parse `pdnsutil list-zone ` tab-separated output +fn parse_zone(domain: &str, output: &str) -> Vec { + 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 { + 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, +) -> Result>, AppError> { + let out = Command::new("pdnsutil") + .args(["list-all-zones"]) + .output() + .map_err(|e| AppError::Internal(anyhow::anyhow!("pdnsutil: {}", e)))?; + + let zones: Vec = 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, + Path(domain): Path, +) -> Result>, 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, + Path(domain): Path, + Json(body): Json, +) -> Result, 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, + Path(domain): Path, + Json(body): Json, +) -> Result, 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 }))) +} diff --git a/nixpanel-api/src/routes/email.rs b/nixpanel-api/src/routes/email.rs new file mode 100644 index 0000000..3c909a4 --- /dev/null +++ b/nixpanel-api/src/routes/email.rs @@ -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, +} + +/* ── 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, + Path(account_username): Path, +) -> Result>, 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, + Json(body): Json, +) -> Result, 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, + Path(id): Path, +) -> Result, AppError> { + sqlx::query("DELETE FROM email_accounts WHERE id = ?") + .bind(id) + .execute(&state.db) + .await?; + + Ok(Json(serde_json::json!({ "ok": true }))) +} diff --git a/nixpanel-api/src/routes/me.rs b/nixpanel-api/src/routes/me.rs new file mode 100644 index 0000000..d598dbd --- /dev/null +++ b/nixpanel-api/src/routes/me.rs @@ -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, + Json(body): Json, +) -> Result, 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 = 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 }))) +} diff --git a/nixpanel-api/src/routes/mod.rs b/nixpanel-api/src/routes/mod.rs index 072e13d..a5e9e6c 100644 --- a/nixpanel-api/src/routes/mod.rs +++ b/nixpanel-api/src/routes/mod.rs @@ -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; diff --git a/nixserver/src/App.tsx b/nixserver/src/App.tsx index f00f0e5..7200dbd 100644 --- a/nixserver/src/App.tsx +++ b/nixserver/src/App.tsx @@ -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 (
{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 (
@@ -177,22 +174,17 @@ function ServiceTable({ services, loading, onAction }: { {['Service', 'Status', 'PID', 'Uptime', 'Actions'].map(h => ( - {h} + {h} ))} {loading ? ( - - - - Loading services… - - + + Loading services… + ) : services.length === 0 ? ( - - No service data available - + No service data ) : services.map(svc => ( {svc.display} @@ -202,12 +194,12 @@ function ServiceTable({ services, loading, onAction }: { {svc.status.charAt(0).toUpperCase() + svc.status.slice(1)} - {svc.pid ?? '—'} - {svc.uptime ?? '—'} + {svc.pid ?? '—'} + {svc.uptime ?? '—'}
- - + +
@@ -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(null) - const [services, setServices] = useState([]) + const [dashData, setDashData] = useState(null) + const [services, setServices] = useState([]) 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 }) {

Server Overview

- {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'}

{actionMsg && ( - - {actionMsg} - + {actionMsg} )} @@ -334,10 +318,8 @@ function Dashboard({ onNav }: { onNav: (p: Page) => void }) {
- -
@@ -346,30 +328,142 @@ function Dashboard({ onNav }: { onNav: (p: Page) => void }) { ) } -/* ── List accounts ───────────────────────────────────────────────────────── */ -function ListAccounts({ onNav }: { onNav: (p: Page) => void }) { - const [accounts, setAccounts] = useState([]) - const [loading, setLoading] = useState(true) - const [search, setSearch] = useState('') +/* ── Service Manager (dedicated page) ────────────────────────────────── */ +function ServiceManager() { + const [services, setServices] = useState([]) + 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 (
+
+
+

Service Manager

+

Control all system services from one place

+
+
+ {actionMsg && ( + {actionMsg} + )} + +
+
+ + {/* Summary pills */} +
+ + + {running} running + + {stopped > 0 && ( + + + {stopped} stopped + + )} +
+ + +
+ ) +} + +/* ── List accounts ────────────────────────────────────────────────────── */ +function ListAccounts({ onNav }: { onNav: (p: Page) => void }) { + const [accounts, setAccounts] = useState([]) + 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 ( +
+ {/* Confirm dialog */} + {confirm && ( +
+
+

+ {confirm.action === 'terminate' ? '⚠️ Terminate Account' : confirm.action === 'suspend' ? 'Suspend Account' : 'Unsuspend Account'} +

+

+ {confirm.action === 'terminate' + ? <>This will permanently delete {confirm.username} and remove all vhost, DNS, and FPM config. This cannot be undone. + : confirm.action === 'suspend' + ? <>Suspend hosting account {confirm.username}? Their site will go offline. + : <>Restore access for {confirm.username}? + } +

+
+ + +
+
+
+ )} +

Hosting Accounts

- +
+ {actionMsg && {actionMsg}} + +
@@ -386,7 +480,7 @@ function ListAccounts({ onNav }: { onNav: (p: Page) => void }) { - {['Username', 'Domain', 'Package', 'Disk Quota', 'Status', 'Created', 'Actions'].map(h => ( + {['Username', 'Domain', 'Package', 'Disk', 'Status', 'Created', 'Actions'].map(h => ( ))} @@ -400,30 +494,35 @@ function ListAccounts({ onNav }: { onNav: (p: Page) => void }) { ) : filtered.map(acc => ( - + @@ -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 }:
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" />
) } -/* ── 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 (

Create Hosting Account

- {error &&
⚠️ {error}
} {success &&
✓ {success}
} -

Domain Information

set('domain', v)} placeholder="example.com" />
-

Account Credentials

@@ -507,7 +595,6 @@ function CreateAccount({ onNav }: { onNav: (p: Page) => void }) {
-

Resource Limits

@@ -515,27 +602,21 @@ function CreateAccount({ onNav }: { onNav: (p: Page) => void }) {
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" />
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" />
-
{h}
👤

- {search ? 'No accounts match your search.' : <>No accounts yet. } + {search ? 'No accounts match your search.' : <>}

{acc.username}{acc.domain} + {acc.domain} ↗ + {acc.package_name} {acc.disk_quota_mb === 0 ? '∞' : `${acc.disk_quota_mb} MB`} - + {acc.status.charAt(0).toUpperCase() + acc.status.slice(1)} {new Date(acc.created_at).toLocaleDateString()}
- - + {acc.status === 'suspended' + ? + : + } +
+ + + {['Domain', 'Type', 'Actions'].map(h => ( + + ))} + + + + {filtered.map(zone => ( + + + + + + ))} + +
{h}
+
+ 🌐 + {zone} +
+
+ Native + + +
+ )} +
+
+ ) +} + +/* ── 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([]) + const [records, setRecords] = useState([]) + 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 ( +
+
+
+

DNS Records

+

+ + {zone && <> / {zone}} +

+
+
+ {msg && {msg}} + +
+
+ + {/* Zone selector */} + {zones.length > 1 && ( +
+ +
+ )} + + {/* Add record form */} + {showAdd && ( + +

Add DNS Record

+
+
+ + 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" /> +
+
+ + +
+
+ + 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" /> +
+
+ + 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" /> +
+
+
+ + +
+ + )} + + {/* Records table */} +
+ {!zone ? ( +
+ Select a zone above or . +
+ ) : loading ? ( +
+ Loading records for {zone}… +
+ ) : ( + + + + {['Name', 'Type', 'TTL', 'Content', 'Actions'].map(h => ( + + ))} + + + + {records.length === 0 ? ( + + ) : records.map((rec, i) => ( + + + + + + + + ))} + +
{h}
No records found for {zone}
{rec.name} + {rec.rtype} + {rec.ttl}s{rec.content} + +
+ )} +
+
+ ) +} + +/* ── Placeholder ──────────────────────────────────────────────────────── */ function Placeholder({ title, icon }: { title: string; icon: string }) { return (
@@ -584,9 +911,9 @@ const PAGE_META: Record = { logs: { title: 'System Logs', icon: '📜' }, } -/* ── Admin panel shell ───────────────────────────────────────────────────── */ +/* ── Admin panel shell ────────────────────────────────────────────────── */ function AdminPanel({ username, onLogout }: { username: string; onLogout: () => void }) { - const [page, setPage] = useState('dashboard') + const [page, setPage] = useState('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 case 'listAccounts': return case 'createAccount': return + case 'domains': return + case 'dns': return + case 'services': return default: return } } @@ -602,7 +932,6 @@ function AdminPanel({ username, onLogout }: { username: string; onLogout: () => return (
setCollapsed(c => !c)} username={username} /> -
@@ -619,7 +948,6 @@ function AdminPanel({ username, onLogout }: { username: string; onLogout: () =>
- {page !== 'dashboard' && (
@@ -627,11 +955,9 @@ function AdminPanel({ username, onLogout }: { username: string; onLogout: () => {meta.title}
)} -
{renderPage()}
-
- NixPanel WHM v0.2.0-alpha · Ubuntu 24.04 LTS + NixPanel WHM v0.3.0-alpha · Ubuntu 24.04 LTS © 2025 NixPanel
@@ -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(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() { ))}
-

NixPanel WHM v0.2.0-alpha · Secured by Fail2ban

+

NixPanel WHM v0.3.0-alpha · Secured by Fail2ban

{/* Login form */} @@ -718,13 +1043,11 @@ export default function App() { NixServer

WHM Control Panel

-

Administrator Login

NixServer WHM · Root Access

-
{error && (
@@ -734,26 +1057,21 @@ export default function App() {
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" />
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" />
-
Secured by Fail2ban - NixPanel v0.2.0-alpha + NixPanel v0.3.0-alpha
diff --git a/nixserver/src/api.ts b/nixserver/src/api.ts index 54ec9e7..c5bf9c1 100644 --- a/nixserver/src/api.ts +++ b/nixserver/src/api.ts @@ -124,6 +124,9 @@ export interface Account { export async function getAccounts(): Promise { return apiFetch('/accounts') } +export async function getAccount(username: string): Promise { + return apiFetch(`/accounts/${username}`) +} export async function createAccount(data: { username: string domain: string @@ -135,3 +138,65 @@ export async function createAccount(data: { }): Promise { return apiFetch('/accounts', { method: 'POST', body: JSON.stringify(data) }) } +export async function suspendAccount(username: string): Promise { + await apiFetch(`/accounts/${username}/suspend`, { method: 'POST' }) +} +export async function unsuspendAccount(username: string): Promise { + await apiFetch(`/accounts/${username}/unsuspend`, { method: 'POST' }) +} +export async function terminateAccount(username: string): Promise { + await apiFetch(`/accounts/${username}`, { method: 'DELETE' }) +} + +/* ── DNS ─────────────────────────────────────────────────────────────── */ +export interface DnsRecord { + name: string + ttl: number + rtype: string + content: string +} +export async function listZones(): Promise { + return apiFetch('/dns') +} +export async function listZoneRecords(domain: string): Promise { + return apiFetch(`/dns/${domain}`) +} +export async function addDnsRecord( + domain: string, + record: { name: string; rtype: string; ttl?: number; content: string } +): Promise { + await apiFetch(`/dns/${domain}`, { method: 'POST', body: JSON.stringify(record) }) +} +export async function deleteDnsRecord( + domain: string, + name: string, + rtype: string +): Promise { + 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 { + return apiFetch(`/email/${username}`) +} +export async function createEmailAccount(data: { + username: string + domain: string + password: string + quota_mb?: number +}): Promise { + return apiFetch('/email', { method: 'POST', body: JSON.stringify(data) }) +} +export async function deleteEmailAccount(id: number): Promise { + await apiFetch(`/email/id/${id}`, { method: 'DELETE' }) +}