Compare commits
5 Commits
87efa1cf0e
...
8b1a9be8c2
Author | SHA1 | Date | |
---|---|---|---|
8b1a9be8c2
|
|||
7b3ae6bb6e
|
|||
88e5f3d694
|
|||
7a7a909440
|
|||
2d2b1b9c00
|
1
server/Cargo.lock
generated
1
server/Cargo.lock
generated
@@ -1363,6 +1363,7 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"rand",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
@@ -14,3 +14,4 @@ uuid = { version = "1.0", features = ["v4", "serde"] }
|
|||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
tower-http = { version = "0.6.6", features = ["cors", "fs"] }
|
tower-http = { version = "0.6.6", features = ["cors", "fs"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
rand = "0.8"
|
@@ -1,48 +1,65 @@
|
|||||||
use crate::utils::{error::*, models::*, DbPool};
|
use crate::utils::{base62::Base62, config::ConfigManager, error::*, models::*, DbPool};
|
||||||
use chrono::Utc;
|
use chrono::{Duration, Utc};
|
||||||
|
use rand::{distributions::Alphanumeric, Rng};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub struct MachinesController;
|
pub struct MachinesController;
|
||||||
|
|
||||||
impl MachinesController {
|
impl MachinesController {
|
||||||
pub async fn register_machine(
|
pub async fn register_machine(pool: &DbPool, user: &User, name: &str) -> AppResult<Machine> {
|
||||||
pool: &DbPool,
|
|
||||||
code: &str,
|
|
||||||
uuid: &Uuid,
|
|
||||||
name: &str,
|
|
||||||
) -> AppResult<Machine> {
|
|
||||||
Self::validate_machine_input(name)?;
|
Self::validate_machine_input(name)?;
|
||||||
|
|
||||||
let provisioning_code = Self::get_provisioning_code(pool, code)
|
let machine_uuid = Uuid::new_v4();
|
||||||
.await?
|
|
||||||
.ok_or_else(|| validation_error("Invalid provisioning code"))?;
|
|
||||||
|
|
||||||
if provisioning_code.used {
|
let machine = Self::create_machine(pool, user.id, &machine_uuid, name).await?;
|
||||||
return Err(validation_error("Provisioning code already used"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if provisioning_code.expires_at < Utc::now() {
|
|
||||||
return Err(validation_error("Provisioning code expired"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if Self::machine_exists_by_uuid(pool, uuid).await? {
|
|
||||||
return Err(conflict_error("Machine with this UUID already exists"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let machine = Self::create_machine(pool, provisioning_code.user_id, uuid, name).await?;
|
|
||||||
|
|
||||||
Self::mark_provisioning_code_used(pool, code).await?;
|
|
||||||
|
|
||||||
Ok(machine)
|
Ok(machine)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_machines_for_user(pool: &DbPool, user: &User) -> AppResult<Vec<Machine>> {
|
pub async fn create_provisioning_code(
|
||||||
if user.role == UserRole::Admin {
|
pool: &DbPool,
|
||||||
Self::get_all_machines(pool).await
|
machine_id: i64,
|
||||||
} else {
|
user: &User,
|
||||||
Self::get_machines_by_user_id(pool, user.id).await
|
) -> AppResult<ProvisioningCodeResponse> {
|
||||||
|
let machine = Self::get_machine_by_id(pool, machine_id).await?;
|
||||||
|
|
||||||
|
if user.role != UserRole::Admin && machine.user_id != user.id {
|
||||||
|
return Err(forbidden_error("Access denied"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let code: String = rand::thread_rng()
|
||||||
|
.sample_iter(&Alphanumeric)
|
||||||
|
.take(5)
|
||||||
|
.map(char::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let external_url = ConfigManager::get_external_url(pool).await?;
|
||||||
|
let provisioning_string = format!("52?#{}/{}", external_url, code);
|
||||||
|
let encoded_code = Base62::encode(&provisioning_string);
|
||||||
|
let expires_at = Utc::now() + Duration::hours(1);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO provisioning_codes (machine_id, code, expires_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(machine_id)
|
||||||
|
.bind(&code)
|
||||||
|
.bind(expires_at)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(ProvisioningCodeResponse {
|
||||||
|
code: encoded_code,
|
||||||
|
raw_code: code,
|
||||||
|
expires_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_machines_for_user(pool: &DbPool, user: &User) -> AppResult<Vec<Machine>> {
|
||||||
|
Self::get_machines_by_user_id(pool, user.id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_machine(pool: &DbPool, machine_id: i64, user: &User) -> AppResult<()> {
|
pub async fn delete_machine(pool: &DbPool, machine_id: i64, user: &User) -> AppResult<()> {
|
||||||
@@ -75,30 +92,6 @@ impl MachinesController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_all_machines(pool: &DbPool) -> AppResult<Vec<Machine>> {
|
|
||||||
let rows = sqlx::query(
|
|
||||||
r#"
|
|
||||||
SELECT id, user_id, uuid, name, created_at
|
|
||||||
FROM machines ORDER BY created_at DESC
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let mut machines = Vec::new();
|
|
||||||
for row in rows {
|
|
||||||
machines.push(Machine {
|
|
||||||
id: row.get("id"),
|
|
||||||
user_id: row.get("user_id"),
|
|
||||||
uuid: Uuid::parse_str(&row.get::<String, _>("uuid")).unwrap(),
|
|
||||||
name: row.get("name"),
|
|
||||||
created_at: row.get("created_at"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(machines)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_machines_by_user_id(pool: &DbPool, user_id: i64) -> AppResult<Vec<Machine>> {
|
async fn get_machines_by_user_id(pool: &DbPool, user_id: i64) -> AppResult<Vec<Machine>> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -169,7 +162,7 @@ impl MachinesController {
|
|||||||
) -> AppResult<Option<ProvisioningCode>> {
|
) -> AppResult<Option<ProvisioningCode>> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, user_id, code, created_at, expires_at, used
|
SELECT id, machine_id, code, created_at, expires_at, used
|
||||||
FROM provisioning_codes WHERE code = ?
|
FROM provisioning_codes WHERE code = ?
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -180,7 +173,7 @@ impl MachinesController {
|
|||||||
if let Some(row) = row {
|
if let Some(row) = row {
|
||||||
Ok(Some(ProvisioningCode {
|
Ok(Some(ProvisioningCode {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
user_id: row.get("user_id"),
|
machine_id: row.get("machine_id"),
|
||||||
code: row.get("code"),
|
code: row.get("code"),
|
||||||
created_at: row.get("created_at"),
|
created_at: row.get("created_at"),
|
||||||
expires_at: row.get("expires_at"),
|
expires_at: row.get("expires_at"),
|
||||||
|
@@ -7,7 +7,7 @@ use axum::{
|
|||||||
routing::{delete, get, post, put},
|
routing::{delete, get, post, put},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use routes::{accounts, admin, auth as auth_routes, machines, setup};
|
use routes::{accounts, admin, auth as auth_routes, config, machines, setup};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tokio::signal;
|
use tokio::signal;
|
||||||
use tower_http::{
|
use tower_http::{
|
||||||
@@ -29,7 +29,11 @@ async fn main() -> Result<()> {
|
|||||||
.route("/admin/users", post(admin::create_user_handler))
|
.route("/admin/users", post(admin::create_user_handler))
|
||||||
.route("/admin/users/{id}", put(admin::update_user_handler))
|
.route("/admin/users/{id}", put(admin::update_user_handler))
|
||||||
.route("/admin/users/{id}", delete(admin::delete_user_handler))
|
.route("/admin/users/{id}", delete(admin::delete_user_handler))
|
||||||
|
.route("/admin/config", get(config::get_all_configs))
|
||||||
|
.route("/admin/config", post(config::set_config))
|
||||||
|
.route("/admin/config/{key}", get(config::get_config))
|
||||||
.route("/machines/register", post(machines::register_machine))
|
.route("/machines/register", post(machines::register_machine))
|
||||||
|
.route("/machines/provisioning-code", post(machines::create_provisioning_code))
|
||||||
.route("/machines", get(machines::get_machines))
|
.route("/machines", get(machines::get_machines))
|
||||||
.route("/machines/{id}", delete(machines::delete_machine))
|
.route("/machines/{id}", delete(machines::delete_machine))
|
||||||
.layer(CorsLayer::permissive())
|
.layer(CorsLayer::permissive())
|
||||||
|
113
server/src/routes/config.rs
Normal file
113
server/src/routes/config.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
use crate::utils::{auth::*, config::ConfigManager, error::*, DbPool};
|
||||||
|
use axum::{extract::State, response::Json};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ConfigRequest {
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ConfigResponse {
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ConfigDefinition {
|
||||||
|
pub key: String,
|
||||||
|
pub description: String,
|
||||||
|
pub value: Option<String>,
|
||||||
|
pub default_value: Option<String>,
|
||||||
|
pub required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ConfigListResponse {
|
||||||
|
pub configs: Vec<ConfigDefinition>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_configs(
|
||||||
|
auth_user: AuthUser,
|
||||||
|
State(pool): State<DbPool>,
|
||||||
|
) -> Result<Json<ConfigListResponse>, AppError> {
|
||||||
|
if auth_user.user.role != crate::utils::models::UserRole::Admin {
|
||||||
|
return Err(forbidden_error("Admin access required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let allowed_configs = vec![
|
||||||
|
ConfigDefinition {
|
||||||
|
key: "EXTERNAL_URL".to_string(),
|
||||||
|
description: "The external URL used for provisioning codes. This should be the public URL where this server can be reached.".to_string(),
|
||||||
|
value: ConfigManager::get_config(&pool, "EXTERNAL_URL").await?,
|
||||||
|
default_value: Some("https://your-domain.com".to_string()),
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
ConfigDefinition {
|
||||||
|
key: "SESSION_TIMEOUT_HOURS".to_string(),
|
||||||
|
description: "Number of hours before user sessions expire and require re-authentication.".to_string(),
|
||||||
|
value: ConfigManager::get_config(&pool, "SESSION_TIMEOUT_HOURS").await?,
|
||||||
|
default_value: Some("24".to_string()),
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
Ok(success_response(ConfigListResponse {
|
||||||
|
configs: allowed_configs,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_config(
|
||||||
|
auth_user: AuthUser,
|
||||||
|
State(pool): State<DbPool>,
|
||||||
|
Json(request): Json<ConfigRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
if auth_user.user.role != crate::utils::models::UserRole::Admin {
|
||||||
|
return Err(forbidden_error("Admin access required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let allowed_keys = vec!["EXTERNAL_URL", "SESSION_TIMEOUT_HOURS"];
|
||||||
|
|
||||||
|
if !allowed_keys.contains(&request.key.as_str()) {
|
||||||
|
return Err(validation_error("Invalid configuration key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
match request.key.as_str() {
|
||||||
|
"EXTERNAL_URL" => {
|
||||||
|
if request.value.trim().is_empty() {
|
||||||
|
return Err(validation_error("External URL cannot be empty"));
|
||||||
|
}
|
||||||
|
if !request.value.starts_with("http://") && !request.value.starts_with("https://") {
|
||||||
|
return Err(validation_error(
|
||||||
|
"External URL must start with http:// or https://",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"SESSION_TIMEOUT_HOURS" => {
|
||||||
|
if request.value.parse::<i32>().is_err() || request.value.parse::<i32>().unwrap() <= 0 {
|
||||||
|
return Err(validation_error("Value must be a positive number"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigManager::set_config(&pool, &request.key, &request.value).await?;
|
||||||
|
Ok(success_message("Configuration updated successfully"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_config(
|
||||||
|
auth_user: AuthUser,
|
||||||
|
State(pool): State<DbPool>,
|
||||||
|
axum::extract::Path(key): axum::extract::Path<String>,
|
||||||
|
) -> Result<Json<ConfigResponse>, AppError> {
|
||||||
|
if auth_user.user.role != crate::utils::models::UserRole::Admin {
|
||||||
|
return Err(forbidden_error("Admin access required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = ConfigManager::get_config(&pool, &key)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| not_found_error("Configuration key not found"))?;
|
||||||
|
|
||||||
|
Ok(success_response(ConfigResponse { key, value }))
|
||||||
|
}
|
@@ -6,13 +6,13 @@ use axum::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub async fn register_machine(
|
pub async fn register_machine(
|
||||||
|
auth_user: AuthUser,
|
||||||
State(pool): State<DbPool>,
|
State(pool): State<DbPool>,
|
||||||
Json(request): Json<RegisterMachineRequest>,
|
Json(request): Json<RegisterMachineRequest>,
|
||||||
) -> Result<Json<Machine>, AppError> {
|
) -> Result<Json<Machine>, AppError> {
|
||||||
let machine = MachinesController::register_machine(
|
let machine = MachinesController::register_machine(
|
||||||
&pool,
|
&pool,
|
||||||
&request.code,
|
&auth_user.user,
|
||||||
&request.uuid,
|
|
||||||
&request.name,
|
&request.name,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -20,6 +20,21 @@ pub async fn register_machine(
|
|||||||
Ok(success_response(machine))
|
Ok(success_response(machine))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn create_provisioning_code(
|
||||||
|
auth_user: AuthUser,
|
||||||
|
State(pool): State<DbPool>,
|
||||||
|
Json(request): Json<CreateProvisioningCodeRequest>,
|
||||||
|
) -> Result<Json<ProvisioningCodeResponse>, AppError> {
|
||||||
|
let response = MachinesController::create_provisioning_code(
|
||||||
|
&pool,
|
||||||
|
request.machine_id,
|
||||||
|
&auth_user.user,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(success_response(response))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_machines(
|
pub async fn get_machines(
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
State(pool): State<DbPool>,
|
State(pool): State<DbPool>,
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
pub mod admin;
|
pub mod admin;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod config;
|
||||||
pub mod machines;
|
pub mod machines;
|
||||||
pub mod setup;
|
pub mod setup;
|
||||||
pub mod accounts;
|
pub mod accounts;
|
||||||
|
108
server/src/utils/base62.rs
Normal file
108
server/src/utils/base62.rs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
const CHARS: &str = "rYTSJ96O2ntiEBkuwQq0vdslyfI8Ph51bpae3LgHoFZAxj7WmzUNCGXcR4MDKV";
|
||||||
|
|
||||||
|
pub struct Base62;
|
||||||
|
|
||||||
|
impl Base62 {
|
||||||
|
pub fn encode(input: &str) -> String {
|
||||||
|
if input.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = input.as_bytes();
|
||||||
|
let alphabet_chars: Vec<char> = CHARS.chars().collect();
|
||||||
|
|
||||||
|
let mut number = bytes.iter().fold(String::from("0"), |acc, &byte| {
|
||||||
|
Self::multiply_and_add(&acc, 256, byte as u32)
|
||||||
|
});
|
||||||
|
|
||||||
|
if number == "0" {
|
||||||
|
return "0".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = String::new();
|
||||||
|
while number != "0" {
|
||||||
|
let (new_number, remainder) = Self::divide_by(&number, 62);
|
||||||
|
result.push(alphabet_chars[remainder as usize]);
|
||||||
|
number = new_number;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.chars().rev().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(encoded: &str) -> Option<String> {
|
||||||
|
if encoded.is_empty() {
|
||||||
|
return Some(String::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let char_to_value: std::collections::HashMap<char, u32> = CHARS
|
||||||
|
.chars()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, c)| (c, i as u32))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut number = String::from("0");
|
||||||
|
for c in encoded.chars() {
|
||||||
|
let value = *char_to_value.get(&c)?;
|
||||||
|
number = Self::multiply_and_add(&number, 62, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if number == "0" {
|
||||||
|
return Some(String::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
while number != "0" {
|
||||||
|
let (new_number, remainder) = Self::divide_by(&number, 256);
|
||||||
|
bytes.push(remainder as u8);
|
||||||
|
number = new_number;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes.reverse();
|
||||||
|
String::from_utf8(bytes).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn multiply_and_add(num_str: &str, base: u32, add: u32) -> String {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let mut carry = add;
|
||||||
|
|
||||||
|
for c in num_str.chars().rev() {
|
||||||
|
let digit = c.to_digit(10).unwrap_or(0);
|
||||||
|
let product = digit * base + carry;
|
||||||
|
result.push((product % 10).to_string());
|
||||||
|
carry = product / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
while carry > 0 {
|
||||||
|
result.push((carry % 10).to_string());
|
||||||
|
carry /= 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.is_empty() {
|
||||||
|
"0".to_string()
|
||||||
|
} else {
|
||||||
|
result.into_iter().rev().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn divide_by(num_str: &str, base: u32) -> (String, u32) {
|
||||||
|
let mut quotient = String::new();
|
||||||
|
let mut remainder = 0u32;
|
||||||
|
|
||||||
|
for c in num_str.chars() {
|
||||||
|
let digit = c.to_digit(10).unwrap_or(0);
|
||||||
|
let current = remainder * 10 + digit;
|
||||||
|
let q = current / base;
|
||||||
|
remainder = current % base;
|
||||||
|
|
||||||
|
if !quotient.is_empty() || q > 0 {
|
||||||
|
quotient.push_str(&q.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if quotient.is_empty() {
|
||||||
|
quotient = "0".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
(quotient, remainder)
|
||||||
|
}
|
||||||
|
}
|
44
server/src/utils/config.rs
Normal file
44
server/src/utils/config.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
use crate::utils::{error::*, DbPool};
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
|
pub struct ConfigManager;
|
||||||
|
|
||||||
|
impl ConfigManager {
|
||||||
|
pub async fn get_config(pool: &DbPool, key: &str) -> AppResult<Option<String>> {
|
||||||
|
let row = sqlx::query("SELECT value FROM config WHERE key = ?")
|
||||||
|
.bind(key)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(row) = row {
|
||||||
|
Ok(Some(row.get("value")))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_config(pool: &DbPool, key: &str, value: &str) -> AppResult<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO config (key, value, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(key)
|
||||||
|
.bind(value)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_external_url(pool: &DbPool) -> AppResult<String> {
|
||||||
|
match Self::get_config(pool, "EXTERNAL_URL").await? {
|
||||||
|
Some(url) => Ok(url),
|
||||||
|
None => Err(internal_error("EXTERNAL_URL not configured")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -72,12 +72,12 @@ async fn run_migrations(pool: &DbPool) -> AppResult<()> {
|
|||||||
r#"
|
r#"
|
||||||
CREATE TABLE IF NOT EXISTS provisioning_codes (
|
CREATE TABLE IF NOT EXISTS provisioning_codes (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL,
|
machine_id INTEGER NOT NULL,
|
||||||
code TEXT UNIQUE NOT NULL,
|
code TEXT UNIQUE NOT NULL,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
expires_at DATETIME NOT NULL,
|
expires_at DATETIME NOT NULL,
|
||||||
used BOOLEAN DEFAULT 0,
|
used BOOLEAN DEFAULT 0,
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY(machine_id) REFERENCES machines(id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -98,6 +98,19 @@ async fn run_migrations(pool: &DbPool) -> AppResult<()> {
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,4 +1,6 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod base62;
|
||||||
|
pub mod config;
|
||||||
pub mod database;
|
pub mod database;
|
||||||
pub mod db_path;
|
pub mod db_path;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
@@ -89,15 +89,32 @@ pub struct Machine {
|
|||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct RegisterMachineRequest {
|
pub struct RegisterMachineRequest {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct UseProvisioningCodeRequest {
|
||||||
pub code: String,
|
pub code: String,
|
||||||
pub uuid: Uuid,
|
pub uuid: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct CreateProvisioningCodeRequest {
|
||||||
|
pub machine_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ProvisioningCodeResponse {
|
||||||
|
pub code: String,
|
||||||
|
pub raw_code: String,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct ProvisioningCode {
|
pub struct ProvisioningCode {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub user_id: i64,
|
pub machine_id: i64,
|
||||||
pub code: String,
|
pub code: String,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub expires_at: DateTime<Utc>,
|
pub expires_at: DateTime<Utc>,
|
||||||
|
@@ -4,6 +4,8 @@ import {ToastProvider} from '@/common/contexts/ToastContext.jsx';
|
|||||||
import "@/common/styles/main.sass";
|
import "@/common/styles/main.sass";
|
||||||
import Root from "@/common/layouts/Root.jsx";
|
import Root from "@/common/layouts/Root.jsx";
|
||||||
import UserManagement from "@/pages/UserManagement";
|
import UserManagement from "@/pages/UserManagement";
|
||||||
|
import SystemSettings from "@/pages/SystemSettings";
|
||||||
|
import Machines from "@/pages/Machines";
|
||||||
import "@fontsource/plus-jakarta-sans/300.css";
|
import "@fontsource/plus-jakarta-sans/300.css";
|
||||||
import "@fontsource/plus-jakarta-sans/400.css";
|
import "@fontsource/plus-jakarta-sans/400.css";
|
||||||
import "@fontsource/plus-jakarta-sans/600.css";
|
import "@fontsource/plus-jakarta-sans/600.css";
|
||||||
@@ -21,9 +23,11 @@ const App = () => {
|
|||||||
children: [
|
children: [
|
||||||
{path: "/", element: <Navigate to="/dashboard"/>},
|
{path: "/", element: <Navigate to="/dashboard"/>},
|
||||||
{path: "/dashboard", element: <Placeholder title="Dashboard"/>},
|
{path: "/dashboard", element: <Placeholder title="Dashboard"/>},
|
||||||
|
{path: "/machines", element: <Machines/>},
|
||||||
{path: "/servers", element: <Placeholder title="Servers"/>},
|
{path: "/servers", element: <Placeholder title="Servers"/>},
|
||||||
{path: "/settings", element: <Placeholder title="Settings"/>},
|
{path: "/settings", element: <Placeholder title="Settings"/>},
|
||||||
{path: "/admin/users", element: <UserManagement/>},
|
{path: "/admin/users", element: <UserManagement/>},
|
||||||
|
{path: "/admin/settings", element: <SystemSettings/>},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
@@ -1,14 +1,13 @@
|
|||||||
import React, {useState, useRef, useEffect, useContext} from 'react';
|
import React, {useState, useRef, useEffect, useContext} from 'react';
|
||||||
import {UserCircleIcon, SignOutIcon, CaretDownIcon} from '@phosphor-icons/react';
|
import {SignOutIcon, CaretDownIcon, UserIcon} from '@phosphor-icons/react';
|
||||||
import {UserContext} from '@/common/contexts/UserContext.jsx';
|
import {UserContext} from '@/common/contexts/UserContext.jsx';
|
||||||
import './styles.sass';
|
import './styles.sass';
|
||||||
|
|
||||||
export const ProfileMenu = () => {
|
export const ProfileMenu = () => {
|
||||||
const {logout} = useContext(UserContext);
|
const {logout, user} = useContext(UserContext);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
|
|
||||||
// Close menu when clicking outside
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event) => {
|
const handleClickOutside = (event) => {
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||||
@@ -22,7 +21,6 @@ export const ProfileMenu = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Close menu on escape key
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscapeKey = (event) => {
|
const handleEscapeKey = (event) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
@@ -56,9 +54,9 @@ export const ProfileMenu = () => {
|
|||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
>
|
>
|
||||||
<div className="profile-menu-avatar">
|
<div className="profile-menu-avatar">
|
||||||
<UserCircleIcon size={16} weight="fill"/>
|
<UserIcon size={16} weight="duotone"/>
|
||||||
</div>
|
</div>
|
||||||
<span className="profile-menu-name">Admin</span>
|
<span className="profile-menu-name">{user.username}</span>
|
||||||
<CaretDownIcon
|
<CaretDownIcon
|
||||||
size={14}
|
size={14}
|
||||||
className={`profile-menu-caret ${isOpen ? 'rotated' : ''}`}
|
className={`profile-menu-caret ${isOpen ? 'rotated' : ''}`}
|
||||||
@@ -69,11 +67,11 @@ export const ProfileMenu = () => {
|
|||||||
<div className="profile-menu-dropdown">
|
<div className="profile-menu-dropdown">
|
||||||
<div className="profile-menu-header">
|
<div className="profile-menu-header">
|
||||||
<div className="profile-menu-avatar profile-menu-avatar--large">
|
<div className="profile-menu-avatar profile-menu-avatar--large">
|
||||||
<UserCircleIcon size={20} weight="fill"/>
|
<UserIcon size={20} weight="duotone"/>
|
||||||
</div>
|
</div>
|
||||||
<div className="profile-menu-info">
|
<div className="profile-menu-info">
|
||||||
<div className="profile-menu-name-large">Admin User</div>
|
<div className="profile-menu-name-large">{user.username}</div>
|
||||||
<div className="profile-menu-role">Administrator</div>
|
<div className="profile-menu-role">{user.role === 'admin' ? 'Administrator' : 'User'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@@ -1,17 +1,19 @@
|
|||||||
import React, { useContext } from 'react';
|
import React, { useContext } from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { HouseIcon, GearSixIcon, SquaresFourIcon, CubeIcon, UsersIcon } from '@phosphor-icons/react';
|
import { HouseIcon, GearSixIcon, SquaresFourIcon, CubeIcon, UsersIcon, SlidersIcon, ComputerTowerIcon } from '@phosphor-icons/react';
|
||||||
import { UserContext } from '@/common/contexts/UserContext.jsx';
|
import { UserContext } from '@/common/contexts/UserContext.jsx';
|
||||||
import './styles.sass';
|
import './styles.sass';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/dashboard', label: 'Dashboard', icon: <HouseIcon weight="duotone" /> },
|
{ to: '/dashboard', label: 'Dashboard', icon: <HouseIcon weight="duotone" /> },
|
||||||
|
{ to: '/machines', label: 'Machines', icon: <ComputerTowerIcon weight="duotone" /> },
|
||||||
{ to: '/servers', label: 'Servers', icon: <SquaresFourIcon weight="duotone" /> },
|
{ to: '/servers', label: 'Servers', icon: <SquaresFourIcon weight="duotone" /> },
|
||||||
{ to: '/settings', label: 'Settings', icon: <GearSixIcon weight="duotone" /> },
|
{ to: '/settings', label: 'Settings', icon: <GearSixIcon weight="duotone" /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminNavItems = [
|
const adminNavItems = [
|
||||||
{ to: '/admin/users', label: 'User Management', icon: <UsersIcon weight="duotone" /> },
|
{ to: '/admin/users', label: 'User Management', icon: <UsersIcon weight="duotone" /> },
|
||||||
|
{ to: '/admin/settings', label: 'System Settings', icon: <SlidersIcon weight="duotone" /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const Sidebar = () => {
|
export const Sidebar = () => {
|
||||||
|
@@ -7,7 +7,7 @@ export const UserContext = createContext({});
|
|||||||
export const UserProvider = ({ children }) => {
|
export const UserProvider = ({ children }) => {
|
||||||
|
|
||||||
const [sessionToken, setSessionToken] = useState(localStorage.getItem("sessionToken"));
|
const [sessionToken, setSessionToken] = useState(localStorage.getItem("sessionToken"));
|
||||||
const [isSetupCompleted, setIsSetupCompleted] = useState(null); // null = unknown, true/false = known
|
const [isSetupCompleted, setIsSetupCompleted] = useState(null);
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ export const UserProvider = ({ children }) => {
|
|||||||
return response?.first_user_exists;
|
return response?.first_user_exists;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
setIsSetupCompleted(false); // Default to setup mode if we can't check
|
setIsSetupCompleted(false);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -33,7 +33,6 @@ export const UserProvider = ({ children }) => {
|
|||||||
try {
|
try {
|
||||||
const userObj = await getRequest("accounts/me");
|
const userObj = await getRequest("accounts/me");
|
||||||
setUser(userObj);
|
setUser(userObj);
|
||||||
// If login is successful, setup must be completed
|
|
||||||
if (isSetupCompleted === null) {
|
if (isSetupCompleted === null) {
|
||||||
setIsSetupCompleted(true);
|
setIsSetupCompleted(true);
|
||||||
}
|
}
|
||||||
@@ -42,7 +41,6 @@ export const UserProvider = ({ children }) => {
|
|||||||
setSessionToken(null);
|
setSessionToken(null);
|
||||||
localStorage.removeItem("sessionToken");
|
localStorage.removeItem("sessionToken");
|
||||||
setUser(null);
|
setUser(null);
|
||||||
// Check setup status when unauthorized
|
|
||||||
await checkFirstTimeSetup();
|
await checkFirstTimeSetup();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -57,12 +55,10 @@ export const UserProvider = ({ children }) => {
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear user state
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setSessionToken(null);
|
setSessionToken(null);
|
||||||
localStorage.removeItem("sessionToken");
|
localStorage.removeItem("sessionToken");
|
||||||
|
|
||||||
// Re-check setup status after logout
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await checkFirstTimeSetup();
|
await checkFirstTimeSetup();
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -85,39 +81,24 @@ export const UserProvider = ({ children }) => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
if (sessionToken) {
|
if (sessionToken) {
|
||||||
// Try to login with existing token
|
|
||||||
await login();
|
await login();
|
||||||
} else {
|
} else {
|
||||||
// No token, check setup status
|
|
||||||
await checkFirstTimeSetup();
|
await checkFirstTimeSetup();
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
initializeAuth();
|
initializeAuth();
|
||||||
}, []); // Only run once on mount
|
}, []);
|
||||||
|
|
||||||
// Handle session token changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionToken && user === null && !isLoading) {
|
if (sessionToken && user === null && !isLoading) {
|
||||||
login();
|
login();
|
||||||
}
|
}
|
||||||
}, [sessionToken]);
|
}, [sessionToken]);
|
||||||
|
|
||||||
// Show loading state while determining auth status
|
|
||||||
if (isLoading || isSetupCompleted === null) {
|
if (isLoading || isSetupCompleted === null) {
|
||||||
return (
|
return (<></>);
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
height: '100vh',
|
|
||||||
background: 'var(--bg)',
|
|
||||||
color: 'var(--text-dim)'
|
|
||||||
}}>
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@@ -7,12 +7,16 @@ const getPageTitle = (pathname) => {
|
|||||||
switch (pathname) {
|
switch (pathname) {
|
||||||
case '/dashboard':
|
case '/dashboard':
|
||||||
return 'Dashboard';
|
return 'Dashboard';
|
||||||
|
case '/machines':
|
||||||
|
return 'Machines';
|
||||||
case '/servers':
|
case '/servers':
|
||||||
return 'Servers';
|
return 'Servers';
|
||||||
case '/settings':
|
case '/settings':
|
||||||
return 'Settings';
|
return 'Settings';
|
||||||
case '/admin/users':
|
case '/admin/users':
|
||||||
return 'User Management';
|
return 'User Management';
|
||||||
|
case '/admin/settings':
|
||||||
|
return 'System Settings';
|
||||||
default:
|
default:
|
||||||
return 'Dashboard';
|
return 'Dashboard';
|
||||||
}
|
}
|
||||||
|
423
webui/src/pages/Machines/Machines.jsx
Normal file
423
webui/src/pages/Machines/Machines.jsx
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
import React, {useState, useEffect, useContext} from 'react';
|
||||||
|
import {UserContext} from '@/common/contexts/UserContext.jsx';
|
||||||
|
import {useToast} from '@/common/contexts/ToastContext.jsx';
|
||||||
|
import {getRequest, postRequest, deleteRequest} from '@/common/utils/RequestUtil.js';
|
||||||
|
import Button from '@/common/components/Button';
|
||||||
|
import Input from '@/common/components/Input';
|
||||||
|
import Modal, {ModalActions} from '@/common/components/Modal';
|
||||||
|
import Card, {CardHeader, CardBody} from '@/common/components/Card';
|
||||||
|
import Badge from '@/common/components/Badge';
|
||||||
|
import Grid from '@/common/components/Grid';
|
||||||
|
import LoadingSpinner from '@/common/components/LoadingSpinner';
|
||||||
|
import EmptyState from '@/common/components/EmptyState';
|
||||||
|
import PageHeader from '@/common/components/PageHeader';
|
||||||
|
import DetailItem, {DetailList} from '@/common/components/DetailItem';
|
||||||
|
import {
|
||||||
|
PlusIcon,
|
||||||
|
TrashIcon,
|
||||||
|
ComputerTowerIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
IdentificationCardIcon,
|
||||||
|
UserIcon,
|
||||||
|
QrCodeIcon,
|
||||||
|
CopyIcon,
|
||||||
|
ClockIcon
|
||||||
|
} from '@phosphor-icons/react';
|
||||||
|
import './styles.sass';
|
||||||
|
|
||||||
|
export const Machines = () => {
|
||||||
|
const {user: currentUser} = useContext(UserContext);
|
||||||
|
const toast = useToast();
|
||||||
|
const [machines, setMachines] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [showProvisioningModal, setShowProvisioningModal] = useState(false);
|
||||||
|
const [selectedMachine, setSelectedMachine] = useState(null);
|
||||||
|
const [provisioningCode, setProvisioningCode] = useState(null);
|
||||||
|
const [formData, setFormData] = useState({name: ''});
|
||||||
|
const [formErrors, setFormErrors] = useState({});
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMachines();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchMachines = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await getRequest('machines');
|
||||||
|
setMachines(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch machines:', error);
|
||||||
|
toast.error('Failed to load machines. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatUuid = (uuid) => {
|
||||||
|
// Show first 8 characters of UUID for display
|
||||||
|
return uuid.substring(0, 8).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
setFormData({name: ''});
|
||||||
|
setFormErrors({});
|
||||||
|
setShowCreateModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeCreateModal = () => {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setFormData({name: ''});
|
||||||
|
setFormErrors({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openProvisioningModal = (machine) => {
|
||||||
|
setSelectedMachine(machine);
|
||||||
|
setProvisioningCode(null);
|
||||||
|
setShowProvisioningModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeProvisioningModal = () => {
|
||||||
|
setShowProvisioningModal(false);
|
||||||
|
setSelectedMachine(null);
|
||||||
|
setProvisioningCode(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const errors = {};
|
||||||
|
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
errors.name = 'Machine name is required';
|
||||||
|
} else if (formData.name.length < 3) {
|
||||||
|
errors.name = 'Machine name must be at least 3 characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await postRequest('machines/register', {
|
||||||
|
name: formData.name.trim()
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(`Machine "${formData.name}" registered successfully`);
|
||||||
|
closeCreateModal();
|
||||||
|
fetchMachines();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to register machine:', error);
|
||||||
|
const errorMessage = error.error || 'Failed to register machine. Please try again.';
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateProvisioningCode = async () => {
|
||||||
|
if (!selectedMachine) return;
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await postRequest('machines/provisioning-code', {
|
||||||
|
machine_id: selectedMachine.id
|
||||||
|
});
|
||||||
|
|
||||||
|
setProvisioningCode(response);
|
||||||
|
toast.success('Provisioning code generated successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate provisioning code:', error);
|
||||||
|
const errorMessage = error.error || 'Failed to generate provisioning code. Please try again.';
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyCode = async (code) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
toast.success('Provisioning code copied to clipboard');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy to clipboard:', error);
|
||||||
|
toast.error('Failed to copy to clipboard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (machineId, machineName) => {
|
||||||
|
if (!window.confirm(`Are you sure you want to delete machine "${machineName}"? This action cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteRequest(`machines/${machineId}`);
|
||||||
|
toast.success(`Machine "${machineName}" deleted successfully`);
|
||||||
|
fetchMachines();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete machine:', error);
|
||||||
|
toast.error('Failed to delete machine. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e) => {
|
||||||
|
const {name, value} = e.target;
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (formErrors[name]) {
|
||||||
|
setFormErrors(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="content">
|
||||||
|
<LoadingSpinner centered text="Loading machines..."/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="content">
|
||||||
|
<PageHeader
|
||||||
|
title="Machines"
|
||||||
|
subtitle="Manage your registered machines and generate provisioning codes"
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
icon={<PlusIcon size={16}/>}
|
||||||
|
onClick={openCreateModal}
|
||||||
|
>
|
||||||
|
Register Machine
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Grid minWidth="400px">
|
||||||
|
{machines.map(machine => (
|
||||||
|
<Card key={machine.id} hover className="machine-card">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="machine-card-header">
|
||||||
|
<div className="machine-icon">
|
||||||
|
<ComputerTowerIcon size={24} weight="duotone"/>
|
||||||
|
</div>
|
||||||
|
<div className="machine-info">
|
||||||
|
<h3 className="machine-name">{machine.name}</h3>
|
||||||
|
<div className="machine-uuid">
|
||||||
|
<IdentificationCardIcon size={14}/>
|
||||||
|
<span className="uuid-text">{formatUuid(machine.uuid)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="machine-actions">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
icon={<QrCodeIcon size={14}/>}
|
||||||
|
onClick={() => openProvisioningModal(machine)}
|
||||||
|
>
|
||||||
|
Code
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
icon={<TrashIcon size={14}/>}
|
||||||
|
onClick={() => handleDelete(machine.id, machine.name)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardBody>
|
||||||
|
<DetailList>
|
||||||
|
<DetailItem icon={<UserIcon size={16}/>}>
|
||||||
|
Owner: {currentUser?.role === 'admin' ? `User ${machine.user_id}` : 'You'}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem icon={<CalendarIcon size={16}/>}>
|
||||||
|
Registered: {formatDate(machine.created_at)}
|
||||||
|
</DetailItem>
|
||||||
|
</DetailList>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{machines.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
icon={<ComputerTowerIcon size={48} weight="duotone"/>}
|
||||||
|
title="No machines registered"
|
||||||
|
description="Register your first machine to get started with backup management"
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
icon={<PlusIcon size={16}/>}
|
||||||
|
onClick={openCreateModal}
|
||||||
|
>
|
||||||
|
Register Machine
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Machine Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showCreateModal}
|
||||||
|
onClose={closeCreateModal}
|
||||||
|
title="Register New Machine"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleCreateSubmit} className="modal-form">
|
||||||
|
<p className="modal-description">
|
||||||
|
Register a new machine to enable backup management. This will create a machine entry
|
||||||
|
that you can later generate provisioning codes for.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Machine Name"
|
||||||
|
name="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
error={formErrors.name}
|
||||||
|
placeholder="Enter a descriptive name for your machine"
|
||||||
|
disabled={submitting}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ModalActions>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="subtle"
|
||||||
|
onClick={closeCreateModal}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
loading={submitting}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Register Machine
|
||||||
|
</Button>
|
||||||
|
</ModalActions>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Provisioning Code Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showProvisioningModal}
|
||||||
|
onClose={closeProvisioningModal}
|
||||||
|
title="Generate Provisioning Code"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<div className="provisioning-modal">
|
||||||
|
<p className="modal-description">
|
||||||
|
Generate a provisioning code for <strong>{selectedMachine?.name}</strong>.
|
||||||
|
This code can be used to register a client machine with the backup system.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!provisioningCode ? (
|
||||||
|
<div className="provisioning-generate">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
icon={<QrCodeIcon size={16}/>}
|
||||||
|
onClick={handleGenerateProvisioningCode}
|
||||||
|
loading={submitting}
|
||||||
|
disabled={submitting}
|
||||||
|
className="generate-button"
|
||||||
|
>
|
||||||
|
Generate Provisioning Code
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="provisioning-result">
|
||||||
|
<div className="code-section">
|
||||||
|
<label className="code-label">Provisioning Code</label>
|
||||||
|
<div className="code-display">
|
||||||
|
<Input
|
||||||
|
value={provisioningCode.code}
|
||||||
|
readOnly
|
||||||
|
className="code-input"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
icon={<CopyIcon size={16}/>}
|
||||||
|
onClick={() => handleCopyCode(provisioningCode.code)}
|
||||||
|
className="copy-button"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="code-info">
|
||||||
|
<DetailList>
|
||||||
|
<DetailItem icon={<ClockIcon size={16}/>}>
|
||||||
|
Expires: {formatDate(provisioningCode.expires_at)}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem icon={<IdentificationCardIcon size={16}/>}>
|
||||||
|
Raw Code: {provisioningCode.raw_code}
|
||||||
|
</DetailItem>
|
||||||
|
</DetailList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="code-notice">
|
||||||
|
<Badge variant="warning">
|
||||||
|
This code expires in 1 hour
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ModalActions>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="subtle"
|
||||||
|
onClick={closeProvisioningModal}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
{provisioningCode && (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
icon={<QrCodeIcon size={16}/>}
|
||||||
|
onClick={handleGenerateProvisioningCode}
|
||||||
|
loading={submitting}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Generate New Code
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ModalActions>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
1
webui/src/pages/Machines/index.js
Normal file
1
webui/src/pages/Machines/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export {Machines as default} from './Machines.jsx';
|
107
webui/src/pages/Machines/styles.sass
Normal file
107
webui/src/pages/Machines/styles.sass
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
.machine-card
|
||||||
|
.machine-card-header
|
||||||
|
display: flex
|
||||||
|
align-items: flex-start
|
||||||
|
gap: 1rem
|
||||||
|
|
||||||
|
.machine-icon
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
width: 3rem
|
||||||
|
height: 3rem
|
||||||
|
background: var(--color-primary-light)
|
||||||
|
border-radius: var(--border-radius)
|
||||||
|
color: var(--color-primary)
|
||||||
|
flex-shrink: 0
|
||||||
|
|
||||||
|
.machine-info
|
||||||
|
flex: 1
|
||||||
|
min-width: 0
|
||||||
|
|
||||||
|
.machine-name
|
||||||
|
font-size: 1.125rem
|
||||||
|
font-weight: 600
|
||||||
|
color: var(--color-text)
|
||||||
|
margin: 0 0 0.5rem 0
|
||||||
|
word-break: break-word
|
||||||
|
|
||||||
|
.machine-uuid
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.25rem
|
||||||
|
font-size: 0.875rem
|
||||||
|
color: var(--color-text-muted)
|
||||||
|
|
||||||
|
.uuid-text
|
||||||
|
font-family: var(--font-mono, 'Courier New', monospace)
|
||||||
|
background: var(--color-surface-variant)
|
||||||
|
padding: 0.125rem 0.375rem
|
||||||
|
border-radius: var(--border-radius-sm)
|
||||||
|
font-size: 0.75rem
|
||||||
|
|
||||||
|
.machine-actions
|
||||||
|
display: flex
|
||||||
|
gap: 0.5rem
|
||||||
|
flex-shrink: 0
|
||||||
|
|
||||||
|
.modal-description
|
||||||
|
color: var(--color-text-muted)
|
||||||
|
font-size: 0.875rem
|
||||||
|
line-height: 1.5
|
||||||
|
margin: 0 0 1.5rem 0
|
||||||
|
|
||||||
|
.provisioning-modal
|
||||||
|
.provisioning-generate
|
||||||
|
text-align: center
|
||||||
|
padding: 2rem 0
|
||||||
|
|
||||||
|
.generate-button
|
||||||
|
min-width: 200px
|
||||||
|
|
||||||
|
.provisioning-result
|
||||||
|
.code-section
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
|
||||||
|
.code-label
|
||||||
|
display: block
|
||||||
|
font-size: 0.875rem
|
||||||
|
font-weight: 600
|
||||||
|
color: var(--color-text)
|
||||||
|
margin-bottom: 0.5rem
|
||||||
|
|
||||||
|
.code-display
|
||||||
|
display: flex
|
||||||
|
gap: 0.5rem
|
||||||
|
align-items: stretch
|
||||||
|
|
||||||
|
.code-input
|
||||||
|
flex: 1
|
||||||
|
font-family: var(--font-mono, 'Courier New', monospace)
|
||||||
|
font-size: 0.875rem
|
||||||
|
|
||||||
|
input
|
||||||
|
font-family: inherit
|
||||||
|
|
||||||
|
.copy-button
|
||||||
|
flex-shrink: 0
|
||||||
|
|
||||||
|
.code-info
|
||||||
|
margin-bottom: 1rem
|
||||||
|
padding: 1rem
|
||||||
|
background: var(--color-surface-variant)
|
||||||
|
border-radius: var(--border-radius)
|
||||||
|
|
||||||
|
.code-notice
|
||||||
|
text-align: center
|
||||||
|
|
||||||
|
// Empty state overrides for machines
|
||||||
|
.content .empty-state
|
||||||
|
.empty-state-icon
|
||||||
|
color: var(--color-primary-light)
|
||||||
|
|
||||||
|
.empty-state-title
|
||||||
|
color: var(--color-text)
|
||||||
|
|
||||||
|
.empty-state-description
|
||||||
|
color: var(--color-text-muted)
|
263
webui/src/pages/SystemSettings/SystemSettings.jsx
Normal file
263
webui/src/pages/SystemSettings/SystemSettings.jsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import React, {useState, useEffect, useContext} from 'react';
|
||||||
|
import {UserContext} from '@/common/contexts/UserContext.jsx';
|
||||||
|
import {useToast} from '@/common/contexts/ToastContext.jsx';
|
||||||
|
import {getRequest, postRequest} from '@/common/utils/RequestUtil.js';
|
||||||
|
import Button from '@/common/components/Button';
|
||||||
|
import Input from '@/common/components/Input';
|
||||||
|
import Card, {CardHeader, CardBody} from '@/common/components/Card';
|
||||||
|
import LoadingSpinner from '@/common/components/LoadingSpinner';
|
||||||
|
import PageHeader from '@/common/components/PageHeader';
|
||||||
|
import {
|
||||||
|
SlidersIcon,
|
||||||
|
FloppyDiskIcon,
|
||||||
|
ArrowClockwiseIcon,
|
||||||
|
InfoIcon,
|
||||||
|
WarningIcon
|
||||||
|
} from '@phosphor-icons/react';
|
||||||
|
import './styles.sass';
|
||||||
|
|
||||||
|
export const SystemSettings = () => {
|
||||||
|
const toast = useToast();
|
||||||
|
const [configs, setConfigs] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [formData, setFormData] = useState({});
|
||||||
|
const [formErrors, setFormErrors] = useState({});
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfigs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchConfigs = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await getRequest('admin/config');
|
||||||
|
setConfigs(response.configs);
|
||||||
|
|
||||||
|
const initialData = {};
|
||||||
|
response.configs.forEach(config => {
|
||||||
|
initialData[config.key] = config.value || config.default_value || '';
|
||||||
|
});
|
||||||
|
setFormData(initialData);
|
||||||
|
setHasChanges(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch configs:', error);
|
||||||
|
toast.error('Failed to load system settings. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (key, value) => {
|
||||||
|
setFormData(prev => {
|
||||||
|
const newData = {...prev, [key]: value};
|
||||||
|
|
||||||
|
const originalConfig = configs.find(c => c.key === key);
|
||||||
|
const hasChange = Object.keys(newData).some(k => {
|
||||||
|
const original = configs.find(c => c.key === k);
|
||||||
|
const origVal = original?.value || original?.default_value || '';
|
||||||
|
return newData[k] !== origVal;
|
||||||
|
});
|
||||||
|
|
||||||
|
setHasChanges(hasChange);
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (formErrors[key]) {
|
||||||
|
setFormErrors(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const errors = {};
|
||||||
|
|
||||||
|
configs.forEach(config => {
|
||||||
|
const value = formData[config.key] || '';
|
||||||
|
|
||||||
|
if (config.required && !value.trim()) {
|
||||||
|
errors[config.key] = `${config.key} is required`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (config.key) {
|
||||||
|
case 'EXTERNAL_URL':
|
||||||
|
if (value && !value.startsWith('http://') && !value.startsWith('https://')) {
|
||||||
|
errors[config.key] = 'URL must start with http:// or https://';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'MAX_UPLOAD_SIZE_MB':
|
||||||
|
case 'BACKUP_RETENTION_DAYS':
|
||||||
|
case 'SESSION_TIMEOUT_HOURS':
|
||||||
|
if (value && (isNaN(value) || parseInt(value) <= 0)) {
|
||||||
|
errors[config.key] = 'Must be a positive number';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setFormErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savePromises = [];
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
const newValue = formData[config.key] || '';
|
||||||
|
const originalValue = config.value || config.default_value || '';
|
||||||
|
|
||||||
|
if (newValue !== originalValue) {
|
||||||
|
savePromises.push(
|
||||||
|
postRequest('admin/config', {
|
||||||
|
key: config.key,
|
||||||
|
value: newValue
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(savePromises);
|
||||||
|
|
||||||
|
toast.success('System settings saved successfully');
|
||||||
|
setHasChanges(false);
|
||||||
|
await fetchConfigs();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save settings:', error);
|
||||||
|
const errorMessage = error.error || 'Failed to save settings. Please try again.';
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
if (!window.confirm('Are you sure you want to reset all settings to their current saved values? Any unsaved changes will be lost.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchConfigs();
|
||||||
|
toast.info('Settings reset to saved values');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFieldType = (key) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'SESSION_TIMEOUT_HOURS':
|
||||||
|
return 'number';
|
||||||
|
case 'EXTERNAL_URL':
|
||||||
|
return 'url';
|
||||||
|
default:
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFieldPlaceholder = (config) => {
|
||||||
|
if (config.default_value) {
|
||||||
|
return `Default: ${config.default_value}`;
|
||||||
|
}
|
||||||
|
return `Enter ${config.key.toLowerCase().replace(/_/g, ' ')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="content">
|
||||||
|
<LoadingSpinner centered text="Loading system settings..."/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="content">
|
||||||
|
<PageHeader
|
||||||
|
title="System Settings"
|
||||||
|
subtitle="Configure system-wide settings and preferences"
|
||||||
|
actions={
|
||||||
|
<div className="settings-actions">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
icon={<ArrowClockwiseIcon size={16}/>}
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={submitting || !hasChanges}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
icon={<FloppyDiskIcon size={16}/>}
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={submitting}
|
||||||
|
disabled={submitting || !hasChanges}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasChanges && (
|
||||||
|
<div className="settings-notice">
|
||||||
|
<WarningIcon size={16}/>
|
||||||
|
<span>You have unsaved changes</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="settings-grid">
|
||||||
|
{configs.map(config => (
|
||||||
|
<Card key={config.key} className="setting-card">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="setting-header">
|
||||||
|
<div className="setting-info">
|
||||||
|
<h3 className="setting-title">
|
||||||
|
{config.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||||
|
{config.required && <span className="required">*</span>}
|
||||||
|
</h3>
|
||||||
|
<p className="setting-description">{config.description}</p>
|
||||||
|
</div>
|
||||||
|
{config.default_value && (
|
||||||
|
<div className="setting-default">
|
||||||
|
<InfoIcon size={14}/>
|
||||||
|
<span>Default: {config.default_value}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardBody>
|
||||||
|
<Input
|
||||||
|
type={getFieldType(config.key)}
|
||||||
|
value={formData[config.key] || ''}
|
||||||
|
onChange={(e) => handleInputChange(config.key, e.target.value)}
|
||||||
|
placeholder={getFieldPlaceholder(config)}
|
||||||
|
error={formErrors[config.key]}
|
||||||
|
disabled={submitting}
|
||||||
|
className="setting-input"
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{configs.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<div className="empty-settings">
|
||||||
|
<SlidersIcon size={48} weight="duotone"/>
|
||||||
|
<h3>No settings available</h3>
|
||||||
|
<p>No configurable settings are currently defined.</p>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
1
webui/src/pages/SystemSettings/index.js
Normal file
1
webui/src/pages/SystemSettings/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { SystemSettings as default } from './SystemSettings.jsx';
|
91
webui/src/pages/SystemSettings/styles.sass
Normal file
91
webui/src/pages/SystemSettings/styles.sass
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
.settings-actions
|
||||||
|
display: flex
|
||||||
|
gap: 0.5rem
|
||||||
|
align-items: center
|
||||||
|
|
||||||
|
.settings-notice
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.5rem
|
||||||
|
padding: 1rem
|
||||||
|
background: var(--color-warning-light)
|
||||||
|
border: 1px solid var(--color-warning)
|
||||||
|
border-radius: var(--border-radius)
|
||||||
|
color: var(--color-warning-dark)
|
||||||
|
font-size: 0.875rem
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
|
||||||
|
svg
|
||||||
|
flex-shrink: 0
|
||||||
|
|
||||||
|
.settings-grid
|
||||||
|
display: grid
|
||||||
|
gap: 1.5rem
|
||||||
|
grid-template-columns: 1fr
|
||||||
|
|
||||||
|
@media (min-width: 768px)
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr))
|
||||||
|
|
||||||
|
.setting-card
|
||||||
|
.setting-header
|
||||||
|
display: flex
|
||||||
|
justify-content: space-between
|
||||||
|
align-items: flex-start
|
||||||
|
gap: 1rem
|
||||||
|
|
||||||
|
.setting-info
|
||||||
|
flex: 1
|
||||||
|
|
||||||
|
.setting-title
|
||||||
|
font-size: 1rem
|
||||||
|
font-weight: 600
|
||||||
|
color: var(--color-text)
|
||||||
|
margin: 0 0 0.5rem 0
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.25rem
|
||||||
|
|
||||||
|
.required
|
||||||
|
color: var(--color-danger)
|
||||||
|
font-size: 0.875rem
|
||||||
|
|
||||||
|
.setting-description
|
||||||
|
font-size: 0.875rem
|
||||||
|
color: var(--color-text-muted)
|
||||||
|
margin: 0
|
||||||
|
line-height: 1.4
|
||||||
|
|
||||||
|
.setting-default
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.25rem
|
||||||
|
font-size: 0.75rem
|
||||||
|
color: var(--color-text-muted)
|
||||||
|
background: var(--color-surface-variant)
|
||||||
|
padding: 0.25rem 0.5rem
|
||||||
|
border-radius: var(--border-radius-sm)
|
||||||
|
white-space: nowrap
|
||||||
|
|
||||||
|
svg
|
||||||
|
flex-shrink: 0
|
||||||
|
|
||||||
|
.setting-input
|
||||||
|
margin: 0
|
||||||
|
|
||||||
|
.empty-settings
|
||||||
|
text-align: center
|
||||||
|
padding: 2rem
|
||||||
|
color: var(--color-text-muted)
|
||||||
|
|
||||||
|
svg
|
||||||
|
color: var(--color-text-disabled)
|
||||||
|
margin-bottom: 1rem
|
||||||
|
|
||||||
|
h3
|
||||||
|
margin: 0 0 0.5rem 0
|
||||||
|
font-size: 1.125rem
|
||||||
|
color: var(--color-text)
|
||||||
|
|
||||||
|
p
|
||||||
|
margin: 0
|
||||||
|
font-size: 0.875rem
|
Reference in New Issue
Block a user