diff --git a/backend/migrations/003_super_admin.sql b/backend/migrations/003_super_admin.sql new file mode 100644 index 0000000..8ed9026 --- /dev/null +++ b/backend/migrations/003_super_admin.sql @@ -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); diff --git a/backend/src/api/admin.rs b/backend/src/api/admin.rs new file mode 100644 index 0000000..07c8667 --- /dev/null +++ b/backend/src/api/admin.rs @@ -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> { + 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, + per_page: Option, + search: Option, + status: Option, +} + +#[derive(Serialize)] +struct PaginatedResponse { + data: Vec, + 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, + modules_enabled: Option>, + webstore_active: bool, + created_at: chrono::DateTime, + expires_at: Option>, +} + +#[derive(Serialize)] +struct LicenseDetail { + license: LicenseListItem, + team_count: i64, + wipe_count: i64, + server_connection: Option, +} + +#[derive(Serialize, sqlx::FromRow)] +struct ServerConnectionInfo { + connection_type: String, + connection_status: String, + server_ip: Option, + game_port: Option, + plugin_last_seen: Option>, + companion_last_seen: Option>, +} + +#[derive(Deserialize)] +struct CreateLicenseRequest { + owner_email: String, + license_key: Option, +} + +#[derive(Deserialize)] +struct UpdateLicenseRequest { + status: Option, + expires_at: Option, +} + +#[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, + last_login_at: Option>, +} + +#[derive(Deserialize)] +struct UpdateUserRequest { + is_super_admin: Option, + disabled: Option, +} + +#[derive(Serialize, sqlx::FromRow)] +struct SubscriptionListItem { + owner_email: String, + owner_username: String, + license_id: Uuid, + module_name: String, +} + +#[derive(Serialize)] +struct SubscriptionSummary { + subscriptions: Vec, + module_counts: Vec, + 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, + connection_type: String, + connection_status: String, + server_ip: Option, + game_port: Option, + plugin_last_seen: Option>, + companion_last_seen: Option>, +} + +#[derive(Serialize)] +struct HealthInfo { + db_pool_size: u32, + nats_connected: bool, + table_counts: Vec, +} + +#[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>, +) -> ApiResult> { + 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>, + Query(params): Query, +) -> ApiResult>> { + 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>, + Path(id): Path, +) -> ApiResult> { + 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>, + Json(body): Json, +) -> ApiResult> { + // 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>, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + 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>, +) -> ApiResult> { + // 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>, + Query(params): Query, +) -> ApiResult>> { + 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>, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + // 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>, + Query(params): Query, +) -> ApiResult>> { + 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>, +) -> ApiResult> { + 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, + })) +} diff --git a/backend/src/api/auth.rs b/backend/src/api/auth.rs index f755502..97538f2 100644 --- a/backend/src/api/auth.rs +++ b/backend/src/api/auth.rs @@ -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, }))) diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs index bf801ce..60708f4 100644 --- a/backend/src/api/mod.rs +++ b/backend/src/api/mod.rs @@ -12,3 +12,4 @@ pub mod notifications; pub mod license; pub mod store; pub mod early_access; +pub mod admin; diff --git a/backend/src/db/users.rs b/backend/src/db/users.rs index 5e38e6a..d15a167 100644 --- a/backend/src/db/users.rs +++ b/backend/src/db/users.rs @@ -18,6 +18,7 @@ pub struct UserRow { #[serde(skip_serializing)] pub backup_codes: Option>, pub email_verified: bool, + pub is_super_admin: bool, pub created_at: DateTime, pub last_login_at: Option>, } @@ -45,7 +46,7 @@ pub async fn create_user( pub async fn get_user_by_id(pool: &PgPool, user_id: Uuid) -> Result> { 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 Result> { 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) diff --git a/backend/src/main.rs b/backend/src/main.rs index abb3d0d..662544c 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -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())); diff --git a/backend/src/middleware/auth.rs b/backend/src/middleware/auth.rs index b1f36a6..8807d66 100644 --- a/backend/src/middleware/auth.rs +++ b/backend/src/middleware/auth.rs @@ -15,6 +15,7 @@ pub struct AuthUser { pub email: String, pub license_id: Option, pub role: Option, + pub is_super_admin: bool, } impl FromRequestParts> for AuthUser { @@ -49,6 +50,35 @@ impl FromRequestParts> 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> for SuperAdmin { + type Rejection = http::StatusCode; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + 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, }) } } diff --git a/backend/src/models/auth.rs b/backend/src/models/auth.rs index bb4107e..97afd96 100644 --- a/backend/src/models/auth.rs +++ b/backend/src/models/auth.rs @@ -27,6 +27,7 @@ pub struct Claims { pub email: String, pub license_id: Option, pub role: Option, + 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 diff --git a/backend/src/services/auth.rs b/backend/src/services/auth.rs index fd67e4b..a59e334 100644 --- a/backend/src/services/auth.rs +++ b/backend/src/services/auth.rs @@ -36,6 +36,7 @@ pub fn create_access_token( email: &str, license_id: Option, role: Option, + is_super_admin: bool, ) -> Result { 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(), diff --git a/frontend/src/components/layout/DashboardLayout.vue b/frontend/src/components/layout/DashboardLayout.vue index 1301217..ad9b5d4 100644 --- a/frontend/src/components/layout/DashboardLayout.vue +++ b/frontend/src/components/layout/DashboardLayout.vue @@ -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() { {{ item.name }} + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index f34dd26..8fad6b5 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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 { diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 943c2dc..f28d4d6 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -9,6 +9,7 @@ export const useAuthStore = defineStore('auth', () => { const refreshToken = ref(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, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 0f28d6a..5924955 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -6,6 +6,7 @@ export interface User { username: string totp_enabled: boolean email_verified: boolean + is_super_admin: boolean } export interface License { diff --git a/frontend/src/views/platform-admin/AdminLicenses.vue b/frontend/src/views/platform-admin/AdminLicenses.vue index d594cdb..2812a5e 100644 --- a/frontend/src/views/platform-admin/AdminLicenses.vue +++ b/frontend/src/views/platform-admin/AdminLicenses.vue @@ -34,13 +34,6 @@ interface LicenseDetail { } | null } -interface LicenseListResponse { - licenses: License[] - total: number - page: number - per_page: number -} - const licenses = ref([]) 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(`/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 diff --git a/frontend/src/views/platform-admin/AdminServers.vue b/frontend/src/views/platform-admin/AdminServers.vue index 4347268..d2445fa 100644 --- a/frontend/src/views/platform-admin/AdminServers.vue +++ b/frontend/src/views/platform-admin/AdminServers.vue @@ -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([]) @@ -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(path) - servers.value = data.servers + const data = await api.get(path) + servers.value = data } catch { // API not wired yet } finally { @@ -172,10 +169,10 @@ onMounted(() => { - {{ srv.server_name }} + {{ srv.server_name || 'Unnamed' }} {{ srv.owner_email }} {
- - - {{ srv.status }} + + + {{ srv.connection_status }}
- {{ srv.server_ip }} - {{ srv.game_port }} - {{ relativeTime(srv.last_heartbeat) }} + {{ srv.server_ip || '—' }} + {{ srv.game_port || '—' }} + {{ srv.plugin_last_seen ? relativeTime(srv.plugin_last_seen) : srv.companion_last_seen ? relativeTime(srv.companion_last_seen) : 'Never' }} diff --git a/frontend/src/views/platform-admin/AdminUsers.vue b/frontend/src/views/platform-admin/AdminUsers.vue index ddaca28..4e40975 100644 --- a/frontend/src/views/platform-admin/AdminUsers.vue +++ b/frontend/src/views/platform-admin/AdminUsers.vue @@ -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([]) @@ -62,8 +55,8 @@ async function fetchUsers() { }) if (searchQuery.value.trim()) params.set('search', searchQuery.value.trim()) - const data = await api.get(`/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 }" > {{ user.email }} {{ user.username }} @@ -188,12 +181,12 @@ onMounted(() => { {{ user.license_count }} {{ formatDate(user.created_at) }} - {{ formatLastLogin(user.last_login) }} + {{ formatLastLogin(user.last_login_at) }}