Files
corrosion-admin-panel/backend/src/services/auth.rs
Vantz Stockwell 88b50a30b4 feat: Phase 1c — Platform Admin Dashboard
Full super-admin dashboard for SaaS platform management:

Backend (10 files):
- Migration 003: Add is_super_admin column to users table
- JWT Claims: Carry is_super_admin through access tokens
- SuperAdmin extractor: Axum FromRequestParts that rejects non-admins (403)
- Admin API module: 10 endpoints behind /api/admin/*
  - GET /stats (KPIs: licenses, users, MRR, servers, signups)
  - GET/POST /licenses (paginated, filterable, manual generation)
  - GET/PATCH /licenses/:id (detail view, revoke/activate)
  - GET /subscriptions (module sub list with MRR breakdown)
  - GET/PATCH /users (paginated, toggle admin, disable accounts)
  - GET /servers (fleet overview across all licenses)
  - GET /health (DB pool, NATS status, table row counts)
- Bootstrap updated: first user gets is_super_admin = true

Frontend (8 files):
- 5 admin views in src/views/platform-admin/
- DashboardLayout: "Platform" nav section (gated on isSuperAdmin)
- Router: /admin/* routes with superAdmin meta guard
- Auth store: isSuperAdmin computed property
- Types: is_super_admin on User interface

Build: 80 chunks, zero TS errors, clean production build.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:07:38 -05:00

98 lines
2.7 KiB
Rust

use anyhow::{Context, Result};
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use chrono::Utc;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use uuid::Uuid;
use crate::config::AppConfig;
use crate::models::auth::Claims;
/// Hash a password using Argon2id.
pub fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("Password hashing failed: {e}"))?;
Ok(hash.to_string())
}
/// Verify a password against an Argon2id hash.
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
let parsed_hash = PasswordHash::new(hash)
.map_err(|e| anyhow::anyhow!("Invalid password hash: {e}"))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}
/// Create a JWT access token.
pub fn create_access_token(
config: &AppConfig,
user_id: Uuid,
email: &str,
license_id: Option<Uuid>,
role: Option<String>,
is_super_admin: bool,
) -> Result<String> {
let now = Utc::now().timestamp();
let claims = Claims {
sub: user_id,
email: email.to_string(),
license_id,
role,
is_super_admin,
exp: now + config.jwt_access_expiry_seconds,
iat: now,
token_type: "access".to_string(),
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(config.jwt_secret.as_bytes()),
)
.context("Failed to create access token")
}
/// Create a JWT refresh token (longer-lived).
pub fn create_refresh_token(
config: &AppConfig,
user_id: Uuid,
email: &str,
) -> Result<String> {
let now = Utc::now().timestamp();
let claims = Claims {
sub: user_id,
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(),
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(config.jwt_secret.as_bytes()),
)
.context("Failed to create refresh token")
}
/// Validate and decode a JWT token. Returns the claims on success.
pub fn validate_token(config: &AppConfig, token: &str) -> Result<Claims> {
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(config.jwt_secret.as_bytes()),
&Validation::default(),
)
.context("Invalid or expired token")?;
Ok(token_data.claims)
}