Files
corrosion-admin-panel/backend/src/api/admin.rs
Vantz Stockwell 88b50a30b4 feat: Phase 1c — Platform Admin Dashboard
Full super-admin dashboard for SaaS platform management:

Backend (10 files):
- Migration 003: Add is_super_admin column to users table
- JWT Claims: Carry is_super_admin through access tokens
- SuperAdmin extractor: Axum FromRequestParts that rejects non-admins (403)
- Admin API module: 10 endpoints behind /api/admin/*
  - GET /stats (KPIs: licenses, users, MRR, servers, signups)
  - GET/POST /licenses (paginated, filterable, manual generation)
  - GET/PATCH /licenses/:id (detail view, revoke/activate)
  - GET /subscriptions (module sub list with MRR breakdown)
  - GET/PATCH /users (paginated, toggle admin, disable accounts)
  - GET /servers (fleet overview across all licenses)
  - GET /health (DB pool, NATS status, table row counts)
- Bootstrap updated: first user gets is_super_admin = true

Frontend (8 files):
- 5 admin views in src/views/platform-admin/
- DashboardLayout: "Platform" nav section (gated on isSuperAdmin)
- Router: /admin/* routes with superAdmin meta guard
- Auth store: isSuperAdmin computed property
- Types: is_super_admin on User interface

Build: 80 chunks, zero TS errors, clean production build.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:07:38 -05:00

545 lines
17 KiB
Rust

use std::sync::Arc;
use axum::{
extract::{Path, Query, State},
routing::{get, patch, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::middleware::auth::SuperAdmin;
use crate::models::error::{ApiError, ApiResult};
use crate::AppState;
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/stats", get(stats))
.route("/licenses", get(list_licenses))
.route("/licenses", post(create_license))
.route("/licenses/{id}", get(get_license))
.route("/licenses/{id}", patch(update_license))
.route("/subscriptions", get(list_subscriptions))
.route("/users", get(list_users))
.route("/users/{id}", patch(update_user))
.route("/servers", get(list_servers))
.route("/health", get(health))
}
// ──────────────────────────────────────────────
// DTOs
// ──────────────────────────────────────────────
#[derive(Serialize)]
struct PlatformStats {
total_licenses: i64,
active_licenses: i64,
total_users: i64,
module_mrr: f64,
servers_online: i64,
new_signups_this_week: i64,
}
#[derive(Deserialize)]
struct PaginationParams {
page: Option<i64>,
per_page: Option<i64>,
search: Option<String>,
status: Option<String>,
}
#[derive(Serialize)]
struct PaginatedResponse<T: Serialize> {
data: Vec<T>,
total: i64,
page: i64,
per_page: i64,
}
#[derive(Serialize, sqlx::FromRow)]
struct LicenseListItem {
id: Uuid,
license_key: String,
status: String,
owner_email: String,
owner_username: String,
server_name: Option<String>,
modules_enabled: Option<Vec<String>>,
webstore_active: bool,
created_at: chrono::DateTime<chrono::Utc>,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Serialize)]
struct LicenseDetail {
license: LicenseListItem,
team_count: i64,
wipe_count: i64,
server_connection: Option<ServerConnectionInfo>,
}
#[derive(Serialize, sqlx::FromRow)]
struct ServerConnectionInfo {
connection_type: String,
connection_status: String,
server_ip: Option<String>,
game_port: Option<i32>,
plugin_last_seen: Option<chrono::DateTime<chrono::Utc>>,
companion_last_seen: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Deserialize)]
struct CreateLicenseRequest {
owner_email: String,
license_key: Option<String>,
}
#[derive(Deserialize)]
struct UpdateLicenseRequest {
status: Option<String>,
expires_at: Option<String>,
}
#[derive(Serialize, sqlx::FromRow)]
struct UserListItem {
id: Uuid,
email: String,
username: String,
is_super_admin: bool,
email_verified: bool,
license_count: i64,
created_at: chrono::DateTime<chrono::Utc>,
last_login_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Deserialize)]
struct UpdateUserRequest {
is_super_admin: Option<bool>,
disabled: Option<bool>,
}
#[derive(Serialize, sqlx::FromRow)]
struct SubscriptionListItem {
owner_email: String,
owner_username: String,
license_id: Uuid,
module_name: String,
}
#[derive(Serialize)]
struct SubscriptionSummary {
subscriptions: Vec<SubscriptionListItem>,
module_counts: Vec<ModuleCount>,
total_subscribers: i64,
}
#[derive(Serialize, sqlx::FromRow)]
struct ModuleCount {
module_name: String,
subscriber_count: i64,
}
#[derive(Serialize, sqlx::FromRow)]
struct ServerFleetItem {
license_id: Uuid,
owner_email: String,
server_name: Option<String>,
connection_type: String,
connection_status: String,
server_ip: Option<String>,
game_port: Option<i32>,
plugin_last_seen: Option<chrono::DateTime<chrono::Utc>>,
companion_last_seen: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Serialize)]
struct HealthInfo {
db_pool_size: u32,
nats_connected: bool,
table_counts: Vec<TableCount>,
}
#[derive(Serialize)]
struct TableCount {
table_name: String,
row_count: i64,
}
// ──────────────────────────────────────────────
// Handlers
// ──────────────────────────────────────────────
/// GET /api/admin/stats — KPI summary
async fn stats(
_admin: SuperAdmin,
State(state): State<Arc<AppState>>,
) -> ApiResult<Json<PlatformStats>> {
let total_licenses: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM licenses")
.fetch_one(&state.db).await.map_err(|e| ApiError::Internal(e.to_string()))?;
let active_licenses: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM licenses WHERE status = 'active'"
).fetch_one(&state.db).await.map_err(|e| ApiError::Internal(e.to_string()))?;
let total_users: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&state.db).await.map_err(|e| ApiError::Internal(e.to_string()))?;
// MRR from webstore subscriptions (count active webstore licenses * $10/mo)
let webstore_count: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM licenses WHERE webstore_active = true"
).fetch_one(&state.db).await.map_err(|e| ApiError::Internal(e.to_string()))?;
let module_mrr = webstore_count.0 as f64 * 10.0;
let servers_online: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM server_connections WHERE connection_status = 'connected'"
).fetch_one(&state.db).await.map_err(|e| ApiError::Internal(e.to_string()))?;
let new_signups: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM users WHERE created_at >= NOW() - INTERVAL '7 days'"
).fetch_one(&state.db).await.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(PlatformStats {
total_licenses: total_licenses.0,
active_licenses: active_licenses.0,
total_users: total_users.0,
module_mrr,
servers_online: servers_online.0,
new_signups_this_week: new_signups.0,
}))
}
/// GET /api/admin/licenses — paginated, filterable
async fn list_licenses(
_admin: SuperAdmin,
State(state): State<Arc<AppState>>,
Query(params): Query<PaginationParams>,
) -> ApiResult<Json<PaginatedResponse<LicenseListItem>>> {
let page = params.page.unwrap_or(1).max(1);
let per_page = params.per_page.unwrap_or(25).min(100);
let offset = (page - 1) * per_page;
let search = params.search.unwrap_or_default();
let status_filter = params.status.unwrap_or_default();
let licenses = sqlx::query_as::<_, LicenseListItem>(
"SELECT l.id, l.license_key, l.status, u.email AS owner_email, \
u.username AS owner_username, l.server_name, l.modules_enabled, \
l.webstore_active, l.created_at, l.expires_at \
FROM licenses l \
JOIN users u ON u.id = l.owner_user_id \
WHERE ($1 = '' OR l.license_key ILIKE '%' || $1 || '%' OR u.email ILIKE '%' || $1 || '%') \
AND ($2 = '' OR l.status = $2) \
ORDER BY l.created_at DESC \
LIMIT $3 OFFSET $4"
)
.bind(&search)
.bind(&status_filter)
.bind(per_page)
.bind(offset)
.fetch_all(&state.db)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let total: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM licenses l \
JOIN users u ON u.id = l.owner_user_id \
WHERE ($1 = '' OR l.license_key ILIKE '%' || $1 || '%' OR u.email ILIKE '%' || $1 || '%') \
AND ($2 = '' OR l.status = $2)"
)
.bind(&search)
.bind(&status_filter)
.fetch_one(&state.db)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(PaginatedResponse {
data: licenses,
total: total.0,
page,
per_page,
}))
}
/// GET /api/admin/licenses/:id — detail view
async fn get_license(
_admin: SuperAdmin,
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<LicenseDetail>> {
let license = sqlx::query_as::<_, LicenseListItem>(
"SELECT l.id, l.license_key, l.status, u.email AS owner_email, \
u.username AS owner_username, l.server_name, l.modules_enabled, \
l.webstore_active, l.created_at, l.expires_at \
FROM licenses l \
JOIN users u ON u.id = l.owner_user_id \
WHERE l.id = $1"
)
.bind(id)
.fetch_optional(&state.db)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::NotFound("License not found".to_string()))?;
let team_count: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM team_members WHERE license_id = $1"
)
.bind(id)
.fetch_one(&state.db)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let wipe_count: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM wipe_history WHERE license_id = $1"
)
.bind(id)
.fetch_one(&state.db)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let server_connection = sqlx::query_as::<_, ServerConnectionInfo>(
"SELECT connection_type, connection_status, server_ip, game_port, \
plugin_last_seen, companion_last_seen \
FROM server_connections WHERE license_id = $1"
)
.bind(id)
.fetch_optional(&state.db)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(LicenseDetail {
license,
team_count: team_count.0,
wipe_count: wipe_count.0,
server_connection,
}))
}
/// POST /api/admin/licenses — manual generation
async fn create_license(
_admin: SuperAdmin,
State(state): State<Arc<AppState>>,
Json(body): Json<CreateLicenseRequest>,
) -> ApiResult<Json<serde_json::Value>> {
// Find user by email
let user = crate::db::users::get_user_by_email(&state.db, &body.owner_email)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::NotFound("User not found".to_string()))?;
let license_key = body.license_key.unwrap_or_else(|| {
format!("CORROSION-{}", crate::services::encryption::generate_token(8).to_uppercase())
});
let id = crate::db::licenses::create_license(&state.db, &license_key, user.id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(serde_json::json!({
"id": id,
"license_key": license_key,
})))
}
/// PATCH /api/admin/licenses/:id — revoke/activate
async fn update_license(
_admin: SuperAdmin,
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(body): Json<UpdateLicenseRequest>,
) -> ApiResult<Json<serde_json::Value>> {
if let Some(status) = &body.status {
let valid = ["active", "suspended", "expired", "revoked"];
if !valid.contains(&status.as_str()) {
return Err(ApiError::BadRequest(format!("Invalid status: {status}")));
}
crate::db::licenses::update_license_status(&state.db, id, status)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
}
if let Some(expires_at) = &body.expires_at {
let parsed = chrono::DateTime::parse_from_rfc3339(expires_at)
.map_err(|_| ApiError::BadRequest("Invalid date format, use RFC3339".to_string()))?;
sqlx::query("UPDATE licenses SET expires_at = $1 WHERE id = $2")
.bind(parsed)
.bind(id)
.execute(&state.db)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
}
Ok(Json(serde_json::json!({ "updated": true })))
}
/// GET /api/admin/subscriptions — module subscription list
async fn list_subscriptions(
_admin: SuperAdmin,
State(state): State<Arc<AppState>>,
) -> ApiResult<Json<SubscriptionSummary>> {
// Unnest modules_enabled array to get per-module subscription data
let subscriptions = sqlx::query_as::<_, SubscriptionListItem>(
"SELECT u.email AS owner_email, u.username AS owner_username, \
l.id AS license_id, unnest(l.modules_enabled) AS module_name \
FROM licenses l \
JOIN users u ON u.id = l.owner_user_id \
WHERE l.status = 'active' AND array_length(l.modules_enabled, 1) > 0 \
ORDER BY u.email"
)
.fetch_all(&state.db)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let module_counts = sqlx::query_as::<_, ModuleCount>(
"SELECT m AS module_name, COUNT(*) AS subscriber_count \
FROM licenses, unnest(modules_enabled) AS m \
WHERE status = 'active' AND array_length(modules_enabled, 1) > 0 \
GROUP BY m ORDER BY subscriber_count DESC"
)
.fetch_all(&state.db)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let total: i64 = module_counts.iter().map(|m| m.subscriber_count).sum();
Ok(Json(SubscriptionSummary {
subscriptions,
module_counts,
total_subscribers: total,
}))
}
/// GET /api/admin/users — paginated, filterable
async fn list_users(
_admin: SuperAdmin,
State(state): State<Arc<AppState>>,
Query(params): Query<PaginationParams>,
) -> ApiResult<Json<PaginatedResponse<UserListItem>>> {
let page = params.page.unwrap_or(1).max(1);
let per_page = params.per_page.unwrap_or(25).min(100);
let offset = (page - 1) * per_page;
let search = params.search.unwrap_or_default();
let users = sqlx::query_as::<_, UserListItem>(
"SELECT u.id, u.email, u.username, u.is_super_admin, u.email_verified, \
COUNT(l.id) AS license_count, u.created_at, u.last_login_at \
FROM users u \
LEFT JOIN licenses l ON l.owner_user_id = u.id \
WHERE ($1 = '' OR u.email ILIKE '%' || $1 || '%' OR u.username ILIKE '%' || $1 || '%') \
GROUP BY u.id \
ORDER BY u.created_at DESC \
LIMIT $2 OFFSET $3"
)
.bind(&search)
.bind(per_page)
.bind(offset)
.fetch_all(&state.db)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let total: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM users \
WHERE ($1 = '' OR email ILIKE '%' || $1 || '%' OR username ILIKE '%' || $1 || '%')"
)
.bind(&search)
.fetch_one(&state.db)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(PaginatedResponse {
data: users,
total: total.0,
page,
per_page,
}))
}
/// PATCH /api/admin/users/:id — disable/enable/set-admin
async fn update_user(
_admin: SuperAdmin,
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(body): Json<UpdateUserRequest>,
) -> ApiResult<Json<serde_json::Value>> {
// Verify user exists
let _user = crate::db::users::get_user_by_id(&state.db, id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::NotFound("User not found".to_string()))?;
if let Some(is_admin) = body.is_super_admin {
sqlx::query("UPDATE users SET is_super_admin = $1 WHERE id = $2")
.bind(is_admin)
.bind(id)
.execute(&state.db)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
}
// "Disabled" means we revoke all their licenses
if body.disabled == Some(true) {
sqlx::query("UPDATE licenses SET status = 'suspended' WHERE owner_user_id = $1")
.bind(id)
.execute(&state.db)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
}
Ok(Json(serde_json::json!({ "updated": true })))
}
/// GET /api/admin/servers — fleet overview
async fn list_servers(
_admin: SuperAdmin,
State(state): State<Arc<AppState>>,
Query(params): Query<PaginationParams>,
) -> ApiResult<Json<Vec<ServerFleetItem>>> {
let status_filter = params.status.unwrap_or_default();
let servers = sqlx::query_as::<_, ServerFleetItem>(
"SELECT sc.license_id, u.email AS owner_email, \
cfg.server_name, sc.connection_type, sc.connection_status, \
sc.server_ip, sc.game_port, sc.plugin_last_seen, sc.companion_last_seen \
FROM server_connections sc \
JOIN licenses l ON l.id = sc.license_id \
JOIN users u ON u.id = l.owner_user_id \
LEFT JOIN server_config cfg ON cfg.license_id = sc.license_id \
WHERE ($1 = '' OR sc.connection_status = $1) \
ORDER BY sc.connection_status ASC, u.email ASC"
)
.bind(&status_filter)
.fetch_all(&state.db)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(servers))
}
/// GET /api/admin/health — system metrics
async fn health(
_admin: SuperAdmin,
State(state): State<Arc<AppState>>,
) -> ApiResult<Json<HealthInfo>> {
let tables = vec![
"users", "licenses", "server_connections", "wipe_history",
"chat_logs", "plugin_registry", "webstore_transactions",
];
let mut table_counts = Vec::new();
for table in tables {
let count: (i64,) = sqlx::query_as(&format!("SELECT COUNT(*) FROM {table}"))
.fetch_one(&state.db)
.await
.unwrap_or((0,));
table_counts.push(TableCount {
table_name: table.to_string(),
row_count: count.0,
});
}
Ok(Json(HealthInfo {
db_pool_size: state.config.database_max_connections,
nats_connected: state.nats.is_some(),
table_counts,
}))
}