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,
|
||||
license_id,
|
||||
role,
|
||||
user.is_super_admin,
|
||||
)
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
@@ -84,6 +85,7 @@ async fn login(
|
||||
username: user.username,
|
||||
totp_enabled: user.totp_enabled,
|
||||
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(
|
||||
&state.config,
|
||||
user_id,
|
||||
&body.email,
|
||||
Some(license.id),
|
||||
Some("Owner".to_string()),
|
||||
false,
|
||||
)
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
@@ -155,6 +158,7 @@ async fn register(
|
||||
username: body.username,
|
||||
totp_enabled: false,
|
||||
email_verified: false,
|
||||
is_super_admin: false,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -196,6 +200,7 @@ async fn refresh(
|
||||
&user.email,
|
||||
license_id,
|
||||
None,
|
||||
user.is_super_admin,
|
||||
)
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
@@ -246,6 +251,7 @@ async fn me(
|
||||
username: user.username,
|
||||
totp_enabled: user.totp_enabled,
|
||||
email_verified: user.email_verified,
|
||||
is_super_admin: user.is_super_admin,
|
||||
},
|
||||
"license": license,
|
||||
})))
|
||||
|
||||
@@ -12,3 +12,4 @@ pub mod notifications;
|
||||
pub mod license;
|
||||
pub mod store;
|
||||
pub mod early_access;
|
||||
pub mod admin;
|
||||
|
||||
@@ -18,6 +18,7 @@ pub struct UserRow {
|
||||
#[serde(skip_serializing)]
|
||||
pub backup_codes: Option<Vec<String>>,
|
||||
pub email_verified: bool,
|
||||
pub is_super_admin: bool,
|
||||
pub created_at: 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>> {
|
||||
let user = sqlx::query_as::<_, UserRow>(
|
||||
"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",
|
||||
)
|
||||
.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>> {
|
||||
let user = sqlx::query_as::<_, UserRow>(
|
||||
"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",
|
||||
)
|
||||
.bind(email)
|
||||
|
||||
@@ -89,6 +89,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.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);
|
||||
@@ -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
|
||||
let license_key = std::env::var("ADMIN_LICENSE_KEY")
|
||||
.unwrap_or_else(|_| format!("CORROSION-{}", services::encryption::generate_token(8).to_uppercase()));
|
||||
|
||||
@@ -15,6 +15,7 @@ pub struct AuthUser {
|
||||
pub email: String,
|
||||
pub license_id: Option<Uuid>,
|
||||
pub role: Option<String>,
|
||||
pub is_super_admin: bool,
|
||||
}
|
||||
|
||||
impl FromRequestParts<Arc<AppState>> for AuthUser {
|
||||
@@ -49,6 +50,35 @@ impl FromRequestParts<Arc<AppState>> for AuthUser {
|
||||
email: claims.email,
|
||||
license_id: claims.license_id,
|
||||
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 license_id: Option<Uuid>,
|
||||
pub role: Option<String>,
|
||||
pub is_super_admin: bool,
|
||||
pub exp: i64,
|
||||
pub iat: i64,
|
||||
pub token_type: String, // "access" or "refresh"
|
||||
@@ -56,6 +57,7 @@ pub struct UserPublic {
|
||||
pub username: String,
|
||||
pub totp_enabled: bool,
|
||||
pub email_verified: bool,
|
||||
pub is_super_admin: bool,
|
||||
}
|
||||
|
||||
/// Registration request
|
||||
|
||||
@@ -36,6 +36,7 @@ pub fn create_access_token(
|
||||
email: &str,
|
||||
license_id: Option<Uuid>,
|
||||
role: Option<String>,
|
||||
is_super_admin: bool,
|
||||
) -> Result<String> {
|
||||
let now = Utc::now().timestamp();
|
||||
let claims = Claims {
|
||||
@@ -43,6 +44,7 @@ pub fn create_access_token(
|
||||
email: email.to_string(),
|
||||
license_id,
|
||||
role,
|
||||
is_super_admin,
|
||||
exp: now + config.jwt_access_expiry_seconds,
|
||||
iat: now,
|
||||
token_type: "access".to_string(),
|
||||
@@ -68,6 +70,7 @@ pub fn create_refresh_token(
|
||||
email: email.to_string(),
|
||||
license_id: None,
|
||||
role: None,
|
||||
is_super_admin: false,
|
||||
exp: now + config.jwt_refresh_expiry_seconds,
|
||||
iat: now,
|
||||
token_type: "refresh".to_string(),
|
||||
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
Package,
|
||||
Settings,
|
||||
LogOut,
|
||||
Shield,
|
||||
Key,
|
||||
CreditCard,
|
||||
Network,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -42,6 +46,14 @@ const navItems = [
|
||||
{ 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 {
|
||||
if (path === '/') return route.path === '/'
|
||||
return route.path.startsWith(path)
|
||||
@@ -99,6 +111,29 @@ function handleLogout() {
|
||||
<component :is="item.icon" class="w-4 h-4" />
|
||||
{{ item.name }}
|
||||
</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>
|
||||
|
||||
<!-- User -->
|
||||
|
||||
@@ -113,6 +113,37 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'settings',
|
||||
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) {
|
||||
next({ name: 'login', query: { redirect: to.fullPath } })
|
||||
} else if (to.meta.superAdmin && !auth.isSuperAdmin) {
|
||||
next({ name: 'dashboard' })
|
||||
} else if (to.meta.guest && auth.isAuthenticated) {
|
||||
next({ name: 'dashboard' })
|
||||
} else {
|
||||
|
||||
@@ -9,6 +9,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const refreshToken = ref<string | null>(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!accessToken.value)
|
||||
const isSuperAdmin = computed(() => user.value?.is_super_admin ?? false)
|
||||
const hasLicense = computed(() => !!license.value)
|
||||
const isLicenseActive = computed(() => license.value?.status === 'active')
|
||||
|
||||
@@ -39,6 +40,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
isAuthenticated,
|
||||
isSuperAdmin,
|
||||
hasLicense,
|
||||
isLicenseActive,
|
||||
setAuth,
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface User {
|
||||
username: string
|
||||
totp_enabled: boolean
|
||||
email_verified: boolean
|
||||
is_super_admin: boolean
|
||||
}
|
||||
|
||||
export interface License {
|
||||
|
||||
@@ -34,13 +34,6 @@ interface LicenseDetail {
|
||||
} | null
|
||||
}
|
||||
|
||||
interface LicenseListResponse {
|
||||
licenses: License[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
}
|
||||
|
||||
const licenses = ref<License[]>([])
|
||||
const isLoading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
@@ -92,8 +85,8 @@ async function fetchLicenses() {
|
||||
if (searchQuery.value.trim()) params.set('search', searchQuery.value.trim())
|
||||
if (statusFilter.value !== 'all') params.set('status', statusFilter.value)
|
||||
|
||||
const data = await api.get<LicenseListResponse>(`/admin/licenses?${params}`)
|
||||
licenses.value = data.licenses
|
||||
const data = await api.get<{ data: License[]; total: number }>(`/admin/licenses?${params}`)
|
||||
licenses.value = data.data
|
||||
total.value = data.total
|
||||
} catch {
|
||||
// API not wired yet
|
||||
|
||||
@@ -6,18 +6,15 @@ import { Server, Search } from 'lucide-vue-next'
|
||||
const api = useApi()
|
||||
|
||||
interface ServerEntry {
|
||||
id: string
|
||||
server_name: string
|
||||
license_id: string
|
||||
server_name: string | null
|
||||
owner_email: string
|
||||
connection_type: 'plugin' | 'companion' | 'amp' | 'pterodactyl' | 'bare_metal'
|
||||
status: 'connected' | 'degraded' | 'offline'
|
||||
server_ip: string
|
||||
game_port: number
|
||||
last_heartbeat: string
|
||||
}
|
||||
|
||||
interface ServerListResponse {
|
||||
servers: ServerEntry[]
|
||||
connection_type: string
|
||||
connection_status: 'connected' | 'degraded' | 'offline'
|
||||
server_ip: string | null
|
||||
game_port: number | null
|
||||
plugin_last_seen: string | null
|
||||
companion_last_seen: string | null
|
||||
}
|
||||
|
||||
const servers = ref<ServerEntry[]>([])
|
||||
@@ -56,15 +53,15 @@ const filteredServers = computed(() => {
|
||||
let result = servers.value
|
||||
|
||||
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()) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
result = result.filter(s =>
|
||||
s.server_name.toLowerCase().includes(q) ||
|
||||
(s.server_name ?? '').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 path = query ? `/admin/servers?${query}` : '/admin/servers'
|
||||
const data = await api.get<ServerListResponse>(path)
|
||||
servers.value = data.servers
|
||||
const data = await api.get<ServerEntry[]>(path)
|
||||
servers.value = data
|
||||
} catch {
|
||||
// API not wired yet
|
||||
} finally {
|
||||
@@ -172,10 +169,10 @@ onMounted(() => {
|
||||
</tr>
|
||||
<tr
|
||||
v-for="srv in filteredServers"
|
||||
:key="srv.id"
|
||||
:key="srv.license_id"
|
||||
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">
|
||||
<span
|
||||
@@ -187,15 +184,15 @@ onMounted(() => {
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full" :class="statusDotClass[srv.status] || 'bg-neutral-500'" />
|
||||
<span class="text-sm capitalize" :class="statusTextClass[srv.status] || 'text-neutral-400'">
|
||||
{{ srv.status }}
|
||||
<span class="h-2 w-2 rounded-full" :class="statusDotClass[srv.connection_status] || 'bg-neutral-500'" />
|
||||
<span class="text-sm capitalize" :class="statusTextClass[srv.connection_status] || 'text-neutral-400'">
|
||||
{{ srv.connection_status }}
|
||||
</span>
|
||||
</div>
|
||||
</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">{{ relativeTime(srv.last_heartbeat) }}</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">{{ srv.plugin_last_seen ? relativeTime(srv.plugin_last_seen) : srv.companion_last_seen ? relativeTime(srv.companion_last_seen) : 'Never' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -10,17 +10,10 @@ interface PlatformUser {
|
||||
email: string
|
||||
username: string
|
||||
is_super_admin: boolean
|
||||
email_verified: boolean
|
||||
license_count: number
|
||||
created_at: string
|
||||
last_login: string | null
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
interface UserListResponse {
|
||||
users: PlatformUser[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
last_login_at: string | null
|
||||
}
|
||||
|
||||
const users = ref<PlatformUser[]>([])
|
||||
@@ -62,8 +55,8 @@ async function fetchUsers() {
|
||||
})
|
||||
if (searchQuery.value.trim()) params.set('search', searchQuery.value.trim())
|
||||
|
||||
const data = await api.get<UserListResponse>(`/admin/users?${params}`)
|
||||
users.value = data.users
|
||||
const data = await api.get<{ data: PlatformUser[]; total: number }>(`/admin/users?${params}`)
|
||||
users.value = data.data
|
||||
total.value = data.total
|
||||
} catch {
|
||||
// API not wired yet
|
||||
@@ -93,7 +86,7 @@ async function disableAccount(user: PlatformUser) {
|
||||
method: 'PATCH',
|
||||
body: { disabled: true },
|
||||
})
|
||||
user.disabled = true
|
||||
await fetchUsers()
|
||||
} catch {
|
||||
// Handle error
|
||||
}
|
||||
@@ -172,7 +165,7 @@ onMounted(() => {
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
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-400">{{ user.username }}</td>
|
||||
@@ -188,12 +181,12 @@ onMounted(() => {
|
||||
</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">{{ 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">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button
|
||||
@click="toggleSuperAdmin(user)"
|
||||
:disabled="user.disabled"
|
||||
:disabled="false"
|
||||
class="p-1.5 rounded transition-colors"
|
||||
:class="user.is_super_admin
|
||||
? 'text-oxide-400 hover:text-oxide-300 hover:bg-oxide-500/10'
|
||||
@@ -205,7 +198,7 @@ onMounted(() => {
|
||||
</button>
|
||||
<button
|
||||
@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"
|
||||
title="Disable Account"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user