From 4eddf7e094a60f04fb5ee963ac963d4432ba1255 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Tue, 3 Mar 2026 07:46:49 +0000 Subject: [PATCH] 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`. --- Cargo.lock | 1 + crates/nxmesh-master/Cargo.toml | 3 + .../nxmesh-master/src/api/middleware/mod.rs | 1 + .../src/api/middleware/setup_check.rs | 55 ++ crates/nxmesh-master/src/api/mod.rs | 6 + crates/nxmesh-master/src/api/routes.rs | 478 ++++++++++++++++-- crates/nxmesh-master/src/api/setup_page.rs | 397 +++++++++++++++ crates/nxmesh-master/src/config/mod.rs | 2 +- crates/nxmesh-master/src/config/settings.rs | 37 ++ .../nxmesh-master/src/db/repositories/mod.rs | 1 + .../src/db/repositories/user_repository.rs | 92 ++++ crates/nxmesh-master/src/lib.rs | 40 +- .../src/services/auth_service.rs | 260 +++++++++- crates/nxmesh-master/src/services/mod.rs | 1 + .../src/services/setup_service.rs | 344 +++++++++++++ docs/setup.md | 98 ++++ frontend/src/App.tsx | 75 ++- frontend/src/api/client.ts | 49 +- .../src/components/auth/ProtectedRoute.tsx | 18 + frontend/src/components/auth/PublicRoute.tsx | 17 + frontend/src/components/layout/Header.tsx | 40 +- frontend/src/pages/Login/Login.tsx | 138 +++++ frontend/src/stores/authStore.ts | 101 ++++ frontend/vite.config.ts | 59 ++- 24 files changed, 2214 insertions(+), 99 deletions(-) create mode 100644 crates/nxmesh-master/src/api/middleware/setup_check.rs create mode 100644 crates/nxmesh-master/src/api/setup_page.rs create mode 100644 crates/nxmesh-master/src/db/repositories/user_repository.rs create mode 100644 crates/nxmesh-master/src/services/setup_service.rs create mode 100644 docs/setup.md create mode 100644 frontend/src/components/auth/ProtectedRoute.tsx create mode 100644 frontend/src/components/auth/PublicRoute.tsx create mode 100644 frontend/src/pages/Login/Login.tsx create mode 100644 frontend/src/stores/authStore.ts diff --git a/Cargo.lock b/Cargo.lock index edacb30..c9e39f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2475,6 +2475,7 @@ dependencies = [ "mockall", "nxmesh-core", "nxmesh-proto", + "rand 0.8.5", "sea-orm", "sea-orm-migration", "serde", diff --git a/crates/nxmesh-master/Cargo.toml b/crates/nxmesh-master/Cargo.toml index 9465e9d..012d8a2 100644 --- a/crates/nxmesh-master/Cargo.toml +++ b/crates/nxmesh-master/Cargo.toml @@ -74,6 +74,9 @@ uuid.workspace = true # Templating handlebars.workspace = true +# Random generation +rand = "0.8" + [dev-dependencies] tokio-test.workspace = true mockall.workspace = true diff --git a/crates/nxmesh-master/src/api/middleware/mod.rs b/crates/nxmesh-master/src/api/middleware/mod.rs index d1271fd..3e888a6 100644 --- a/crates/nxmesh-master/src/api/middleware/mod.rs +++ b/crates/nxmesh-master/src/api/middleware/mod.rs @@ -1,6 +1,7 @@ //! API middleware pub mod auth; +pub mod setup_check; use axum::{ extract::Request, diff --git a/crates/nxmesh-master/src/api/middleware/setup_check.rs b/crates/nxmesh-master/src/api/middleware/setup_check.rs new file mode 100644 index 0000000..4b26813 --- /dev/null +++ b/crates/nxmesh-master/src/api/middleware/setup_check.rs @@ -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, + request: Request, + 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() + } + } +} diff --git a/crates/nxmesh-master/src/api/mod.rs b/crates/nxmesh-master/src/api/mod.rs index 3ad892d..d053f96 100644 --- a/crates/nxmesh-master/src/api/mod.rs +++ b/crates/nxmesh-master/src/api/mod.rs @@ -2,6 +2,7 @@ pub mod middleware; pub mod routes; +pub mod setup_page; pub mod v1; pub mod websocket; @@ -13,6 +14,8 @@ use utoipa::OpenApi; paths( routes::health_check, routes::login, + routes::get_setup_status, + routes::setup_admin, v1::organizations::list, v1::organizations::create, v1::organizations::get, @@ -47,6 +50,9 @@ use utoipa::OpenApi; schemas( routes::LoginRequest, routes::LoginResponse, + routes::SetupStatus, + routes::SetupAdminRequest, + routes::SetupAdminResponse, v1::organizations::OrganizationResponse, v1::organizations::CreateOrganizationRequest, v1::workspaces::WorkspaceResponse, diff --git a/crates/nxmesh-master/src/api/routes.rs b/crates/nxmesh-master/src/api/routes.rs index 3193080..7986f65 100644 --- a/crates/nxmesh-master/src/api/routes.rs +++ b/crates/nxmesh-master/src/api/routes.rs @@ -1,21 +1,29 @@ //! API route definitions use axum::{ + extract::State, + middleware, routing::{get, post}, - Router, + Json, Router, }; 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::db::Database; -use crate::api::middleware::auth::AuthState; -use crate::api::ApiDoc; -use crate::api::v1; +use crate::services::auth_service::{AuthError, AuthService, UserInfo}; +use crate::services::setup_service::{SetupError, SetupService}; + +// Re-export SetupStatus for OpenAPI +pub use crate::services::setup_service::SetupStatus; use super::middleware::auth::auth_middleware; use super::middleware::log_request; -use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; /// Application state @@ -23,67 +31,351 @@ use utoipa_swagger_ui::SwaggerUi; pub struct AppState { pub db: Database, pub settings: Arc, + pub auth_service: AuthService, + pub setup_service: SetupService, +} + +impl AppState { + /// Create a new app state + pub fn new(db: Database, settings: Arc) -> 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 pub fn create_router(state: AppState) -> Router { 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) let public_routes = Router::new() .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)) // Swagger UI (includes OpenAPI spec at /api/openapi.json) .merge(SwaggerUi::new("/swagger-ui").url("/api/openapi.json", ApiDoc::generate())); // Protected routes (auth required) let protected_routes = Router::new() + // User info + .route("/api/v1/auth/me", get(get_current_user)) // Organizations - .route("/api/v1/organizations", 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)) - + .route( + "/api/v1/organizations", + 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 - .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/virtual-hosts", 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)) - + .route( + "/api/v1/workspaces/:id/virtual-hosts", + 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 - .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/workspaces/:id/agents/tokens", post(v1::agents::create_token)) - + .route( + "/api/v1/workspaces/:id/agents/tokens", + post(v1::agents::create_token), + ) // 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 - .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 - .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 - .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() .merge(public_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(cors_layer) .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 #[utoipa::path( get, path = "/health", responses( - (status = 200, description = "Server is healthy", body = String), + (status = 200, description = "Server is healthy", body = HealthResponse), ) )] -async fn health_check() -> &'static str { - "OK" +async fn health_check() -> Json { + 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, +) -> Result, (axum::http::StatusCode, Json)> { + 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, + Json(req): Json, +) -> Result< + (axum::http::StatusCode, Json), + (axum::http::StatusCode, Json), +> { + 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, + pub role: String, + pub organization_id: Option, +} + +impl From 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 @@ -91,24 +383,136 @@ async fn health_check() -> &'static str { post, path = "/api/v1/auth/login", operation_id = "login", + tag = "Authentication", request_body = LoginRequest, responses( (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 { - axum::Json(serde_json::json!({ - "token": "placeholder_token" - })) +async fn login( + State(state): State, + Json(req): Json, +) -> Result, (axum::http::StatusCode, Json)> { + // 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)] -pub struct LoginRequest { - email: String, - password: String, +/// Get current user endpoint +#[utoipa::path( + get, + 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, + request: axum::extract::Request, +) -> Result, (axum::http::StatusCode, Json)> { + // Extract claims from request extensions + let claims = request + .extensions() + .get::() + .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)] -pub struct LoginResponse { - token: String, +pub struct HealthResponse { + pub status: String, + pub version: String, } diff --git a/crates/nxmesh-master/src/api/setup_page.rs b/crates/nxmesh-master/src/api/setup_page.rs new file mode 100644 index 0000000..0d39f42 --- /dev/null +++ b/crates/nxmesh-master/src/api/setup_page.rs @@ -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#" + + + + + NxMesh - Initial Setup + + + +
+
+
+ +

Initial Setup

+

Create the first admin account

+
+ +
+ + + +
+

Setup Token Required

+

Please check the server console/logs for the setup token. It was displayed when the server started.

+
+
+ +
+ +
+
+ + +

Found in server console/logs

+
+ +
+ + +
+ +
+ + +
+ +
+ + +

Must be at least 8 characters

+
+ +
+ + +
+ + +
+
+ + +
+ + + +"#; diff --git a/crates/nxmesh-master/src/config/mod.rs b/crates/nxmesh-master/src/config/mod.rs index be9c3c6..4fa5c15 100644 --- a/crates/nxmesh-master/src/config/mod.rs +++ b/crates/nxmesh-master/src/config/mod.rs @@ -2,4 +2,4 @@ pub mod settings; -pub use settings::Settings; +pub use settings::{CorsSettings, Settings}; diff --git a/crates/nxmesh-master/src/config/settings.rs b/crates/nxmesh-master/src/config/settings.rs index 6e2160f..e1ebb85 100644 --- a/crates/nxmesh-master/src/config/settings.rs +++ b/crates/nxmesh-master/src/config/settings.rs @@ -10,6 +10,8 @@ pub struct Settings { pub database: DatabaseSettings, pub grpc: GrpcSettings, pub auth: AuthSettings, + #[serde(default)] + pub cors: CorsSettings, } /// HTTP server settings @@ -40,6 +42,19 @@ pub struct AuthSettings { pub jwt_expiration_hours: u64, } +/// CORS settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CorsSettings { + /// Allowed origins (default: any) + pub allowed_origins: Vec, + /// Allowed methods (default: GET, POST, PUT, DELETE, PATCH, OPTIONS) + pub allowed_methods: Vec, + /// Allowed headers (default: Content-Type, Authorization) + pub allowed_headers: Vec, + /// Allow credentials (default: true) + pub allow_credentials: bool, +} + impl Default for Settings { fn default() -> Self { Self { @@ -59,6 +74,28 @@ impl Default for Settings { jwt_secret: "change-me-in-production".to_string(), 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, } } } diff --git a/crates/nxmesh-master/src/db/repositories/mod.rs b/crates/nxmesh-master/src/db/repositories/mod.rs index 9dcec8c..c4153d3 100644 --- a/crates/nxmesh-master/src/db/repositories/mod.rs +++ b/crates/nxmesh-master/src/db/repositories/mod.rs @@ -2,4 +2,5 @@ pub mod agent_repository; pub mod organization_repository; +pub mod user_repository; pub mod workspace_repository; diff --git a/crates/nxmesh-master/src/db/repositories/user_repository.rs b/crates/nxmesh-master/src/db/repositories/user_repository.rs new file mode 100644 index 0000000..b0fba2c --- /dev/null +++ b/crates/nxmesh-master/src/db/repositories/user_repository.rs @@ -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, 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, 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, + ) -> Result { + 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 { + 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, 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 { + let result = users::Entity::delete_by_id(id).exec(&self.db).await?; + Ok(result.rows_affected) + } +} diff --git a/crates/nxmesh-master/src/lib.rs b/crates/nxmesh-master/src/lib.rs index 435c078..869b4db 100644 --- a/crates/nxmesh-master/src/lib.rs +++ b/crates/nxmesh-master/src/lib.rs @@ -37,10 +37,42 @@ pub async fn start(settings: Settings) -> Result<(), Box> info!("Database migrations complete"); // Create application state - let app_state = api::routes::AppState { - db: db.clone(), - settings: settings.clone(), - }; + let app_state = api::routes::AppState::new(db.clone(), 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 let app = api::routes::create_router(app_state); diff --git a/crates/nxmesh-master/src/services/auth_service.rs b/crates/nxmesh-master/src/services/auth_service.rs index c25c61a..100c85b 100644 --- a/crates/nxmesh-master/src/services/auth_service.rs +++ b/crates/nxmesh-master/src/services/auth_service.rs @@ -1,25 +1,259 @@ //! 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, + pub role: String, + pub organization_id: Option, +} + /// Auth service -pub struct AuthService; +#[derive(Clone)] +pub struct AuthService { + user_repo: UserRepository, + settings: Arc, +} impl AuthService { /// Create a new service instance - pub fn new() -> Self { - Self + pub fn new(db: DatabaseConnection, settings: Arc) -> Self { + Self { + user_repo: UserRepository::new(db), + settings, + } } - /// Authenticate user - pub async fn authenticate(&self, email: &str, password: &str) -> Result { - // TODO: Implement - tracing::info!("Authenticating user: {}", email); - Ok("jwt_token_placeholder".to_string()) + /// Register a new user + pub async fn register( + &self, + email: &str, + password: &str, + name: Option<&str>, + role: &str, + organization_id: Option, + ) -> Result { + 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); + } + + // Hash password + let password_hash = hash_password(password)?; + + // Create user + 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, + }) } - /// Validate token - pub async fn validate_token(&self, token: &str) -> Result { - // TODO: Implement - tracing::info!("Validating token: {}", token); - Ok(true) + /// Authenticate user and generate JWT token + pub async fn login(&self, email: &str, password: &str) -> Result { + 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 { + 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, 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, 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 { + 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 { + 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()); } } diff --git a/crates/nxmesh-master/src/services/mod.rs b/crates/nxmesh-master/src/services/mod.rs index cf357a4..481c4e3 100644 --- a/crates/nxmesh-master/src/services/mod.rs +++ b/crates/nxmesh-master/src/services/mod.rs @@ -5,4 +5,5 @@ pub mod auth_service; pub mod certificate_service; pub mod config_service; pub mod organization_service; +pub mod setup_service; pub mod workspace_service; diff --git a/crates/nxmesh-master/src/services/setup_service.rs b/crates/nxmesh-master/src/services/setup_service.rs new file mode 100644 index 0000000..80a57cb --- /dev/null +++ b/crates/nxmesh-master/src/services/setup_service.rs @@ -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, +} + +/// 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>, +} + +/// 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 { + 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 { + 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 { + // 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 { + 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 = 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 { + // 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 { + 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 { + 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")); + } +} diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..262fbd5 --- /dev/null +++ b/docs/setup.md @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b80947e..a69d677 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,22 +1,79 @@ import { Routes, Route } from 'react-router-dom' 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 { Agents } from './pages/Agents/Agents' import { Configurations } from './pages/Configurations/Configurations' import { Certificates } from './pages/Certificates/Certificates' import { Settings } from './pages/Settings/Settings' +import { Login } from './pages/Login/Login' function App() { return ( - - - } /> - } /> - } /> - } /> - } /> - - + + {/* Public routes */} + + + + } + /> + + {/* Protected routes */} + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + ) } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6eac8f8..8b16e09 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -23,22 +23,26 @@ client.use({ }); // Type aliases for request/response bodies -type LoginRequest = components['schemas']['LoginRequest']; -type LoginResponse = components['schemas']['LoginResponse']; -type OrganizationResponse = components['schemas']['OrganizationResponse']; -type CreateOrganizationRequest = components['schemas']['CreateOrganizationRequest']; -type WorkspaceResponse = components['schemas']['WorkspaceResponse']; -type CreateWorkspaceRequest = components['schemas']['CreateWorkspaceRequest']; -type AgentResponse = components['schemas']['AgentResponse']; -type CreateAgentTokenRequest = components['schemas']['CreateAgentTokenRequest']; -type VirtualHostResponse = components['schemas']['VirtualHostResponse']; -type CreateVirtualHostRequest = components['schemas']['CreateVirtualHostRequest']; -type UpstreamResponse = components['schemas']['UpstreamResponse']; -type CreateUpstreamRequest = components['schemas']['CreateUpstreamRequest']; -type CertificateResponse = components['schemas']['CertificateResponse']; -type CreateCertificateRequest = components['schemas']['CreateCertificateRequest']; +export type LoginRequest = components['schemas']['LoginRequest']; +export type LoginResponse = components['schemas']['LoginResponse']; +export type OrganizationResponse = components['schemas']['OrganizationResponse']; +export type CreateOrganizationRequest = components['schemas']['CreateOrganizationRequest']; +export type WorkspaceResponse = components['schemas']['WorkspaceResponse']; +export type CreateWorkspaceRequest = components['schemas']['CreateWorkspaceRequest']; +export type AgentResponse = components['schemas']['AgentResponse']; +export type CreateAgentTokenRequest = components['schemas']['CreateAgentTokenRequest']; +export type VirtualHostResponse = components['schemas']['VirtualHostResponse']; +export type CreateVirtualHostRequest = components['schemas']['CreateVirtualHostRequest']; +export type UpstreamResponse = components['schemas']['UpstreamResponse']; +export type CreateUpstreamRequest = components['schemas']['CreateUpstreamRequest']; +export type CertificateResponse = components['schemas']['CertificateResponse']; +export type CreateCertificateRequest = components['schemas']['CreateCertificateRequest']; export const api = { + // Auth + login: (body: { email: string; password: string }) => + client.POST('/api/v1/auth/login', { body }), + // Organizations listOrganizations: () => client.GET('/api/v1/organizations'), @@ -108,21 +112,6 @@ export const api = { client.POST('/api/v1/workspaces/{id}/certificates', { params: { path: { id: wsId } }, body }), deleteCertificate: (id: string) => client.DELETE('/api/v1/certificates/{id}', { params: { path: { id } } }), - - // Auth - login: (body: LoginRequest) => - client.POST('/api/v1/auth/login', { body }), }; -export type { - components, - paths, - LoginRequest, - LoginResponse, - OrganizationResponse, - WorkspaceResponse, - AgentResponse, - VirtualHostResponse, - UpstreamResponse, - CertificateResponse, -}; +export type { paths, components }; diff --git a/frontend/src/components/auth/ProtectedRoute.tsx b/frontend/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..d8dfb42 --- /dev/null +++ b/frontend/src/components/auth/ProtectedRoute.tsx @@ -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 ; + } + + return <>{children}; +} diff --git a/frontend/src/components/auth/PublicRoute.tsx b/frontend/src/components/auth/PublicRoute.tsx new file mode 100644 index 0000000..28605af --- /dev/null +++ b/frontend/src/components/auth/PublicRoute.tsx @@ -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 ; + } + + return <>{children}; +} diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 5c2ce30..5b264a7 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -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() { + const navigate = useNavigate(); + const { user, logout } = useAuthStore(); + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + return (
@@ -8,14 +18,36 @@ export function Header() { NxMesh Admin
+ {/* Notifications */} -
- ) + ); } diff --git a/frontend/src/pages/Login/Login.tsx b/frontend/src/pages/Login/Login.tsx new file mode 100644 index 0000000..bf3cb62 --- /dev/null +++ b/frontend/src/pages/Login/Login.tsx @@ -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 ( +
+
+ {/* Header */} +
+
+ +
+

+ Sign in to NxMesh +

+

+ Distributed Nginx Management System +

+
+ + {/* Error Message */} + {error && ( +
+
+
+ +
+
+

+ {error} +

+
+
+
+ )} + + {/* Form */} +
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+ +
+
+ + {/* First time setup hint */} +
+
+
+
+

+ First Time? +

+
+

+ If this is a fresh installation, you need to complete the initial setup. +

+ + Go to Setup Page + + +

+ (Check server logs for the setup token) +

+
+
+
+
+
+
+
+ ); +} diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts new file mode 100644 index 0000000..c209553 --- /dev/null +++ b/frontend/src/stores/authStore.ts @@ -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; + logout: () => void; + clearError: () => void; +} + +export const useAuthStore = create()( + 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 => { + 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 }), + } + ) +); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3ba750b..9ce7882 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,10 +1,67 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' 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((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/ export default defineConfig({ - plugins: [react()], + plugins: [react(), setupCheckPlugin()], resolve: { alias: { '@': path.resolve(__dirname, './src'),