Files
corrosion-admin-panel/backend/src/main.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

170 lines
5.2 KiB
Rust

use std::sync::Arc;
use axum::Router;
use sqlx::postgres::PgPoolOptions;
use tokio::net::TcpListener;
use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod api;
mod config;
mod db;
mod middleware;
mod models;
mod services;
use config::AppConfig;
/// Shared application state available to all handlers
pub struct AppState {
pub db: sqlx::PgPool,
pub nats: Option<async_nats::Client>,
pub config: AppConfig,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Load environment variables
dotenvy::dotenv().ok();
// Initialize tracing
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
"corrosion_api=debug,tower_http=debug".into()
}))
.with(tracing_subscriber::fmt::layer())
.init();
let config = AppConfig::from_env()?;
// Database connection pool
let db = PgPoolOptions::new()
.max_connections(config.database_max_connections)
.connect(&config.database_url)
.await?;
tracing::info!("Connected to PostgreSQL");
// Run migrations
sqlx::migrate!("./migrations").run(&db).await?;
tracing::info!("Database migrations applied");
// NATS connection (optional for dev)
let nats = match async_nats::connect(&config.nats_url).await {
Ok(client) => {
tracing::info!("Connected to NATS at {}", config.nats_url);
Some(client)
}
Err(e) => {
tracing::warn!("NATS not available ({}), running without event bus", e);
None
}
};
// Bootstrap: create admin user + license on first run
bootstrap_admin(&db).await;
let state = Arc::new(AppState { db, nats, config });
// CORS — permissive in dev, locked down in production
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
// Build router
let app = Router::new()
.nest("/api/auth", api::auth::router())
.nest("/api/servers", api::servers::router())
.nest("/api/wipes", api::wipes::router())
.nest("/api/maps", api::maps::router())
.nest("/api/plugins", api::plugins::router())
.nest("/api/panels", api::panels::router())
.nest("/api/schedules", api::schedules::router())
.nest("/api/logs", api::logs::router())
.nest("/api/public", api::public::router())
.nest("/api/team", api::team::router())
.nest("/api/notifications", api::notifications::router())
.nest("/api/license", api::license::router())
.nest("/api/store", api::store::router())
.nest("/api/early-access", api::early_access::router())
.nest("/api/admin", api::admin::router())
.layer(cors)
.layer(TraceLayer::new_for_http())
.with_state(state);
let bind_addr = "0.0.0.0:3000";
let listener = TcpListener::bind(bind_addr).await?;
tracing::info!("Corrosion API listening on {}", bind_addr);
axum::serve(listener, app).await?;
Ok(())
}
/// Bootstrap: if no users exist and ADMIN_EMAIL/ADMIN_PASSWORD are set,
/// create the initial admin user and a dev license key.
async fn bootstrap_admin(db: &sqlx::PgPool) {
let admin_email = std::env::var("ADMIN_EMAIL").unwrap_or_default();
let admin_password = std::env::var("ADMIN_PASSWORD").unwrap_or_default();
let admin_username = std::env::var("ADMIN_USERNAME").unwrap_or_else(|_| "Commander".to_string());
if admin_email.is_empty() || admin_password.is_empty() {
return;
}
// Check if any users exist
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(db)
.await
.unwrap_or((0,));
if count.0 > 0 {
tracing::debug!("Users already exist, skipping bootstrap");
return;
}
tracing::info!("No users found — bootstrapping admin user: {}", admin_email);
// Hash password
let password_hash = match services::auth::hash_password(&admin_password) {
Ok(h) => h,
Err(e) => {
tracing::error!("Failed to hash admin password: {e}");
return;
}
};
// Create admin user
let user_id = match db::users::create_user(db, &admin_email, &admin_username, &password_hash).await {
Ok(id) => id,
Err(e) => {
tracing::error!("Failed to create admin user: {e}");
return;
}
};
// Flag as super-admin
if let Err(e) = sqlx::query("UPDATE users SET is_super_admin = true WHERE id = $1")
.bind(user_id)
.execute(db)
.await
{
tracing::error!("Failed to set super-admin flag: {e}");
}
// Create a license for the admin
let license_key = std::env::var("ADMIN_LICENSE_KEY")
.unwrap_or_else(|_| format!("CORROSION-{}", services::encryption::generate_token(8).to_uppercase()));
match db::licenses::create_license(db, &license_key, user_id).await {
Ok(_) => {
tracing::info!("Bootstrap complete — admin: {}, license: {}", admin_email, license_key);
}
Err(e) => {
tracing::error!("Failed to create admin license: {e}");
}
}
}