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",
|
||||
"nxmesh-core",
|
||||
"nxmesh-proto",
|
||||
"rand 0.8.5",
|
||||
"sea-orm",
|
||||
"sea-orm-migration",
|
||||
"serde",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! API middleware
|
||||
|
||||
pub mod auth;
|
||||
pub mod setup_check;
|
||||
|
||||
use axum::{
|
||||
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 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,
|
||||
|
||||
@@ -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<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
|
||||
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<serde_json::Value> {
|
||||
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
|
||||
@@ -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<serde_json::Value> {
|
||||
axum::Json(serde_json::json!({
|
||||
"token": "placeholder_token"
|
||||
}))
|
||||
async fn login(
|
||||
State(state): State<AppState>,
|
||||
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)]
|
||||
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<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)]
|
||||
pub struct LoginResponse {
|
||||
token: String,
|
||||
pub struct HealthResponse {
|
||||
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 use settings::Settings;
|
||||
pub use settings::{CorsSettings, Settings};
|
||||
|
||||
@@ -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<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 {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
pub mod agent_repository;
|
||||
pub mod organization_repository;
|
||||
pub mod user_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");
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -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<String>,
|
||||
pub role: String,
|
||||
pub organization_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// Auth service
|
||||
pub struct AuthService;
|
||||
#[derive(Clone)]
|
||||
pub struct AuthService {
|
||||
user_repo: UserRepository,
|
||||
settings: Arc<Settings>,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
/// Create a new service instance
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
pub fn new(db: DatabaseConnection, settings: Arc<Settings>) -> Self {
|
||||
Self {
|
||||
user_repo: UserRepository::new(db),
|
||||
settings,
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate user
|
||||
pub async fn authenticate(&self, email: &str, password: &str) -> Result<String, String> {
|
||||
// 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<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);
|
||||
}
|
||||
|
||||
// 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<bool, String> {
|
||||
// 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<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 config_service;
|
||||
pub mod organization_service;
|
||||
pub mod setup_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 { 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 (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/agents" element={<Agents />} />
|
||||
<Route path="/configurations" element={<Configurations />} />
|
||||
<Route path="/certificates" element={<Certificates />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<Login />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Dashboard />
|
||||
</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 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 };
|
||||
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuthStore();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-white dark:bg-gray-800 shadow-sm">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
@@ -8,14 +18,36 @@ export function Header() {
|
||||
NxMesh Admin
|
||||
</h1>
|
||||
<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">
|
||||
<Bell className="w-5 h-5" />
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</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 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<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/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), setupCheckPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
|
||||
Reference in New Issue
Block a user