diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d62175..c8fe279 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,32 @@ Versions follow [Semantic Versioning](https://semver.org/). ### Planned - SSL certificate management (Let's Encrypt via Certbot) - File manager -- MySQL/MariaDB database manager (per-account) +- FTP account management (vsftpd) - WordPress one-click installer - Backup / restore system - Firewall rule editor (CSF/iptables) - 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.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 0825a61..975f787 100644 --- a/nixclient/src/App.tsx +++ b/nixclient/src/App.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import * as api from './api' -import type { AccountInfo, EmailAccount } from './api' +import type { AccountInfo, EmailAccount, Database } from './api' /* ── Types ───────────────────────────────────────────────────────────── */ 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([]) + const [loading, setLoading] = useState(true) + const [showAdd, setShowAdd] = useState(false) + const [msg, setMsg] = useState('') + const [confirm, setConfirm] = useState(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 ( +
+ {/* Confirm drop modal */} + {confirm && ( +
+
+

⚠️ Drop Database

+

+ Permanently drop {confirm}? All data will be lost and cannot be recovered. +

+
+ + +
+
+
+ )} + +
+
+

MySQL Databases

+

{account?.domain ?? username}

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

New MySQL Database

+ +
+
+ +
+ {username}_ + 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" /> +
+
+
+ +
+ {username}_ + 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" /> +
+
+
+ + 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" /> +
+
+ + 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" /> +
+
+ + {/* Connection info preview */} + {form.db_suffix && ( +
+

Connection Details (after creation)

+ {[ + { 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 => ( +
+ {r.label}: + {r.value} +
+ ))} +
+ )} + +
+ + +
+
+ )} + + {/* Database list */} +
+ {loading ? ( +
+ Loading… +
+ ) : dbs.length === 0 ? ( +
+
🗄️
+

No databases yet.

+ +
+ ) : ( + <> + + + + {['Database', 'Username', 'Host', 'Created', 'Actions'].map(h => ( + + ))} + + + + {dbs.map(db => ( + + + + + + + + ))} + +
{h}
{db.db_name}{db.db_user}localhost:3306{new Date(db.created_at).toLocaleDateString()} + +
+
+

+ Connect via: mysql -u <username> -p <database> +  · Host: localhost +  · Port: 3306 +

+
+ + )} +
+
+ ) +} + /* ── Change Password page ────────────────────────────────────────────── */ function ChangePassword() { const [form, setForm] = useState({ current: '', newPw: '', confirm: '' }) @@ -527,6 +729,8 @@ function ClientPanel({ username, onLogout }: { username: string; onLogout: () => case 'domains': return case 'emailAccounts': return case 'email': return + case 'mysql': return + case 'databases': return case 'passwords': return default: return } diff --git a/nixclient/src/api.ts b/nixclient/src/api.ts index 1e3088f..a0979e6 100644 --- a/nixclient/src/api.ts +++ b/nixclient/src/api.ts @@ -94,6 +94,29 @@ export async function deleteEmailAccount(id: number): Promise { 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 { + return apiFetch(`/databases/${username}`) +} +export async function createDatabase(data: { + username: string + db_suffix: string + db_user_suffix?: string + db_password: string +}): Promise { + return apiFetch('/databases', { method: 'POST', body: JSON.stringify(data) }) +} +export async function deleteDatabase(dbName: string): Promise { + await apiFetch(`/databases/name/${encodeURIComponent(dbName)}`, { method: 'DELETE' }) +} + /* ── Password change ─────────────────────────────────────────────────── */ export async function changePassword( currentPassword: string, diff --git a/nixpanel-api/Cargo.toml b/nixpanel-api/Cargo.toml index bd833d0..2294513 100644 --- a/nixpanel-api/Cargo.toml +++ b/nixpanel-api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nixpanel" -version = "0.3.0-alpha" +version = "0.4.0-alpha" edition = "2021" [[bin]] diff --git a/nixpanel-api/src/main.rs b/nixpanel-api/src/main.rs index d708c19..a684427 100644 --- a/nixpanel-api/src/main.rs +++ b/nixpanel-api/src/main.rs @@ -124,6 +124,14 @@ fn build_router(state: AppState) -> Router { post(routes::accounts::suspend_account)) .route("/api/accounts/:username/unsuspend", 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 .route("/api/dns", get(routes::dns::list_zones)) .route("/api/dns/:domain", diff --git a/nixpanel-api/src/routes/databases.rs b/nixpanel-api/src/routes/databases.rs new file mode 100644 index 0000000..aa4f704 --- /dev/null +++ b/nixpanel-api/src/routes/databases.rs @@ -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, + 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 { + 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, +) -> Result>, 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, + Path(account_username): Path, +) -> Result>, 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, + Json(body): Json, +) -> Result, 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 = 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, + Path(db_name): Path, +) -> Result, 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 }))) +} diff --git a/nixpanel-api/src/routes/mod.rs b/nixpanel-api/src/routes/mod.rs index a5e9e6c..3ca54a4 100644 --- a/nixpanel-api/src/routes/mod.rs +++ b/nixpanel-api/src/routes/mod.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod accounts; pub mod dashboard; +pub mod databases; pub mod dns; pub mod email; pub mod me; diff --git a/nixserver/src/App.tsx b/nixserver/src/App.tsx index 7200dbd..d758b67 100644 --- a/nixserver/src/App.tsx +++ b/nixserver/src/App.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react' 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 ───────────────────────────────────────────────────────────── */ type Page = @@ -879,6 +879,211 @@ function DnsRecords({ onNav }: { onNav: (p: Page) => void }) { ) } +/* ── Database Manager ─────────────────────────────────────────────────── */ +function DatabaseManager() { + const [dbs, setDbs] = useState([]) + const [accounts, setAccounts] = useState([]) + const [loading, setLoading] = useState(true) + const [showAdd, setShowAdd] = useState(false) + const [msg, setMsg] = useState('') + const [confirm, setConfirm] = useState(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 ( +
+ {/* Confirm modal */} + {confirm && ( +
+
+

⚠️ Drop Database

+

+ Permanently drop {confirm} and its dedicated user? All data will be lost. +

+
+ + +
+
+
+ )} + +
+
+

MySQL / MariaDB

+

{dbs.length} database{dbs.length !== 1 ? 's' : ''} across all accounts

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

New Database

+
+
+ + +
+
+ +
+ {form.username || 'user'}_ + 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" /> +
+
+
+ +
+ {form.username || 'user'}_ + 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" /> +
+
+
+ + 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" /> +
+
+ + {/* Live preview */} + {form.username && form.db_suffix && ( +
+
+ Database: + {previewDb} +
+
+ Username: + {previewUser} +
+
+ Host: + localhost +
+
+ )} + +
+ + +
+
+ )} + + {/* Databases table */} +
+ {loading ? ( +
+ Loading… +
+ ) : dbs.length === 0 ? ( +
+
🗄️
+

No databases yet.

+ +
+ ) : ( + + + + {['Database', 'DB User', 'Host', 'Account', 'Created', 'Actions'].map(h => ( + + ))} + + + + {dbs.map(db => ( + + + + + + + + + ))} + +
{h}
{db.db_name}{db.db_user}localhost{db.account_username ?? '—'}{new Date(db.created_at).toLocaleDateString()} + +
+ )} +
+
+ ) +} + /* ── Placeholder ──────────────────────────────────────────────────────── */ function Placeholder({ title, icon }: { title: string; icon: string }) { return ( @@ -925,6 +1130,7 @@ function AdminPanel({ username, onLogout }: { username: string; onLogout: () => case 'domains': return case 'dns': return case 'services': return + case 'databases': return default: return } } diff --git a/nixserver/src/api.ts b/nixserver/src/api.ts index c5bf9c1..6a121c1 100644 --- a/nixserver/src/api.ts +++ b/nixserver/src/api.ts @@ -148,6 +148,33 @@ export async function terminateAccount(username: string): Promise { 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 { + return apiFetch('/databases') +} +export async function listAccountDatabases(username: string): Promise { + return apiFetch(`/databases/${username}`) +} +export async function createDatabase(data: { + username: string + db_suffix: string + db_user_suffix?: string + db_password: string +}): Promise { + return apiFetch('/databases', { method: 'POST', body: JSON.stringify(data) }) +} +export async function deleteDatabase(dbName: string): Promise { + await apiFetch(`/databases/name/${encodeURIComponent(dbName)}`, { method: 'DELETE' }) +} + /* ── DNS ─────────────────────────────────────────────────────────────── */ export interface DnsRecord { name: string