From 75ddafee8fbc837a77cae5fe6a92fd5dbf6d7a51 Mon Sep 17 00:00:00 2001 From: Michal Chruscielski Date: Sun, 15 Mar 2026 19:15:15 +0100 Subject: [PATCH 1/8] chore(frontend): refactor main files --- web/src/App.css | 40 ++-------- web/src/App.tsx | 157 +++++++++++++++++++++----------------- web/src/index.css | 13 ++-- web/src/lib/api-client.ts | 2 + web/src/main.tsx | 33 ++------ web/src/types/api.ts | 4 +- 6 files changed, 112 insertions(+), 137 deletions(-) diff --git a/web/src/App.css b/web/src/App.css index b9d355d..220e69d 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -1,42 +1,14 @@ #root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; } .card { - padding: 2em; + padding: 2em; } .read-the-docs { - color: #888; + color: #888; } diff --git a/web/src/App.tsx b/web/src/App.tsx index 24e2512..ca532a8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { Routes, Route, Navigate } from "react-router-dom"; +import { Routes, Route, Navigate, BrowserRouter } from "react-router-dom"; import { useAuth } from "@/contexts/AuthContext"; import { Login } from "@/pages/Login"; import { Dashboard } from "@/pages/Dashboard"; @@ -12,7 +12,13 @@ import { Settings } from "@/pages/Settings"; import { AuthCallback } from "@/pages/AuthCallback"; import { useState, useEffect } from "react"; import { fetchApi } from "@/lib/api-client"; +import { setOnBackendDown } from "./lib/api-client"; +import { NetworkErrorPage } from "./components/NetworkErrorPage"; +import { useError } from "@/contexts/ErrorContext"; +/** + * A wrapper for protected routes that checks authentication and authorization status. + */ function ProtectedRoute({ children, adminOnly = false, @@ -33,9 +39,16 @@ function ProtectedRoute({ } function App() { + // Separate state handler for lack of backend connection + const { isBackendDown, setBackendDown } = useError(); + const [homepagePublic, setHomepagePublic] = useState(false); const [settingsLoaded, setSettingsLoaded] = useState(false); + useState(() => { + setOnBackendDown(() => setBackendDown(true)); + }); + useEffect(() => { fetchApi>("/api/settings/public", { silent: true }) .then((data) => { @@ -45,78 +58,82 @@ function App() { .catch(() => setSettingsLoaded(true)); }, []); + if (isBackendDown) return ; + if (!settingsLoaded) return null; return ( - - } /> - } /> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - + + + } /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + ); } diff --git a/web/src/index.css b/web/src/index.css index 88364d7..d5bb2c4 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,5 +1,9 @@ @import "tailwindcss"; + +/*todo migrate config to v4*/ @config "../tailwind.config.js"; + +/*todo migrate dark mode management to v4*/ @layer base { :root { --background: 0 0% 100%; @@ -74,20 +78,15 @@ scrollbar-width: none; /* Firefox */ } - - /*check for better background images, this seems coloristically like 6 or 8bit*/ - .bg-premium-grid { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' width='32' height='32' fill='none' stroke='white' stroke-opacity='0.05'%3e%3cpath d='M0 .5H31.5V32'/%3e%3c/svg%3e"); - } } @layer base { * { - @apply border-border; + /*@apply border-border;*/ } body { - @apply bg-background text-foreground overflow-hidden; + /*@apply bg-background text-foreground overflow-hidden;*/ height: 100vh; width: 100vw; } diff --git a/web/src/lib/api-client.ts b/web/src/lib/api-client.ts index d91f517..0724177 100644 --- a/web/src/lib/api-client.ts +++ b/web/src/lib/api-client.ts @@ -1,5 +1,7 @@ import { toast } from "sonner"; +// todo refactor this file + // Simple event bus/callback for non-component files to trigger UI updates let onBackendDown: (() => void) | null = null; diff --git a/web/src/main.tsx b/web/src/main.tsx index bd550e9..18df2e5 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,41 +1,24 @@ -import { StrictMode, useState } from "react"; +import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; import "./index.css"; -import { BrowserRouter } from "react-router-dom"; -import { ErrorProvider, useError } from "./contexts/ErrorContext"; +import { ErrorProvider } from "./contexts/ErrorContext"; import { AuthProvider } from "./contexts/AuthContext"; -import { setOnBackendDown } from "./lib/api-client"; -import { NetworkErrorPage } from "./components/NetworkErrorPage"; import { Toaster } from "@/components/ui/sonner"; const Main = () => { - const { isBackendDown, setBackendDown } = useError(); - - useState(() => { - setOnBackendDown(() => setBackendDown(true)); - }); - - if (isBackendDown) return ; - return ( - <> - + + - - - + + + ); }; -createRoot(document.getElementById("root")!).render( - - -
- - , -); +createRoot(document.getElementById("root")!).render(
); export default Main; diff --git a/web/src/types/api.ts b/web/src/types/api.ts index b1b0312..b4e87b6 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -1,3 +1,5 @@ +// todo check if we can replace some types/consts with enums +// also check if we can use undefined instead of null for optional fields export interface Service { id: string; device_id: string | null; @@ -29,7 +31,7 @@ export interface CreateServicePayload { icon_url?: string | null; } -// --- Devices & Networks --- +// Devices & Networks export const DeviceType = { Physical: "PHYSICAL", From a46e917e4065ecc427f80e2781f4239d62b5e6c1 Mon Sep 17 00:00:00 2001 From: Michal Chruscielski Date: Fri, 20 Mar 2026 22:59:04 +0100 Subject: [PATCH 2/8] chore: comments, optimizations, cleanups --- .env.example | 4 ++ Cargo.lock | 2 +- compose.yaml | 3 +- src/auth/mod.rs | 53 ++++++++++++++----- src/config.rs | 127 ++++++++++++++++++++++++++++++++++++++-------- src/lib.rs | 9 ++-- src/main.rs | 28 ++++++++++ src/routes.rs | 69 ++++++++++++------------- src/validation.rs | 7 +-- 9 files changed, 223 insertions(+), 79 deletions(-) diff --git a/.env.example b/.env.example index 1da2e55..d25ee23 100644 --- a/.env.example +++ b/.env.example @@ -32,4 +32,8 @@ SESSION_SECRET=devsecret-change-me-in-production # Misc # RUST_LOG=debug +# App network ports: +# - APP_PORT: host port exposed by Docker +# - SERVER_PORT: internal app listen port # APP_PORT=8789 +# SERVER_PORT=8789 diff --git a/Cargo.lock b/Cargo.lock index 341d4a7..184ef84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1864,7 +1864,7 @@ dependencies = [ [[package]] name = "netweave" -version = "0.1.2" +version = "0.1.3" dependencies = [ "aes-gcm", "anyhow", diff --git a/compose.yaml b/compose.yaml index dfcebe9..0e1b1e0 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,6 +12,7 @@ services: container_name: netweave environment: DATABASE_URL: "postgres://${DATABASE_USER}:${DATABASE_PASSWORD}@db:5432/${DATABASE_NAME}" + SERVER_PORT: ${SERVER_PORT:-8789} DEFAULT_ADMIN_USER: ${DEFAULT_ADMIN_USER:-admin} DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-adminpassword} ENCRYPTION_KEY: ${ENCRYPTION_KEY} @@ -19,7 +20,7 @@ services: RUST_LOG: ${RUST_LOG:-info} SESSION_SECURE_COOKIE: ${SESSION_SECURE_COOKIE:-false} ports: - - "${APP_PORT:-8789}:8789" + - "${APP_PORT:-8789}:${SERVER_PORT:-8789}" depends_on: db: condition: service_healthy diff --git a/src/auth/mod.rs b/src/auth/mod.rs index c18e7de..dd0d2e9 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,3 +1,7 @@ +//! # Authentication module +//! +//! Manages authentication, this app uses standard JWT and alterntively OIDC + use crate::db::Db; use crate::handlers::common::{AppError, AppResult}; use crate::AppState; @@ -18,6 +22,7 @@ pub mod oidc; pub const AUTH_SESSION_KEY: &str = "auth_user"; +/// User role enum #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Role { #[serde(rename = "ADMIN")] @@ -56,6 +61,8 @@ impl FromStr for Role { } } +/// Ensures that default admin user exsit if one was configured using ENV variables. +/// Should be used only on startup. It won't override existing user data. pub async fn ensure_default_users(db: &Db) { if let (Ok(username), Ok(password)) = ( std::env::var("DEFAULT_ADMIN_USER"), @@ -72,6 +79,7 @@ pub async fn ensure_default_users(db: &Db) { } } +/// Authenticated user info #[derive(Serialize, Deserialize, Clone, Debug)] pub struct AuthUser { pub id: Uuid, @@ -79,39 +87,46 @@ pub struct AuthUser { pub role: Role, } +/// Login DTO #[derive(Deserialize)] pub struct LoginPayload { pub username: String, pub password: String, } +/// Auth response #[derive(Serialize)] pub struct AuthStatusResponse { pub status: &'static str, pub message: &'static str, } +/// Logout response DTO #[derive(Serialize)] pub struct LogoutResponse { pub status: &'static str, } +/// OIDC status response DTO #[derive(Serialize)] pub struct OidcStatusResponse { pub oidc_enabled: bool, } +/// Change password DTO #[derive(Deserialize)] pub struct ChangePasswordPayload { pub current_password: Option, pub new_password: String, } +/// Main login handler pub async fn login_username_password( State(state): State, session: Session, axum::Json(payload): axum::Json, ) -> AppResult> { + // see [../utils/rate_limiter] if !state.login_rate_limiter.check(&payload.username).await { return Err(AppError::TooManyRequests( "Too many login attempts. Please try again later.".into(), @@ -163,6 +178,7 @@ pub async fn login_username_password( )) } +/// Returns self user data pub async fn me_handler(session: Session) -> AppResult> { let user = session .get::(AUTH_SESSION_KEY) @@ -178,6 +194,7 @@ pub async fn me_handler(session: Session) -> AppResult> { } } +/// Handles user self password change pub async fn change_password_handler( State(state): State, session: Session, @@ -187,16 +204,13 @@ pub async fn change_password_handler( return Err(AppError::BadRequest("New password cannot be empty".into())); } - let Some(auth_user) = session + // todo maybe clean this app using native error mapping + let auth_user = session .get::(AUTH_SESSION_KEY) .await - .unwrap_or_else(|e| { - tracing::warn!("Failed to read auth session: {}", e); - None - }) - else { - return Err(AppError::Unauthorized("Not authenticated".into())); - }; + .ok() + .flatten() + .ok_or(AppError::Unauthorized("Not authenticated".into()))?; let user = state .db @@ -209,6 +223,7 @@ pub async fn change_password_handler( } // Require current password only when an existing local password is set. + // OIDC user can change password without having set one. if let Some(existing_hash) = user.password_hash.filter(|h| !h.is_empty()) { let Some(current_password) = payload.current_password.as_deref() else { return Err(AppError::BadRequest("Current password is required".into())); @@ -237,24 +252,33 @@ pub async fn change_password_handler( })) } +// SECTION: OIDC endpoints +// +// Check [oidc guide](https://openid.net/developers/how-connect-works/) + +/// Checks if oidc is enabled pub async fn check_oidc_handler( State(state): State, ) -> AppResult> { - Ok(Json(OidcStatusResponse { - oidc_enabled: state.oidc.is_some(), - })) + let oidc_enabled = state.oidc.read().await.is_some(); + Ok(Json(OidcStatusResponse { oidc_enabled })) } const OIDC_NONCE_KEY: &str = "oidc_nonce"; const OIDC_CSRF_KEY: &str = "oidc_csrf"; +/// Oidc login handler pub async fn oidc_login(State(state): State, session: Session) -> AppResult { - if let Some(oidc) = &state.oidc { + let oidc = state.oidc.read().await.clone(); + + if let Some(oidc) = oidc { let (auth_url, csrf_token, nonce) = oidc.authorization_url(); + if let Err(e) = session.insert(OIDC_NONCE_KEY, nonce.secret().clone()).await { tracing::error!("Failed to store OIDC nonce in session: {}", e); return Err(AppError::Internal(e.into())); } + if let Err(e) = session .insert(OIDC_CSRF_KEY, csrf_token.secret().clone()) .await @@ -262,6 +286,7 @@ pub async fn oidc_login(State(state): State, session: Session) -> AppR tracing::error!("Failed to store OIDC CSRF in session: {}", e); return Err(AppError::Internal(e.into())); } + Ok(Redirect::to(auth_url.as_str())) } else { Err(AppError::ServiceUnavailable( @@ -270,18 +295,20 @@ pub async fn oidc_login(State(state): State, session: Session) -> AppR } } +/// OIDC auth callback parameters #[derive(Deserialize)] pub struct AuthCallbackParams { code: String, state: String, } +/// OIDC callback endpoint pub async fn oidc_callback( State(state): State, session: Session, Query(params): Query, ) -> impl IntoResponse { - let oidc = match &state.oidc { + let oidc = match state.oidc.read().await.clone() { Some(s) => s, None => { return ( diff --git a/src/config.rs b/src/config.rs index 28916e1..55c0d6b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,9 +1,10 @@ //! Application configuration management. -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use rand::RngCore; use std::env; +/// General config struct. Can be passed as argument in order tu use it in the whole app. #[derive(Debug, Clone)] pub struct Config { pub database_url: String, @@ -16,6 +17,7 @@ pub struct Config { pub oidc_config: Option, } +/// General oidc config. #[derive(Debug, Clone)] pub struct OidcConfig { pub client_id: String, @@ -25,7 +27,36 @@ pub struct OidcConfig { } impl Config { + fn read_env_any(keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| env::var(key).ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + } + + fn normalize_oidc_discovery_url(raw: &str) -> Result { + let mut url = raw.trim().to_string(); + if !(url.starts_with("http://") || url.starts_with("https://")) { + return Err(anyhow!( + "OIDC discovery/issuer URL must start with http:// or https://" + )); + } + + if !url.ends_with("/.well-known/openid-configuration") + && !url.ends_with(".well-known/openid-configuration") + { + if !url.ends_with('/') { + url.push('/'); + } + url.push_str(".well-known/openid-configuration"); + } + Ok(url) + } + /// Load configuration from environment variables + /// + /// For now loading is manual, because it is simpler to implement but it should + /// be optimized later. pub fn from_env() -> Result { let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?; @@ -54,30 +85,25 @@ impl Config { let rust_log = env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()); - let oidc_enabled = env::var("OIDC_CLIENT_ID").is_ok(); + let oidc_client_id = Self::read_env_any(&["OIDC_CLIENT_ID"]); + let oidc_enabled = oidc_client_id.is_some(); let oidc_config = if oidc_enabled { + let discovery_input = + Self::read_env_any(&["OIDC_CONFIGURATION_URL", "OIDC_DISCOVERY_URL", "OIDC_ISSUER"]) + .context( + "OIDC_CONFIGURATION_URL / OIDC_DISCOVERY_URL / OIDC_ISSUER required when OIDC is enabled", + )?; + Some(OidcConfig { - client_id: env::var("OIDC_CLIENT_ID") + client_id: oidc_client_id .context("OIDC_CLIENT_ID required when OIDC is enabled")?, - client_secret: env::var("OIDC_CLIENT_SECRET") - .context("OIDC_CLIENT_SECRET required when OIDC is enabled")?, - discovery_url: { - let mut url = env::var("OIDC_CONFIGURATION_URL") - .or_else(|_| env::var("OIDC_ISSUER")) - .context( - "OIDC_CONFIGURATION_URL or OIDC_ISSUER required when OIDC is enabled", - )?; - - if !url.ends_with("openid-configuration") { - if !url.ends_with('/') { - url.push('/'); - } - url.push_str(".well-known/openid-configuration"); - } - url - }, - redirect_uri: env::var("OIDC_REDIRECT_URL") - .context("OIDC_REDIRECT_URL required when OIDC is enabled")?, + client_secret: Self::read_env_any(&["OIDC_CLIENT_SECRET", "OIDC_SECRET"]) + .context("OIDC_CLIENT_SECRET (or OIDC_SECRET) required when OIDC is enabled")?, + discovery_url: Self::normalize_oidc_discovery_url(&discovery_input)?, + redirect_uri: Self::read_env_any(&["OIDC_REDIRECT_URL", "OIDC_REDIRECT_URI"]) + .context( + "OIDC_REDIRECT_URL (or OIDC_REDIRECT_URI) required when OIDC is enabled", + )?, }) } else { None @@ -96,6 +122,7 @@ impl Config { } } +/// Session secret spare generator. fn generate_session_secret() -> String { let mut bytes = [0u8; 32]; rand::thread_rng().fill_bytes(&mut bytes); @@ -105,9 +132,24 @@ fn generate_session_secret() -> String { #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn clear_oidc_env() { + env::remove_var("OIDC_CLIENT_ID"); + env::remove_var("OIDC_CLIENT_SECRET"); + env::remove_var("OIDC_SECRET"); + env::remove_var("OIDC_CONFIGURATION_URL"); + env::remove_var("OIDC_DISCOVERY_URL"); + env::remove_var("OIDC_ISSUER"); + env::remove_var("OIDC_REDIRECT_URL"); + env::remove_var("OIDC_REDIRECT_URI"); + } #[test] fn test_config_from_env() { + let _guard = ENV_LOCK.lock().expect("ENV_LOCK poisoned"); env::set_var("DATABASE_URL", "postgres://localhost"); env::set_var("SERVER_PORT", "8789"); @@ -118,10 +160,51 @@ mod tests { #[test] fn test_config_invalid_port() { + let _guard = ENV_LOCK.lock().expect("ENV_LOCK poisoned"); env::set_var("DATABASE_URL", "postgres://localhost"); env::set_var("SERVER_PORT", "invalid"); let result = Config::from_env(); assert!(result.is_err()); } + + #[test] + fn test_oidc_aliases_are_supported() { + let _guard = ENV_LOCK.lock().expect("ENV_LOCK poisoned"); + clear_oidc_env(); + env::set_var("DATABASE_URL", "postgres://localhost"); + env::set_var("OIDC_CLIENT_ID", "netweave"); + env::set_var("OIDC_SECRET", "secret-from-alias"); + env::set_var("OIDC_DISCOVERY_URL", "https://auth.example.com"); + env::set_var("OIDC_REDIRECT_URI", "http://localhost:8789/auth/callback"); + + let config = Config::from_env().expect("Failed to load config"); + let oidc = config.oidc_config.expect("OIDC should be enabled"); + + assert_eq!(oidc.client_id, "netweave"); + assert_eq!(oidc.client_secret, "secret-from-alias"); + assert_eq!( + oidc.discovery_url, + "https://auth.example.com/.well-known/openid-configuration" + ); + assert_eq!(oidc.redirect_uri, "http://localhost:8789/auth/callback"); + } + + #[test] + fn test_oidc_issuer_gets_normalized_to_discovery_url() { + let _guard = ENV_LOCK.lock().expect("ENV_LOCK poisoned"); + clear_oidc_env(); + env::set_var("DATABASE_URL", "postgres://localhost"); + env::set_var("OIDC_CLIENT_ID", "netweave"); + env::set_var("OIDC_CLIENT_SECRET", "super-secret"); + env::set_var("OIDC_ISSUER", "https://issuer.example.com/"); + env::set_var("OIDC_REDIRECT_URL", "http://localhost:8789/auth/callback"); + + let config = Config::from_env().expect("Failed to load config"); + let oidc = config.oidc_config.expect("OIDC should be enabled"); + assert_eq!( + oidc.discovery_url, + "https://issuer.example.com/.well-known/openid-configuration" + ); + } } diff --git a/src/lib.rs b/src/lib.rs index 84eb5a7..2e5cd1f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,15 +30,17 @@ pub enum ServiceStatus { Unknown, } +/// Geeneral app state object. It should contain all app data used during runtime (mutable and not). #[derive(Clone)] pub struct AppState { pub db: Db, pub config: Config, - pub oidc: Option, + pub oidc: Arc>>, pub service_statuses: Arc>>, pub login_rate_limiter: LoginRateLimiter, } +/// State builder pub async fn create_state( config: Config, pool: PgPool, @@ -48,6 +50,7 @@ pub async fn create_state( let service_statuses = Arc::new(RwLock::new(HashMap::new())); let login_rate_limiter = LoginRateLimiter::new(10, Duration::from_secs(60)); + // Runs migrations, it should allow to seamless version updates or database creation. sqlx::migrate!("./migrations") .run(&db.pool) .await @@ -58,12 +61,12 @@ pub async fn create_state( Ok(AppState { db, config, - oidc, + oidc: Arc::new(RwLock::new(oidc)), service_statuses, login_rate_limiter, }) } - +// Builds app router pub async fn create_app(state: AppState) -> anyhow::Result { routes::create_router(state).await } diff --git a/src/main.rs b/src/main.rs index ff3a6af..4e779be 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ use netweave::config::Config; use sqlx::postgres::PgPoolOptions; use std::net::SocketAddr; +use std::time::Duration; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[tokio::main] @@ -56,6 +57,7 @@ async fn main() { tokio::spawn(netweave::monitoring::start_monitoring(state.clone())); tokio::spawn(netweave::integrations::run_sync_task(state.clone())); + tokio::spawn(oidc_retry_loop(state.clone())); let app = netweave::create_app(state).await.unwrap_or_else(|e| { eprintln!("ERROR: {}", e); @@ -86,6 +88,32 @@ async fn main() { tracing::info!("Server shut down gracefully."); } +async fn oidc_retry_loop(state: netweave::AppState) { + if state.config.oidc_config.is_none() { + return; + } + + let retry_delay = Duration::from_secs(30); + + loop { + if state.oidc.read().await.is_none() { + if let Some(oidc_config) = state.config.oidc_config.as_ref() { + match netweave::auth::oidc::OidcService::from_config(oidc_config).await { + Ok(service) => { + *state.oidc.write().await = Some(service); + tracing::info!("OIDC service initialized by retry loop."); + } + Err(e) => { + tracing::warn!("OIDC retry failed, will retry: {}", e); + } + } + } + } + + tokio::time::sleep(retry_delay).await; + } +} + async fn shutdown_signal() { let ctrl_c = async { tokio::signal::ctrl_c().await.unwrap_or_else(|e| { diff --git a/src/routes.rs b/src/routes.rs index c346ef1..bd51283 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -3,6 +3,7 @@ use crate::handlers; use crate::AppState; use axum::{ extract::{Request, State}, + http::StatusCode, middleware::{self, Next}, response::{IntoResponse, Response}, routing::{delete, get, post, put}, @@ -13,34 +14,36 @@ use tower_http::services::{ServeDir, ServeFile}; use tower_sessions::{Expiry, Session, SessionManagerLayer}; use tower_sessions_sqlx_store::PostgresStore; +/// Authentication middleware. Authentication is based on asymmetricaly encrypted JWT, check [`auth`] for more info. async fn auth_middleware( - State(_): State, + _: State, session: Session, mut request: Request, next: Next, ) -> Response { - let user: Option = match session.get(AUTH_SESSION_KEY).await { - Ok(u) => u, - Err(e) => { - tracing::warn!("Failed to read auth session: {}", e); - None - } + // todo clean this + let Ok(Some(user)) = session.get::(AUTH_SESSION_KEY).await else { + return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); }; - if let Some(user) = user { - request.extensions_mut().insert(user); - next.run(request).await - } else { - (axum::http::StatusCode::UNAUTHORIZED, "Unauthorized").into_response() - } + request.extensions_mut().insert(user); + next.run(request).await } +/// Additional auth middleware for pages that may or may not require authentication. When `homepage_public` setting +/// is enabled this middleware skips authentication. async fn optional_auth_middleware( - State(state): State, + state: State, session: Session, mut request: Request, next: Next, ) -> Response { + // attempt to authenticate, ignore on error + if let Ok(Some(user)) = session.get::(AUTH_SESSION_KEY).await { + request.extensions_mut().insert(user); + return next.run(request).await; + } + // Check if homepage is public let homepage_public = state .db @@ -56,23 +59,11 @@ async fn optional_auth_middleware( return next.run(request).await; } - // Otherwise, require authentication - let user: Option = match session.get(AUTH_SESSION_KEY).await { - Ok(u) => u, - Err(e) => { - tracing::warn!("Failed to read auth session: {}", e); - None - } - }; - - if let Some(user) = user { - request.extensions_mut().insert(user); - next.run(request).await - } else { - (axum::http::StatusCode::UNAUTHORIZED, "Unauthorized").into_response() - } + // Otherwise, run standard authentication middleware + return auth_middleware(state, session, request, next).await; } +/// Admin access check async fn require_admin(request: Request, next: Next) -> Response { let is_admin = request .extensions() @@ -81,16 +72,17 @@ async fn require_admin(request: Request, next: Next) -> Response { .unwrap_or(false); if is_admin { - next.run(request).await - } else { - ( - axum::http::StatusCode::FORBIDDEN, - "Forbidden: Admin access required", - ) - .into_response() + return next.run(request).await; } + + ( + axum::http::StatusCode::FORBIDDEN, + "Forbidden: Admin access required", + ) + .into_response() } +/// As we all love cors, it configures it fn build_cors_layer(allowed_origins: &[String]) -> CorsLayer { use axum::http::{header, Method}; @@ -121,6 +113,9 @@ fn build_cors_layer(allowed_origins: &[String]) -> CorsLayer { } } +// SECTION: routes +// todo refactor + fn dashboard_routes(state: AppState) -> Router { Router::new() .route("/dashboard", get(handlers::show_dashboard)) @@ -244,6 +239,8 @@ fn auth_protected_api_routes(state: AppState) -> Router { .route_layer(middleware::from_fn_with_state(state, auth_middleware)) } +/// Creates main app router +// todo refactor pub async fn create_router(state: AppState) -> anyhow::Result { let session_store = PostgresStore::new(state.db.pool.clone()); session_store diff --git a/src/validation.rs b/src/validation.rs index 026acc2..6c9ef5b 100644 --- a/src/validation.rs +++ b/src/validation.rs @@ -4,9 +4,10 @@ use anyhow::{anyhow, Result}; use serde::{Deserialize, Deserializer}; use serde_json::Value; -// ---------- Deserializers for common patterns ---------- +// SECTION: Common deserializers -/// Deserialize checkbox-like values ("on", "true", "1") to boolean +/// Deserialize checkbox-like values ("on", "true", "1") to boolean. +/// Project uses libraries so it's easier to just use this function pub fn deserialize_checkbox<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -34,7 +35,7 @@ where } } -// ---------- Domain validators ---------- +// SECTION: Domain validators /// Validate hostname format (RFC 1123 simplified) pub fn validate_hostname(hostname: &str) -> Result<()> { From b042d4e1239711541eeeaeccfbd4d0c895eb2e46 Mon Sep 17 00:00:00 2001 From: Michal Chruscielski Date: Sun, 26 Apr 2026 15:30:22 +0200 Subject: [PATCH 3/8] refactor: DRY, code purity --- web/src/App.tsx | 5 +- web/src/components/AddDeviceDialog.tsx | 230 ++++++++++ web/src/components/ConfirmDialog.tsx | 26 +- web/src/components/CrudPage.tsx | 161 +++++++ web/src/components/FormWrapper.tsx | 28 -- web/src/components/UserDialog.tsx | 11 +- web/src/components/ui/button.tsx | 2 +- web/src/hooks/index.ts | 2 + web/src/hooks/useCRUDList.ts | 44 +- web/src/hooks/useDeleteWithConfirm.ts | 46 ++ web/src/hooks/useServices.ts | 21 +- web/src/hooks/useUsers.ts | 3 +- web/src/index.css | 13 +- web/src/lib/api-client.ts | 7 +- web/src/main.tsx | 26 +- web/src/pages/Devices.tsx | 585 +++++++++---------------- web/src/pages/Integrations.tsx | 325 +++++++------- web/src/pages/Networks.tsx | 203 ++++----- web/src/pages/Services.tsx | 230 +++++----- web/src/pages/Users.tsx | 208 ++++----- web/src/types/api.ts | 14 +- 21 files changed, 1210 insertions(+), 980 deletions(-) create mode 100644 web/src/components/AddDeviceDialog.tsx create mode 100644 web/src/components/CrudPage.tsx create mode 100644 web/src/hooks/useDeleteWithConfirm.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index ca532a8..6c7ac3e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -45,9 +45,10 @@ function App() { const [homepagePublic, setHomepagePublic] = useState(false); const [settingsLoaded, setSettingsLoaded] = useState(false); - useState(() => { + useEffect(() => { setOnBackendDown(() => setBackendDown(true)); - }); + return () => setOnBackendDown(null); + }, [setBackendDown]); useEffect(() => { fetchApi>("/api/settings/public", { silent: true }) diff --git a/web/src/components/AddDeviceDialog.tsx b/web/src/components/AddDeviceDialog.tsx new file mode 100644 index 0000000..f7cf2d6 --- /dev/null +++ b/web/src/components/AddDeviceDialog.tsx @@ -0,0 +1,230 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { FormWrapper } from "@/components/FormWrapper"; +import { DeviceType, type CreateDevicePayload, type DeviceListView } from "@/types/api"; +import { useState } from "react"; +import { fetchApi } from "@/lib/api-client"; +import { toast } from "sonner"; + +const INPUT_CLASS = + "bg-secondary/40 border-border/40 focus-visible:ring-primary/40 focus-visible:border-primary/50 transition-all rounded-lg"; +const SELECT_TRIGGER_CLASS = + "bg-secondary/40 border-border/40 focus:ring-primary/40 rounded-lg transition-all"; + +const defaultFormData = (): Partial => ({ + device_type: DeviceType.Other, +}); + +interface AddDeviceDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSaved: () => void; + devices: DeviceListView[]; +} + +export function AddDeviceDialog({ open, onOpenChange, onSaved, devices }: AddDeviceDialogProps) { + const [formData, setFormData] = useState>(defaultFormData()); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleCreate = async () => { + setIsSubmitting(true); + try { + await fetchApi("/api/devices", { + method: "POST", + silent: true, + body: JSON.stringify(formData), + }); + onSaved(); + onOpenChange(false); + setFormData(defaultFormData()); + toast.success("Device created", { + description: "The new device has been added to the registry.", + }); + } catch (e) { + console.error(e); + toast.error("Failed to create device"); + } finally { + setIsSubmitting(false); + } + }; + + return ( + { + onOpenChange(open); + if (!open) setFormData(defaultFormData()); + }} + > + + + + Add New Device + + + Create a new device. You can add detailed interfaces later. + + + +
+
+ + + setFormData({ ...formData, hostname: e.target.value }) + } + className={INPUT_CLASS} + required + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + + setFormData({ ...formData, mac_address: e.target.value }) + } + className={INPUT_CLASS} + /> +
+ +
+ + + setFormData({ ...formData, os_info: e.target.value }) + } + className={INPUT_CLASS} + /> +
+ +
+ + + setFormData({ ...formData, ip_address: e.target.value }) + } + className={INPUT_CLASS} + /> +
+
+ + + + +
+
+
+ ); +} diff --git a/web/src/components/ConfirmDialog.tsx b/web/src/components/ConfirmDialog.tsx index a57f77a..ab4f38b 100644 --- a/web/src/components/ConfirmDialog.tsx +++ b/web/src/components/ConfirmDialog.tsx @@ -13,11 +13,13 @@ import type { ReactNode } from "react"; interface ConfirmDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - onConfirm: () => void; + onConfirm: () => void | Promise; title: string; description: ReactNode; confirmLabel?: string; variant?: "destructive" | "default"; + isSubmitting?: boolean; + submittingLabel?: string; } export function ConfirmDialog({ @@ -28,21 +30,35 @@ export function ConfirmDialog({ description, confirmLabel = "Confirm", variant = "destructive", + isSubmitting = false, + submittingLabel = "Working...", }: ConfirmDialogProps) { return ( - + { + if (isSubmitting && !nextOpen) return; + onOpenChange(nextOpen); + }} + > {title} {description} - Cancel + Cancel { + event.preventDefault(); + void Promise.resolve(onConfirm()).catch(() => { + // Callers handle user-facing errors (toasts/messages). + }); + }} > - {confirmLabel} + {isSubmitting ? submittingLabel : confirmLabel} diff --git a/web/src/components/CrudPage.tsx b/web/src/components/CrudPage.tsx new file mode 100644 index 0000000..deafbb8 --- /dev/null +++ b/web/src/components/CrudPage.tsx @@ -0,0 +1,161 @@ +import type { LucideIcon } from "lucide-react"; +import type React from "react"; +import { Button } from "@/components/ui/button"; +import { CardContent } from "@/components/ui/card"; +import { GlassCard } from "@/components/ui/glass-card"; +import { DataFetcher } from "@/components/DataFetcher"; +import { EmptyState } from "@/components/EmptyState"; +import { ErrorState } from "@/components/ErrorState"; +import { TableLoadingSkeleton } from "@/components/LoadingSkeletons"; +import { SearchInput } from "@/components/SearchInput"; +import { PageHeader } from "@/components/PageHeader"; +import { AppLayout } from "@/layouts/AppLayout"; +import { Plus } from "lucide-react"; + +export interface CrudPageProps { + /** Page title shown in the header */ + title: string; + /** Page description shown below the title */ + description: string; + /** Icon shown in the empty-state illustration */ + emptyIcon: LucideIcon; + /** Label for the "empty" state */ + emptyTitle: string; + /** Description for the "empty" state */ + emptyDescription: string; + /** Label on the primary action button */ + addLabel: string; + + // Data + data: T[] | undefined; + filteredData: T[]; + isLoading: boolean; + error: Error | undefined; + onRetry: () => void; + + // Search + searchValue: string; + onSearchChange: (value: string) => void; + searchPlaceholder?: string; + + // Add action + onAdd: () => void; + + /** Number of table skeleton columns shown while loading */ + skeletonColumns?: number; + + /** Optional custom loading component */ + loadingComponent?: React.ReactNode; + + /** + * If true, the children won't be wrapped in a GlassCard. + * Useful for grid layouts where items are cards themselves. + */ + noContainer?: boolean; + + /** + * Renders the table body content. Receives the filtered data so you can + * map over it and return elements. + */ + children: (data: T[]) => React.ReactNode; + + /** + * Optional extra elements rendered inside PageHeader (e.g. additional buttons) + */ + headerActions?: React.ReactNode; +} + +/** + * Generic CRUD list page. + * + * Encapsulates the repetitive layout shared by Services, Networks, Users, etc.: + * - AppLayout wrapper + * - PageHeader with search + add button + * - DataFetcher (loading / error / empty / data states) + * - GlassCard table container (optional) + * + * The caller only needs to provide the content via the `children` render prop. + */ +export function CrudPage({ + title, + description, + emptyIcon, + emptyTitle, + emptyDescription, + addLabel, + data, + filteredData, + isLoading, + error, + onRetry, + searchValue, + onSearchChange, + searchPlaceholder = "Search...", + onAdd, + skeletonColumns = 5, + loadingComponent, + noContainer = false, + children, + headerActions, +}: CrudPageProps) { + return ( + +
+ + + {headerActions} + + + + + + + + + ) + } + errorComponent={(err, retry) => ( + + )} + emptyComponent={() => ( + + )} + > + {() => + noContainer ? ( + children(filteredData) + ) : ( + + + {children(filteredData)} + + + ) + } + +
+
+ ); +} diff --git a/web/src/components/FormWrapper.tsx b/web/src/components/FormWrapper.tsx index db22c21..f91eaf7 100644 --- a/web/src/components/FormWrapper.tsx +++ b/web/src/components/FormWrapper.tsx @@ -50,31 +50,3 @@ export function FormField({ ); } - -export interface FormInputProps - extends React.InputHTMLAttributes { - label: string; - error?: string; -} - -/** - * Reusable form input with label and error - */ -export function FormInput({ - label, - error, - required, - className, - ...props -}: FormInputProps) { - return ( - - - - ); -} diff --git a/web/src/components/UserDialog.tsx b/web/src/components/UserDialog.tsx index fd5527b..270cffa 100644 --- a/web/src/components/UserDialog.tsx +++ b/web/src/components/UserDialog.tsx @@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button"; import { useState, useEffect } from "react"; import { fetchApi } from "@/lib/api-client"; import { toast } from "sonner"; -import type { User, CreateUserPayload } from "@/types/api"; +import { UserRole, type User, type CreateUserPayload } from "@/types/api"; interface UserDialogProps { open: boolean; @@ -23,7 +23,7 @@ export function UserDialog({ open, onOpenChange, onSaved, initialData }: UserDia useEffect(() => { if (open) { // eslint-disable-next-line react-hooks/set-state-in-effect - setFormData(initialData ?? { role: "VIEWER", is_active: true }); + setFormData(initialData ?? { role: UserRole.Viewer, is_active: true }); } }, [open, initialData]); @@ -32,6 +32,7 @@ export function UserDialog({ open, onOpenChange, onSaved, initialData }: UserDia const url = isEdit ? `/api/users/${initialData!.id}` : "/api/users"; await fetchApi(url, { method: isEdit ? "PUT" : "POST", + silent: true, body: JSON.stringify(formData), }); onSaved(); @@ -66,11 +67,11 @@ export function UserDialog({ open, onOpenChange, onSaved, initialData }: UserDia
- setFormData({ ...formData, role: val as CreateUserPayload["role"] })}> - Viewer - Admin + Viewer + Admin
diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index fc416a5..fb7e5cd 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + "inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { diff --git a/web/src/hooks/index.ts b/web/src/hooks/index.ts index 99e779f..13fc1af 100644 --- a/web/src/hooks/index.ts +++ b/web/src/hooks/index.ts @@ -5,9 +5,11 @@ export { useTableSearch } from './useTableSearch'; export { useForm } from './useForm'; export { useServices } from './useServices'; export { useUsers } from './useUsers'; +export { useDeleteWithConfirm } from './useDeleteWithConfirm'; export type { UseFormOptions, UseFormResult } from './useForm'; export type { UseCRUDListOptions, UseCRUDListResult } from './useCRUDList'; export type { UseTableSearchOptions, UseTableSearchResult } from './useTableSearch'; export type { UseServicesOptions, UseServicesResult } from './useServices'; export type { UseUsersOptions, UseUsersResult } from './useUsers'; +export type { DeleteConfirmState } from './useDeleteWithConfirm'; diff --git a/web/src/hooks/useCRUDList.ts b/web/src/hooks/useCRUDList.ts index 4fb19e5..2200399 100644 --- a/web/src/hooks/useCRUDList.ts +++ b/web/src/hooks/useCRUDList.ts @@ -4,7 +4,8 @@ import { fetchApi } from '@/lib/api-client'; export interface UseCRUDListOptions { endpoint: string; - onError?: (error: Error) => void; + onLoadError?: (error: Error) => void; + onMutationError?: (error: Error) => void; revalidateOnFocus?: boolean; } @@ -25,7 +26,12 @@ export interface UseCRUDListResult { export function useCRUDList( options: UseCRUDListOptions ): UseCRUDListResult { - const { endpoint, onError, revalidateOnFocus = false } = options; + const { + endpoint, + onLoadError, + onMutationError, + revalidateOnFocus = false, + } = options; const { data, error, isLoading, mutate: swrMutate } = useSWR( endpoint, @@ -33,35 +39,45 @@ export function useCRUDList( { revalidateOnFocus, dedupingInterval: 60000, + onError: (err) => { + const normalized = err instanceof Error ? err : new Error(String(err)); + if (onLoadError) onLoadError(normalized); + console.error(`Load error in ${endpoint}:`, normalized); + }, } ); const handleError = useCallback( (err: Error) => { - if (onError) onError(err); - console.error(`Error in ${endpoint}:`, err); + if (onMutationError) onMutationError(err); + console.error(`Mutation error in ${endpoint}:`, err); }, - [endpoint, onError] + [endpoint, onMutationError] ); const mrefresh = useCallback(async () => { try { await swrMutate(); } catch (err) { - handleError(err instanceof Error ? err : new Error(String(err))); + const error = err instanceof Error ? err : new Error(String(err)); + if (onLoadError) onLoadError(error); + console.error(`Refresh error in ${endpoint}:`, error); } - }, [swrMutate, handleError]); + }, [swrMutate, onLoadError, endpoint]); const add = useCallback( async (item: T) => { try { await fetchApi(endpoint, { method: 'POST', + silent: true, body: JSON.stringify(item), }); await swrMutate(); } catch (err) { - handleError(err instanceof Error ? err : new Error(String(err))); + const error = err instanceof Error ? err : new Error(String(err)); + handleError(error); + throw error; } }, [endpoint, swrMutate, handleError] @@ -70,10 +86,13 @@ export function useCRUDList( const remove = useCallback( async (id: string) => { try { - await fetchApi(`${endpoint}/${id}`, { method: 'DELETE' }); + await fetchApi(`${endpoint}/${id}`, { method: 'DELETE', silent: true }); + // Explicit revalidation to keep list UI and count badges in sync. await swrMutate(); } catch (err) { - handleError(err instanceof Error ? err : new Error(String(err))); + const error = err instanceof Error ? err : new Error(String(err)); + handleError(error); + throw error; } }, [endpoint, swrMutate, handleError] @@ -84,11 +103,14 @@ export function useCRUDList( try { await fetchApi(`${endpoint}/${id}`, { method: 'PUT', + silent: true, body: JSON.stringify(item), }); await swrMutate(); } catch (err) { - handleError(err instanceof Error ? err : new Error(String(err))); + const error = err instanceof Error ? err : new Error(String(err)); + handleError(error); + throw error; } }, [endpoint, swrMutate, handleError] diff --git a/web/src/hooks/useDeleteWithConfirm.ts b/web/src/hooks/useDeleteWithConfirm.ts new file mode 100644 index 0000000..82b787a --- /dev/null +++ b/web/src/hooks/useDeleteWithConfirm.ts @@ -0,0 +1,46 @@ +import { useCallback, useState } from "react"; + +export interface DeleteConfirmState { + id: string; + label: string; +} + +/** + * Reusable state machine for delete-confirm dialogs. + * Keeps pages focused on copy/content instead of modal wiring boilerplate. + */ +export function useDeleteWithConfirm( + onDelete: (id: string, label: string) => Promise, +) { + const [deleteConfirm, setDeleteConfirm] = useState( + null, + ); + const [isDeleting, setIsDeleting] = useState(false); + + const promptDelete = useCallback((id: string, label: string) => { + setDeleteConfirm({ id, label }); + }, []); + + const clearDeleteConfirm = useCallback(() => { + setDeleteConfirm(null); + }, []); + + const confirmDelete = useCallback(async () => { + if (!deleteConfirm) return; + setIsDeleting(true); + try { + await onDelete(deleteConfirm.id, deleteConfirm.label); + clearDeleteConfirm(); + } finally { + setIsDeleting(false); + } + }, [clearDeleteConfirm, deleteConfirm, onDelete]); + + return { + deleteConfirm, + isDeleting, + promptDelete, + clearDeleteConfirm, + confirmDelete, + }; +} diff --git a/web/src/hooks/useServices.ts b/web/src/hooks/useServices.ts index fc54dc9..815ff01 100644 --- a/web/src/hooks/useServices.ts +++ b/web/src/hooks/useServices.ts @@ -5,7 +5,8 @@ import type { Service } from '@/types/api'; export interface UseServicesOptions { refreshInterval?: number; - onError?: (error: Error) => void; + onLoadError?: (error: Error) => void; + onMutationError?: (error: Error) => void; } export interface UseServicesResult { @@ -17,12 +18,18 @@ export interface UseServicesResult { } export function useServices(options: UseServicesOptions = {}): UseServicesResult { - const { refreshInterval = 10000, onError } = options; + const { refreshInterval = 10000, onLoadError, onMutationError } = options; const { data, error, isLoading, mutate: swrMutate } = useSWR<{ services: Service[] }>( '/api/dashboard', fetchApi, - { refreshInterval } + { + refreshInterval, + onError: (err) => { + const normalized = err instanceof Error ? err : new Error(String(err)); + if (onLoadError) onLoadError(normalized); + }, + } ); const mutate = useCallback(async () => { @@ -32,19 +39,19 @@ export function useServices(options: UseServicesOptions = {}): UseServicesResult const remove = useCallback( async (id: string) => { try { - await fetchApi(`/api/services/${id}`, { method: 'DELETE' }); + await fetchApi(`/api/services/${id}`, { method: 'DELETE', silent: true }); await swrMutate(); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); - if (onError) onError(error); + if (onMutationError) onMutationError(error); throw error; } }, - [onError, swrMutate] + [onMutationError, swrMutate] ); return { - services: data?.services || [], + services: data?.services ?? [], isLoading, error, mutate, diff --git a/web/src/hooks/useUsers.ts b/web/src/hooks/useUsers.ts index 8bfb8ee..68e6ff4 100644 --- a/web/src/hooks/useUsers.ts +++ b/web/src/hooks/useUsers.ts @@ -2,7 +2,8 @@ import { useCRUDList, type UseCRUDListResult } from './useCRUDList'; import type { User } from '../types/api'; export interface UseUsersOptions { - onError?: (error: Error) => void; + onLoadError?: (error: Error) => void; + onMutationError?: (error: Error) => void; revalidateOnFocus?: boolean; } diff --git a/web/src/index.css b/web/src/index.css index d5bb2c4..f51d676 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,9 +1,7 @@ @import "tailwindcss"; -/*todo migrate config to v4*/ @config "../tailwind.config.js"; -/*todo migrate dark mode management to v4*/ @layer base { :root { --background: 0 0% 100%; @@ -78,15 +76,16 @@ scrollbar-width: none; /* Firefox */ } -} -@layer base { - * { - /*@apply border-border;*/ + .bg-premium-grid { + background-size: 40px 40px; + background-image: linear-gradient(to right, hsl(var(--border) / 0.3) 1px, transparent 1px), + linear-gradient(to bottom, hsl(var(--border) / 0.3) 1px, transparent 1px); } +} +@layer base { body { - /*@apply bg-background text-foreground overflow-hidden;*/ height: 100vh; width: 100vw; } diff --git a/web/src/lib/api-client.ts b/web/src/lib/api-client.ts index 0724177..8ade61b 100644 --- a/web/src/lib/api-client.ts +++ b/web/src/lib/api-client.ts @@ -5,7 +5,7 @@ import { toast } from "sonner"; // Simple event bus/callback for non-component files to trigger UI updates let onBackendDown: (() => void) | null = null; -export const setOnBackendDown = (cb: () => void) => { +export const setOnBackendDown = (cb: (() => void) | null) => { onBackendDown = cb; }; @@ -69,8 +69,9 @@ export async function fetchApi( if (error instanceof TypeError) { console.error("Network request failed - Backend might be down", error); if (onBackendDown) onBackendDown(); - // Also toast just in case - toast.error("Could not connect to backend server."); + if (!silent) { + toast.error("Could not connect to backend server."); + } } throw error; } diff --git a/web/src/main.tsx b/web/src/main.tsx index 18df2e5..7080b91 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -6,19 +6,13 @@ import { ErrorProvider } from "./contexts/ErrorContext"; import { AuthProvider } from "./contexts/AuthContext"; import { Toaster } from "@/components/ui/sonner"; -const Main = () => { - return ( - - - - - - - - - ); -}; - -createRoot(document.getElementById("root")!).render(
); - -export default Main; +createRoot(document.getElementById("root")!).render( + + + + + + + + , +); diff --git a/web/src/pages/Devices.tsx b/web/src/pages/Devices.tsx index e32d430..a7b3baa 100644 --- a/web/src/pages/Devices.tsx +++ b/web/src/pages/Devices.tsx @@ -1,5 +1,4 @@ import { Button } from "@/components/ui/button"; -import { CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Table, @@ -9,61 +8,29 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { Label } from "@/components/ui/label"; -import { GlassCard } from "@/components/ui/glass-card"; import { ConfirmDialog } from "@/components/ConfirmDialog"; -import { EmptyState } from "@/components/EmptyState"; -import { ErrorState } from "@/components/ErrorState"; -import { TableLoadingSkeleton } from "@/components/LoadingSkeletons"; -import { DataFetcher } from "@/components/DataFetcher"; -import { FormWrapper, FormInput } from "@/components/FormWrapper"; +import { CrudPage } from "@/components/CrudPage"; +import { EditDeviceDialog } from "@/components/EditDeviceDialog"; +import { AddDeviceDialog } from "@/components/AddDeviceDialog"; import { DeviceIcon } from "@/lib/device-utils"; -import { Plus, Trash2, Edit2, Server } from "lucide-react"; -import { useMemo, useState } from "react"; -import { AppLayout } from "../layouts/AppLayout"; +import { Trash2, Edit2, Server } from "lucide-react"; +import { useState } from "react"; import { fetchApi } from "@/lib/api-client"; import { type DeviceListView, - DeviceType, type CreateDevicePayload, } from "@/types/api"; -import { useCRUDList, useTableSearch } from "@/hooks"; +import { useCRUDList, useDeleteWithConfirm, useTableSearch } from "@/hooks"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; -import { SearchInput } from "@/components/SearchInput"; -import { PageHeader } from "@/components/PageHeader"; export const Devices = () => { - const defaultDeviceFormData = (): Partial => ({ - device_type: DeviceType.Other, - }); - - const deviceInputClass = - "bg-secondary/40 border-border/40 focus-visible:ring-primary/40 focus-visible:border-primary/50 transition-all rounded-lg"; - const deviceSelectTriggerClass = - "bg-secondary/40 border-border/40 focus:ring-primary/40 rounded-lg transition-all"; - const navigate = useNavigate(); const { @@ -74,7 +41,7 @@ export const Devices = () => { remove, } = useCRUDList({ endpoint: "/api/devices", - onError: (e) => + onLoadError: (e) => toast.error("Failed to load devices", { description: e.message }), }); @@ -92,51 +59,53 @@ export const Devices = () => { ], }); - const [isDialogOpen, setIsDialogOpen] = useState(false); - const [formData, setFormData] = useState>( - defaultDeviceFormData(), - ); - const [deleteConfirm, setDeleteConfirm] = useState<{ - id: string; - hostname: string; - } | null>(null); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [editDevice, setEditDevice] = useState(null); const handleDelete = async (id: string, hostname: string) => { try { await remove(id); - setDeleteConfirm(null); toast.success("Device deleted", { description: `${hostname} has been removed successfully.`, }); } catch (e) { console.error(e); + toast.error("Failed to delete device"); + throw e; } }; + const { + deleteConfirm, + isDeleting, + promptDelete, + clearDeleteConfirm, + confirmDelete, + } = + useDeleteWithConfirm(handleDelete); - const handleSave = async () => { + const handleEditSubmit = async (data: Partial) => { + if (!editDevice) return; try { - await fetchApi("/api/devices", { - method: "POST", - body: JSON.stringify(formData), + await fetchApi(`/api/devices/${editDevice.id}`, { + method: "PUT", + silent: true, + body: JSON.stringify(data), }); mutate(); - setIsDialogOpen(false); - setFormData(defaultDeviceFormData()); - toast.success("Device created", { - description: "The new device has been added to the registry.", - }); + setEditDevice(null); + toast.success("Device updated"); } catch (e) { console.error(e); + toast.error("Failed to update device"); } }; - const sortedDevices = useMemo(() => { - return [...filteredData].sort((a, b) => { + const sortDevices = (items: DeviceListView[]) => + [...items].sort((a, b) => { const group = (d: DeviceListView) => d.is_static === true ? 0 : d.is_static === false ? 1 : 2; const diff = group(a) - group(b); if (diff !== 0) return diff; - // Within the same group, sort by IP numerically const ipToNum = (ip?: string | null) => { if (!ip) return Infinity; return ip @@ -145,338 +114,180 @@ export const Devices = () => { }; return ipToNum(a.primary_ip) - ipToNum(b.primary_ip); }); - }, [filteredData]); - return ( - -
- - - - + const editInitialData: Partial = editDevice + ? { + hostname: editDevice.hostname, + device_type: editDevice.device_type, + os_info: editDevice.os_info ?? undefined, + mac_address: editDevice.mac_address ?? undefined, + } + : {}; - - - - - - } - errorComponent={(err, retry) => ( - - )} - emptyComponent={() => ( - - )} - > - {() => ( - - - - - - Hostname - Type - Primary IP - MAC Address - OS - Actions - - - - {sortedDevices.map((device) => ( - navigate(`/devices/${device.id}`)} - > - - {device.hostname} - - - - - {device.device_type} - - - - {device.primary_ip ? ( -
- - - - - {device.is_static ? "S" : "D"} - - - - {device.is_static - ? "Static reservation — this IP is manually assigned and will not change." - : "Dynamic lease — this IP was assigned by DHCP and may change on renewal."} - - - - - {device.primary_ip} - -
- ) : ( - - - )} -
- - {device.mac_address || ( - - - )} - - - {device.os_info || ( - - - )} - - -
- -
+ + + Hostname + Type + Primary IP + MAC Address + OS + Actions + + + + {sortedDevices.map((device) => ( + navigate(`/devices/${device.id}`)} + > + + {device.hostname} + + + + + {device.device_type} + + + + {device.primary_ip ? ( +
+ + + + + {device.is_static ? "S" : "D"} + + + - - -
-
-
- ))} -
-
-
-
- )} -
- - {/* Create Device Dialog */} - { - setIsDialogOpen(open); - if (!open) { - setFormData(defaultDeviceFormData()); - } - }} - > - - - - Add New Device - - - Create a new device. You can add detailed interfaces later. - - - { - await handleSave(); - }} - > -
- - setFormData({ ...formData, hostname: e.target.value }) - } - className={deviceInputClass} - required - /> - -
- - -
- -
- - -
- - - setFormData({ ...formData, mac_address: e.target.value }) - } - className={deviceInputClass} - /> + + + +
+ + + ))} + + + ); + }} + - - setFormData({ ...formData, os_info: e.target.value }) - } - className={deviceInputClass} - /> + - - setFormData({ ...formData, ip_address: e.target.value }) - } - className={deviceInputClass} - /> -
- - - - - - - + !open && setEditDevice(null)} + onSubmit={handleEditSubmit} + initialData={editInitialData} + /> - !open && setDeleteConfirm(null)} - onConfirm={() => - deleteConfirm && - handleDelete(deleteConfirm.id, deleteConfirm.hostname) - } - title="Delete Device?" - description={ - <> - This will permanently delete{" "} - - {deleteConfirm?.hostname} - {" "} - and all its associated data. This action cannot be undone. - - } - confirmLabel="Delete Device" - /> - -
+ !open && clearDeleteConfirm()} + onConfirm={confirmDelete} + isSubmitting={isDeleting} + submittingLabel="Deleting..." + title="Delete Device?" + description={ + <> + This will permanently delete{" "} + + {deleteConfirm?.label} + {" "} + and all its associated data. This action cannot be undone. + + } + confirmLabel="Delete Device" + /> + ); }; diff --git a/web/src/pages/Integrations.tsx b/web/src/pages/Integrations.tsx index 8eb5093..c212f43 100644 --- a/web/src/pages/Integrations.tsx +++ b/web/src/pages/Integrations.tsx @@ -1,21 +1,18 @@ import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { ConfirmDialog } from "@/components/ConfirmDialog"; -import { EmptyState } from "@/components/EmptyState"; -import { ErrorState } from "@/components/ErrorState"; +import { CrudPage } from "@/components/CrudPage"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { CardGridLoadingSkeleton } from "@/components/LoadingSkeletons"; -import { DataFetcher } from "@/components/DataFetcher"; -import { Plus, Trash2, RefreshCw, Settings2 } from "lucide-react"; +import { RefreshCw, Settings2, Trash2 } from "lucide-react"; import { useState } from "react"; import useSWR from "swr"; -import { AppLayout } from "../layouts/AppLayout"; import { fetchApi } from "@/lib/api-client"; import { cn } from "@/lib/utils"; import { AddIntegrationDialog } from "../components/AddIntegrationDialog"; import { toast } from "sonner"; import type { Integration } from "@/types/api"; -import { PageHeader } from "@/components/PageHeader"; +import { useDeleteWithConfirm, useTableSearch } from "@/hooks"; export const Integrations = () => { const [refreshInterval, setRefreshInterval] = useState(5000); @@ -33,28 +30,37 @@ export const Integrations = () => { setRefreshInterval(hasActiveWork ? 1000 : 5000); }, }); + + const { searchTerm: search, setSearchTerm: setSearch, filteredData: filteredIntegrations } = + useTableSearch(integrations, { searchableFields: ["name", "provider_type"] }); + const [isDialogOpen, setIsDialogOpen] = useState(false); const [syncingId, setSyncingId] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState<{ - id: string; - name: string; - } | null>(null); const handleDelete = async (id: string) => { try { - await fetchApi(`/api/integrations/${id}`, { method: "DELETE" }); - setDeleteConfirm(null); + await fetchApi(`/api/integrations/${id}`, { method: "DELETE", silent: true }); mutate(); + toast.success("Integration deleted"); } catch (e) { console.error(e); + toast.error("Failed to delete integration"); + throw e; } }; + const { + deleteConfirm, + isDeleting, + promptDelete, + clearDeleteConfirm, + confirmDelete, + } = useDeleteWithConfirm(async (id) => handleDelete(id)); const handleSync = async (id: string, name: string) => { setSyncingId(id); const startTime = Date.now(); try { - await fetchApi(`/api/integrations/${id}/sync`, { method: "POST" }); + await fetchApi(`/api/integrations/${id}/sync`, { method: "POST", silent: true }); mutate(); } catch (e) { console.error(e); @@ -67,162 +73,149 @@ export const Integrations = () => { } }; - const handleDialogClose = (open: boolean) => { - setIsDialogOpen(open); - if (!open) mutate(); - }; - return ( - -
- - - + <> + setIsDialogOpen(true)} + noContainer={true} + loadingComponent={} + > + {(items) => ( +
+ {items.map((int) => { + const isSyncing = syncingId === int.id || int.status === "SYNCING"; + const displayStatus = int.status.startsWith("ERROR") + ? int.status.match(/\b\d{3}\b/) + ? `ERROR ${int.status.match(/\b\d{3}\b/)?.[0]}` + : "ERROR" + : int.status; - } - errorComponent={(err, retry) => ( - - )} - emptyComponent={() => ( - - )} - > - {(items) => ( -
- {items.map((int) => { - const isSyncing = - syncingId === int.id || int.status === "SYNCING"; - const displayStatus = int.status.startsWith("ERROR") - ? int.status.match(/\b\d{3}\b/) - ? `ERROR ${int.status.match(/\b\d{3}\b/)?.[0]}` - : "ERROR" - : int.status; - - return ( - -
- - - {int.name} - - - {int.status === "ACTIVE" && ( - - - - - )} - {displayStatus} - - - -
- {int.provider_type} Provider -
-
-

- Last Sync:{" "} - {int.last_sync_at - ? new Date(int.last_sync_at).toLocaleString() - : "Never"} -

-
- - -
+ return ( + +
+ + + {int.name} + + + {int.status === "ACTIVE" && ( + + + + + )} + {displayStatus} + + + +
+ {int.provider_type} Provider +
+
+

+ Last Sync:{" "} + {int.last_sync_at + ? new Date(int.last_sync_at).toLocaleString() + : "Never"} +

+
+ +
- - - ); - })} -
- )} - +
+ +
+ ); + })} +
+ )} + - + { + setIsDialogOpen(open); + if (!open) mutate(); + }} + onSaved={mutate} + /> - !open && setDeleteConfirm(null)} - onConfirm={() => deleteConfirm && handleDelete(deleteConfirm.id)} - title="Delete Integration?" - description={ - <> - This will permanently remove the{" "} - - {deleteConfirm?.name} - {" "} - integration and its configuration. This action cannot be undone. - - } - confirmLabel="Delete Integration" - /> -
- + !open && clearDeleteConfirm()} + onConfirm={confirmDelete} + isSubmitting={isDeleting} + submittingLabel="Deleting..." + title="Delete Integration?" + description={ + <> + This will permanently remove the{" "} + + {deleteConfirm?.label} + {" "} + integration and its configuration. This action cannot be undone. + + } + confirmLabel="Delete Integration" + /> + ); }; diff --git a/web/src/pages/Networks.tsx b/web/src/pages/Networks.tsx index 1d14328..91b0802 100644 --- a/web/src/pages/Networks.tsx +++ b/web/src/pages/Networks.tsx @@ -1,138 +1,127 @@ import { Button } from "@/components/ui/button"; -import { CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { GlassCard } from "@/components/ui/glass-card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { ConfirmDialog } from "@/components/ConfirmDialog"; -import { EmptyState } from "@/components/EmptyState"; -import { ErrorState } from "@/components/ErrorState"; -import { TableLoadingSkeleton } from "@/components/LoadingSkeletons"; -import { DataFetcher } from "@/components/DataFetcher"; -import { Plus, Trash2, Edit2, Network as NetworkIcon } from "lucide-react"; +import { CrudPage } from "@/components/CrudPage"; +import { Trash2, Edit2, Network as NetworkIcon } from "lucide-react"; import { useState } from "react"; -import { useCRUDList, useTableSearch } from "@/hooks"; +import { useCRUDList, useDeleteWithConfirm, useTableSearch } from "@/hooks"; import { toast } from "sonner"; -import { AppLayout } from "../layouts/AppLayout"; -import { SearchInput } from "@/components/SearchInput"; -import { PageHeader } from "@/components/PageHeader"; import { NetworkDialog, type Network } from "@/components/NetworkDialog"; export const Networks = () => { const { data: networks, error, isLoading, mutate, remove } = useCRUDList({ endpoint: "/api/networks", - onError: (e) => toast.error("Failed to load networks", { description: e.message }), + onLoadError: (e) => toast.error("Failed to load networks", { description: e.message }), }); - const { searchTerm: search, setSearchTerm: setSearch, filteredData: filteredNetworks } = useTableSearch(networks, { - searchableFields: ["name", "cidr", "description"], - }); + const { searchTerm: search, setSearchTerm: setSearch, filteredData: filteredNetworks } = + useTableSearch(networks, { searchableFields: ["name", "cidr", "description"] }); const [isDialogOpen, setIsDialogOpen] = useState(false); const [selectedNetwork, setSelectedNetwork] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null); const handleDelete = async (id: string, name: string) => { try { await remove(id); - setDeleteConfirm(null); toast.success("Network deleted", { description: `${name} has been removed successfully.` }); } catch (e) { console.error(e); toast.error("Failed to delete network", { description: "An error occurred while deleting the network." }); + throw e; } }; + const { deleteConfirm, isDeleting, promptDelete, clearDeleteConfirm, confirmDelete } = + useDeleteWithConfirm(handleDelete); - return ( - -
- - - - + const openCreate = () => { setSelectedNetwork(null); setIsDialogOpen(true); }; - - - - - - } - errorComponent={(err, retry) => ( - - )} - emptyComponent={() => ( - - )} - > - {() => ( - - - - - - Name - CIDR - VLAN - Gateway - Description - Actions - - - - {filteredNetworks.map((item) => ( - - -
- - {item.name} -
-
- {item.cidr} - {item.vlan_id || "-"} - {item.gateway || "-"} - {item.description || "-"} - -
- - -
-
-
- ))} -
-
-
-
- )} -
+ return ( + <> + + {(items) => ( + + + + Name + CIDR + VLAN + Gateway + Description + Actions + + + + {items.map((item) => ( + + +
+ + {item.name} +
+
+ {item.cidr} + {item.vlan_id || "-"} + {item.gateway || "-"} + {item.description || "-"} + +
+ + +
+
+
+ ))} +
+
+ )} +
- { mutate(); setIsDialogOpen(false); }} - initialData={selectedNetwork} - /> + { mutate(); setIsDialogOpen(false); }} + initialData={selectedNetwork} + /> - !open && setDeleteConfirm(null)} - onConfirm={() => deleteConfirm && handleDelete(deleteConfirm.id, deleteConfirm.name)} - title="Delete Network?" - description={<>This will permanently delete the {deleteConfirm?.name} network and all associated IP assignments. This action cannot be undone.} - confirmLabel="Delete Network" - /> -
-
+ !open && clearDeleteConfirm()} + onConfirm={confirmDelete} + isSubmitting={isDeleting} + submittingLabel="Deleting..." + title="Delete Network?" + description={<>This will permanently delete the {deleteConfirm?.label} network and all associated IP assignments. This action cannot be undone.} + confirmLabel="Delete Network" + /> + ); }; diff --git a/web/src/pages/Services.tsx b/web/src/pages/Services.tsx index 0c74ade..0552c33 100644 --- a/web/src/pages/Services.tsx +++ b/web/src/pages/Services.tsx @@ -1,150 +1,146 @@ import { Button } from "@/components/ui/button"; -import { CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { GlassCard } from "@/components/ui/glass-card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { StatusBadge } from "@/components/StatusBadge"; import { ConfirmDialog } from "@/components/ConfirmDialog"; -import { EmptyState } from "@/components/EmptyState"; -import { ErrorState } from "@/components/ErrorState"; -import { DataFetcher } from "@/components/DataFetcher"; +import { CrudPage } from "@/components/CrudPage"; import { ServiceDialog } from "@/components/ServiceDialog"; -import { TableLoadingSkeleton } from "@/components/LoadingSkeletons"; import { Plus, Trash2, Edit2, ExternalLink } from "lucide-react"; import { useState } from "react"; -import { AppLayout } from "../layouts/AppLayout"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; -import { useServices, useTableSearch } from "@/hooks"; -import { SearchInput } from "@/components/SearchInput"; -import { PageHeader } from "@/components/PageHeader"; +import { useDeleteWithConfirm, useServices, useTableSearch } from "@/hooks"; import type { Service } from "../types/api"; export const Services = () => { const { services, error, isLoading, mutate, remove } = useServices({ - onError: (e) => toast.error("Failed to update services", { description: e.message }), + onLoadError: (e) => toast.error("Failed to load services", { description: e.message }), }); const [isDialogOpen, setIsDialogOpen] = useState(false); const [selectedService, setSelectedService] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null); - const { filteredData: filteredServices, setSearchTerm: setSearch, searchTerm: search } = useTableSearch(services, { - searchableFields: ["name", "base_url"], - }); + const { filteredData: filteredServices, setSearchTerm: setSearch, searchTerm: search } = + useTableSearch(services, { searchableFields: ["name", "base_url"] }); const handleDelete = async (id: string, name: string) => { try { await remove(id); - setDeleteConfirm(null); toast.success("Service deleted", { description: `${name} has been removed.` }); - } catch (e) { console.error(e); } + } catch (e) { + console.error(e); + toast.error("Failed to delete service"); + throw e; + } }; + const { deleteConfirm, isDeleting, promptDelete, clearDeleteConfirm, confirmDelete } = + useDeleteWithConfirm(handleDelete); const openEdit = (service: Service) => { setSelectedService(service); setIsDialogOpen(true); }; const openCreate = () => { setSelectedService(null); setIsDialogOpen(true); }; return ( - -
- - - - - - - - - - - } - errorComponent={(err, retry) => ( - - )} - emptyComponent={() => ( - - )} - > - {() => ( - - - - - - Name - URL - Visibility - Uptime - Status - Actions - - - - {filteredServices.map((item) => ( - - {item.name} - - - {item.base_url} - - - - {item.is_public ? "Public" : "Private"} - - - = 99 ? "text-green-500" : (item.uptime_percentage ?? 100) >= 95 ? "text-amber-500" : "text-destructive" - )}> - {(item.uptime_percentage ?? 100).toFixed(1)}% - - - - - - -
- - -
-
-
- ))} -
-
-
-
- )} -
+ <> + + {(items) => ( + + + + Name + URL + Visibility + Uptime + Status + Actions + + + + {items.map((item) => ( + + {item.name} + + + {item.base_url} + + + + + {item.is_public ? "Public" : "Private"} + + + + = 99 + ? "text-green-500" + : (item.uptime_percentage ?? 100) >= 95 + ? "text-amber-500" + : "text-destructive" + )}> + {(item.uptime_percentage ?? 100).toFixed(1)}% + + + + + + +
+ + +
+
+
+ ))} +
+
+ )} +
- mutate()} - initialData={selectedService} - /> + mutate()} + initialData={selectedService} + /> - !open && setDeleteConfirm(null)} - onConfirm={() => deleteConfirm && handleDelete(deleteConfirm.id, deleteConfirm.name)} - title="Delete Service?" - description={<>This will permanently remove {deleteConfirm?.name} and its monitoring history. This action cannot be undone.} - confirmLabel="Delete Service" - /> -
-
+ !open && clearDeleteConfirm()} + onConfirm={confirmDelete} + isSubmitting={isDeleting} + submittingLabel="Deleting..." + title="Delete Service?" + description={<>This will permanently remove {deleteConfirm?.label} and its monitoring history. This action cannot be undone.} + confirmLabel="Delete Service" + /> + ); }; diff --git a/web/src/pages/Users.tsx b/web/src/pages/Users.tsx index b6025c1..2d7f79c 100644 --- a/web/src/pages/Users.tsx +++ b/web/src/pages/Users.tsx @@ -1,143 +1,129 @@ import { Button } from "@/components/ui/button"; -import { CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { GlassCard } from "@/components/ui/glass-card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { ConfirmDialog } from "@/components/ConfirmDialog"; -import { EmptyState } from "@/components/EmptyState"; -import { ErrorState } from "@/components/ErrorState"; -import { DataFetcher } from "@/components/DataFetcher"; +import { CrudPage } from "@/components/CrudPage"; import { UserDialog } from "@/components/UserDialog"; -import { TableLoadingSkeleton } from "@/components/LoadingSkeletons"; -import { Plus, Trash2, Edit2, Users as UsersIcon } from "lucide-react"; +import { Trash2, Edit2, Users as UsersIcon } from "lucide-react"; import { useState } from "react"; -import { AppLayout } from "../layouts/AppLayout"; import { toast } from "sonner"; -import { useUsers, useTableSearch } from "@/hooks"; -import { SearchInput } from "@/components/SearchInput"; -import { PageHeader } from "@/components/PageHeader"; -import type { User } from "../types/api"; +import { useDeleteWithConfirm, useUsers, useTableSearch } from "@/hooks"; +import { UserRole, type User } from "../types/api"; export const Users = () => { const { data: users, error, isLoading, mutate, remove } = useUsers({ - onError: (e) => toast.error("Failed to load users", { description: e.message }), + onLoadError: (e) => toast.error("Failed to load users", { description: e.message }), }); const [isDialogOpen, setIsDialogOpen] = useState(false); const [selectedUser, setSelectedUser] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; username: string } | null>(null); - const { filteredData: filteredUsers, setSearchTerm: setSearch, searchTerm: search } = useTableSearch(users, { - searchableFields: ["username", "email", "role"], - }); + const { filteredData: filteredUsers, setSearchTerm: setSearch, searchTerm: search } = + useTableSearch(users, { searchableFields: ["username", "email", "role"] }); const handleDelete = async (id: string, username: string) => { try { await remove(id); - setDeleteConfirm(null); toast.success("User deleted", { description: `${username} has been removed.` }); } catch (e) { console.error(e); - const err = e as Error; - toast.error("Error deleting user", { description: err.message }); + toast.error("Failed to delete user"); + throw e; } }; + const { deleteConfirm, isDeleting, promptDelete, clearDeleteConfirm, confirmDelete } = + useDeleteWithConfirm(handleDelete); const openEdit = (user: User) => { setSelectedUser(user); setIsDialogOpen(true); }; const openCreate = () => { setSelectedUser(null); setIsDialogOpen(true); }; return ( - -
- - - - - - - - - - - } - errorComponent={(err, retry) => ( - - )} - emptyComponent={() => ( - - )} - > - {() => ( - - - - - - Username - Email - Role - Status - Actions - - - - {filteredUsers.map((item) => ( - - {item.username} - {item.email} - - - {item.role === "ADMIN" ? "Admin" : "Viewer"} - - - - - {item.is_active ? "Active" : "Disabled"} - - - -
- - -
-
-
- ))} -
-
-
-
- )} -
+ <> + + {(items) => ( + + + + Username + Email + Role + Status + Actions + + + + {items.map((item) => ( + + {item.username} + {item.email} + + + {item.role === UserRole.Admin ? "Admin" : "Viewer"} + + + + + {item.is_active ? "Active" : "Disabled"} + + + +
+ + +
+
+
+ ))} +
+
+ )} +
- mutate()} - initialData={selectedUser} - /> + mutate()} + initialData={selectedUser} + /> - !open && setDeleteConfirm(null)} - onConfirm={() => deleteConfirm && handleDelete(deleteConfirm.id, deleteConfirm.username)} - title="Delete User?" - description={<>This will permanently remove {deleteConfirm?.username} and revoke their access. This action cannot be undone.} - confirmLabel="Delete User" - /> -
-
+ !open && clearDeleteConfirm()} + onConfirm={confirmDelete} + isSubmitting={isDeleting} + submittingLabel="Deleting..." + title="Delete User?" + description={<>This will permanently remove {deleteConfirm?.label} and revoke their access. This action cannot be undone.} + confirmLabel="Delete User" + /> + ); }; diff --git a/web/src/types/api.ts b/web/src/types/api.ts index b4e87b6..fe30790 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -1,5 +1,3 @@ -// todo check if we can replace some types/consts with enums -// also check if we can use undefined instead of null for optional fields export interface Service { id: string; device_id: string | null; @@ -43,7 +41,6 @@ export const DeviceType = { Router: "ROUTER", Other: "OTHER", } as const; - export type DeviceType = (typeof DeviceType)[keyof typeof DeviceType]; export const IpStatus = { @@ -53,7 +50,6 @@ export const IpStatus = { Deprecated: "DEPRECATED", Free: "FREE", } as const; - export type IpStatus = (typeof IpStatus)[keyof typeof IpStatus]; export interface Interface { @@ -134,11 +130,17 @@ export interface Integration { last_sync_at: string | null; } +export const UserRole = { + Admin: "ADMIN", + Viewer: "VIEWER", +} as const; +export type UserRole = (typeof UserRole)[keyof typeof UserRole]; + export interface User { id: string; username: string; email: string; - role: string; + role: UserRole; is_active: boolean; created_at: string; } @@ -146,7 +148,7 @@ export interface User { export interface CreateUserPayload { username: string; email: string; - role: string; + role: UserRole; password?: string; is_active: boolean; } From c07b79dbc2342d0e320c32085f5e104677c47a7c Mon Sep 17 00:00:00 2001 From: Michal Chruscielski Date: Sun, 26 Apr 2026 15:31:47 +0200 Subject: [PATCH 4/8] refactor: DRY, code purity backend --- package.json | 3 --- src/auth/mod.rs | 11 ++++++-- src/auth/oidc.rs | 23 +++++------------ src/config.rs | 2 ++ src/db/users.rs | 30 +++++++++++++++++++++- src/handlers/users.rs | 59 ++++++++++++++++++++++++++++--------------- src/lib.rs | 2 +- src/main.rs | 25 +++++++++++++++--- src/routes.rs | 4 ++- 9 files changed, 111 insertions(+), 48 deletions(-) delete mode 100644 package.json diff --git a/package.json b/package.json deleted file mode 100644 index 052850e..0000000 --- a/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be" -} diff --git a/src/auth/mod.rs b/src/auth/mod.rs index dd0d2e9..1ba2f5c 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,6 +1,7 @@ //! # Authentication module //! -//! Manages authentication, this app uses standard JWT and alterntively OIDC +//! Manages authentication using server-side sessions (tower-sessions) and, +//! optionally, OIDC for login. use crate::db::Db; use crate::handlers::common::{AppError, AppResult}; @@ -68,7 +69,13 @@ pub async fn ensure_default_users(db: &Db) { std::env::var("DEFAULT_ADMIN_USER"), std::env::var("DEFAULT_ADMIN_PASSWORD"), ) { - let hashed = hash(password, DEFAULT_COST).expect("Failed to hash password"); + let hashed = match hash(password, DEFAULT_COST) { + Ok(hashed) => hashed, + Err(e) => { + tracing::error!("Failed to hash default admin password: {}", e); + return; + } + }; match db .create_user(&username, "admin@homelab.local", Some(&hashed), "ADMIN") .await diff --git a/src/auth/oidc.rs b/src/auth/oidc.rs index 49c40a6..7fe9630 100644 --- a/src/auth/oidc.rs +++ b/src/auth/oidc.rs @@ -14,23 +14,14 @@ pub struct OidcService { impl OidcService { pub async fn from_config(config: &OidcConfig) -> Result { - let mut issuer_str = config.discovery_url.clone(); - if issuer_str.ends_with(".well-known/openid-configuration") { - issuer_str = issuer_str - .strip_suffix(".well-known/openid-configuration") - .unwrap() - .to_string(); - } - if issuer_str.ends_with(".well-known/openid-configuration/") { - issuer_str = issuer_str - .strip_suffix(".well-known/openid-configuration/") - .unwrap() - .to_string(); - } + // config.rs normalizes discovery_url to end with /.well-known/openid-configuration. + // The openidconnect crate needs the issuer base URL (without the well-known path). + let issuer_str = config + .discovery_url + .trim_end_matches(".well-known/openid-configuration") + .trim_end_matches('/'); // openidconnect-rs often requires a trailing slash for authentik issuers. - if !issuer_str.ends_with('/') { - issuer_str.push('/'); - } + let issuer_str = format!("{issuer_str}/"); let issuer_url = IssuerUrl::new(issuer_str)?; let client_id = ClientId::new(config.client_id.clone()); diff --git a/src/config.rs b/src/config.rs index 55c0d6b..cfd6be7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -173,6 +173,7 @@ mod tests { let _guard = ENV_LOCK.lock().expect("ENV_LOCK poisoned"); clear_oidc_env(); env::set_var("DATABASE_URL", "postgres://localhost"); + env::set_var("SERVER_PORT", "8789"); env::set_var("OIDC_CLIENT_ID", "netweave"); env::set_var("OIDC_SECRET", "secret-from-alias"); env::set_var("OIDC_DISCOVERY_URL", "https://auth.example.com"); @@ -195,6 +196,7 @@ mod tests { let _guard = ENV_LOCK.lock().expect("ENV_LOCK poisoned"); clear_oidc_env(); env::set_var("DATABASE_URL", "postgres://localhost"); + env::set_var("SERVER_PORT", "8789"); env::set_var("OIDC_CLIENT_ID", "netweave"); env::set_var("OIDC_CLIENT_SECRET", "super-secret"); env::set_var("OIDC_ISSUER", "https://issuer.example.com/"); diff --git a/src/db/users.rs b/src/db/users.rs index c2d0adc..b51dd74 100644 --- a/src/db/users.rs +++ b/src/db/users.rs @@ -1,5 +1,6 @@ use super::Db; use crate::entities::users; +use anyhow::anyhow; use sea_orm::*; use uuid::Uuid; @@ -28,6 +29,15 @@ impl Db { return Ok(user.id); } + let email_exists = users::Entity::find() + .filter(users::Column::Email.eq(email)) + .one(&self.conn) + .await? + .is_some(); + if email_exists { + return Err(anyhow!("Email already exists")); + } + let new_id = Uuid::now_v7(); let user = users::ActiveModel { id: Set(new_id), @@ -38,7 +48,25 @@ impl Db { ..Default::default() }; - user.insert(&self.conn).await?; + if let Err(err) = user.insert(&self.conn).await { + if let Some(SqlErr::UniqueConstraintViolation(detail)) = err.sql_err() { + if detail.contains("users_username_key") || detail.contains("users.username") { + return Err(anyhow!("Username already exists")); + } + if detail.contains("users_email_key") || detail.contains("users.email") { + return Err(anyhow!("Email already exists")); + } + } + + let msg = err.to_string(); + if msg.contains("users_username_key") || msg.contains("users.username") { + return Err(anyhow!("Username already exists")); + } + if msg.contains("users_email_key") || msg.contains("users.email") { + return Err(anyhow!("Email already exists")); + } + return Err(err.into()); + } Ok(new_id) } diff --git a/src/handlers/users.rs b/src/handlers/users.rs index aaff9ea..aac1487 100644 --- a/src/handlers/users.rs +++ b/src/handlers/users.rs @@ -9,23 +9,46 @@ use axum::{ Json, }; use bcrypt::{hash, DEFAULT_COST}; -use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, Set}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, IntoActiveModel, PaginatorTrait, + QueryFilter, Set, SqlErr, +}; use std::str::FromStr; use uuid::Uuid; async fn active_admin_count(state: &AppState) -> Result { - let count: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE role = $1 AND is_active = TRUE") - .bind(Role::Admin.as_str()) - .fetch_one(&state.db.pool) - .await?; - Ok(count as u64) + let count = users::Entity::find() + .filter(users::Column::Role.eq(Role::Admin.as_str())) + .filter(users::Column::IsActive.eq(true)) + .count(&state.db.conn) + .await?; + Ok(count) } fn role_or_bad_request(role: &str) -> AppResult { Role::from_str(role).map_err(AppError::BadRequest) } +fn map_user_write_error(err: DbErr) -> AppError { + if let Some(SqlErr::UniqueConstraintViolation(detail)) = err.sql_err() { + if detail.contains("users_username_key") || detail.contains("users.username") { + return AppError::Conflict("Username already exists".into()); + } + if detail.contains("users_email_key") || detail.contains("users.email") { + return AppError::Conflict("Email already exists".into()); + } + } + + let msg = err.to_string(); + if msg.contains("users_username_key") || msg.contains("users.username") { + return AppError::Conflict("Username already exists".into()); + } + if msg.contains("users_email_key") || msg.contains("users.email") { + return AppError::Conflict("Email already exists".into()); + } + AppError::from(err) +} + pub async fn list_users(State(state): State) -> AppResult>> { let users = users::Entity::find().all(&state.db.conn).await?; let user_models = users.into_iter().map(User::from).collect(); @@ -50,15 +73,8 @@ pub async fn create_user( Json(payload): Json, ) -> AppResult> { let role = role_or_bad_request(&payload.role)?; - - let existing_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username = $1") - .bind(&payload.username) - .fetch_one(&state.db.pool) - .await?; - - if existing_count > 0 { - return Err(AppError::Conflict("Username already exists".into())); - } + let username = payload.username; + let email = payload.email; let password_hash = if let Some(pwd) = payload.password { Some(hash(pwd, DEFAULT_COST).map_err(|e| AppError::Internal(e.into()))?) @@ -69,15 +85,18 @@ pub async fn create_user( let new_id = Uuid::now_v7(); let user_model = users::ActiveModel { id: Set(new_id), - username: Set(payload.username), - email: Set(payload.email), + username: Set(username), + email: Set(email), role: Set(role.as_str().to_string()), password_hash: Set(password_hash), is_active: Set(payload.is_active), ..Default::default() }; - user_model.insert(&state.db.conn).await?; + user_model + .insert(&state.db.conn) + .await + .map_err(map_user_write_error)?; Ok(Json(new_id)) } @@ -132,7 +151,7 @@ pub async fn update_user( } } - user.update(&state.db.conn).await?; + user.update(&state.db.conn).await.map_err(map_user_write_error)?; Ok(Json(true)) } diff --git a/src/lib.rs b/src/lib.rs index 2e5cd1f..8b39925 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,7 +30,7 @@ pub enum ServiceStatus { Unknown, } -/// Geeneral app state object. It should contain all app data used during runtime (mutable and not). +/// General app state object. It should contain all app data used during runtime (mutable and not). #[derive(Clone)] pub struct AppState { pub db: Db, diff --git a/src/main.rs b/src/main.rs index 4e779be..1f24275 100644 --- a/src/main.rs +++ b/src/main.rs @@ -93,7 +93,10 @@ async fn oidc_retry_loop(state: netweave::AppState) { return; } - let retry_delay = Duration::from_secs(30); + // Initial delay before first retry attempt (give the network a moment to settle). + let base_delay = Duration::from_secs(30); + let max_delay = Duration::from_secs(300); + let mut attempts: u32 = 0; loop { if state.oidc.read().await.is_none() { @@ -102,15 +105,29 @@ async fn oidc_retry_loop(state: netweave::AppState) { Ok(service) => { *state.oidc.write().await = Some(service); tracing::info!("OIDC service initialized by retry loop."); + attempts = 0; + // OIDC is up — no need to keep looping aggressively. + // Sleep a long interval before checking again in case it drops. + tokio::time::sleep(max_delay).await; + continue; } Err(e) => { - tracing::warn!("OIDC retry failed, will retry: {}", e); + attempts = attempts.saturating_add(1); + // Exponential back-off: 30s, 60s, 120s, 240s, capped at 300s. + let delay = + (base_delay * 2u32.saturating_pow(attempts - 1)).min(max_delay); + tracing::warn!( + "OIDC retry failed (attempt {}), retrying in {:?}: {}", + attempts, delay, e + ); + tokio::time::sleep(delay).await; } } } + } else { + // Already initialized — check again after max_delay in case it gets reset. + tokio::time::sleep(max_delay).await; } - - tokio::time::sleep(retry_delay).await; } } diff --git a/src/routes.rs b/src/routes.rs index bd51283..35ab3f1 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -14,7 +14,9 @@ use tower_http::services::{ServeDir, ServeFile}; use tower_sessions::{Expiry, Session, SessionManagerLayer}; use tower_sessions_sqlx_store::PostgresStore; -/// Authentication middleware. Authentication is based on asymmetricaly encrypted JWT, check [`auth`] for more info. +/// Authentication middleware backed by server-side sessions (tower-sessions). +/// The authenticated [`AuthUser`] is loaded from session storage and attached +/// to request extensions for downstream handlers. async fn auth_middleware( _: State, session: Session, From 421e56ce82cada476ec8e5b5c4969c3a68ec7735 Mon Sep 17 00:00:00 2001 From: Michal Chruscielski Date: Sun, 26 Apr 2026 15:33:51 +0200 Subject: [PATCH 5/8] fix: restore package.json --- package.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 package.json diff --git a/package.json b/package.json new file mode 100644 index 0000000..052850e --- /dev/null +++ b/package.json @@ -0,0 +1,3 @@ +{ + "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be" +} From 5f872d8ebbf9060b8262ac647251d00a7105452a Mon Sep 17 00:00:00 2001 From: Michal Chruscielski Date: Sun, 26 Apr 2026 15:34:21 +0200 Subject: [PATCH 6/8] fix: backend formatting --- src/handlers/users.rs | 4 +++- src/main.rs | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/handlers/users.rs b/src/handlers/users.rs index aac1487..bb21d22 100644 --- a/src/handlers/users.rs +++ b/src/handlers/users.rs @@ -151,7 +151,9 @@ pub async fn update_user( } } - user.update(&state.db.conn).await.map_err(map_user_write_error)?; + user.update(&state.db.conn) + .await + .map_err(map_user_write_error)?; Ok(Json(true)) } diff --git a/src/main.rs b/src/main.rs index 1f24275..e61bbcd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -114,11 +114,12 @@ async fn oidc_retry_loop(state: netweave::AppState) { Err(e) => { attempts = attempts.saturating_add(1); // Exponential back-off: 30s, 60s, 120s, 240s, capped at 300s. - let delay = - (base_delay * 2u32.saturating_pow(attempts - 1)).min(max_delay); + let delay = (base_delay * 2u32.saturating_pow(attempts - 1)).min(max_delay); tracing::warn!( "OIDC retry failed (attempt {}), retrying in {:?}: {}", - attempts, delay, e + attempts, + delay, + e ); tokio::time::sleep(delay).await; } From ae88bea5bde5ede68b4cee537aad41e855e75bd4 Mon Sep 17 00:00:00 2001 From: Michal Chruscielski Date: Sun, 26 Apr 2026 15:37:24 +0200 Subject: [PATCH 7/8] chore: cargo clippy fix --- src/routes.rs | 2 +- tests/api.rs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/routes.rs b/src/routes.rs index 35ab3f1..0d4e20e 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -62,7 +62,7 @@ async fn optional_auth_middleware( } // Otherwise, run standard authentication middleware - return auth_middleware(state, session, request, next).await; + auth_middleware(state, session, request, next).await } /// Admin access check diff --git a/tests/api.rs b/tests/api.rs index 934b0fb..c7f0658 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -3,7 +3,6 @@ use axum::body::Body; use axum::http::Request; -use netweave; use netweave::config::Config; use sqlx::postgres::PgPoolOptions; use tower::{Service, ServiceExt}; From bd685810d39ff75f394f13be8a40eb619f5da779 Mon Sep 17 00:00:00 2001 From: Michal Chruscielski Date: Sun, 26 Apr 2026 15:43:19 +0200 Subject: [PATCH 8/8] ci: simplify backend checks --- .github/workflows/ci.yml | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e96d790..f8424f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,24 +19,14 @@ jobs: with: components: clippy, rustfmt - - name: Cache cargo - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: ${{ runner.os }}-cargo- + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 - name: Check formatting run: cargo fmt --check - name: Clippy - run: cargo clippy -- -D warnings - - - name: Build - run: cargo build --release + run: cargo clippy --all-targets -- -D warnings - name: Test run: cargo test