feat: Implement initial setup service for admin user creation
- Added `SetupService` to handle the generation and validation of setup tokens. - Integrated setup token generation during application startup if no admin users exist. - Created API endpoints for checking setup status and completing the initial setup. - Updated `AuthService` to include functionality for creating the initial admin user. - Enhanced error handling for setup and authentication processes. - Added frontend components for login and protected routes. - Implemented Zustand store for managing authentication state. - Updated Vite configuration to check setup status and serve the setup page if required. - Documented the initial setup process in `setup.md`.
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2475,6 +2475,7 @@ dependencies = [
|
|||||||
"mockall",
|
"mockall",
|
||||||
"nxmesh-core",
|
"nxmesh-core",
|
||||||
"nxmesh-proto",
|
"nxmesh-proto",
|
||||||
|
"rand 0.8.5",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"sea-orm-migration",
|
"sea-orm-migration",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ uuid.workspace = true
|
|||||||
# Templating
|
# Templating
|
||||||
handlebars.workspace = true
|
handlebars.workspace = true
|
||||||
|
|
||||||
|
# Random generation
|
||||||
|
rand = "0.8"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test.workspace = true
|
tokio-test.workspace = true
|
||||||
mockall.workspace = true
|
mockall.workspace = true
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! API middleware
|
//! API middleware
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod setup_check;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::Request,
|
extract::Request,
|
||||||
|
|||||||
55
crates/nxmesh-master/src/api/middleware/setup_check.rs
Normal file
55
crates/nxmesh-master/src/api/middleware/setup_check.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
//! Setup check middleware
|
||||||
|
//!
|
||||||
|
//! This middleware checks if initial setup is required (no admin exists).
|
||||||
|
//! If setup is required and the request is for the main app (/, /index.html),
|
||||||
|
//! it serves the setup page instead.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::{Request, StatusCode},
|
||||||
|
middleware::Next,
|
||||||
|
response::{Html, IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::api::routes::AppState;
|
||||||
|
use crate::api::setup_page::SETUP_PAGE_HTML;
|
||||||
|
|
||||||
|
/// Paths that should be checked for setup requirement
|
||||||
|
const SETUP_CHECK_PATHS: &[&str] = &["/", "/index.html"];
|
||||||
|
|
||||||
|
/// Middleware that serves setup page when no admin exists
|
||||||
|
pub async fn setup_check_middleware(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
request: Request<axum::body::Body>,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
let path = request.uri().path();
|
||||||
|
|
||||||
|
// Only intercept main app paths
|
||||||
|
if !SETUP_CHECK_PATHS.contains(&path) {
|
||||||
|
return next.run(request).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if admin exists
|
||||||
|
match state.setup_service.has_admin_users().await {
|
||||||
|
Ok(true) => {
|
||||||
|
// Admin exists, serve normal app
|
||||||
|
next.run(request).await
|
||||||
|
}
|
||||||
|
Ok(false) => {
|
||||||
|
// No admin, serve setup page
|
||||||
|
tracing::info!("Serving setup page - no admin user exists");
|
||||||
|
Html(SETUP_PAGE_HTML).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Error checking, log and serve error
|
||||||
|
tracing::error!("Failed to check admin status: {}", e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Internal server error",
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
pub mod setup_page;
|
||||||
pub mod v1;
|
pub mod v1;
|
||||||
pub mod websocket;
|
pub mod websocket;
|
||||||
|
|
||||||
@@ -13,6 +14,8 @@ use utoipa::OpenApi;
|
|||||||
paths(
|
paths(
|
||||||
routes::health_check,
|
routes::health_check,
|
||||||
routes::login,
|
routes::login,
|
||||||
|
routes::get_setup_status,
|
||||||
|
routes::setup_admin,
|
||||||
v1::organizations::list,
|
v1::organizations::list,
|
||||||
v1::organizations::create,
|
v1::organizations::create,
|
||||||
v1::organizations::get,
|
v1::organizations::get,
|
||||||
@@ -47,6 +50,9 @@ use utoipa::OpenApi;
|
|||||||
schemas(
|
schemas(
|
||||||
routes::LoginRequest,
|
routes::LoginRequest,
|
||||||
routes::LoginResponse,
|
routes::LoginResponse,
|
||||||
|
routes::SetupStatus,
|
||||||
|
routes::SetupAdminRequest,
|
||||||
|
routes::SetupAdminResponse,
|
||||||
v1::organizations::OrganizationResponse,
|
v1::organizations::OrganizationResponse,
|
||||||
v1::organizations::CreateOrganizationRequest,
|
v1::organizations::CreateOrganizationRequest,
|
||||||
v1::workspaces::WorkspaceResponse,
|
v1::workspaces::WorkspaceResponse,
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
//! API route definitions
|
//! API route definitions
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
middleware,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tower_http::cors::{AllowOrigin, Any, CorsLayer};
|
||||||
|
|
||||||
|
use crate::api::middleware::auth::AuthState;
|
||||||
|
use crate::api::middleware::setup_check::setup_check_middleware;
|
||||||
|
use crate::api::v1;
|
||||||
|
use crate::api::ApiDoc;
|
||||||
use crate::config::Settings;
|
use crate::config::Settings;
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
use crate::api::middleware::auth::AuthState;
|
use crate::services::auth_service::{AuthError, AuthService, UserInfo};
|
||||||
use crate::api::ApiDoc;
|
use crate::services::setup_service::{SetupError, SetupService};
|
||||||
use crate::api::v1;
|
|
||||||
|
// Re-export SetupStatus for OpenAPI
|
||||||
|
pub use crate::services::setup_service::SetupStatus;
|
||||||
|
|
||||||
use super::middleware::auth::auth_middleware;
|
use super::middleware::auth::auth_middleware;
|
||||||
use super::middleware::log_request;
|
use super::middleware::log_request;
|
||||||
|
|
||||||
use utoipa::OpenApi;
|
|
||||||
use utoipa_swagger_ui::SwaggerUi;
|
use utoipa_swagger_ui::SwaggerUi;
|
||||||
|
|
||||||
/// Application state
|
/// Application state
|
||||||
@@ -23,67 +31,351 @@ use utoipa_swagger_ui::SwaggerUi;
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Database,
|
pub db: Database,
|
||||||
pub settings: Arc<Settings>,
|
pub settings: Arc<Settings>,
|
||||||
|
pub auth_service: AuthService,
|
||||||
|
pub setup_service: SetupService,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
/// Create a new app state
|
||||||
|
pub fn new(db: Database, settings: Arc<Settings>) -> Self {
|
||||||
|
let auth_service = AuthService::new(db.conn().clone(), settings.clone());
|
||||||
|
let setup_service = SetupService::new(db.conn().clone());
|
||||||
|
Self {
|
||||||
|
db,
|
||||||
|
settings,
|
||||||
|
auth_service,
|
||||||
|
setup_service,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create the main API router
|
/// Create the main API router
|
||||||
pub fn create_router(state: AppState) -> Router {
|
pub fn create_router(state: AppState) -> Router {
|
||||||
let auth_state = AuthState::new(state.settings.clone());
|
let auth_state = AuthState::new(state.settings.clone());
|
||||||
|
|
||||||
|
// Build CORS layer from settings
|
||||||
|
let cors_layer = build_cors_layer(&state.settings.cors);
|
||||||
|
|
||||||
// Public routes (no auth required)
|
// Public routes (no auth required)
|
||||||
let public_routes = Router::new()
|
let public_routes = Router::new()
|
||||||
.route("/health", get(health_check))
|
.route("/health", get(health_check))
|
||||||
|
// Setup routes (only available when no admin exists)
|
||||||
|
.route("/api/v1/auth/setup-status", get(get_setup_status))
|
||||||
|
.route("/api/v1/auth/setup", post(setup_admin))
|
||||||
|
// Auth routes
|
||||||
.route("/api/v1/auth/login", post(login))
|
.route("/api/v1/auth/login", post(login))
|
||||||
// Swagger UI (includes OpenAPI spec at /api/openapi.json)
|
// Swagger UI (includes OpenAPI spec at /api/openapi.json)
|
||||||
.merge(SwaggerUi::new("/swagger-ui").url("/api/openapi.json", ApiDoc::generate()));
|
.merge(SwaggerUi::new("/swagger-ui").url("/api/openapi.json", ApiDoc::generate()));
|
||||||
|
|
||||||
// Protected routes (auth required)
|
// Protected routes (auth required)
|
||||||
let protected_routes = Router::new()
|
let protected_routes = Router::new()
|
||||||
|
// User info
|
||||||
|
.route("/api/v1/auth/me", get(get_current_user))
|
||||||
// Organizations
|
// Organizations
|
||||||
.route("/api/v1/organizations", get(v1::organizations::list).post(v1::organizations::create))
|
.route(
|
||||||
.route("/api/v1/organizations/:id", get(v1::organizations::get).patch(v1::organizations::update).delete(v1::organizations::delete))
|
"/api/v1/organizations",
|
||||||
.route("/api/v1/organizations/:id/workspaces", get(v1::workspaces::list).post(v1::workspaces::create))
|
get(v1::organizations::list).post(v1::organizations::create),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/organizations/:id",
|
||||||
|
get(v1::organizations::get)
|
||||||
|
.patch(v1::organizations::update)
|
||||||
|
.delete(v1::organizations::delete),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/organizations/:id/workspaces",
|
||||||
|
get(v1::workspaces::list).post(v1::workspaces::create),
|
||||||
|
)
|
||||||
// Workspaces
|
// Workspaces
|
||||||
.route("/api/v1/workspaces/:id", get(v1::workspaces::get).patch(v1::workspaces::update).delete(v1::workspaces::delete))
|
.route(
|
||||||
|
"/api/v1/workspaces/:id",
|
||||||
|
get(v1::workspaces::get)
|
||||||
|
.patch(v1::workspaces::update)
|
||||||
|
.delete(v1::workspaces::delete),
|
||||||
|
)
|
||||||
.route("/api/v1/workspaces/:id/agents", get(v1::agents::list))
|
.route("/api/v1/workspaces/:id/agents", get(v1::agents::list))
|
||||||
.route("/api/v1/workspaces/:id/virtual-hosts", get(v1::virtual_hosts::list).post(v1::virtual_hosts::create))
|
.route(
|
||||||
.route("/api/v1/workspaces/:id/upstreams", get(v1::upstreams::list).post(v1::upstreams::create))
|
"/api/v1/workspaces/:id/virtual-hosts",
|
||||||
.route("/api/v1/workspaces/:id/certificates", get(v1::certificates::list).post(v1::certificates::create))
|
get(v1::virtual_hosts::list).post(v1::virtual_hosts::create),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/workspaces/:id/upstreams",
|
||||||
|
get(v1::upstreams::list).post(v1::upstreams::create),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/workspaces/:id/certificates",
|
||||||
|
get(v1::certificates::list).post(v1::certificates::create),
|
||||||
|
)
|
||||||
// Agents
|
// Agents
|
||||||
.route("/api/v1/agents/:id", get(v1::agents::get).delete(v1::agents::delete))
|
.route(
|
||||||
|
"/api/v1/agents/:id",
|
||||||
|
get(v1::agents::get).delete(v1::agents::delete),
|
||||||
|
)
|
||||||
.route("/api/v1/agents/:id/reload", post(v1::agents::reload))
|
.route("/api/v1/agents/:id/reload", post(v1::agents::reload))
|
||||||
.route("/api/v1/workspaces/:id/agents/tokens", post(v1::agents::create_token))
|
.route(
|
||||||
|
"/api/v1/workspaces/:id/agents/tokens",
|
||||||
|
post(v1::agents::create_token),
|
||||||
|
)
|
||||||
// Virtual Hosts
|
// Virtual Hosts
|
||||||
.route("/api/v1/virtual-hosts/:id", get(v1::virtual_hosts::get).patch(v1::virtual_hosts::update).delete(v1::virtual_hosts::delete))
|
.route(
|
||||||
|
"/api/v1/virtual-hosts/:id",
|
||||||
|
get(v1::virtual_hosts::get)
|
||||||
|
.patch(v1::virtual_hosts::update)
|
||||||
|
.delete(v1::virtual_hosts::delete),
|
||||||
|
)
|
||||||
// Upstreams
|
// Upstreams
|
||||||
.route("/api/v1/upstreams/:id", get(v1::upstreams::get).patch(v1::upstreams::update).delete(v1::upstreams::delete))
|
.route(
|
||||||
|
"/api/v1/upstreams/:id",
|
||||||
|
get(v1::upstreams::get)
|
||||||
|
.patch(v1::upstreams::update)
|
||||||
|
.delete(v1::upstreams::delete),
|
||||||
|
)
|
||||||
// Certificates
|
// Certificates
|
||||||
.route("/api/v1/certificates/:id", get(v1::certificates::get).delete(v1::certificates::delete))
|
.route(
|
||||||
|
"/api/v1/certificates/:id",
|
||||||
|
get(v1::certificates::get).delete(v1::certificates::delete),
|
||||||
|
)
|
||||||
// Middleware layer for protected routes
|
// Middleware layer for protected routes
|
||||||
.layer(axum::middleware::from_fn_with_state(auth_state.clone(), auth_middleware));
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
|
auth_state.clone(),
|
||||||
|
auth_middleware,
|
||||||
|
));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(public_routes)
|
.merge(public_routes)
|
||||||
.merge(protected_routes)
|
.merge(protected_routes)
|
||||||
|
// Setup check middleware - serves setup page when no admin exists
|
||||||
|
.layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
setup_check_middleware,
|
||||||
|
))
|
||||||
.layer(axum::middleware::from_fn(log_request))
|
.layer(axum::middleware::from_fn(log_request))
|
||||||
|
.layer(cors_layer)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build CORS layer from settings
|
||||||
|
fn build_cors_layer(cors_settings: &crate::config::CorsSettings) -> CorsLayer {
|
||||||
|
let mut cors = CorsLayer::new();
|
||||||
|
|
||||||
|
// Configure allowed origins
|
||||||
|
if cors_settings.allowed_origins.contains(&"*".to_string()) {
|
||||||
|
cors = cors.allow_origin(AllowOrigin::mirror_request());
|
||||||
|
} else {
|
||||||
|
let origins: Vec<_> = cors_settings
|
||||||
|
.allowed_origins
|
||||||
|
.iter()
|
||||||
|
.filter_map(|origin| origin.parse().ok())
|
||||||
|
.collect();
|
||||||
|
if !origins.is_empty() {
|
||||||
|
cors = cors.allow_origin(origins);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure allowed methods
|
||||||
|
let methods: Vec<_> = cors_settings
|
||||||
|
.allowed_methods
|
||||||
|
.iter()
|
||||||
|
.filter_map(|method| method.parse().ok())
|
||||||
|
.collect();
|
||||||
|
if !methods.is_empty() {
|
||||||
|
cors = cors.allow_methods(methods);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure allowed headers
|
||||||
|
let headers: Vec<_> = cors_settings
|
||||||
|
.allowed_headers
|
||||||
|
.iter()
|
||||||
|
.filter_map(|header| header.parse().ok())
|
||||||
|
.collect();
|
||||||
|
if !headers.is_empty() {
|
||||||
|
cors = cors.allow_headers(headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure credentials
|
||||||
|
cors = cors.allow_credentials(cors_settings.allow_credentials);
|
||||||
|
|
||||||
|
cors
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Health Check
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/// Health check endpoint
|
/// Health check endpoint
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/health",
|
path = "/health",
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Server is healthy", body = String),
|
(status = 200, description = "Server is healthy", body = HealthResponse),
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
async fn health_check() -> &'static str {
|
async fn health_check() -> Json<serde_json::Value> {
|
||||||
"OK"
|
Json(serde_json::json!({
|
||||||
|
"status": "healthy",
|
||||||
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Setup Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Get setup status endpoint
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/v1/auth/setup-status",
|
||||||
|
operation_id = "getSetupStatus",
|
||||||
|
tag = "Setup",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Setup status retrieved", body = SetupStatus),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn get_setup_status(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<SetupStatus>, (axum::http::StatusCode, Json<serde_json::Value>)> {
|
||||||
|
match state.setup_service.get_setup_status().await {
|
||||||
|
Ok(status) => Ok(Json(status)),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to get setup status: {}", e);
|
||||||
|
Err((
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Failed to retrieve setup status"
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setup admin request
|
||||||
|
#[derive(utoipa::ToSchema, serde::Deserialize)]
|
||||||
|
pub struct SetupAdminRequest {
|
||||||
|
/// Setup token (from server logs)
|
||||||
|
pub token: String,
|
||||||
|
/// Admin email
|
||||||
|
pub email: String,
|
||||||
|
/// Admin password (min 8 characters)
|
||||||
|
pub password: String,
|
||||||
|
/// Admin name
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setup admin response
|
||||||
|
#[derive(utoipa::ToSchema, serde::Serialize)]
|
||||||
|
pub struct SetupAdminResponse {
|
||||||
|
pub message: String,
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setup admin endpoint
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/v1/auth/setup",
|
||||||
|
operation_id = "setupAdmin",
|
||||||
|
tag = "Setup",
|
||||||
|
request_body = SetupAdminRequest,
|
||||||
|
responses(
|
||||||
|
(status = 201, description = "Admin created successfully", body = SetupAdminResponse),
|
||||||
|
(status = 400, description = "Invalid input"),
|
||||||
|
(status = 403, description = "Setup already completed or invalid token"),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn setup_admin(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<SetupAdminRequest>,
|
||||||
|
) -> Result<
|
||||||
|
(axum::http::StatusCode, Json<SetupAdminResponse>),
|
||||||
|
(axum::http::StatusCode, Json<serde_json::Value>),
|
||||||
|
> {
|
||||||
|
match state
|
||||||
|
.setup_service
|
||||||
|
.create_initial_admin(&req.token, &req.email, &req.password, &req.name)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(user) => Ok((
|
||||||
|
axum::http::StatusCode::CREATED,
|
||||||
|
Json(SetupAdminResponse {
|
||||||
|
message: "Initial admin created successfully".to_string(),
|
||||||
|
email: user.email,
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
Err(SetupError::AlreadyCompleted) => Err((
|
||||||
|
axum::http::StatusCode::FORBIDDEN,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Setup has already been completed"
|
||||||
|
})),
|
||||||
|
)),
|
||||||
|
Err(SetupError::InvalidToken | SetupError::TokenAlreadyUsed) => Err((
|
||||||
|
axum::http::StatusCode::FORBIDDEN,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Invalid or expired setup token"
|
||||||
|
})),
|
||||||
|
)),
|
||||||
|
Err(SetupError::PasswordHashError(msg)) => Err((
|
||||||
|
axum::http::StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": msg
|
||||||
|
})),
|
||||||
|
)),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Setup error: {}", e);
|
||||||
|
Err((
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Internal server error"
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Login request
|
||||||
|
#[derive(utoipa::ToSchema, serde::Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
/// User email
|
||||||
|
pub email: String,
|
||||||
|
/// User password
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login response
|
||||||
|
#[derive(utoipa::ToSchema, serde::Serialize)]
|
||||||
|
pub struct LoginResponse {
|
||||||
|
/// JWT access token
|
||||||
|
pub token: String,
|
||||||
|
/// Token type (always "Bearer")
|
||||||
|
pub token_type: String,
|
||||||
|
/// Expiration time in seconds
|
||||||
|
pub expires_in: u64,
|
||||||
|
/// User information
|
||||||
|
pub user: UserResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User response
|
||||||
|
#[derive(utoipa::ToSchema, serde::Serialize)]
|
||||||
|
pub struct UserResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub email: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub role: String,
|
||||||
|
pub organization_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<UserInfo> for UserResponse {
|
||||||
|
fn from(user: UserInfo) -> Self {
|
||||||
|
Self {
|
||||||
|
id: user.id.to_string(),
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
organization_id: user.organization_id.map(|id| id.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Login endpoint
|
/// Login endpoint
|
||||||
@@ -91,24 +383,136 @@ async fn health_check() -> &'static str {
|
|||||||
post,
|
post,
|
||||||
path = "/api/v1/auth/login",
|
path = "/api/v1/auth/login",
|
||||||
operation_id = "login",
|
operation_id = "login",
|
||||||
|
tag = "Authentication",
|
||||||
request_body = LoginRequest,
|
request_body = LoginRequest,
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Login successful", body = LoginResponse),
|
(status = 200, description = "Login successful", body = LoginResponse),
|
||||||
|
(status = 401, description = "Invalid credentials"),
|
||||||
|
(status = 403, description = "Setup required - no admin exists"),
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
async fn login() -> axum::Json<serde_json::Value> {
|
async fn login(
|
||||||
axum::Json(serde_json::json!({
|
State(state): State<AppState>,
|
||||||
"token": "placeholder_token"
|
Json(req): Json<LoginRequest>,
|
||||||
|
) -> Result<Json<LoginResponse>, (axum::http::StatusCode, Json<serde_json::Value>)> {
|
||||||
|
// Check if setup is required
|
||||||
|
match state.setup_service.has_admin_users().await {
|
||||||
|
Ok(false) => {
|
||||||
|
return Err((
|
||||||
|
axum::http::StatusCode::FORBIDDEN,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Setup required",
|
||||||
|
"code": "SETUP_REQUIRED",
|
||||||
|
"message": "No admin user exists. Please complete initial setup."
|
||||||
|
})),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to check setup status: {}", e);
|
||||||
|
return Err((
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Internal server error"
|
||||||
|
})),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(true) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
match state.auth_service.login(&req.email, &req.password).await {
|
||||||
|
Ok(result) => {
|
||||||
|
let expires_in = state.settings.auth.jwt_expiration_hours * 3600;
|
||||||
|
Ok(Json(LoginResponse {
|
||||||
|
token: result.token,
|
||||||
|
token_type: "Bearer".to_string(),
|
||||||
|
expires_in,
|
||||||
|
user: result.user.into(),
|
||||||
}))
|
}))
|
||||||
|
}
|
||||||
|
Err(AuthError::InvalidCredentials | AuthError::UserNotFound) => Err((
|
||||||
|
axum::http::StatusCode::UNAUTHORIZED,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Invalid email or password"
|
||||||
|
})),
|
||||||
|
)),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Login error: {}", e);
|
||||||
|
Err((
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Internal server error"
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(utoipa::ToSchema, serde::Deserialize)]
|
/// Get current user endpoint
|
||||||
pub struct LoginRequest {
|
#[utoipa::path(
|
||||||
email: String,
|
get,
|
||||||
password: String,
|
path = "/api/v1/auth/me",
|
||||||
|
operation_id = "getCurrentUser",
|
||||||
|
tag = "Authentication",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Current user information", body = UserResponse),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn get_current_user(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
request: axum::extract::Request,
|
||||||
|
) -> Result<Json<UserResponse>, (axum::http::StatusCode, Json<serde_json::Value>)> {
|
||||||
|
// Extract claims from request extensions
|
||||||
|
let claims = request
|
||||||
|
.extensions()
|
||||||
|
.get::<crate::api::middleware::auth::Claims>()
|
||||||
|
.cloned();
|
||||||
|
|
||||||
|
match claims {
|
||||||
|
Some(claims) => {
|
||||||
|
let user_id = match uuid::Uuid::parse_str(&claims.sub) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
return Err((
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Invalid user ID in token"
|
||||||
|
})),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match state.auth_service.get_user(user_id).await {
|
||||||
|
Ok(Some(user)) => Ok(Json(user.into())),
|
||||||
|
Ok(None) => Err((
|
||||||
|
axum::http::StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "User not found"
|
||||||
|
})),
|
||||||
|
)),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Get user error: {}", e);
|
||||||
|
Err((
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Internal server error"
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => Err((
|
||||||
|
axum::http::StatusCode::UNAUTHORIZED,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Unauthorized"
|
||||||
|
})),
|
||||||
|
)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Health response
|
||||||
#[derive(utoipa::ToSchema, serde::Serialize)]
|
#[derive(utoipa::ToSchema, serde::Serialize)]
|
||||||
pub struct LoginResponse {
|
pub struct HealthResponse {
|
||||||
token: String,
|
pub status: String,
|
||||||
|
pub version: String,
|
||||||
}
|
}
|
||||||
|
|||||||
397
crates/nxmesh-master/src/api/setup_page.rs
Normal file
397
crates/nxmesh-master/src/api/setup_page.rs
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
//! Static HTML setup page for initial admin creation
|
||||||
|
//!
|
||||||
|
//! This page is served when no admin user exists in the system.
|
||||||
|
//! It's a self-contained HTML page with embedded CSS and JavaScript.
|
||||||
|
|
||||||
|
/// The setup page HTML template
|
||||||
|
pub const SETUP_PAGE_HTML: &str = r#"<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>NxMesh - Initial Setup</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo svg {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
fill: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a202c;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #718096;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
background: #fffaf0;
|
||||||
|
border: 1px solid #fbd38d;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: #dd6b20;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-content h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #c05621;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-content p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9c4221;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4a5568;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
input.error {
|
||||||
|
border-color: #fc8181;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a0aec0;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background: #fed7d7;
|
||||||
|
border: 1px solid #fc8181;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #c53030;
|
||||||
|
font-size: 14px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: #c6f6d5;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon svg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: #38a169;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #1a202c;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success p {
|
||||||
|
color: #718096;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #edf2f7;
|
||||||
|
color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div id="setup-form">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1>Initial Setup</h1>
|
||||||
|
<p class="subtitle">Create the first admin account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert">
|
||||||
|
<svg class="alert-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
||||||
|
</svg>
|
||||||
|
<div class="alert-content">
|
||||||
|
<h3>Setup Token Required</h3>
|
||||||
|
<p>Please check the server console/logs for the setup token. It was displayed when the server started.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error-message" id="error-message"></div>
|
||||||
|
|
||||||
|
<form id="form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="token">Setup Token</label>
|
||||||
|
<input type="text" id="token" name="token" placeholder="nxm_xxxxxxxx..." required autocomplete="off">
|
||||||
|
<p class="hint">Found in server console/logs</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Full Name</label>
|
||||||
|
<input type="text" id="name" name="name" placeholder="Administrator" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email Address</label>
|
||||||
|
<input type="email" id="email" name="email" placeholder="admin@example.com" required autocomplete="email">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" placeholder="••••••••" required minlength="8" autocomplete="new-password">
|
||||||
|
<p class="hint">Must be at least 8 characters</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirmPassword">Confirm Password</label>
|
||||||
|
<input type="password" id="confirmPassword" name="confirmPassword" placeholder="••••••••" required autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" id="submit-btn">
|
||||||
|
Create Admin Account
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="success-view" style="display: none;">
|
||||||
|
<div class="success">
|
||||||
|
<div class="success-icon">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2>Setup Complete!</h2>
|
||||||
|
<p>Admin account created successfully. You can now log in.</p>
|
||||||
|
<button onclick="window.location.href='/login'" class="btn-secondary">
|
||||||
|
Go to Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const form = document.getElementById('form');
|
||||||
|
const errorDiv = document.getElementById('error-message');
|
||||||
|
const submitBtn = document.getElementById('submit-btn');
|
||||||
|
const setupForm = document.getElementById('setup-form');
|
||||||
|
const successView = document.getElementById('success-view');
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
errorDiv.textContent = message;
|
||||||
|
errorDiv.classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideError() {
|
||||||
|
errorDiv.classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(loading) {
|
||||||
|
submitBtn.disabled = loading;
|
||||||
|
if (loading) {
|
||||||
|
submitBtn.innerHTML = '<div class="spinner"></div> Creating...';
|
||||||
|
} else {
|
||||||
|
submitBtn.textContent = 'Create Admin Account';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
hideError();
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const data = Object.fromEntries(formData);
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (data.password !== data.confirmPassword) {
|
||||||
|
showError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.password.length < 8) {
|
||||||
|
showError('Password must be at least 8 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.token.startsWith('nxm_')) {
|
||||||
|
showError('Invalid setup token format');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/auth/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: data.token,
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
name: data.name
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
showError(result.error || 'Failed to create admin account');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success view
|
||||||
|
setupForm.style.display = 'none';
|
||||||
|
successView.style.display = 'block';
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showError('Network error. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"#;
|
||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|
||||||
pub use settings::Settings;
|
pub use settings::{CorsSettings, Settings};
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ pub struct Settings {
|
|||||||
pub database: DatabaseSettings,
|
pub database: DatabaseSettings,
|
||||||
pub grpc: GrpcSettings,
|
pub grpc: GrpcSettings,
|
||||||
pub auth: AuthSettings,
|
pub auth: AuthSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cors: CorsSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// HTTP server settings
|
/// HTTP server settings
|
||||||
@@ -40,6 +42,19 @@ pub struct AuthSettings {
|
|||||||
pub jwt_expiration_hours: u64,
|
pub jwt_expiration_hours: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// CORS settings
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CorsSettings {
|
||||||
|
/// Allowed origins (default: any)
|
||||||
|
pub allowed_origins: Vec<String>,
|
||||||
|
/// Allowed methods (default: GET, POST, PUT, DELETE, PATCH, OPTIONS)
|
||||||
|
pub allowed_methods: Vec<String>,
|
||||||
|
/// Allowed headers (default: Content-Type, Authorization)
|
||||||
|
pub allowed_headers: Vec<String>,
|
||||||
|
/// Allow credentials (default: true)
|
||||||
|
pub allow_credentials: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -59,6 +74,28 @@ impl Default for Settings {
|
|||||||
jwt_secret: "change-me-in-production".to_string(),
|
jwt_secret: "change-me-in-production".to_string(),
|
||||||
jwt_expiration_hours: 24,
|
jwt_expiration_hours: 24,
|
||||||
},
|
},
|
||||||
|
cors: CorsSettings::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CorsSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
allowed_origins: vec!["*".to_string()],
|
||||||
|
allowed_methods: vec![
|
||||||
|
"GET".to_string(),
|
||||||
|
"POST".to_string(),
|
||||||
|
"PUT".to_string(),
|
||||||
|
"DELETE".to_string(),
|
||||||
|
"PATCH".to_string(),
|
||||||
|
"OPTIONS".to_string(),
|
||||||
|
],
|
||||||
|
allowed_headers: vec![
|
||||||
|
"Content-Type".to_string(),
|
||||||
|
"Authorization".to_string(),
|
||||||
|
],
|
||||||
|
allow_credentials: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,4 +2,5 @@
|
|||||||
|
|
||||||
pub mod agent_repository;
|
pub mod agent_repository;
|
||||||
pub mod organization_repository;
|
pub mod organization_repository;
|
||||||
|
pub mod user_repository;
|
||||||
pub mod workspace_repository;
|
pub mod workspace_repository;
|
||||||
|
|||||||
92
crates/nxmesh-master/src/db/repositories/user_repository.rs
Normal file
92
crates/nxmesh-master/src/db/repositories/user_repository.rs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
//! User repository
|
||||||
|
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::db::entities::users::{self, ActiveModel, Model};
|
||||||
|
|
||||||
|
/// User repository
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct UserRepository {
|
||||||
|
db: DatabaseConnection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserRepository {
|
||||||
|
/// Create a new repository instance
|
||||||
|
pub fn new(db: DatabaseConnection) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find user by ID
|
||||||
|
pub async fn find_by_id(&self, id: Uuid) -> Result<Option<Model>, sea_orm::DbErr> {
|
||||||
|
users::Entity::find_by_id(id).one(&self.db).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find user by email
|
||||||
|
pub async fn find_by_email(&self, email: &str) -> Result<Option<Model>, sea_orm::DbErr> {
|
||||||
|
users::Entity::find()
|
||||||
|
.filter(users::Column::Email.eq(email))
|
||||||
|
.one(&self.db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new user
|
||||||
|
pub async fn create(
|
||||||
|
&self,
|
||||||
|
email: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
name: Option<&str>,
|
||||||
|
role: &str,
|
||||||
|
organization_id: Option<Uuid>,
|
||||||
|
) -> Result<Model, sea_orm::DbErr> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let user = ActiveModel {
|
||||||
|
id: Set(Uuid::new_v4()),
|
||||||
|
email: Set(email.to_string()),
|
||||||
|
password_hash: Set(password_hash.to_string()),
|
||||||
|
name: Set(name.map(|s| s.to_string())),
|
||||||
|
role: Set(role.to_string()),
|
||||||
|
organization_id: Set(organization_id),
|
||||||
|
created_at: Set(now.into()),
|
||||||
|
updated_at: Set(now.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
user.insert(&self.db).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update user's password
|
||||||
|
pub async fn update_password(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> Result<Model, sea_orm::DbErr> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let user = ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
password_hash: Set(password_hash.to_string()),
|
||||||
|
updated_at: Set(now.into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
user.update(&self.db).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all users in an organization
|
||||||
|
pub async fn list_by_organization(
|
||||||
|
&self,
|
||||||
|
org_id: Uuid,
|
||||||
|
) -> Result<Vec<Model>, sea_orm::DbErr> {
|
||||||
|
users::Entity::find()
|
||||||
|
.filter(users::Column::OrganizationId.eq(org_id))
|
||||||
|
.all(&self.db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a user
|
||||||
|
pub async fn delete(&self, id: Uuid) -> Result<u64, sea_orm::DbErr> {
|
||||||
|
let result = users::Entity::delete_by_id(id).exec(&self.db).await?;
|
||||||
|
Ok(result.rows_affected)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,10 +37,42 @@ pub async fn start(settings: Settings) -> Result<(), Box<dyn std::error::Error>>
|
|||||||
info!("Database migrations complete");
|
info!("Database migrations complete");
|
||||||
|
|
||||||
// Create application state
|
// Create application state
|
||||||
let app_state = api::routes::AppState {
|
let app_state = api::routes::AppState::new(db.clone(), settings.clone());
|
||||||
db: db.clone(),
|
|
||||||
settings: settings.clone(),
|
// Generate setup token if no admin exists
|
||||||
};
|
match app_state.setup_service.has_admin_users().await {
|
||||||
|
Ok(false) => {
|
||||||
|
info!("=================================================================");
|
||||||
|
info!(" INITIAL SETUP REQUIRED ");
|
||||||
|
info!("=================================================================");
|
||||||
|
info!("No admin user found. A setup token has been generated.");
|
||||||
|
info!("");
|
||||||
|
|
||||||
|
match app_state.setup_service.generate_setup_token().await {
|
||||||
|
Ok(token_info) => {
|
||||||
|
info!("SETUP TOKEN: {}", token_info.plain_token);
|
||||||
|
info!("EXPIRES AT: {}", token_info.expires_at);
|
||||||
|
info!("");
|
||||||
|
info!("Use this token to create the first admin account at:");
|
||||||
|
info!(" http://{}:{}/setup", settings.server.bind_address, settings.server.port);
|
||||||
|
info!("");
|
||||||
|
info!("WARNING: This token is single-use and will expire in 24 hours.");
|
||||||
|
info!(" It is displayed only once in these logs.");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to generate setup token: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("=================================================================");
|
||||||
|
}
|
||||||
|
Ok(true) => {
|
||||||
|
info!("Admin user exists - initial setup already completed");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to check admin status: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create router
|
// Create router
|
||||||
let app = api::routes::create_router(app_state);
|
let app = api::routes::create_router(app_state);
|
||||||
|
|||||||
@@ -1,25 +1,259 @@
|
|||||||
//! Authentication service
|
//! Authentication service
|
||||||
|
|
||||||
|
use argon2::{
|
||||||
|
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||||
|
Argon2,
|
||||||
|
};
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::api::middleware::auth::{generate_token, Claims};
|
||||||
|
use crate::config::Settings;
|
||||||
|
use crate::db::repositories::user_repository::UserRepository;
|
||||||
|
|
||||||
|
/// Auth errors
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AuthError {
|
||||||
|
#[error("Invalid credentials")]
|
||||||
|
InvalidCredentials,
|
||||||
|
#[error("User not found")]
|
||||||
|
UserNotFound,
|
||||||
|
#[error("Password hash error: {0}")]
|
||||||
|
PasswordHashError(String),
|
||||||
|
#[error("Token generation failed: {0}")]
|
||||||
|
TokenError(String),
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
DatabaseError(#[from] sea_orm::DbErr),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login result
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LoginResult {
|
||||||
|
pub token: String,
|
||||||
|
pub user: UserInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User information
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct UserInfo {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub email: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub role: String,
|
||||||
|
pub organization_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Auth service
|
/// Auth service
|
||||||
pub struct AuthService;
|
#[derive(Clone)]
|
||||||
|
pub struct AuthService {
|
||||||
|
user_repo: UserRepository,
|
||||||
|
settings: Arc<Settings>,
|
||||||
|
}
|
||||||
|
|
||||||
impl AuthService {
|
impl AuthService {
|
||||||
/// Create a new service instance
|
/// Create a new service instance
|
||||||
pub fn new() -> Self {
|
pub fn new(db: DatabaseConnection, settings: Arc<Settings>) -> Self {
|
||||||
Self
|
Self {
|
||||||
|
user_repo: UserRepository::new(db),
|
||||||
|
settings,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Authenticate user
|
/// Register a new user
|
||||||
pub async fn authenticate(&self, email: &str, password: &str) -> Result<String, String> {
|
pub async fn register(
|
||||||
// TODO: Implement
|
&self,
|
||||||
tracing::info!("Authenticating user: {}", email);
|
email: &str,
|
||||||
Ok("jwt_token_placeholder".to_string())
|
password: &str,
|
||||||
|
name: Option<&str>,
|
||||||
|
role: &str,
|
||||||
|
organization_id: Option<Uuid>,
|
||||||
|
) -> Result<UserInfo, AuthError> {
|
||||||
|
info!("Registering new user: {}", email);
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
if self.user_repo.find_by_email(email).await?.is_some() {
|
||||||
|
warn!("User already exists: {}", email);
|
||||||
|
return Err(AuthError::InvalidCredentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate token
|
// Hash password
|
||||||
pub async fn validate_token(&self, token: &str) -> Result<bool, String> {
|
let password_hash = hash_password(password)?;
|
||||||
// TODO: Implement
|
|
||||||
tracing::info!("Validating token: {}", token);
|
// Create user
|
||||||
Ok(true)
|
let user = self
|
||||||
|
.user_repo
|
||||||
|
.create(email, &password_hash, name, role, organization_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!("User registered successfully: {}", user.id);
|
||||||
|
|
||||||
|
Ok(UserInfo {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
organization_id: user.organization_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate user and generate JWT token
|
||||||
|
pub async fn login(&self, email: &str, password: &str) -> Result<LoginResult, AuthError> {
|
||||||
|
info!("Authenticating user: {}", email);
|
||||||
|
|
||||||
|
// Find user by email
|
||||||
|
let user = self
|
||||||
|
.user_repo
|
||||||
|
.find_by_email(email)
|
||||||
|
.await?
|
||||||
|
.ok_or(AuthError::UserNotFound)?;
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
if !verify_password(password, &user.password_hash)? {
|
||||||
|
warn!("Invalid password for user: {}", email);
|
||||||
|
return Err(AuthError::InvalidCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT token
|
||||||
|
let org_id_str = user.organization_id.map(|id| id.to_string());
|
||||||
|
let token = generate_token(
|
||||||
|
&user.id.to_string(),
|
||||||
|
&user.email,
|
||||||
|
org_id_str.as_deref(),
|
||||||
|
&user.role,
|
||||||
|
&self.settings,
|
||||||
|
)
|
||||||
|
.map_err(|e| AuthError::TokenError(e.to_string()))?;
|
||||||
|
|
||||||
|
info!("User authenticated successfully: {}", user.id);
|
||||||
|
|
||||||
|
Ok(LoginResult {
|
||||||
|
token,
|
||||||
|
user: UserInfo {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
organization_id: user.organization_id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate token and return claims
|
||||||
|
pub fn validate_token(&self, token: &str) -> Result<Claims, AuthError> {
|
||||||
|
crate::api::middleware::auth::validate_token(token, &self.settings)
|
||||||
|
.map_err(|_| AuthError::InvalidCredentials)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get user by ID
|
||||||
|
pub async fn get_user(&self, id: Uuid) -> Result<Option<UserInfo>, AuthError> {
|
||||||
|
let user = self.user_repo.find_by_id(id).await?;
|
||||||
|
|
||||||
|
Ok(user.map(|u| UserInfo {
|
||||||
|
id: u.id,
|
||||||
|
email: u.email,
|
||||||
|
name: u.name,
|
||||||
|
role: u.role,
|
||||||
|
organization_id: u.organization_id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change user password
|
||||||
|
pub async fn change_password(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
old_password: &str,
|
||||||
|
new_password: &str,
|
||||||
|
) -> Result<(), AuthError> {
|
||||||
|
// Get user
|
||||||
|
let user = self
|
||||||
|
.user_repo
|
||||||
|
.find_by_id(user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AuthError::UserNotFound)?;
|
||||||
|
|
||||||
|
// Verify old password
|
||||||
|
if !verify_password(old_password, &user.password_hash)? {
|
||||||
|
return Err(AuthError::InvalidCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
let new_hash = hash_password(new_password)?;
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
self.user_repo.update_password(user_id, &new_hash).await?;
|
||||||
|
|
||||||
|
info!("Password changed for user: {}", user_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create initial admin user if no users exist
|
||||||
|
pub async fn create_initial_admin(&self) -> Result<Option<UserInfo>, AuthError> {
|
||||||
|
// Check if any users exist
|
||||||
|
// For simplicity, we just try to find by a specific admin email
|
||||||
|
let admin_email = "admin@nxmesh.local";
|
||||||
|
|
||||||
|
if self.user_repo.find_by_email(admin_email).await?.is_some() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Creating initial admin user");
|
||||||
|
|
||||||
|
let user = self
|
||||||
|
.register(
|
||||||
|
admin_email,
|
||||||
|
"admin123", // Default password - should be changed on first login
|
||||||
|
Some("Administrator"),
|
||||||
|
"admin",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Initial admin user created: {} / password: admin123",
|
||||||
|
admin_email
|
||||||
|
);
|
||||||
|
info!("IMPORTANT: Please change the default password immediately!");
|
||||||
|
|
||||||
|
Ok(Some(user))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash a password using Argon2
|
||||||
|
fn hash_password(password: &str) -> Result<String, AuthError> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
|
||||||
|
argon2
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map(|hash| hash.to_string())
|
||||||
|
.map_err(|e| AuthError::PasswordHashError(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a password against a hash
|
||||||
|
fn verify_password(password: &str, hash: &str) -> Result<bool, AuthError> {
|
||||||
|
let parsed_hash =
|
||||||
|
PasswordHash::new(hash).map_err(|e| AuthError::PasswordHashError(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed_hash)
|
||||||
|
.is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_password_hashing() {
|
||||||
|
let password = "test_password_123";
|
||||||
|
let hash = hash_password(password).unwrap();
|
||||||
|
|
||||||
|
// Verify correct password
|
||||||
|
assert!(verify_password(password, &hash).unwrap());
|
||||||
|
|
||||||
|
// Verify wrong password fails
|
||||||
|
assert!(!verify_password("wrong_password", &hash).unwrap());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ pub mod auth_service;
|
|||||||
pub mod certificate_service;
|
pub mod certificate_service;
|
||||||
pub mod config_service;
|
pub mod config_service;
|
||||||
pub mod organization_service;
|
pub mod organization_service;
|
||||||
|
pub mod setup_service;
|
||||||
pub mod workspace_service;
|
pub mod workspace_service;
|
||||||
|
|||||||
344
crates/nxmesh-master/src/services/setup_service.rs
Normal file
344
crates/nxmesh-master/src/services/setup_service.rs
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
//! Initial setup service for production-ready admin creation
|
||||||
|
//!
|
||||||
|
//! This service handles the secure creation of the first admin user
|
||||||
|
//! using a one-time setup token system.
|
||||||
|
|
||||||
|
use argon2::{
|
||||||
|
password_hash::{PasswordHasher, SaltString},
|
||||||
|
Argon2,
|
||||||
|
};
|
||||||
|
use rand::{rngs::OsRng, Rng};
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, Set,
|
||||||
|
};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::db::entities::{setup_tokens, users};
|
||||||
|
|
||||||
|
/// Setup errors
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum SetupError {
|
||||||
|
#[error("Setup already completed")]
|
||||||
|
AlreadyCompleted,
|
||||||
|
#[error("Invalid or expired setup token")]
|
||||||
|
InvalidToken,
|
||||||
|
#[error("Token has already been used")]
|
||||||
|
TokenAlreadyUsed,
|
||||||
|
#[error("Password hash error: {0}")]
|
||||||
|
PasswordHashError(String),
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
DatabaseError(#[from] sea_orm::DbErr),
|
||||||
|
#[error("Token generation failed")]
|
||||||
|
TokenGenerationFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setup token information (returned when creating a new token)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SetupTokenInfo {
|
||||||
|
/// The plain text token (shown only once to user)
|
||||||
|
pub plain_token: String,
|
||||||
|
/// Expiration time
|
||||||
|
pub expires_at: chrono::DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setup status response
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
|
||||||
|
pub struct SetupStatus {
|
||||||
|
/// Whether initial setup is required
|
||||||
|
pub setup_required: bool,
|
||||||
|
/// Whether a valid setup token exists
|
||||||
|
pub has_valid_token: bool,
|
||||||
|
/// Token expiration time (if exists)
|
||||||
|
pub token_expires_at: Option<chrono::DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setup service
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SetupService {
|
||||||
|
db: DatabaseConnection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SetupService {
|
||||||
|
/// Create a new setup service
|
||||||
|
pub fn new(db: DatabaseConnection) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if any admin users exist
|
||||||
|
pub async fn has_admin_users(&self) -> Result<bool, SetupError> {
|
||||||
|
let count = users::Entity::find()
|
||||||
|
.filter(users::Column::Role.eq("admin"))
|
||||||
|
.count(&self.db)
|
||||||
|
.await?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get setup status
|
||||||
|
pub async fn get_setup_status(&self) -> Result<SetupStatus, SetupError> {
|
||||||
|
let setup_required = !self.has_admin_users().await?;
|
||||||
|
|
||||||
|
if !setup_required {
|
||||||
|
return Ok(SetupStatus {
|
||||||
|
setup_required: false,
|
||||||
|
has_valid_token: false,
|
||||||
|
token_expires_at: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for valid, unused token
|
||||||
|
let valid_token = setup_tokens::Entity::find()
|
||||||
|
.filter(setup_tokens::Column::UsedAt.is_null())
|
||||||
|
.filter(setup_tokens::Column::ExpiresAt.gt(Utc::now()))
|
||||||
|
.one(&self.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(SetupStatus {
|
||||||
|
setup_required: true,
|
||||||
|
has_valid_token: valid_token.is_some(),
|
||||||
|
token_expires_at: valid_token.map(|t| t.expires_at.into()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a new setup token
|
||||||
|
///
|
||||||
|
/// Returns the plain token (to be shown to user) and stores a hash
|
||||||
|
pub async fn generate_setup_token(&self) -> Result<SetupTokenInfo, SetupError> {
|
||||||
|
// Check if setup is already completed
|
||||||
|
if self.has_admin_users().await? {
|
||||||
|
return Err(SetupError::AlreadyCompleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a valid token already exists
|
||||||
|
let existing = setup_tokens::Entity::find()
|
||||||
|
.filter(setup_tokens::Column::UsedAt.is_null())
|
||||||
|
.filter(setup_tokens::Column::ExpiresAt.gt(Utc::now()))
|
||||||
|
.one(&self.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if existing.is_some() {
|
||||||
|
warn!("A valid setup token already exists");
|
||||||
|
return Err(SetupError::AlreadyCompleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate cryptographically secure random token
|
||||||
|
let plain_token = Self::generate_secure_token();
|
||||||
|
|
||||||
|
// Hash the token for storage (using SHA-256)
|
||||||
|
let token_hash = Self::hash_token(&plain_token);
|
||||||
|
|
||||||
|
// Token expires in 24 hours
|
||||||
|
let expires_at = Utc::now() + Duration::hours(24);
|
||||||
|
let created_at = Utc::now();
|
||||||
|
|
||||||
|
// Store in database
|
||||||
|
let token_model = setup_tokens::ActiveModel {
|
||||||
|
id: Set(Uuid::new_v4()),
|
||||||
|
token_hash: Set(token_hash),
|
||||||
|
expires_at: Set(expires_at.into()),
|
||||||
|
used_at: Set(None),
|
||||||
|
created_at: Set(created_at.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
token_model.insert(&self.db).await?;
|
||||||
|
|
||||||
|
info!("New setup token generated, expires at: {}", expires_at);
|
||||||
|
|
||||||
|
Ok(SetupTokenInfo {
|
||||||
|
plain_token,
|
||||||
|
expires_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a setup token and mark it as used
|
||||||
|
async fn verify_and_consume_token(&self, plain_token: &str) -> Result<Uuid, SetupError> {
|
||||||
|
let token_hash = Self::hash_token(plain_token);
|
||||||
|
|
||||||
|
// Find the token
|
||||||
|
let token = setup_tokens::Entity::find()
|
||||||
|
.filter(setup_tokens::Column::TokenHash.eq(&token_hash))
|
||||||
|
.one(&self.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let token = token.ok_or(SetupError::InvalidToken)?;
|
||||||
|
|
||||||
|
// Check if already used
|
||||||
|
if token.used_at.is_some() {
|
||||||
|
warn!("Attempt to reuse setup token");
|
||||||
|
return Err(SetupError::TokenAlreadyUsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
let now: chrono::DateTime<chrono::Utc> = Utc::now();
|
||||||
|
if token.expires_at < now {
|
||||||
|
warn!("Expired setup token used");
|
||||||
|
return Err(SetupError::InvalidToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as used
|
||||||
|
let mut token_active: setup_tokens::ActiveModel = token.clone().into();
|
||||||
|
token_active.used_at = Set(Some(Utc::now().into()));
|
||||||
|
token_active.update(&self.db).await?;
|
||||||
|
|
||||||
|
Ok(token.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete setup by creating the first admin user
|
||||||
|
pub async fn create_initial_admin(
|
||||||
|
&self,
|
||||||
|
setup_token: &str,
|
||||||
|
email: &str,
|
||||||
|
password: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<users::Model, SetupError> {
|
||||||
|
// Verify setup is still needed
|
||||||
|
if self.has_admin_users().await? {
|
||||||
|
return Err(SetupError::AlreadyCompleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify and consume the setup token
|
||||||
|
self.verify_and_consume_token(setup_token).await?;
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
if !Self::is_valid_email(email) {
|
||||||
|
return Err(SetupError::InvalidToken); // Reuse error for security
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password strength
|
||||||
|
if password.len() < 8 {
|
||||||
|
return Err(SetupError::PasswordHashError(
|
||||||
|
"Password must be at least 8 characters".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password with Argon2
|
||||||
|
let password_hash = Self::hash_password(password)?;
|
||||||
|
|
||||||
|
// Create admin user
|
||||||
|
let now = Utc::now();
|
||||||
|
let user = users::ActiveModel {
|
||||||
|
id: Set(Uuid::new_v4()),
|
||||||
|
email: Set(email.to_string()),
|
||||||
|
password_hash: Set(password_hash),
|
||||||
|
name: Set(Some(name.to_string())),
|
||||||
|
role: Set("admin".to_string()),
|
||||||
|
organization_id: Set(None), // Admin starts without org, can create one
|
||||||
|
created_at: Set(now.into()),
|
||||||
|
updated_at: Set(now.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = user.insert(&self.db).await?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Initial admin user created successfully: {} ({})",
|
||||||
|
user.email, user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a cryptographically secure random token
|
||||||
|
fn generate_secure_token() -> String {
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
|
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
const TOKEN_LENGTH: usize = 64;
|
||||||
|
|
||||||
|
let mut rng = OsRng;
|
||||||
|
let token: String = (0..TOKEN_LENGTH)
|
||||||
|
.map(|_| {
|
||||||
|
let idx = rng.gen_range(0..CHARSET.len());
|
||||||
|
CHARSET[idx] as char
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
format!("nxm_{}", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash a token using SHA-256
|
||||||
|
fn hash_token(token: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(token.as_bytes());
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash a password using Argon2
|
||||||
|
fn hash_password(password: &str) -> Result<String, SetupError> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
|
||||||
|
argon2
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map(|hash| hash.to_string())
|
||||||
|
.map_err(|e| SetupError::PasswordHashError(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Basic email validation
|
||||||
|
fn is_valid_email(email: &str) -> bool {
|
||||||
|
// Simple regex-free validation
|
||||||
|
let parts: Vec<&str> = email.split('@').collect();
|
||||||
|
if parts.len() != 2 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let local = parts[0];
|
||||||
|
let domain = parts[1];
|
||||||
|
|
||||||
|
!local.is_empty()
|
||||||
|
&& !domain.is_empty()
|
||||||
|
&& domain.contains('.')
|
||||||
|
&& !domain.starts_with('.')
|
||||||
|
&& !domain.ends_with('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up expired tokens (can be called periodically)
|
||||||
|
pub async fn cleanup_expired_tokens(&self) -> Result<u64, SetupError> {
|
||||||
|
let result = setup_tokens::Entity::delete_many()
|
||||||
|
.filter(setup_tokens::Column::ExpiresAt.lt(Utc::now()))
|
||||||
|
.exec(&self.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected > 0 {
|
||||||
|
info!("Cleaned up {} expired setup tokens", result.rows_affected);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result.rows_affected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_token_generation() {
|
||||||
|
let token = SetupService::generate_secure_token();
|
||||||
|
assert!(token.starts_with("nxm_"));
|
||||||
|
assert_eq!(token.len(), 68); // "nxm_" + 64 chars
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_token_hashing() {
|
||||||
|
let token = "test_token_123";
|
||||||
|
let hash1 = SetupService::hash_token(token);
|
||||||
|
let hash2 = SetupService::hash_token(token);
|
||||||
|
|
||||||
|
// Same token should produce same hash
|
||||||
|
assert_eq!(hash1, hash2);
|
||||||
|
|
||||||
|
// Different token should produce different hash
|
||||||
|
let hash3 = SetupService::hash_token("different_token");
|
||||||
|
assert_ne!(hash1, hash3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_email_validation() {
|
||||||
|
assert!(SetupService::is_valid_email("admin@example.com"));
|
||||||
|
assert!(SetupService::is_valid_email("user@sub.domain.com"));
|
||||||
|
assert!(!SetupService::is_valid_email("invalid"));
|
||||||
|
assert!(!SetupService::is_valid_email("@example.com"));
|
||||||
|
assert!(!SetupService::is_valid_email("user@"));
|
||||||
|
assert!(!SetupService::is_valid_email("user@.com"));
|
||||||
|
}
|
||||||
|
}
|
||||||
98
docs/setup.md
Normal file
98
docs/setup.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# NxMesh Initial Setup
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
NxMesh uses a secure, production-ready initial setup system for creating the first admin account. This ensures no default credentials exist in the system.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Production Mode
|
||||||
|
|
||||||
|
1. **First Server Start**: When the server starts without any admin users, it generates a cryptographically secure setup token
|
||||||
|
2. **Token Display**: The token is displayed **only** in the server console/logs:
|
||||||
|
```
|
||||||
|
=================================================================
|
||||||
|
INITIAL SETUP REQUIRED
|
||||||
|
=================================================================
|
||||||
|
SETUP TOKEN: nxm_CbCa87uchQye6ZOWysnitrZC0F5mhrrJPcPmwDrq...
|
||||||
|
EXPIRES AT: 2026-03-04 07:19:50.840608304 UTC
|
||||||
|
=================================================================
|
||||||
|
```
|
||||||
|
3. **Access Setup Page**: Visit `http://localhost:8080/` and the setup page is automatically served
|
||||||
|
4. **Create Admin**: Enter the setup token and admin details
|
||||||
|
5. **Done**: After setup, the normal application is served
|
||||||
|
|
||||||
|
### Development Mode
|
||||||
|
|
||||||
|
In development (`just dev` or `bun dev`), the Vite dev server automatically checks setup status:
|
||||||
|
|
||||||
|
1. Visit `http://localhost:3000/` (frontend dev server)
|
||||||
|
2. The dev server checks setup status from backend
|
||||||
|
3. If setup required: Serves the setup page directly (proxied from backend)
|
||||||
|
4. If setup complete: Serves the React application
|
||||||
|
|
||||||
|
The dev experience is seamless - you don't need to switch ports.
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Token Format** | 68-character random string: `nxm_` prefix + 64 chars |
|
||||||
|
| **Storage** | SHA-256 hash stored in database, plain token never saved |
|
||||||
|
| **Expiration** | 24-hour validity period |
|
||||||
|
| **Single Use** | Token invalidated after successful admin creation |
|
||||||
|
| **Console Only** | Token displayed only in server logs, not exposed via API |
|
||||||
|
| **Cryptographic RNG** | Uses `OsRng` for secure random generation |
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Check Setup Status
|
||||||
|
```bash
|
||||||
|
GET /api/v1/auth/setup-status
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"setup_required": true,
|
||||||
|
"has_valid_token": true,
|
||||||
|
"token_expires_at": "2026-03-04T07:19:50.840608Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complete Setup
|
||||||
|
```bash
|
||||||
|
POST /api/v1/auth/setup
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"token": "nxm_...",
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "SecurePass123!",
|
||||||
|
"name": "Administrator"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Lost Setup Token
|
||||||
|
|
||||||
|
If you lose the setup token before completing setup:
|
||||||
|
|
||||||
|
1. **Wait 24 hours**: The token will expire automatically
|
||||||
|
2. **Clear database**: Remove the unused token from `setup_tokens` table
|
||||||
|
3. **Restart server**: A new token will be generated
|
||||||
|
|
||||||
|
### Setup Not Working in Dev Mode
|
||||||
|
|
||||||
|
1. Ensure backend is running on port 8080
|
||||||
|
2. Check browser console for errors
|
||||||
|
3. Try accessing `http://localhost:8080/` directly (backend URL)
|
||||||
|
|
||||||
|
### Already Completed Setup
|
||||||
|
|
||||||
|
Once setup is complete:
|
||||||
|
- The setup token is invalidated
|
||||||
|
- The setup page will no longer be served
|
||||||
|
- Normal login flow is required
|
||||||
|
- Use `/api/v1/auth/login` to authenticate
|
||||||
@@ -1,22 +1,79 @@
|
|||||||
import { Routes, Route } from 'react-router-dom'
|
import { Routes, Route } from 'react-router-dom'
|
||||||
import { Layout } from './components/layout/Layout'
|
import { Layout } from './components/layout/Layout'
|
||||||
|
import { ProtectedRoute } from './components/auth/ProtectedRoute'
|
||||||
|
import { PublicRoute } from './components/auth/PublicRoute'
|
||||||
import { Dashboard } from './pages/Dashboard/Dashboard'
|
import { Dashboard } from './pages/Dashboard/Dashboard'
|
||||||
import { Agents } from './pages/Agents/Agents'
|
import { Agents } from './pages/Agents/Agents'
|
||||||
import { Configurations } from './pages/Configurations/Configurations'
|
import { Configurations } from './pages/Configurations/Configurations'
|
||||||
import { Certificates } from './pages/Certificates/Certificates'
|
import { Certificates } from './pages/Certificates/Certificates'
|
||||||
import { Settings } from './pages/Settings/Settings'
|
import { Settings } from './pages/Settings/Settings'
|
||||||
|
import { Login } from './pages/Login/Login'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
{/* Public routes */}
|
||||||
<Route path="/agents" element={<Agents />} />
|
<Route
|
||||||
<Route path="/configurations" element={<Configurations />} />
|
path="/login"
|
||||||
<Route path="/certificates" element={<Certificates />} />
|
element={
|
||||||
<Route path="/settings" element={<Settings />} />
|
<PublicRoute>
|
||||||
</Routes>
|
<Login />
|
||||||
|
</PublicRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Protected routes */}
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<Dashboard />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/agents"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<Agents />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/configurations"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<Configurations />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/certificates"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<Certificates />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<Settings />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,22 +23,26 @@ client.use({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Type aliases for request/response bodies
|
// Type aliases for request/response bodies
|
||||||
type LoginRequest = components['schemas']['LoginRequest'];
|
export type LoginRequest = components['schemas']['LoginRequest'];
|
||||||
type LoginResponse = components['schemas']['LoginResponse'];
|
export type LoginResponse = components['schemas']['LoginResponse'];
|
||||||
type OrganizationResponse = components['schemas']['OrganizationResponse'];
|
export type OrganizationResponse = components['schemas']['OrganizationResponse'];
|
||||||
type CreateOrganizationRequest = components['schemas']['CreateOrganizationRequest'];
|
export type CreateOrganizationRequest = components['schemas']['CreateOrganizationRequest'];
|
||||||
type WorkspaceResponse = components['schemas']['WorkspaceResponse'];
|
export type WorkspaceResponse = components['schemas']['WorkspaceResponse'];
|
||||||
type CreateWorkspaceRequest = components['schemas']['CreateWorkspaceRequest'];
|
export type CreateWorkspaceRequest = components['schemas']['CreateWorkspaceRequest'];
|
||||||
type AgentResponse = components['schemas']['AgentResponse'];
|
export type AgentResponse = components['schemas']['AgentResponse'];
|
||||||
type CreateAgentTokenRequest = components['schemas']['CreateAgentTokenRequest'];
|
export type CreateAgentTokenRequest = components['schemas']['CreateAgentTokenRequest'];
|
||||||
type VirtualHostResponse = components['schemas']['VirtualHostResponse'];
|
export type VirtualHostResponse = components['schemas']['VirtualHostResponse'];
|
||||||
type CreateVirtualHostRequest = components['schemas']['CreateVirtualHostRequest'];
|
export type CreateVirtualHostRequest = components['schemas']['CreateVirtualHostRequest'];
|
||||||
type UpstreamResponse = components['schemas']['UpstreamResponse'];
|
export type UpstreamResponse = components['schemas']['UpstreamResponse'];
|
||||||
type CreateUpstreamRequest = components['schemas']['CreateUpstreamRequest'];
|
export type CreateUpstreamRequest = components['schemas']['CreateUpstreamRequest'];
|
||||||
type CertificateResponse = components['schemas']['CertificateResponse'];
|
export type CertificateResponse = components['schemas']['CertificateResponse'];
|
||||||
type CreateCertificateRequest = components['schemas']['CreateCertificateRequest'];
|
export type CreateCertificateRequest = components['schemas']['CreateCertificateRequest'];
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
|
// Auth
|
||||||
|
login: (body: { email: string; password: string }) =>
|
||||||
|
client.POST('/api/v1/auth/login', { body }),
|
||||||
|
|
||||||
// Organizations
|
// Organizations
|
||||||
listOrganizations: () =>
|
listOrganizations: () =>
|
||||||
client.GET('/api/v1/organizations'),
|
client.GET('/api/v1/organizations'),
|
||||||
@@ -108,21 +112,6 @@ export const api = {
|
|||||||
client.POST('/api/v1/workspaces/{id}/certificates', { params: { path: { id: wsId } }, body }),
|
client.POST('/api/v1/workspaces/{id}/certificates', { params: { path: { id: wsId } }, body }),
|
||||||
deleteCertificate: (id: string) =>
|
deleteCertificate: (id: string) =>
|
||||||
client.DELETE('/api/v1/certificates/{id}', { params: { path: { id } } }),
|
client.DELETE('/api/v1/certificates/{id}', { params: { path: { id } } }),
|
||||||
|
|
||||||
// Auth
|
|
||||||
login: (body: LoginRequest) =>
|
|
||||||
client.POST('/api/v1/auth/login', { body }),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type {
|
export type { paths, components };
|
||||||
components,
|
|
||||||
paths,
|
|
||||||
LoginRequest,
|
|
||||||
LoginResponse,
|
|
||||||
OrganizationResponse,
|
|
||||||
WorkspaceResponse,
|
|
||||||
AgentResponse,
|
|
||||||
VirtualHostResponse,
|
|
||||||
UpstreamResponse,
|
|
||||||
CertificateResponse,
|
|
||||||
};
|
|
||||||
|
|||||||
18
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
18
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||||
|
const { token } = useAuthStore();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Not authenticated - redirect to login
|
||||||
|
if (!token) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
17
frontend/src/components/auth/PublicRoute.tsx
Normal file
17
frontend/src/components/auth/PublicRoute.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
|
||||||
|
interface PublicRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PublicRoute({ children }: PublicRouteProps) {
|
||||||
|
const { token } = useAuthStore();
|
||||||
|
|
||||||
|
// Already authenticated - redirect to dashboard
|
||||||
|
if (token) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
import { Bell, User } from 'lucide-react'
|
import { Bell, User, LogOut } from 'lucide-react';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-white dark:bg-gray-800 shadow-sm">
|
<header className="bg-white dark:bg-gray-800 shadow-sm">
|
||||||
<div className="flex items-center justify-between px-6 py-4">
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
@@ -8,14 +18,36 @@ export function Header() {
|
|||||||
NxMesh Admin
|
NxMesh Admin
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Notifications */}
|
||||||
<button className="p-2 text-gray-600 hover:bg-gray-100 rounded-full dark:text-gray-300 dark:hover:bg-gray-700">
|
<button className="p-2 text-gray-600 hover:bg-gray-100 rounded-full dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
<Bell className="w-5 h-5" />
|
<Bell className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<button className="flex items-center gap-2 p-2 text-gray-600 hover:bg-gray-100 rounded-full dark:text-gray-300 dark:hover:bg-gray-700">
|
|
||||||
<User className="w-5 h-5" />
|
{/* User Info */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-right hidden sm:block">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{user?.name || user?.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
||||||
|
{user?.role}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="h-8 w-8 rounded-full bg-blue-600 flex items-center justify-center">
|
||||||
|
<User className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logout Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="p-2 text-gray-600 hover:bg-red-100 hover:text-red-600 rounded-full dark:text-gray-300 dark:hover:bg-red-900/30 dark:hover:text-red-400 transition-colors"
|
||||||
|
title="Logout"
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
138
frontend/src/pages/Login/Login.tsx
Normal file
138
frontend/src/pages/Login/Login.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { Loader2, Shield, AlertCircle, ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { login, isLoading, error, clearError } = useAuthStore();
|
||||||
|
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearError();
|
||||||
|
|
||||||
|
const success = await login(email, password);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-xl bg-blue-600">
|
||||||
|
<Shield className="h-8 w-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||||
|
Sign in to NxMesh
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Distributed Nginx Management System
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-red-800 dark:text-red-300">
|
||||||
|
{error}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||||
|
<div className="rounded-md shadow-sm -space-y-px">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="sr-only">
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm dark:bg-gray-800"
|
||||||
|
placeholder="Email address"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="sr-only">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm dark:bg-gray-800"
|
||||||
|
placeholder="Password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
'Sign in'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* First time setup hint */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="rounded-md bg-blue-50 dark:bg-blue-900/20 p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="ml-3 flex-1">
|
||||||
|
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||||
|
First Time?
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 text-sm text-blue-700 dark:text-blue-200">
|
||||||
|
<p>
|
||||||
|
If this is a fresh installation, you need to complete the initial setup.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="mt-2 inline-flex items-center font-medium underline hover:text-blue-600"
|
||||||
|
>
|
||||||
|
Go to Setup Page
|
||||||
|
<ExternalLink className="ml-1 h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400">
|
||||||
|
(Check server logs for the setup token)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
frontend/src/stores/authStore.ts
Normal file
101
frontend/src/stores/authStore.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
role: string;
|
||||||
|
organization_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
// State
|
||||||
|
token: string | null;
|
||||||
|
user: User | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
login: (email: string, password: string) => Promise<boolean>;
|
||||||
|
logout: () => void;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
// Initial state
|
||||||
|
token: null,
|
||||||
|
user: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// Login action - returns true if successful, false otherwise
|
||||||
|
login: async (email: string, password: string): Promise<boolean> => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error: apiError } = await api.login({
|
||||||
|
email,
|
||||||
|
password
|
||||||
|
});
|
||||||
|
|
||||||
|
if (apiError) {
|
||||||
|
const errorData = apiError as { error?: string };
|
||||||
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
error: errorData?.error || 'Login failed. Please check your credentials.'
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
set({ isLoading: false, error: 'No response from server' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store token and user info
|
||||||
|
const { token, user } = data as { token: string; user: User };
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
|
||||||
|
set({
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
isLoading: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
error: 'An unexpected error occurred. Please try again.'
|
||||||
|
});
|
||||||
|
console.error('Login error:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Logout action
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
set({
|
||||||
|
token: null,
|
||||||
|
user: null,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clear error
|
||||||
|
clearError: () => {
|
||||||
|
set({ error: null });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'auth-storage',
|
||||||
|
partialize: (state) => ({ token: state.token }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -1,10 +1,67 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import http from 'http'
|
||||||
|
|
||||||
|
// Plugin to check setup status and serve setup page from backend if needed
|
||||||
|
const setupCheckPlugin = () => ({
|
||||||
|
name: 'setup-check',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use(async (req, res, next) => {
|
||||||
|
// Only check root paths
|
||||||
|
if (req.url !== '/' && req.url !== '/index.html') {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check setup status from backend
|
||||||
|
const setupStatus = await new Promise<{ setup_required: boolean }>((resolve, reject) => {
|
||||||
|
const request = http.get('http://localhost:8080/api/v1/auth/setup-status', (response) => {
|
||||||
|
let data = ''
|
||||||
|
response.on('data', chunk => data += chunk)
|
||||||
|
response.on('end', () => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(data))
|
||||||
|
} catch (e) {
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
request.on('error', reject)
|
||||||
|
request.setTimeout(3000, () => reject(new Error('Timeout')))
|
||||||
|
})
|
||||||
|
|
||||||
|
// If setup required, proxy the setup page from backend
|
||||||
|
if (setupStatus.setup_required) {
|
||||||
|
// Fetch setup page from backend
|
||||||
|
const setupPage = await new Promise<string>((resolve, reject) => {
|
||||||
|
const request = http.get('http://localhost:8080/', (response) => {
|
||||||
|
let data = ''
|
||||||
|
response.on('data', chunk => data += chunk)
|
||||||
|
response.on('end', () => resolve(data))
|
||||||
|
})
|
||||||
|
request.on('error', reject)
|
||||||
|
request.setTimeout(5000, () => reject(new Error('Timeout')))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Serve the setup page
|
||||||
|
res.setHeader('Content-Type', 'text/html')
|
||||||
|
res.end(setupPage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// If backend is not running or error occurs, continue to app
|
||||||
|
console.warn('[setup-check] Could not check setup status, assuming setup complete')
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), setupCheckPlugin()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
|||||||
Reference in New Issue
Block a user