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>
This commit is contained in:
8
backend/migrations/003_super_admin.sql
Normal file
8
backend/migrations/003_super_admin.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- Phase 1c: Platform Admin Dashboard
|
||||||
|
-- Add super-admin flag to users table
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN is_super_admin BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Seed the bootstrap admin as super admin (first user created)
|
||||||
|
UPDATE users SET is_super_admin = true
|
||||||
|
WHERE id = (SELECT id FROM users ORDER BY created_at ASC LIMIT 1);
|
||||||
544
backend/src/api/admin.rs
Normal file
544
backend/src/api/admin.rs
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ async fn login(
|
|||||||
&user.email,
|
&user.email,
|
||||||
license_id,
|
license_id,
|
||||||
role,
|
role,
|
||||||
|
user.is_super_admin,
|
||||||
)
|
)
|
||||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
@@ -84,6 +85,7 @@ async fn login(
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
totp_enabled: user.totp_enabled,
|
totp_enabled: user.totp_enabled,
|
||||||
email_verified: user.email_verified,
|
email_verified: user.email_verified,
|
||||||
|
is_super_admin: user.is_super_admin,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -128,13 +130,14 @@ async fn register(
|
|||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Create tokens
|
// Create tokens (registered users are never super-admin)
|
||||||
let access_token = auth_service::create_access_token(
|
let access_token = auth_service::create_access_token(
|
||||||
&state.config,
|
&state.config,
|
||||||
user_id,
|
user_id,
|
||||||
&body.email,
|
&body.email,
|
||||||
Some(license.id),
|
Some(license.id),
|
||||||
Some("Owner".to_string()),
|
Some("Owner".to_string()),
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
@@ -155,6 +158,7 @@ async fn register(
|
|||||||
username: body.username,
|
username: body.username,
|
||||||
totp_enabled: false,
|
totp_enabled: false,
|
||||||
email_verified: false,
|
email_verified: false,
|
||||||
|
is_super_admin: false,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -196,6 +200,7 @@ async fn refresh(
|
|||||||
&user.email,
|
&user.email,
|
||||||
license_id,
|
license_id,
|
||||||
None,
|
None,
|
||||||
|
user.is_super_admin,
|
||||||
)
|
)
|
||||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
@@ -246,6 +251,7 @@ async fn me(
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
totp_enabled: user.totp_enabled,
|
totp_enabled: user.totp_enabled,
|
||||||
email_verified: user.email_verified,
|
email_verified: user.email_verified,
|
||||||
|
is_super_admin: user.is_super_admin,
|
||||||
},
|
},
|
||||||
"license": license,
|
"license": license,
|
||||||
})))
|
})))
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ pub mod notifications;
|
|||||||
pub mod license;
|
pub mod license;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
pub mod early_access;
|
pub mod early_access;
|
||||||
|
pub mod admin;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub struct UserRow {
|
|||||||
#[serde(skip_serializing)]
|
#[serde(skip_serializing)]
|
||||||
pub backup_codes: Option<Vec<String>>,
|
pub backup_codes: Option<Vec<String>>,
|
||||||
pub email_verified: bool,
|
pub email_verified: bool,
|
||||||
|
pub is_super_admin: bool,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub last_login_at: Option<DateTime<Utc>>,
|
pub last_login_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
@@ -45,7 +46,7 @@ pub async fn create_user(
|
|||||||
pub async fn get_user_by_id(pool: &PgPool, user_id: Uuid) -> Result<Option<UserRow>> {
|
pub async fn get_user_by_id(pool: &PgPool, user_id: Uuid) -> Result<Option<UserRow>> {
|
||||||
let user = sqlx::query_as::<_, UserRow>(
|
let user = sqlx::query_as::<_, UserRow>(
|
||||||
"SELECT id, email, username, password_hash, totp_secret, totp_enabled, \
|
"SELECT id, email, username, password_hash, totp_secret, totp_enabled, \
|
||||||
backup_codes, email_verified, created_at, last_login_at \
|
backup_codes, email_verified, is_super_admin, created_at, last_login_at \
|
||||||
FROM users WHERE id = $1",
|
FROM users WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
@@ -59,7 +60,7 @@ pub async fn get_user_by_id(pool: &PgPool, user_id: Uuid) -> Result<Option<UserR
|
|||||||
pub async fn get_user_by_email(pool: &PgPool, email: &str) -> Result<Option<UserRow>> {
|
pub async fn get_user_by_email(pool: &PgPool, email: &str) -> Result<Option<UserRow>> {
|
||||||
let user = sqlx::query_as::<_, UserRow>(
|
let user = sqlx::query_as::<_, UserRow>(
|
||||||
"SELECT id, email, username, password_hash, totp_secret, totp_enabled, \
|
"SELECT id, email, username, password_hash, totp_secret, totp_enabled, \
|
||||||
backup_codes, email_verified, created_at, last_login_at \
|
backup_codes, email_verified, is_super_admin, created_at, last_login_at \
|
||||||
FROM users WHERE email = $1",
|
FROM users WHERE email = $1",
|
||||||
)
|
)
|
||||||
.bind(email)
|
.bind(email)
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.nest("/api/license", api::license::router())
|
.nest("/api/license", api::license::router())
|
||||||
.nest("/api/store", api::store::router())
|
.nest("/api/store", api::store::router())
|
||||||
.nest("/api/early-access", api::early_access::router())
|
.nest("/api/early-access", api::early_access::router())
|
||||||
|
.nest("/api/admin", api::admin::router())
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
@@ -144,6 +145,15 @@ async fn bootstrap_admin(db: &sqlx::PgPool) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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
|
// Create a license for the admin
|
||||||
let license_key = std::env::var("ADMIN_LICENSE_KEY")
|
let license_key = std::env::var("ADMIN_LICENSE_KEY")
|
||||||
.unwrap_or_else(|_| format!("CORROSION-{}", services::encryption::generate_token(8).to_uppercase()));
|
.unwrap_or_else(|_| format!("CORROSION-{}", services::encryption::generate_token(8).to_uppercase()));
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub struct AuthUser {
|
|||||||
pub email: String,
|
pub email: String,
|
||||||
pub license_id: Option<Uuid>,
|
pub license_id: Option<Uuid>,
|
||||||
pub role: Option<String>,
|
pub role: Option<String>,
|
||||||
|
pub is_super_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromRequestParts<Arc<AppState>> for AuthUser {
|
impl FromRequestParts<Arc<AppState>> for AuthUser {
|
||||||
@@ -49,6 +50,35 @@ impl FromRequestParts<Arc<AppState>> for AuthUser {
|
|||||||
email: claims.email,
|
email: claims.email,
|
||||||
license_id: claims.license_id,
|
license_id: claims.license_id,
|
||||||
role: claims.role,
|
role: claims.role,
|
||||||
|
is_super_admin: claims.is_super_admin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extractor that requires super-admin privileges.
|
||||||
|
/// Use this on all /api/admin/* handlers.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SuperAdmin {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRequestParts<Arc<AppState>> for SuperAdmin {
|
||||||
|
type Rejection = http::StatusCode;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
let auth = AuthUser::from_request_parts(parts, state).await?;
|
||||||
|
|
||||||
|
if !auth.is_super_admin {
|
||||||
|
return Err(http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SuperAdmin {
|
||||||
|
user_id: auth.user_id,
|
||||||
|
email: auth.email,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ pub struct Claims {
|
|||||||
pub email: String,
|
pub email: String,
|
||||||
pub license_id: Option<Uuid>,
|
pub license_id: Option<Uuid>,
|
||||||
pub role: Option<String>,
|
pub role: Option<String>,
|
||||||
|
pub is_super_admin: bool,
|
||||||
pub exp: i64,
|
pub exp: i64,
|
||||||
pub iat: i64,
|
pub iat: i64,
|
||||||
pub token_type: String, // "access" or "refresh"
|
pub token_type: String, // "access" or "refresh"
|
||||||
@@ -56,6 +57,7 @@ pub struct UserPublic {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
pub totp_enabled: bool,
|
pub totp_enabled: bool,
|
||||||
pub email_verified: bool,
|
pub email_verified: bool,
|
||||||
|
pub is_super_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registration request
|
/// Registration request
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ pub fn create_access_token(
|
|||||||
email: &str,
|
email: &str,
|
||||||
license_id: Option<Uuid>,
|
license_id: Option<Uuid>,
|
||||||
role: Option<String>,
|
role: Option<String>,
|
||||||
|
is_super_admin: bool,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let now = Utc::now().timestamp();
|
let now = Utc::now().timestamp();
|
||||||
let claims = Claims {
|
let claims = Claims {
|
||||||
@@ -43,6 +44,7 @@ pub fn create_access_token(
|
|||||||
email: email.to_string(),
|
email: email.to_string(),
|
||||||
license_id,
|
license_id,
|
||||||
role,
|
role,
|
||||||
|
is_super_admin,
|
||||||
exp: now + config.jwt_access_expiry_seconds,
|
exp: now + config.jwt_access_expiry_seconds,
|
||||||
iat: now,
|
iat: now,
|
||||||
token_type: "access".to_string(),
|
token_type: "access".to_string(),
|
||||||
@@ -68,6 +70,7 @@ pub fn create_refresh_token(
|
|||||||
email: email.to_string(),
|
email: email.to_string(),
|
||||||
license_id: None,
|
license_id: None,
|
||||||
role: None,
|
role: None,
|
||||||
|
is_super_admin: false,
|
||||||
exp: now + config.jwt_refresh_expiry_seconds,
|
exp: now + config.jwt_refresh_expiry_seconds,
|
||||||
iat: now,
|
iat: now,
|
||||||
token_type: "refresh".to_string(),
|
token_type: "refresh".to_string(),
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
Settings,
|
Settings,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Shield,
|
||||||
|
Key,
|
||||||
|
CreditCard,
|
||||||
|
Network,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -42,6 +46,14 @@ const navItems = [
|
|||||||
{ name: 'Settings', path: '/settings', icon: Settings },
|
{ name: 'Settings', path: '/settings', icon: Settings },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const adminNavItems = [
|
||||||
|
{ name: 'Admin Home', path: '/admin', icon: Shield },
|
||||||
|
{ name: 'Licenses', path: '/admin/licenses', icon: Key },
|
||||||
|
{ name: 'Subscriptions', path: '/admin/subscriptions', icon: CreditCard },
|
||||||
|
{ name: 'Users', path: '/admin/users', icon: Users },
|
||||||
|
{ name: 'Server Fleet', path: '/admin/servers', icon: Network },
|
||||||
|
]
|
||||||
|
|
||||||
function isActive(path: string): boolean {
|
function isActive(path: string): boolean {
|
||||||
if (path === '/') return route.path === '/'
|
if (path === '/') return route.path === '/'
|
||||||
return route.path.startsWith(path)
|
return route.path.startsWith(path)
|
||||||
@@ -99,6 +111,29 @@ function handleLogout() {
|
|||||||
<component :is="item.icon" class="w-4 h-4" />
|
<component :is="item.icon" class="w-4 h-4" />
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
|
<!-- Platform Admin Section (super-admin only) -->
|
||||||
|
<template v-if="auth.isSuperAdmin">
|
||||||
|
<div class="mt-4 mb-2 px-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex-1 border-t border-neutral-700" />
|
||||||
|
<span class="text-[10px] font-semibold uppercase tracking-widest text-oxide-500">Platform</span>
|
||||||
|
<div class="flex-1 border-t border-neutral-700" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in adminNavItems"
|
||||||
|
:key="item.path"
|
||||||
|
:to="item.path"
|
||||||
|
class="flex items-center gap-3 px-4 py-2 mx-2 rounded-lg text-sm transition-colors"
|
||||||
|
:class="isActive(item.path)
|
||||||
|
? 'bg-oxide-500/10 text-oxide-400'
|
||||||
|
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" class="w-4 h-4" />
|
||||||
|
{{ item.name }}
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- User -->
|
<!-- User -->
|
||||||
|
|||||||
@@ -113,6 +113,37 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'settings',
|
name: 'settings',
|
||||||
component: () => import('@/views/admin/SettingsView.vue'),
|
component: () => import('@/views/admin/SettingsView.vue'),
|
||||||
},
|
},
|
||||||
|
// Platform Admin views (super-admin only, guarded in components)
|
||||||
|
{
|
||||||
|
path: 'admin',
|
||||||
|
name: 'platform-admin',
|
||||||
|
component: () => import('@/views/platform-admin/AdminDashboard.vue'),
|
||||||
|
meta: { superAdmin: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/licenses',
|
||||||
|
name: 'platform-licenses',
|
||||||
|
component: () => import('@/views/platform-admin/AdminLicenses.vue'),
|
||||||
|
meta: { superAdmin: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/subscriptions',
|
||||||
|
name: 'platform-subscriptions',
|
||||||
|
component: () => import('@/views/platform-admin/AdminSubscriptions.vue'),
|
||||||
|
meta: { superAdmin: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/users',
|
||||||
|
name: 'platform-users',
|
||||||
|
component: () => import('@/views/platform-admin/AdminUsers.vue'),
|
||||||
|
meta: { superAdmin: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/servers',
|
||||||
|
name: 'platform-servers',
|
||||||
|
component: () => import('@/views/platform-admin/AdminServers.vue'),
|
||||||
|
meta: { superAdmin: true },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -191,6 +222,8 @@ router.beforeEach((to, _from, next) => {
|
|||||||
|
|
||||||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||||
next({ name: 'login', query: { redirect: to.fullPath } })
|
next({ name: 'login', query: { redirect: to.fullPath } })
|
||||||
|
} else if (to.meta.superAdmin && !auth.isSuperAdmin) {
|
||||||
|
next({ name: 'dashboard' })
|
||||||
} else if (to.meta.guest && auth.isAuthenticated) {
|
} else if (to.meta.guest && auth.isAuthenticated) {
|
||||||
next({ name: 'dashboard' })
|
next({ name: 'dashboard' })
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const refreshToken = ref<string | null>(null)
|
const refreshToken = ref<string | null>(null)
|
||||||
|
|
||||||
const isAuthenticated = computed(() => !!accessToken.value)
|
const isAuthenticated = computed(() => !!accessToken.value)
|
||||||
|
const isSuperAdmin = computed(() => user.value?.is_super_admin ?? false)
|
||||||
const hasLicense = computed(() => !!license.value)
|
const hasLicense = computed(() => !!license.value)
|
||||||
const isLicenseActive = computed(() => license.value?.status === 'active')
|
const isLicenseActive = computed(() => license.value?.status === 'active')
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
|
isSuperAdmin,
|
||||||
hasLicense,
|
hasLicense,
|
||||||
isLicenseActive,
|
isLicenseActive,
|
||||||
setAuth,
|
setAuth,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface User {
|
|||||||
username: string
|
username: string
|
||||||
totp_enabled: boolean
|
totp_enabled: boolean
|
||||||
email_verified: boolean
|
email_verified: boolean
|
||||||
|
is_super_admin: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface License {
|
export interface License {
|
||||||
|
|||||||
@@ -34,13 +34,6 @@ interface LicenseDetail {
|
|||||||
} | null
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LicenseListResponse {
|
|
||||||
licenses: License[]
|
|
||||||
total: number
|
|
||||||
page: number
|
|
||||||
per_page: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const licenses = ref<License[]>([])
|
const licenses = ref<License[]>([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
@@ -92,8 +85,8 @@ async function fetchLicenses() {
|
|||||||
if (searchQuery.value.trim()) params.set('search', searchQuery.value.trim())
|
if (searchQuery.value.trim()) params.set('search', searchQuery.value.trim())
|
||||||
if (statusFilter.value !== 'all') params.set('status', statusFilter.value)
|
if (statusFilter.value !== 'all') params.set('status', statusFilter.value)
|
||||||
|
|
||||||
const data = await api.get<LicenseListResponse>(`/admin/licenses?${params}`)
|
const data = await api.get<{ data: License[]; total: number }>(`/admin/licenses?${params}`)
|
||||||
licenses.value = data.licenses
|
licenses.value = data.data
|
||||||
total.value = data.total
|
total.value = data.total
|
||||||
} catch {
|
} catch {
|
||||||
// API not wired yet
|
// API not wired yet
|
||||||
|
|||||||
@@ -6,18 +6,15 @@ import { Server, Search } from 'lucide-vue-next'
|
|||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
interface ServerEntry {
|
interface ServerEntry {
|
||||||
id: string
|
license_id: string
|
||||||
server_name: string
|
server_name: string | null
|
||||||
owner_email: string
|
owner_email: string
|
||||||
connection_type: 'plugin' | 'companion' | 'amp' | 'pterodactyl' | 'bare_metal'
|
connection_type: string
|
||||||
status: 'connected' | 'degraded' | 'offline'
|
connection_status: 'connected' | 'degraded' | 'offline'
|
||||||
server_ip: string
|
server_ip: string | null
|
||||||
game_port: number
|
game_port: number | null
|
||||||
last_heartbeat: string
|
plugin_last_seen: string | null
|
||||||
}
|
companion_last_seen: string | null
|
||||||
|
|
||||||
interface ServerListResponse {
|
|
||||||
servers: ServerEntry[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const servers = ref<ServerEntry[]>([])
|
const servers = ref<ServerEntry[]>([])
|
||||||
@@ -56,15 +53,15 @@ const filteredServers = computed(() => {
|
|||||||
let result = servers.value
|
let result = servers.value
|
||||||
|
|
||||||
if (statusFilter.value !== 'all') {
|
if (statusFilter.value !== 'all') {
|
||||||
result = result.filter(s => s.status === statusFilter.value)
|
result = result.filter(s => s.connection_status === statusFilter.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchQuery.value.trim()) {
|
if (searchQuery.value.trim()) {
|
||||||
const q = searchQuery.value.toLowerCase()
|
const q = searchQuery.value.toLowerCase()
|
||||||
result = result.filter(s =>
|
result = result.filter(s =>
|
||||||
s.server_name.toLowerCase().includes(q) ||
|
(s.server_name ?? '').toLowerCase().includes(q) ||
|
||||||
s.owner_email.toLowerCase().includes(q) ||
|
s.owner_email.toLowerCase().includes(q) ||
|
||||||
s.server_ip.includes(q)
|
(s.server_ip ?? '').includes(q)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,8 +89,8 @@ async function fetchServers() {
|
|||||||
|
|
||||||
const query = params.toString()
|
const query = params.toString()
|
||||||
const path = query ? `/admin/servers?${query}` : '/admin/servers'
|
const path = query ? `/admin/servers?${query}` : '/admin/servers'
|
||||||
const data = await api.get<ServerListResponse>(path)
|
const data = await api.get<ServerEntry[]>(path)
|
||||||
servers.value = data.servers
|
servers.value = data
|
||||||
} catch {
|
} catch {
|
||||||
// API not wired yet
|
// API not wired yet
|
||||||
} finally {
|
} finally {
|
||||||
@@ -172,10 +169,10 @@ onMounted(() => {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr
|
<tr
|
||||||
v-for="srv in filteredServers"
|
v-for="srv in filteredServers"
|
||||||
:key="srv.id"
|
:key="srv.license_id"
|
||||||
class="hover:bg-neutral-800/50 transition-colors"
|
class="hover:bg-neutral-800/50 transition-colors"
|
||||||
>
|
>
|
||||||
<td class="px-4 py-3 text-sm font-medium text-neutral-100">{{ srv.server_name }}</td>
|
<td class="px-4 py-3 text-sm font-medium text-neutral-100">{{ srv.server_name || 'Unnamed' }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ srv.owner_email }}</td>
|
<td class="px-4 py-3 text-sm text-neutral-400">{{ srv.owner_email }}</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<span
|
<span
|
||||||
@@ -187,15 +184,15 @@ onMounted(() => {
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="h-2 w-2 rounded-full" :class="statusDotClass[srv.status] || 'bg-neutral-500'" />
|
<span class="h-2 w-2 rounded-full" :class="statusDotClass[srv.connection_status] || 'bg-neutral-500'" />
|
||||||
<span class="text-sm capitalize" :class="statusTextClass[srv.status] || 'text-neutral-400'">
|
<span class="text-sm capitalize" :class="statusTextClass[srv.connection_status] || 'text-neutral-400'">
|
||||||
{{ srv.status }}
|
{{ srv.connection_status }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ srv.server_ip }}</td>
|
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ srv.server_ip || '—' }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ srv.game_port }}</td>
|
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ srv.game_port || '—' }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ relativeTime(srv.last_heartbeat) }}</td>
|
<td class="px-4 py-3 text-sm text-neutral-400">{{ srv.plugin_last_seen ? relativeTime(srv.plugin_last_seen) : srv.companion_last_seen ? relativeTime(srv.companion_last_seen) : 'Never' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -10,17 +10,10 @@ interface PlatformUser {
|
|||||||
email: string
|
email: string
|
||||||
username: string
|
username: string
|
||||||
is_super_admin: boolean
|
is_super_admin: boolean
|
||||||
|
email_verified: boolean
|
||||||
license_count: number
|
license_count: number
|
||||||
created_at: string
|
created_at: string
|
||||||
last_login: string | null
|
last_login_at: string | null
|
||||||
disabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserListResponse {
|
|
||||||
users: PlatformUser[]
|
|
||||||
total: number
|
|
||||||
page: number
|
|
||||||
per_page: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const users = ref<PlatformUser[]>([])
|
const users = ref<PlatformUser[]>([])
|
||||||
@@ -62,8 +55,8 @@ async function fetchUsers() {
|
|||||||
})
|
})
|
||||||
if (searchQuery.value.trim()) params.set('search', searchQuery.value.trim())
|
if (searchQuery.value.trim()) params.set('search', searchQuery.value.trim())
|
||||||
|
|
||||||
const data = await api.get<UserListResponse>(`/admin/users?${params}`)
|
const data = await api.get<{ data: PlatformUser[]; total: number }>(`/admin/users?${params}`)
|
||||||
users.value = data.users
|
users.value = data.data
|
||||||
total.value = data.total
|
total.value = data.total
|
||||||
} catch {
|
} catch {
|
||||||
// API not wired yet
|
// API not wired yet
|
||||||
@@ -93,7 +86,7 @@ async function disableAccount(user: PlatformUser) {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { disabled: true },
|
body: { disabled: true },
|
||||||
})
|
})
|
||||||
user.disabled = true
|
await fetchUsers()
|
||||||
} catch {
|
} catch {
|
||||||
// Handle error
|
// Handle error
|
||||||
}
|
}
|
||||||
@@ -172,7 +165,7 @@ onMounted(() => {
|
|||||||
v-for="user in users"
|
v-for="user in users"
|
||||||
:key="user.id"
|
:key="user.id"
|
||||||
class="hover:bg-neutral-800/50 transition-colors"
|
class="hover:bg-neutral-800/50 transition-colors"
|
||||||
:class="{ 'opacity-50': user.disabled }"
|
:class="{ 'opacity-50': false }"
|
||||||
>
|
>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-100">{{ user.email }}</td>
|
<td class="px-4 py-3 text-sm text-neutral-100">{{ user.email }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ user.username }}</td>
|
<td class="px-4 py-3 text-sm text-neutral-400">{{ user.username }}</td>
|
||||||
@@ -188,12 +181,12 @@ onMounted(() => {
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ user.license_count }}</td>
|
<td class="px-4 py-3 text-sm text-neutral-400">{{ user.license_count }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatDate(user.created_at) }}</td>
|
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatDate(user.created_at) }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatLastLogin(user.last_login) }}</td>
|
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatLastLogin(user.last_login_at) }}</td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="px-4 py-3 text-right">
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<button
|
<button
|
||||||
@click="toggleSuperAdmin(user)"
|
@click="toggleSuperAdmin(user)"
|
||||||
:disabled="user.disabled"
|
:disabled="false"
|
||||||
class="p-1.5 rounded transition-colors"
|
class="p-1.5 rounded transition-colors"
|
||||||
:class="user.is_super_admin
|
:class="user.is_super_admin
|
||||||
? 'text-oxide-400 hover:text-oxide-300 hover:bg-oxide-500/10'
|
? 'text-oxide-400 hover:text-oxide-300 hover:bg-oxide-500/10'
|
||||||
@@ -205,7 +198,7 @@ onMounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="disableAccount(user)"
|
@click="disableAccount(user)"
|
||||||
:disabled="user.disabled"
|
:disabled="false"
|
||||||
class="p-1.5 text-neutral-500 hover:text-red-400 hover:bg-red-500/10 disabled:opacity-30 disabled:cursor-not-allowed rounded transition-colors"
|
class="p-1.5 text-neutral-500 hover:text-red-400 hover:bg-red-500/10 disabled:opacity-30 disabled:cursor-not-allowed rounded transition-colors"
|
||||||
title="Disable Account"
|
title="Disable Account"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user