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:
Vantz Stockwell
2026-02-15 02:07:38 -05:00
parent 0ac1738c85
commit 88b50a30b4
16 changed files with 711 additions and 52 deletions

View 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
View 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,
}))
}

View File

@@ -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,
})))

View File

@@ -12,3 +12,4 @@ pub mod notifications;
pub mod license;
pub mod store;
pub mod early_access;
pub mod admin;

View File

@@ -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)

View File

@@ -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()));

View File

@@ -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,
})
}
}

View File

@@ -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

View File

@@ -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(),