feat: v0.4.0-alpha — MySQL/MariaDB database manager
Backend: - routes/databases.rs: full CRUD for MariaDB databases + users - mysql -u root via stdin pipe (unix socket auth, API runs as root) - cPanel-style naming: username_dbname / username_dbuser - Identifier validation (alphanumeric + underscore only) - Password escaping for mysql string literals - Safety guard: refuses to drop DBs without account prefix - GET /api/databases (admin all), GET /api/databases/:username (per-account) - POST /api/databases (create DB + user + GRANT ALL + nixpanel record) - DELETE /api/databases/name/:db_name (drop DB + user + nixpanel record) - routes/mod.rs: add databases module - main.rs: register database routes NixServer: - DatabaseManager page: table across all accounts, create form with account selector + live name preview, drop with confirmation modal NixClient: - MySQLDatabases page: per-account view, create form with password confirm + connection-details card, drop with confirmation modal - api.ts: listMyDatabases, createDatabase, deleteDatabase CHANGELOG.md: v0.4.0-alpha entry Cargo.toml: version bump 0.3.0-alpha -> 0.4.0-alpha
This commit is contained in:
+24
-3
@@ -11,12 +11,32 @@ Versions follow [Semantic Versioning](https://semver.org/).
|
|||||||
### Planned
|
### Planned
|
||||||
- SSL certificate management (Let's Encrypt via Certbot)
|
- SSL certificate management (Let's Encrypt via Certbot)
|
||||||
- File manager
|
- File manager
|
||||||
- MySQL/MariaDB database manager (per-account)
|
- FTP account management (vsftpd)
|
||||||
- WordPress one-click installer
|
- WordPress one-click installer
|
||||||
- Backup / restore system
|
- Backup / restore system
|
||||||
- Firewall rule editor (CSF/iptables)
|
- Firewall rule editor (CSF/iptables)
|
||||||
- Multi-server support (Agency plan)
|
- Multi-server support (Agency plan)
|
||||||
- NixClient pages: databases, FTP, subdomains, redirects, PHP version selector, stats
|
- NixClient pages: FTP, subdomains, redirects, PHP version selector, stats
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.4.0-alpha] — 2026-04-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **MySQL / MariaDB database manager**
|
||||||
|
- `GET /api/databases` — list all databases across all accounts (admin)
|
||||||
|
- `GET /api/databases/:username` — list databases for one hosting account
|
||||||
|
- `POST /api/databases` — create database + dedicated MariaDB user with GRANT ALL
|
||||||
|
- `DELETE /api/databases/name/:db_name` — drop database + user, cascade nixpanel record
|
||||||
|
- cPanel-style namespacing: `username_dbname` / `username_dbuser` (max 64/32 chars)
|
||||||
|
- Identifier validation (alphanumeric + underscore only)
|
||||||
|
- Live connection-details preview in the create form (database, username, host, port)
|
||||||
|
- Safety check: refuses to drop databases without the account prefix separator
|
||||||
|
- **NixServer — Databases page**: admin view across all accounts, create form with live preview, drop with confirmation modal
|
||||||
|
- **NixClient — MySQL Databases page**: per-account view, create with password confirm + connection-info card, drop with confirmation
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- NixServer/NixClient footer version: `v0.3.0-alpha` → `v0.4.0-alpha`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -130,7 +150,8 @@ Versions follow [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[Unreleased]: https://github.com/NixReaper/nixpanel/compare/v0.3.0-alpha...HEAD
|
[Unreleased]: https://github.com/NixReaper/nixpanel/compare/v0.4.0-alpha...HEAD
|
||||||
|
[0.4.0-alpha]: https://github.com/NixReaper/nixpanel/compare/v0.3.0-alpha...v0.4.0-alpha
|
||||||
[0.3.0-alpha]: https://github.com/NixReaper/nixpanel/compare/v0.2.0-alpha...v0.3.0-alpha
|
[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.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
|
[0.1.0-alpha]: https://github.com/NixReaper/nixpanel/releases/tag/v0.1.0-alpha
|
||||||
|
|||||||
+205
-1
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import * as api from './api'
|
import * as api from './api'
|
||||||
import type { AccountInfo, EmailAccount } from './api'
|
import type { AccountInfo, EmailAccount, Database } from './api'
|
||||||
|
|
||||||
/* ── Types ───────────────────────────────────────────────────────────── */
|
/* ── Types ───────────────────────────────────────────────────────────── */
|
||||||
type Page =
|
type Page =
|
||||||
@@ -413,6 +413,208 @@ function EmailAccounts({ account }: { account: AccountInfo | null }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── MySQL Databases page ────────────────────────────────────────────── */
|
||||||
|
function MySQLDatabases({ account }: { account: AccountInfo | null }) {
|
||||||
|
const [dbs, setDbs] = useState<Database[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [confirm, setConfirm] = useState<string | null>(null)
|
||||||
|
const [form, setForm] = useState({ db_suffix: '', db_user_suffix: '', db_password: '', db_password2: '' })
|
||||||
|
|
||||||
|
const username = account?.username ?? localStorage.getItem('nixclient_username') ?? ''
|
||||||
|
const flash = (m: string) => { setMsg(m); setTimeout(() => setMsg(''), 5000) }
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
if (!username) return
|
||||||
|
setLoading(true)
|
||||||
|
api.listMyDatabases(username).then(setDbs).catch(console.error).finally(() => setLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { refresh() }, [username])
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (form.db_password !== form.db_password2) { flash('Passwords do not match'); return }
|
||||||
|
if (form.db_password.length < 6) { flash('Password must be at least 6 characters'); return }
|
||||||
|
try {
|
||||||
|
const db = await api.createDatabase({
|
||||||
|
username,
|
||||||
|
db_suffix: form.db_suffix,
|
||||||
|
db_user_suffix: form.db_user_suffix || undefined,
|
||||||
|
db_password: form.db_password,
|
||||||
|
})
|
||||||
|
flash(`Created ${db.db_name}`)
|
||||||
|
setShowAdd(false)
|
||||||
|
setForm({ db_suffix: '', db_user_suffix: '', db_password: '', db_password2: '' })
|
||||||
|
refresh()
|
||||||
|
} catch (e: any) {
|
||||||
|
flash(`Error: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (dbName: string) => {
|
||||||
|
try {
|
||||||
|
await api.deleteDatabase(dbName)
|
||||||
|
flash(`Dropped ${dbName}`)
|
||||||
|
refresh()
|
||||||
|
} catch (e: any) {
|
||||||
|
flash(`Error: ${e.message}`)
|
||||||
|
}
|
||||||
|
setConfirm(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewDb = form.db_suffix ? `${username}_${form.db_suffix}` : '…'
|
||||||
|
const previewUser = `${username}_${form.db_user_suffix || form.db_suffix || '…'}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Confirm drop modal */}
|
||||||
|
{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">⚠️ Drop Database</h3>
|
||||||
|
<p className="text-sm text-gray-400 mb-5">
|
||||||
|
Permanently drop <span className="text-white font-semibold">{confirm}</span>? All data will be lost and cannot be recovered.
|
||||||
|
</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={() => handleDelete(confirm)} className="px-4 py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-semibold transition-colors">Drop Database</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-black text-white">MySQL Databases</h1>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">{account?.domain ?? username}</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 Database
|
||||||
|
</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 MySQL Database</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Database Name</label>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-gray-500 text-xs shrink-0 font-mono">{username}_</span>
|
||||||
|
<input value={form.db_suffix} onChange={e => setForm(f => ({ ...f, db_suffix: e.target.value }))} required
|
||||||
|
placeholder="myapp"
|
||||||
|
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" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">DB Username <span className="text-gray-600 font-normal normal-case">(optional, defaults to DB name)</span></label>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-gray-500 text-xs shrink-0 font-mono">{username}_</span>
|
||||||
|
<input value={form.db_user_suffix} onChange={e => setForm(f => ({ ...f, db_user_suffix: e.target.value }))}
|
||||||
|
placeholder={form.db_suffix || 'myapp'}
|
||||||
|
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" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Password</label>
|
||||||
|
<input type="password" value={form.db_password} onChange={e => setForm(f => ({ ...f, db_password: e.target.value }))} required
|
||||||
|
placeholder="Min 6 characters"
|
||||||
|
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">Confirm Password</label>
|
||||||
|
<input type="password" value={form.db_password2} onChange={e => setForm(f => ({ ...f, db_password2: e.target.value }))} required
|
||||||
|
placeholder="Repeat password"
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Connection info preview */}
|
||||||
|
{form.db_suffix && (
|
||||||
|
<div className="bg-[#0f1520] border border-[#2a3044] rounded-lg p-3 space-y-1">
|
||||||
|
<p className="text-[10px] font-bold text-gray-600 uppercase mb-2">Connection Details (after creation)</p>
|
||||||
|
{[
|
||||||
|
{ label: 'Database', value: previewDb, color: 'text-emerald-400' },
|
||||||
|
{ label: 'Username', value: previewUser, color: 'text-blue-400' },
|
||||||
|
{ label: 'Host', value: 'localhost', color: 'text-gray-400' },
|
||||||
|
{ label: 'Port', value: '3306', color: 'text-gray-400' },
|
||||||
|
].map(r => (
|
||||||
|
<div key={r.label} className="flex gap-3 text-xs font-mono">
|
||||||
|
<span className="text-gray-600 w-20">{r.label}:</span>
|
||||||
|
<span className={r.color}>{r.value}</span>
|
||||||
|
</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 Database</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Database 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>
|
||||||
|
) : dbs.length === 0 ? (
|
||||||
|
<div className="py-16 text-center">
|
||||||
|
<div className="text-4xl mb-3">🗄️</div>
|
||||||
|
<p className="text-gray-500 text-sm">No databases yet.</p>
|
||||||
|
<button onClick={() => setShowAdd(true)} className="mt-3 text-blue-400 hover:underline text-sm">Create your first database →</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[#2a3044] bg-[#0f1520]">
|
||||||
|
{['Database', 'Username', 'Host', '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>
|
||||||
|
{dbs.map(db => (
|
||||||
|
<tr key={db.id} className="border-b border-[#252b3d] hover:bg-[#252b3d]/50 transition-colors">
|
||||||
|
<td className="px-4 py-2.5 text-emerald-400 font-medium font-mono text-xs">{db.db_name}</td>
|
||||||
|
<td className="px-4 py-2.5 text-blue-400 font-mono text-xs">{db.db_user}</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-600 text-xs">localhost:3306</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-600 text-xs">{new Date(db.created_at).toLocaleDateString()}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<button onClick={() => setConfirm(db.db_name)}
|
||||||
|
className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:text-red-400 text-gray-400 transition-colors">
|
||||||
|
Drop
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="px-4 py-3 border-t border-[#2a3044] bg-[#0f1520]">
|
||||||
|
<p className="text-[10px] text-gray-600">
|
||||||
|
Connect via: <span className="font-mono text-gray-500">mysql -u <username> -p <database></span>
|
||||||
|
· Host: <span className="font-mono text-gray-500">localhost</span>
|
||||||
|
· Port: <span className="font-mono text-gray-500">3306</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Change Password page ────────────────────────────────────────────── */
|
/* ── Change Password page ────────────────────────────────────────────── */
|
||||||
function ChangePassword() {
|
function ChangePassword() {
|
||||||
const [form, setForm] = useState({ current: '', newPw: '', confirm: '' })
|
const [form, setForm] = useState({ current: '', newPw: '', confirm: '' })
|
||||||
@@ -527,6 +729,8 @@ function ClientPanel({ username, onLogout }: { username: string; onLogout: () =>
|
|||||||
case 'domains': return <Domains account={account} />
|
case 'domains': return <Domains account={account} />
|
||||||
case 'emailAccounts': return <EmailAccounts account={account} />
|
case 'emailAccounts': return <EmailAccounts account={account} />
|
||||||
case 'email': return <EmailAccounts account={account} />
|
case 'email': return <EmailAccounts account={account} />
|
||||||
|
case 'mysql': return <MySQLDatabases account={account} />
|
||||||
|
case 'databases': return <MySQLDatabases account={account} />
|
||||||
case 'passwords': return <ChangePassword />
|
case 'passwords': return <ChangePassword />
|
||||||
default: return <Placeholder title={meta.title} icon={meta.icon} />
|
default: return <Placeholder title={meta.title} icon={meta.icon} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,29 @@ export async function deleteEmailAccount(id: number): Promise<void> {
|
|||||||
await apiFetch(`/email/id/${id}`, { method: 'DELETE' })
|
await apiFetch(`/email/id/${id}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Databases ───────────────────────────────────────────────────────── */
|
||||||
|
export interface Database {
|
||||||
|
id: number
|
||||||
|
account_id: number
|
||||||
|
db_name: string
|
||||||
|
db_user: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
export async function listMyDatabases(username: string): Promise<Database[]> {
|
||||||
|
return apiFetch(`/databases/${username}`)
|
||||||
|
}
|
||||||
|
export async function createDatabase(data: {
|
||||||
|
username: string
|
||||||
|
db_suffix: string
|
||||||
|
db_user_suffix?: string
|
||||||
|
db_password: string
|
||||||
|
}): Promise<Database> {
|
||||||
|
return apiFetch('/databases', { method: 'POST', body: JSON.stringify(data) })
|
||||||
|
}
|
||||||
|
export async function deleteDatabase(dbName: string): Promise<void> {
|
||||||
|
await apiFetch(`/databases/name/${encodeURIComponent(dbName)}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Password change ─────────────────────────────────────────────────── */
|
/* ── Password change ─────────────────────────────────────────────────── */
|
||||||
export async function changePassword(
|
export async function changePassword(
|
||||||
currentPassword: string,
|
currentPassword: string,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "nixpanel"
|
name = "nixpanel"
|
||||||
version = "0.3.0-alpha"
|
version = "0.4.0-alpha"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
@@ -124,6 +124,14 @@ fn build_router(state: AppState) -> Router {
|
|||||||
post(routes::accounts::suspend_account))
|
post(routes::accounts::suspend_account))
|
||||||
.route("/api/accounts/:username/unsuspend",
|
.route("/api/accounts/:username/unsuspend",
|
||||||
post(routes::accounts::unsuspend_account))
|
post(routes::accounts::unsuspend_account))
|
||||||
|
// Databases
|
||||||
|
.route("/api/databases",
|
||||||
|
get(routes::databases::list_all_databases)
|
||||||
|
.post(routes::databases::create_database))
|
||||||
|
.route("/api/databases/:username",
|
||||||
|
get(routes::databases::list_account_databases))
|
||||||
|
.route("/api/databases/name/:db_name",
|
||||||
|
delete(routes::databases::delete_database))
|
||||||
// DNS
|
// DNS
|
||||||
.route("/api/dns", get(routes::dns::list_zones))
|
.route("/api/dns", get(routes::dns::list_zones))
|
||||||
.route("/api/dns/:domain",
|
.route("/api/dns/:domain",
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
use crate::{auth::Claims, error::AppError, state::AppState};
|
||||||
|
|
||||||
|
/* ── Types ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
|
pub struct Database {
|
||||||
|
pub id: i64,
|
||||||
|
pub account_id: i64,
|
||||||
|
pub db_name: String,
|
||||||
|
pub db_user: String,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
|
pub struct DatabaseWithAccount {
|
||||||
|
pub id: i64,
|
||||||
|
pub account_id: i64,
|
||||||
|
pub db_name: String,
|
||||||
|
pub db_user: String,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub account_username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateDatabaseRequest {
|
||||||
|
/// Hosting account username (admin supplies this; NixClient uses JWT sub)
|
||||||
|
pub username: String,
|
||||||
|
/// Short suffix — final DB name will be `username_db_suffix`
|
||||||
|
pub db_suffix: String,
|
||||||
|
/// Short suffix — final DB user will be `username_user_suffix` (defaults to db_suffix)
|
||||||
|
pub db_user_suffix: Option<String>,
|
||||||
|
pub db_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── MySQL helpers ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/// Escape a string value safe for use inside a single-quoted MySQL literal.
|
||||||
|
fn escape_mysql(s: &str) -> String {
|
||||||
|
s.replace('\\', "\\\\")
|
||||||
|
.replace('\'', "\\'")
|
||||||
|
.replace('\0', "\\0")
|
||||||
|
.replace('\n', "\\n")
|
||||||
|
.replace('\r', "\\r")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute one or more SQL statements via `mysql -u root` (unix socket auth).
|
||||||
|
fn mysql_exec(sql: &str) -> Result<String, String> {
|
||||||
|
let mut child = Command::new("mysql")
|
||||||
|
.args(["-u", "root"])
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("mysql spawn: {}", e))?;
|
||||||
|
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
stdin.write_all(sql.as_bytes()).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = child.wait_with_output().map_err(|e| format!("mysql wait: {}", 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate an identifier: only alphanumeric + underscore, non-empty.
|
||||||
|
fn valid_ident(s: &str) -> bool {
|
||||||
|
!s.is_empty() && s.chars().all(|c| c.is_alphanumeric() || c == '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Handlers ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/// List all databases across all accounts (admin / NixServer).
|
||||||
|
pub async fn list_all_databases(
|
||||||
|
_claims: Claims,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Vec<DatabaseWithAccount>>, AppError> {
|
||||||
|
let rows = sqlx::query_as::<_, DatabaseWithAccount>(
|
||||||
|
"SELECT ad.id, ad.account_id, ad.db_name, ad.db_user, ad.created_at,
|
||||||
|
a.username AS account_username
|
||||||
|
FROM account_databases ad
|
||||||
|
JOIN accounts a ON a.id = ad.account_id
|
||||||
|
ORDER BY ad.created_at DESC",
|
||||||
|
)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List databases for a specific hosting account (NixClient).
|
||||||
|
pub async fn list_account_databases(
|
||||||
|
_claims: Claims,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(account_username): Path<String>,
|
||||||
|
) -> Result<Json<Vec<Database>>, AppError> {
|
||||||
|
let rows = sqlx::query_as::<_, Database>(
|
||||||
|
"SELECT ad.id, ad.account_id, ad.db_name, ad.db_user, ad.created_at
|
||||||
|
FROM account_databases ad
|
||||||
|
JOIN accounts a ON a.id = ad.account_id
|
||||||
|
WHERE a.username = ?
|
||||||
|
ORDER BY ad.created_at DESC",
|
||||||
|
)
|
||||||
|
.bind(&account_username)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a MariaDB database + user, then record in nixpanel DB.
|
||||||
|
pub async fn create_database(
|
||||||
|
_claims: Claims,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<CreateDatabaseRequest>,
|
||||||
|
) -> Result<Json<Database>, AppError> {
|
||||||
|
// Validate inputs
|
||||||
|
if body.username.is_empty() || body.db_suffix.is_empty() || body.db_password.is_empty() {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"username, db_suffix, and db_password are required".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !valid_ident(&body.db_suffix) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"db_suffix must be alphanumeric with underscores only".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let user_suffix = body.db_user_suffix.as_deref().unwrap_or(&body.db_suffix);
|
||||||
|
if !valid_ident(user_suffix) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"db_user_suffix must be alphanumeric with underscores only".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if body.db_password.len() < 6 {
|
||||||
|
return Err(AppError::BadRequest("Password must be at least 6 characters".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build cPanel-style namespaced names
|
||||||
|
let db_name = format!("{}_{}", body.username, body.db_suffix);
|
||||||
|
let db_user = format!("{}_{}", body.username, user_suffix);
|
||||||
|
|
||||||
|
// MySQL identifier length limits
|
||||||
|
if db_name.len() > 64 {
|
||||||
|
return Err(AppError::BadRequest("Database name too long (max 64 chars)".into()));
|
||||||
|
}
|
||||||
|
if db_user.len() > 32 {
|
||||||
|
return Err(AppError::BadRequest("Database username too long (max 32 chars)".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get account_id
|
||||||
|
let account_id: Option<i64> = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM accounts WHERE username = ?",
|
||||||
|
)
|
||||||
|
.bind(&body.username)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let account_id = account_id.ok_or_else(|| {
|
||||||
|
AppError::BadRequest(format!("Account '{}' not found", body.username))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Check DB doesn't already exist in our tracking table
|
||||||
|
let exists: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM account_databases WHERE db_name = ?",
|
||||||
|
)
|
||||||
|
.bind(&db_name)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if exists > 0 {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Database '{}' already exists",
|
||||||
|
db_name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create database + user in MariaDB
|
||||||
|
let pw = escape_mysql(&body.db_password);
|
||||||
|
let sql = format!(
|
||||||
|
"CREATE DATABASE IF NOT EXISTS `{db_name}` \
|
||||||
|
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;\n\
|
||||||
|
CREATE USER IF NOT EXISTS '{db_user}'@'localhost' IDENTIFIED BY '{pw}';\n\
|
||||||
|
GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{db_user}'@'localhost';\n\
|
||||||
|
FLUSH PRIVILEGES;\n",
|
||||||
|
db_name = db_name,
|
||||||
|
db_user = db_user,
|
||||||
|
pw = pw,
|
||||||
|
);
|
||||||
|
|
||||||
|
mysql_exec(&sql).map_err(|e| AppError::BadRequest(format!("MariaDB: {}", e)))?;
|
||||||
|
|
||||||
|
// Record in nixpanel tracking table
|
||||||
|
let insert_id: u64 = sqlx::query(
|
||||||
|
"INSERT INTO account_databases (account_id, db_name, db_user) VALUES (?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(account_id)
|
||||||
|
.bind(&db_name)
|
||||||
|
.bind(&db_user)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?
|
||||||
|
.last_insert_id();
|
||||||
|
|
||||||
|
let db = sqlx::query_as::<_, Database>(
|
||||||
|
"SELECT id, account_id, db_name, db_user, created_at
|
||||||
|
FROM account_databases WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(insert_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(db))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop a MariaDB database and its dedicated user.
|
||||||
|
pub async fn delete_database(
|
||||||
|
_claims: Claims,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(db_name): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
// Fetch tracking record
|
||||||
|
let row: Option<(i64, String)> = sqlx::query_as(
|
||||||
|
"SELECT id, db_user FROM account_databases WHERE db_name = ?",
|
||||||
|
)
|
||||||
|
.bind(&db_name)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (db_id, db_user) = row.ok_or(AppError::NotFound)?;
|
||||||
|
|
||||||
|
// Safety: only allow dropping databases that follow the user_name pattern
|
||||||
|
if !db_name.contains('_') {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Will not drop databases without account prefix".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop database and user from MariaDB
|
||||||
|
let sql = format!(
|
||||||
|
"DROP DATABASE IF EXISTS `{db_name}`;\n\
|
||||||
|
DROP USER IF EXISTS '{db_user}'@'localhost';\n\
|
||||||
|
FLUSH PRIVILEGES;\n",
|
||||||
|
db_name = db_name,
|
||||||
|
db_user = db_user,
|
||||||
|
);
|
||||||
|
|
||||||
|
mysql_exec(&sql).map_err(|e| AppError::BadRequest(format!("MariaDB: {}", e)))?;
|
||||||
|
|
||||||
|
// Remove tracking record
|
||||||
|
sqlx::query("DELETE FROM account_databases WHERE id = ?")
|
||||||
|
.bind(db_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "ok": true, "deleted": db_name })))
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod accounts;
|
pub mod accounts;
|
||||||
pub mod dashboard;
|
pub mod dashboard;
|
||||||
|
pub mod databases;
|
||||||
pub mod dns;
|
pub mod dns;
|
||||||
pub mod email;
|
pub mod email;
|
||||||
pub mod me;
|
pub mod me;
|
||||||
|
|||||||
+207
-1
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import * as api from './api'
|
import * as api from './api'
|
||||||
import type { DashboardData, ServiceInfo, Account, DnsRecord, EmailAccount } from './api'
|
import type { DashboardData, ServiceInfo, Account, DnsRecord, EmailAccount, Database } from './api'
|
||||||
|
|
||||||
/* ── Types ───────────────────────────────────────────────────────────── */
|
/* ── Types ───────────────────────────────────────────────────────────── */
|
||||||
type Page =
|
type Page =
|
||||||
@@ -879,6 +879,211 @@ function DnsRecords({ onNav }: { onNav: (p: Page) => void }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Database Manager ─────────────────────────────────────────────────── */
|
||||||
|
function DatabaseManager() {
|
||||||
|
const [dbs, setDbs] = useState<Database[]>([])
|
||||||
|
const [accounts, setAccounts] = useState<Account[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [confirm, setConfirm] = useState<string | null>(null)
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
username: '', db_suffix: '', db_user_suffix: '', db_password: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const flash = (m: string) => { setMsg(m); setTimeout(() => setMsg(''), 5000) }
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
setLoading(true)
|
||||||
|
api.listAllDatabases()
|
||||||
|
.then(setDbs)
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh()
|
||||||
|
api.getAccounts().then(setAccounts).catch(console.error)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
try {
|
||||||
|
const db = await api.createDatabase({
|
||||||
|
username: form.username,
|
||||||
|
db_suffix: form.db_suffix,
|
||||||
|
db_user_suffix: form.db_user_suffix || undefined,
|
||||||
|
db_password: form.db_password,
|
||||||
|
})
|
||||||
|
flash(`Created database ${db.db_name}`)
|
||||||
|
setShowAdd(false)
|
||||||
|
setForm({ username: '', db_suffix: '', db_user_suffix: '', db_password: '' })
|
||||||
|
refresh()
|
||||||
|
} catch (e: any) {
|
||||||
|
flash(`Error: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (dbName: string) => {
|
||||||
|
try {
|
||||||
|
await api.deleteDatabase(dbName)
|
||||||
|
flash(`Deleted ${dbName}`)
|
||||||
|
refresh()
|
||||||
|
} catch (e: any) {
|
||||||
|
flash(`Error: ${e.message}`)
|
||||||
|
}
|
||||||
|
setConfirm(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview the namespaced names
|
||||||
|
const previewDb = form.username && form.db_suffix
|
||||||
|
? `${form.username}_${form.db_suffix}`
|
||||||
|
: '…'
|
||||||
|
const previewUser = form.username && (form.db_user_suffix || form.db_suffix)
|
||||||
|
? `${form.username}_${form.db_user_suffix || form.db_suffix}`
|
||||||
|
: '…'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Confirm modal */}
|
||||||
|
{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">⚠️ Drop Database</h3>
|
||||||
|
<p className="text-sm text-gray-400 mb-5">
|
||||||
|
Permanently drop <span className="text-white font-semibold">{confirm}</span> and its dedicated user? All data will be lost.
|
||||||
|
</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={() => handleDelete(confirm)} className="px-4 py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-semibold transition-colors">Drop Database</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-black text-white">MySQL / MariaDB</h1>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">{dbs.length} database{dbs.length !== 1 ? 's' : ''} across all accounts</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">
|
||||||
|
+ Create Database
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create form */}
|
||||||
|
{showAdd && (
|
||||||
|
<form onSubmit={handleCreate} className="bg-[#1a1f2e] border border-orange-500/30 rounded-lg p-5 space-y-4">
|
||||||
|
<h3 className="text-sm font-bold text-orange-400">New Database</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Account</label>
|
||||||
|
<select value={form.username} onChange={e => setForm(f => ({ ...f, username: e.target.value }))} required
|
||||||
|
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">
|
||||||
|
<option value="">— Select account —</option>
|
||||||
|
{accounts.map(a => <option key={a.username} value={a.username}>{a.username} ({a.domain})</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Database Name</label>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-gray-500 text-sm shrink-0">{form.username || 'user'}_</span>
|
||||||
|
<input value={form.db_suffix} onChange={e => setForm(f => ({ ...f, db_suffix: e.target.value }))} required
|
||||||
|
placeholder="wordpress"
|
||||||
|
className="flex-1 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>
|
||||||
|
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">DB Username <span className="text-gray-600 normal-case font-normal">(leave blank to match DB name)</span></label>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-gray-500 text-sm shrink-0">{form.username || 'user'}_</span>
|
||||||
|
<input value={form.db_user_suffix} onChange={e => setForm(f => ({ ...f, db_user_suffix: e.target.value }))}
|
||||||
|
placeholder={form.db_suffix || 'wordpress'}
|
||||||
|
className="flex-1 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>
|
||||||
|
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">DB Password</label>
|
||||||
|
<input type="password" value={form.db_password} onChange={e => setForm(f => ({ ...f, db_password: e.target.value }))} required
|
||||||
|
placeholder="Min 6 characters"
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Live preview */}
|
||||||
|
{form.username && form.db_suffix && (
|
||||||
|
<div className="bg-[#0f1520] border border-[#2a3044] rounded-lg p-3 text-xs font-mono space-y-1">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<span className="text-gray-600 w-20">Database:</span>
|
||||||
|
<span className="text-emerald-400">{previewDb}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<span className="text-gray-600 w-20">Username:</span>
|
||||||
|
<span className="text-blue-400">{previewUser}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<span className="text-gray-600 w-20">Host:</span>
|
||||||
|
<span className="text-gray-400">localhost</span>
|
||||||
|
</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">Create Database</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Databases table */}
|
||||||
|
<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…
|
||||||
|
</div>
|
||||||
|
) : dbs.length === 0 ? (
|
||||||
|
<div className="py-16 text-center">
|
||||||
|
<div className="text-4xl mb-3">🗄️</div>
|
||||||
|
<p className="text-gray-500 text-sm">No databases yet.</p>
|
||||||
|
<button onClick={() => setShowAdd(true)} className="mt-3 text-orange-400 hover:underline text-sm">Create your first database →</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[#2a3044] bg-[#0f1520]">
|
||||||
|
{['Database', 'DB User', 'Host', 'Account', '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>
|
||||||
|
{dbs.map(db => (
|
||||||
|
<tr key={db.id} className="border-b border-[#252b3d] hover:bg-[#252b3d]/50 transition-colors">
|
||||||
|
<td className="px-4 py-2.5 text-emerald-400 font-medium font-mono text-xs">{db.db_name}</td>
|
||||||
|
<td className="px-4 py-2.5 text-blue-400 font-mono text-xs">{db.db_user}</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-600 text-xs">localhost</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-400 text-xs">{db.account_username ?? '—'}</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-600 text-xs">{new Date(db.created_at).toLocaleDateString()}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<button onClick={() => setConfirm(db.db_name)}
|
||||||
|
className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:text-red-400 text-gray-400 transition-colors">
|
||||||
|
Drop
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Placeholder ──────────────────────────────────────────────────────── */
|
/* ── Placeholder ──────────────────────────────────────────────────────── */
|
||||||
function Placeholder({ title, icon }: { title: string; icon: string }) {
|
function Placeholder({ title, icon }: { title: string; icon: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -925,6 +1130,7 @@ function AdminPanel({ username, onLogout }: { username: string; onLogout: () =>
|
|||||||
case 'domains': return <ZoneManager onNav={setPage} />
|
case 'domains': return <ZoneManager onNav={setPage} />
|
||||||
case 'dns': return <DnsRecords onNav={setPage} />
|
case 'dns': return <DnsRecords onNav={setPage} />
|
||||||
case 'services': return <ServiceManager />
|
case 'services': return <ServiceManager />
|
||||||
|
case 'databases': return <DatabaseManager />
|
||||||
default: return <Placeholder title={meta.title} icon={meta.icon} />
|
default: return <Placeholder title={meta.title} icon={meta.icon} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,33 @@ export async function terminateAccount(username: string): Promise<void> {
|
|||||||
await apiFetch(`/accounts/${username}`, { method: 'DELETE' })
|
await apiFetch(`/accounts/${username}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Databases ───────────────────────────────────────────────────────── */
|
||||||
|
export interface Database {
|
||||||
|
id: number
|
||||||
|
account_id: number
|
||||||
|
db_name: string
|
||||||
|
db_user: string
|
||||||
|
created_at: string
|
||||||
|
account_username?: string // present in admin list view
|
||||||
|
}
|
||||||
|
export async function listAllDatabases(): Promise<Database[]> {
|
||||||
|
return apiFetch('/databases')
|
||||||
|
}
|
||||||
|
export async function listAccountDatabases(username: string): Promise<Database[]> {
|
||||||
|
return apiFetch(`/databases/${username}`)
|
||||||
|
}
|
||||||
|
export async function createDatabase(data: {
|
||||||
|
username: string
|
||||||
|
db_suffix: string
|
||||||
|
db_user_suffix?: string
|
||||||
|
db_password: string
|
||||||
|
}): Promise<Database> {
|
||||||
|
return apiFetch('/databases', { method: 'POST', body: JSON.stringify(data) })
|
||||||
|
}
|
||||||
|
export async function deleteDatabase(dbName: string): Promise<void> {
|
||||||
|
await apiFetch(`/databases/name/${encodeURIComponent(dbName)}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
/* ── DNS ─────────────────────────────────────────────────────────────── */
|
/* ── DNS ─────────────────────────────────────────────────────────────── */
|
||||||
export interface DnsRecord {
|
export interface DnsRecord {
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
Reference in New Issue
Block a user