Compare commits

..

5 Commits

23 changed files with 1286 additions and 99 deletions

1
server/Cargo.lock generated
View File

@@ -1363,6 +1363,7 @@ dependencies = [
"axum", "axum",
"bcrypt", "bcrypt",
"chrono", "chrono",
"rand",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",

View File

@@ -13,4 +13,5 @@ bcrypt = "0.17.1"
uuid = { version = "1.0", features = ["v4", "serde"] } 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"

View File

@@ -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"),

View File

@@ -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
View 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 }))
}

View File

@@ -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>,

View File

@@ -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
View 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)
}
}

View 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")),
}
}
}

View File

@@ -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(())
} }

View File

@@ -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;

View File

@@ -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>,

View File

@@ -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/>},
], ],
}, },
]); ]);

View File

@@ -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>

View File

@@ -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 = () => {

View File

@@ -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 {
@@ -56,13 +54,11 @@ export const UserProvider = ({ children }) => {
} catch (e) { } catch (e) {
// 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 (

View File

@@ -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';
} }

View 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>
);
};

View File

@@ -0,0 +1 @@
export {Machines as default} from './Machines.jsx';

View 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)

View 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>
);
};

View File

@@ -0,0 +1 @@
export { SystemSettings as default } from './SystemSettings.jsx';

View 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