feat: v0.3.0-alpha — DNS management, email accounts, suspend/terminate, password change
Backend: - routes/dns.rs: GET/POST/DELETE /api/dns/:domain (pdnsutil CRUD, SOA hidden, auto-rectify) - routes/email.rs: GET/POST/DELETE email accounts with Maildir provisioning - routes/me.rs: POST /api/me/password (verify current, hash new, update users+accounts) - routes/accounts.rs: GET /api/accounts/:username, suspend, unsuspend, terminate (deprovision+userdel) - main.rs: register all new routes; import axum::routing::delete - Cargo.toml: version bump 0.2.0-alpha -> 0.3.0-alpha NixServer: - Zone Manager page: lists all PowerDNS zones, click -> DNS editor - DNS Records page: add/delete records, colour-coded type badges, zone switcher - Service Manager page: dedicated page with refresh + running/stopped pills - List Accounts: Suspend/Unsuspend/Terminate buttons with confirmation modal - Domain cell links to live site NixClient: - Domains page: primary domain info, document root, quota, bandwidth - Email Accounts page: create/list/delete mailboxes with quota display - Change Password page: Argon2id verify + update server-side - AccountSidebar: fetches real account data (domain, package, quota) - api.ts: listMyEmailAccounts, createEmailAccount, deleteEmailAccount, changePassword, getMyAccount(username) CHANGELOG.md: v0.3.0-alpha entry
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "nixpanel"
|
||||
version = "0.2.0-alpha"
|
||||
version = "0.3.0-alpha"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::sync::Arc;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
routing::{delete, get, post},
|
||||
Router,
|
||||
};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
@@ -110,11 +110,32 @@ fn build_router(state: AppState) -> Router {
|
||||
// Dashboard
|
||||
.route("/api/dashboard", get(routes::dashboard::get_dashboard))
|
||||
// Services
|
||||
.route("/api/services", get(routes::services::list_services))
|
||||
.route("/api/services/:name/:action", post(routes::services::service_action))
|
||||
// Accounts
|
||||
.route("/api/accounts", get(routes::accounts::list_accounts)
|
||||
.post(routes::accounts::create_account))
|
||||
.route("/api/services", get(routes::services::list_services))
|
||||
.route("/api/services/:name/:action", post(routes::services::service_action))
|
||||
// Accounts — list / create
|
||||
.route("/api/accounts",
|
||||
get(routes::accounts::list_accounts)
|
||||
.post(routes::accounts::create_account))
|
||||
// Accounts — per-account actions
|
||||
.route("/api/accounts/:username",
|
||||
get(routes::accounts::get_account)
|
||||
.delete(routes::accounts::terminate_account))
|
||||
.route("/api/accounts/:username/suspend",
|
||||
post(routes::accounts::suspend_account))
|
||||
.route("/api/accounts/:username/unsuspend",
|
||||
post(routes::accounts::unsuspend_account))
|
||||
// DNS
|
||||
.route("/api/dns", get(routes::dns::list_zones))
|
||||
.route("/api/dns/:domain",
|
||||
get(routes::dns::list_zone_records)
|
||||
.post(routes::dns::add_record)
|
||||
.delete(routes::dns::delete_record))
|
||||
// Email accounts
|
||||
.route("/api/email", post(routes::email::create_email_account))
|
||||
.route("/api/email/:username", get(routes::email::list_email_accounts))
|
||||
.route("/api/email/id/:id", delete(routes::email::delete_email_account))
|
||||
// Profile / password
|
||||
.route("/api/me/password", post(routes::me::change_password))
|
||||
.with_state(state)
|
||||
.layer(cors)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use axum::{extract::State, Json};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -133,3 +136,117 @@ pub async fn create_account(
|
||||
|
||||
Ok(Json(account))
|
||||
}
|
||||
|
||||
/* ── Suspend / Unsuspend ─────────────────────────────────────────────── */
|
||||
|
||||
pub async fn suspend_account(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Path(username): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let rows = sqlx::query(
|
||||
"UPDATE accounts SET status = 'suspended' WHERE username = ?",
|
||||
)
|
||||
.bind(&username)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if rows == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "ok": true, "status": "suspended" })))
|
||||
}
|
||||
|
||||
pub async fn unsuspend_account(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Path(username): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let rows = sqlx::query(
|
||||
"UPDATE accounts SET status = 'active' WHERE username = ?",
|
||||
)
|
||||
.bind(&username)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if rows == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "ok": true, "status": "active" })))
|
||||
}
|
||||
|
||||
/* ── Terminate (delete + deprovision) ────────────────────────────────── */
|
||||
|
||||
pub async fn terminate_account(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Path(username): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
// Fetch domain so we can deprovision DNS
|
||||
let domain: Option<String> = sqlx::query_scalar(
|
||||
"SELECT domain FROM accounts WHERE username = ?",
|
||||
)
|
||||
.bind(&username)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
if domain.is_none() {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
let domain = domain.unwrap();
|
||||
|
||||
// Run nixpanel-accounts deprovision (best effort)
|
||||
let result = crate::section::call(
|
||||
&state.config.bin_dir,
|
||||
"nixpanel-accounts",
|
||||
"deprovision",
|
||||
serde_json::json!({ "username": username, "domain": domain }),
|
||||
);
|
||||
if let Err(ref e) = result {
|
||||
tracing::warn!("Deprovision error for {}: {}", username, e);
|
||||
}
|
||||
|
||||
// Remove system user (keep home dir for safety — admin can wipe manually)
|
||||
let _ = std::process::Command::new("userdel")
|
||||
.arg(&username)
|
||||
.output();
|
||||
|
||||
// Remove DB records (cascades to email, ftp, db, ssl, etc.)
|
||||
sqlx::query("DELETE FROM accounts WHERE username = ?")
|
||||
.bind(&username)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
// Also remove panel login user
|
||||
sqlx::query("DELETE FROM users WHERE username = ? AND role = 'user'")
|
||||
.bind(&username)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"message": format!("Account {} terminated", username)
|
||||
})))
|
||||
}
|
||||
|
||||
/* ── Single account lookup ───────────────────────────────────────────── */
|
||||
|
||||
pub async fn get_account(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Path(username): Path<String>,
|
||||
) -> Result<Json<Account>, AppError> {
|
||||
let account = sqlx::query_as::<_, Account>(
|
||||
"SELECT id, username, domain, email, package_name, disk_quota_mb, bandwidth_mb, status, created_at
|
||||
FROM accounts WHERE username = ?",
|
||||
)
|
||||
.bind(&username)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
Ok(Json(account))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::{auth::Claims, error::AppError, state::AppState};
|
||||
|
||||
/* ── Types ───────────────────────────────────────────────────────────── */
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct DnsRecord {
|
||||
pub name: String,
|
||||
pub ttl: u32,
|
||||
pub rtype: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AddRecordRequest {
|
||||
pub name: String,
|
||||
pub rtype: String,
|
||||
pub ttl: Option<u32>,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteRecordRequest {
|
||||
pub name: String,
|
||||
pub rtype: String,
|
||||
}
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/// Parse `pdnsutil list-zone <domain>` tab-separated output
|
||||
fn parse_zone(domain: &str, output: &str) -> Vec<DnsRecord> {
|
||||
let mut records = Vec::new();
|
||||
for line in output.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
// Columns: name\tttl\tIN\ttype\tcontent
|
||||
let cols: Vec<&str> = line.splitn(5, '\t').collect();
|
||||
if cols.len() < 5 {
|
||||
continue;
|
||||
}
|
||||
let raw_name = cols[0].trim().trim_end_matches('.');
|
||||
let ttl: u32 = cols[1].trim().parse().unwrap_or(3600);
|
||||
// cols[2] == "IN"
|
||||
let rtype = cols[3].trim().to_uppercase();
|
||||
let content = cols[4].trim().to_string();
|
||||
|
||||
if rtype == "SOA" {
|
||||
continue; // skip internal records
|
||||
}
|
||||
|
||||
// Normalise name: FQDN → relative label (@=apex)
|
||||
let name = if raw_name == domain || raw_name.is_empty() {
|
||||
"@".to_string()
|
||||
} else {
|
||||
let suffix = format!(".{}", domain);
|
||||
if raw_name.ends_with(&suffix) {
|
||||
raw_name[..raw_name.len() - suffix.len()].to_string()
|
||||
} else {
|
||||
raw_name.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
records.push(DnsRecord { name, ttl, rtype, content });
|
||||
}
|
||||
records
|
||||
}
|
||||
|
||||
fn pdnsutil(args: &[&str]) -> Result<String, String> {
|
||||
let out = Command::new("pdnsutil")
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run pdnsutil: {}", e))?;
|
||||
if out.status.success() {
|
||||
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Handlers ────────────────────────────────────────────────────────── */
|
||||
|
||||
/// List all DNS zones managed by PowerDNS
|
||||
pub async fn list_zones(
|
||||
_claims: Claims,
|
||||
State(_state): State<AppState>,
|
||||
) -> Result<Json<Vec<String>>, AppError> {
|
||||
let out = Command::new("pdnsutil")
|
||||
.args(["list-all-zones"])
|
||||
.output()
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("pdnsutil: {}", e)))?;
|
||||
|
||||
let zones: Vec<String> = String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.map(|l| l.trim().trim_end_matches('.').to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect();
|
||||
|
||||
Ok(Json(zones))
|
||||
}
|
||||
|
||||
/// List all records in a DNS zone
|
||||
pub async fn list_zone_records(
|
||||
_claims: Claims,
|
||||
State(_state): State<AppState>,
|
||||
Path(domain): Path<String>,
|
||||
) -> Result<Json<Vec<DnsRecord>>, AppError> {
|
||||
let out = Command::new("pdnsutil")
|
||||
.args(["list-zone", &domain])
|
||||
.output()
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("pdnsutil: {}", e)))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
Ok(Json(parse_zone(&domain, &stdout)))
|
||||
}
|
||||
|
||||
/// Add a DNS record to a zone
|
||||
pub async fn add_record(
|
||||
_claims: Claims,
|
||||
State(_state): State<AppState>,
|
||||
Path(domain): Path<String>,
|
||||
Json(body): Json<AddRecordRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
if body.name.is_empty() || body.rtype.is_empty() || body.content.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"name, rtype, and content are required".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let ttl = body.ttl.unwrap_or(3600).to_string();
|
||||
pdnsutil(&["add-record", &domain, &body.name, &body.rtype, &ttl, &body.content])
|
||||
.map_err(|e| AppError::BadRequest(format!("DNS error: {}", e)))?;
|
||||
|
||||
let _ = pdnsutil(&["rectify-zone", &domain]);
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// Delete an RRset (all records of a type at a name) from a zone
|
||||
pub async fn delete_record(
|
||||
_claims: Claims,
|
||||
State(_state): State<AppState>,
|
||||
Path(domain): Path<String>,
|
||||
Json(body): Json<DeleteRecordRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
if body.name.is_empty() || body.rtype.is_empty() {
|
||||
return Err(AppError::BadRequest("name and rtype are required".into()));
|
||||
}
|
||||
|
||||
pdnsutil(&["delete-rrset", &domain, &body.name, &body.rtype])
|
||||
.map_err(|e| AppError::BadRequest(format!("DNS error: {}", e)))?;
|
||||
|
||||
let _ = pdnsutil(&["rectify-zone", &domain]);
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{auth::Claims, error::AppError, state::AppState};
|
||||
|
||||
/* ── Types ───────────────────────────────────────────────────────────── */
|
||||
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
pub struct EmailAccount {
|
||||
pub id: i64,
|
||||
pub account_id: i64,
|
||||
pub address: String,
|
||||
pub quota_mb: i32,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateEmailRequest {
|
||||
pub username: String, // local part (before @)
|
||||
pub domain: String, // domain
|
||||
pub password: String,
|
||||
pub quota_mb: Option<i32>,
|
||||
}
|
||||
|
||||
/* ── Handlers ────────────────────────────────────────────────────────── */
|
||||
|
||||
/// List all email accounts for a hosting account (by account username).
|
||||
/// Admin can pass any username; a regular user's JWT sub is used automatically.
|
||||
pub async fn list_email_accounts(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Path(account_username): Path<String>,
|
||||
) -> Result<Json<Vec<EmailAccount>>, AppError> {
|
||||
let emails = sqlx::query_as::<_, EmailAccount>(
|
||||
"SELECT ea.id, ea.account_id, ea.address, ea.quota_mb, ea.created_at
|
||||
FROM email_accounts ea
|
||||
JOIN accounts a ON a.id = ea.account_id
|
||||
WHERE a.username = ?
|
||||
ORDER BY ea.created_at DESC",
|
||||
)
|
||||
.bind(&account_username)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(emails))
|
||||
}
|
||||
|
||||
/// Create a new email account under a domain.
|
||||
pub async fn create_email_account(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<CreateEmailRequest>,
|
||||
) -> Result<Json<EmailAccount>, AppError> {
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
|
||||
if body.username.is_empty() || body.domain.is_empty() || body.password.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"username, domain, and password are required".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate local part (no @, spaces, etc.)
|
||||
if body.username.contains('@') || body.username.contains(' ') {
|
||||
return Err(AppError::BadRequest("Invalid email username".into()));
|
||||
}
|
||||
|
||||
let address = format!("{}@{}", body.username, body.domain);
|
||||
|
||||
// Look up account by domain
|
||||
let row: Option<(i64, String)> = sqlx::query_as(
|
||||
"SELECT id, username FROM accounts WHERE domain = ?",
|
||||
)
|
||||
.bind(&body.domain)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (account_id, acct_username) = row.ok_or_else(|| {
|
||||
AppError::BadRequest(format!("No account for domain {}", body.domain))
|
||||
})?;
|
||||
|
||||
// Hash password
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hash = Argon2::default()
|
||||
.hash_password(body.password.as_bytes(), &salt)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("argon2: {}", e)))?
|
||||
.to_string();
|
||||
|
||||
let quota_mb: i32 = body.quota_mb.unwrap_or(1024);
|
||||
|
||||
let insert_id: u64 = sqlx::query(
|
||||
"INSERT INTO email_accounts (account_id, address, password_hash, quota_mb)
|
||||
VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(&address)
|
||||
.bind(&hash)
|
||||
.bind(quota_mb)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.last_insert_id();
|
||||
|
||||
// Provision Maildir structure under the account's home directory
|
||||
let mail_root = format!("/home/{}/mail/{}", acct_username, body.username);
|
||||
for sub in ["new", "cur", "tmp"] {
|
||||
let _ = std::fs::create_dir_all(format!("{}/{}", mail_root, sub));
|
||||
}
|
||||
let _ = std::process::Command::new("chown")
|
||||
.args([
|
||||
"-R",
|
||||
&format!("{}:{}", acct_username, acct_username),
|
||||
&mail_root,
|
||||
])
|
||||
.output();
|
||||
|
||||
let email = sqlx::query_as::<_, EmailAccount>(
|
||||
"SELECT id, account_id, address, quota_mb, created_at
|
||||
FROM email_accounts WHERE id = ?",
|
||||
)
|
||||
.bind(insert_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(email))
|
||||
}
|
||||
|
||||
/// Delete an email account by ID.
|
||||
pub async fn delete_email_account(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
sqlx::query("DELETE FROM email_accounts WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{auth::Claims, error::AppError, state::AppState};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ChangePasswordRequest {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
/// Change the password of the currently logged-in user.
|
||||
pub async fn change_password(
|
||||
claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<ChangePasswordRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2, PasswordHash,
|
||||
};
|
||||
|
||||
if body.new_password.len() < 8 {
|
||||
return Err(AppError::BadRequest(
|
||||
"New password must be at least 8 characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Fetch current hash from users table
|
||||
let hash: Option<String> = sqlx::query_scalar(
|
||||
"SELECT password_hash FROM users WHERE id = ?",
|
||||
)
|
||||
.bind(claims.user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let hash = hash.ok_or(AppError::NotFound)?;
|
||||
|
||||
// Verify current password
|
||||
let parsed = PasswordHash::new(&hash)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("hash parse: {}", e)))?;
|
||||
|
||||
Argon2::default()
|
||||
.verify_password(body.current_password.as_bytes(), &parsed)
|
||||
.map_err(|_| AppError::BadRequest("Current password is incorrect".into()))?;
|
||||
|
||||
// Hash new password
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let new_hash = Argon2::default()
|
||||
.hash_password(body.new_password.as_bytes(), &salt)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("argon2: {}", e)))?
|
||||
.to_string();
|
||||
|
||||
// Update users table
|
||||
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||
.bind(&new_hash)
|
||||
.bind(claims.user_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
// Also update accounts table if there's a matching account (for panel login)
|
||||
sqlx::query(
|
||||
"UPDATE accounts SET password_hash = ? WHERE username = ?",
|
||||
)
|
||||
.bind(&new_hash)
|
||||
.bind(&claims.sub)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.ok(); // not an error if no matching account
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
pub mod auth;
|
||||
pub mod accounts;
|
||||
pub mod dashboard;
|
||||
pub mod dns;
|
||||
pub mod email;
|
||||
pub mod me;
|
||||
pub mod services;
|
||||
|
||||
Reference in New Issue
Block a user