13 Commits

Author SHA1 Message Date
Wilman Yesid Farfan Diaz
f94c75cdd0 Revert "Actualizaci[on de flujo concurrente en el instalador"
This reverts commit 8f239c6ff4.
2026-04-11 16:52:03 -05:00
Wilman Yesid Farfan Diaz
7b9d3998b1 Revert "Actualizaci[on de los archivos para el flujo"
This reverts commit c62c9991d6.
2026-04-11 16:50:32 -05:00
Wilman Yesid Farfan Diaz
c62c9991d6 Actualizaci[on de los archivos para el flujo 2026-04-11 16:31:03 -05:00
Wilman Yesid Farfan Diaz
8f239c6ff4 Actualizaci[on de flujo concurrente en el instalador 2026-04-11 16:03:14 -05:00
Wilman Yesid Farfan Diaz
399c203b10 Actualizada configuración de contenedores para visualizarse entre ellos 2026-04-11 14:49:04 -05:00
Wilman Yesid Farfan Diaz
eba624d58b Actualizaci[on del instalador 2026-04-11 14:22:20 -05:00
Wilman Yesid Farfan Diaz
582348ef45 Correcciones en el json generator 2026-04-11 11:06:36 -05:00
Wilman Yesid Farfan Diaz
f6e1a2e616 Actualizaci[on del metodo de sqlx 2026-04-11 10:41:40 -05:00
Wilman Yesid Farfan Diaz
66dfedaf20 Actualizaci[on del contrato y nombre del contrato con el operador 2026-04-11 10:34:01 -05:00
Wilman Yesid Farfan Diaz
f776f61430 Arreglando bucle infinito 2026-04-11 09:04:06 -05:00
Wilman Yesid Farfan Diaz
c9d3afe4c0 feat: implement auto-update mechanism and optimize Nginx cache-control policies for frontend services 2026-04-11 08:39:37 -05:00
9393886836 docs: agregar reporte de correccion auth mobile 2026-04-11 00:35:33 -05:00
5cbadb17e1 fix(auth): deshabilitar registro publico en mobile 2026-04-11 00:27:29 -05:00
38 changed files with 565 additions and 400 deletions

1
.gitignore vendored
View File

@@ -45,3 +45,4 @@ CLAUDE.md
.atl/ .atl/
.npm .npm
__pycache__ __pycache__
build_test/

13
Cargo.lock generated
View File

@@ -1925,6 +1925,7 @@ dependencies = [
"tower", "tower",
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-appender",
"tracing-subscriber", "tracing-subscriber",
"tray-icon", "tray-icon",
"uuid", "uuid",
@@ -6900,6 +6901,18 @@ dependencies = [
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-appender"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf"
dependencies = [
"crossbeam-channel",
"thiserror 2.0.18",
"time",
"tracing-subscriber",
]
[[package]] [[package]]
name = "tracing-attributes" name = "tracing-attributes"
version = "0.1.31" version = "0.1.31"

View File

@@ -65,6 +65,9 @@ services:
- MQTT_USER=${MQTT_USER} - MQTT_USER=${MQTT_USER}
- MQTT_PASSWORD=${MQTT_PASSWORD} - MQTT_PASSWORD=${MQTT_PASSWORD}
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379 - REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
- MINIO_ENDPOINT_URL=http://minio:9000
- AWS_ACCESS_KEY_ID=${MINIO_USER}
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
ports: ports:
- "8000:8000" - "8000:8000"
depends_on: depends_on:
@@ -130,6 +133,8 @@ services:
environment: environment:
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME} - DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
- MINIO_ENDPOINT=http://minio:9000 - MINIO_ENDPOINT=http://minio:9000
- AWS_ACCESS_KEY_ID=${MINIO_USER}
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
depends_on: depends_on:
backend-api: backend-api:
condition: service_healthy condition: service_healthy

View File

@@ -0,0 +1,52 @@
# Reporte de correccion auth mobile y recuperacion asociada
## 1. Contexto del problema
Durante la recuperacion del proyecto se detecto que la superficie mobile (PWA de campo) estaba mezclando responsabilidades de autenticacion y navegacion con la superficie administrativa. Esto generaba inconsistencias en el acceso, confusion para usuarios de campo y una exposicion indebida del flujo de registro publico desde mobile.
## 2. Que estaba ocurriendo
- La PWA mobile mostraba y exponia un flujo de registro publico (`/register`) que no correspondia al modelo operativo real.
- El backend todavia aceptaba el endpoint de registro publico, en lugar de bloquearlo de forma explicita.
- La experiencia de login en mobile todavia arrastraba señales visuales y de navegacion propias del entorno admin.
- La separacion entre `app.omnioil.app` (admin) y `mobile.omnioil.app` (campo) no estaba completamente resuelta, incluyendo redirecciones y recuperacion de sesion.
## 3. Que se diagnostico
- El registro publico no debia existir en mobile: el alta de usuarios de campo debe quedar bajo provisionamiento administrado.
- Habia que reforzar la separacion de superficies entre admin y mobile tanto a nivel UI como a nivel routing/autorizacion.
- La recuperacion de sesion necesitaba cubrir mejor el arranque de la PWA para evitar estados en blanco o redirecciones inconsistentes.
- El bloqueo del registro debia hacerse en dos capas: quitando la entrada desde frontend y devolviendo una respuesta formal desde backend.
## 4. Que se corrigio
- Se deshabilito el registro publico en mobile.
- Se elimino la ruta `/register` de la PWA y se retiro la pantalla/formulario asociado.
- El login mobile dejo de invitar al auto-registro y ahora indica que el alta debe solicitarse a un administrador.
- El backend ahora responde `403 Forbidden` cuando se intenta usar el registro publico, con un mensaje explicito indicando que el alta publica esta deshabilitada.
- Se consolido la separacion de superficies entre admin y mobile:
- mobile queda orientado a operadores/cuadrillas y tareas de campo;
- admin queda como superficie administrativa independiente.
- Se fortalecio la resolucion de rutas segun rol y dominio para redirigir al entorno correcto.
- Se mejoro la recuperacion de sesion/bootstrapping para que la app no quede en blanco mientras valida el acceso.
## 5. Validaciones realizadas
- Validacion local de los flujos de autenticacion y navegacion por superficie.
- Validacion en runtime de:
- redireccion por dominio/rol;
- recuperacion de sesion al iniciar la PWA;
- manejo de sesiones expiradas;
- bloqueo del registro publico.
- Cobertura automatizada agregada/ajustada para:
- respuesta `403` del backend al registro deshabilitado;
- identidad visual y mensajes exclusivos de mobile;
- redirecciones entre superficie admin y superficie mobile;
- bootstrap de sesion y manejo de refresh.
## 6. Estado final
- El registro publico en mobile quedo deshabilitado.
- La PWA mobile ya no expone una via de alta publica.
- El backend protege el endpoint de registro devolviendo `403`.
- La separacion entre superficie administrativa y superficie mobile quedo implementada de forma mas consistente.
- El flujo de login/recuperacion de sesion en mobile quedo estabilizado dentro del trabajo de recuperacion del proyecto.
## 7. Notas y riesgos pendientes
- Si existieran clientes antiguos cacheados apuntando a `/register`, recibiran el nuevo comportamiento bloqueado y deberian actualizarse.
- El alta de usuarios depende ahora completamente del flujo administrado, por lo que conviene mantener documentado ese procedimiento operativo.
- Como siguiente control recomendable, conviene revisar periodicamente que no reaparezcan accesos cruzados entre superficies en futuros cambios de auth o routing.

View File

@@ -56,7 +56,9 @@ server {
server_name api.omnioil.app; server_name api.omnioil.app;
location / { location / {
proxy_pass http://backend-api:8000; resolver 127.0.0.11 valid=30s;
set $backend_upstream backend-api;
proxy_pass http://$backend_upstream:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
} }

View File

@@ -0,0 +1,22 @@
-- MIGRATION: Add legal info to projects level only
-- Date: 2026-04-11
-- 1. Asegurar que edge_projects tenga los campos obligatorios
ALTER TABLE edge_projects
ADD COLUMN IF NOT EXISTS operator_name VARCHAR(150),
ADD COLUMN IF NOT EXISTS contract_number VARCHAR(100);
-- 2. Para datos de prueba existentes, llenamos con valores base
-- (Solo por integridad de la migración, el usuario dice que no importa)
UPDATE edge_projects
SET operator_name = client
WHERE operator_name IS NULL;
UPDATE edge_projects
SET contract_number = 'PENDIENTE'
WHERE contract_number IS NULL;
-- 3. Hacerlos NOT NULL para el futuro
ALTER TABLE edge_projects
ALTER COLUMN operator_name SET NOT NULL,
ALTER COLUMN contract_number SET NOT NULL;

View File

@@ -14,9 +14,8 @@ use std::env;
#[derive(Debug)] #[derive(Debug)]
pub enum AuthError { pub enum AuthError {
InvalidCredentials, InvalidCredentials,
EmailAlreadyExists,
AccountDisabled, AccountDisabled,
InvalidInput(String), RegistrationDisabled,
Internal(anyhow::Error), Internal(anyhow::Error),
} }
@@ -27,12 +26,15 @@ impl IntoResponse for AuthError {
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
"Credenciales inválidas".to_string(), "Credenciales inválidas".to_string(),
), ),
AuthError::EmailAlreadyExists => (StatusCode::CONFLICT, "El correo ya se encuentra registrado".to_string()),
AuthError::AccountDisabled => ( AuthError::AccountDisabled => (
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
"Tu cuenta ha sido desactivada. Contacta al administrador.".to_string(), "Tu cuenta ha sido desactivada. Contacta al administrador.".to_string(),
), ),
AuthError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, msg), AuthError::RegistrationDisabled => (
StatusCode::FORBIDDEN,
"El alta publica esta deshabilitada. Solicita acceso a un administrador."
.to_string(),
),
AuthError::Internal(ref e) => { AuthError::Internal(ref e) => {
tracing::error!("Auth internal error: {:?}", e); tracing::error!("Auth internal error: {:?}", e);
( (
@@ -52,6 +54,32 @@ impl IntoResponse for AuthError {
} }
} }
#[cfg(test)]
mod tests {
use super::AuthError;
use axum::{body, http::StatusCode, response::IntoResponse};
use serde_json::{Value, json};
#[tokio::test]
async fn registration_disabled_maps_to_forbidden_response() {
let response = AuthError::RegistrationDisabled.into_response();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let body = body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("response body");
let payload: Value = serde_json::from_slice(&body).expect("json response");
assert_eq!(
payload,
json!({
"error": "El alta publica esta deshabilitada. Solicita acceso a un administrador."
})
);
}
}
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims { pub struct Claims {
pub sub: String, // User ID pub sub: String, // User ID
@@ -119,8 +147,6 @@ pub fn create_jwt_tokens(uid: &str, role: &str, projects: Vec<uuid::Uuid>) -> Re
}) })
} }
use uuid::Uuid;
pub fn verify_jwt(token: &str) -> Result<TokenData<Claims>, jsonwebtoken::errors::Error> { pub fn verify_jwt(token: &str) -> Result<TokenData<Claims>, jsonwebtoken::errors::Error> {
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set"); let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
decode( decode(

View File

@@ -2,12 +2,8 @@ use crate::{
auth::{AuthError, create_jwt_tokens}, auth::{AuthError, create_jwt_tokens},
db::DbPool, db::DbPool,
}; };
use argon2::{ use argon2::{Argon2, password_hash::{PasswordHash, PasswordVerifier}};
Argon2,
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core},
};
use axum::{Json, extract::State, response::IntoResponse}; use axum::{Json, extract::State, response::IntoResponse};
use rand_core::OsRng;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
@@ -17,13 +13,6 @@ pub struct LoginPayload {
password: String, password: String,
} }
#[derive(Deserialize)]
pub struct RegisterPayload {
email: String,
password: String,
full_name: String,
}
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct UserRow { struct UserRow {
id: uuid::Uuid, id: uuid::Uuid,
@@ -208,78 +197,12 @@ pub async fn refresh(
Err(AuthError::InvalidCredentials) Err(AuthError::InvalidCredentials)
} }
#[derive(sqlx::FromRow)]
struct RegisterResult {
id: uuid::Uuid,
}
pub async fn register( pub async fn register(
State(pool): State<DbPool>, State(pool): State<DbPool>,
Json(payload): Json<RegisterPayload>, Json(_payload): Json<serde_json::Value>,
) -> Result<impl IntoResponse, AuthError> { ) -> Result<Json<serde_json::Value>, AuthError> {
// 1. Validaciones de entrada let _ = pool;
if payload.email.is_empty() || !payload.email.contains('@') {
return Err(AuthError::InvalidInput(
"Formato de correo electrónico inválido".to_string(),
));
}
if payload.password.len() < 8 { // Public signup stays disabled so access provisioning only happens from managed admin flows.
return Err(AuthError::InvalidInput( return Err(AuthError::RegistrationDisabled);
"La contraseña debe tener al menos 8 caracteres".to_string(),
));
}
if payload.full_name.trim().is_empty() {
return Err(AuthError::InvalidInput(
"El nombre completo es obligatorio".to_string(),
));
}
// 2. Cifrar la contraseña
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(payload.password.as_bytes(), &salt)
.map_err(|e| {
tracing::error!("Hashing error: {}", e);
AuthError::Internal(anyhow::anyhow!("Error de cifrado"))
})?
.to_string();
// 2. Insert the user
// We default to role 'admin' (id 3 as per init.sql) if not specified
//El operador es solo para la pwa.
let register_result: RegisterResult = sqlx::query_as::<sqlx::Postgres, RegisterResult>(
r#"
INSERT INTO users (email, password_hash, full_name, role_id)
VALUES ($1, $2, $3, (SELECT id FROM roles WHERE name = 'admin'))
RETURNING id
"#,
)
.bind(&payload.email)
.bind(&password_hash)
.bind(&payload.full_name)
.fetch_one(&pool)
.await
.map_err(|e| {
tracing::error!("User creation error: {}", e);
let err_str = e.to_string().to_lowercase();
if err_str.contains("unique constraint") || err_str.contains("already exists") {
AuthError::EmailAlreadyExists
} else {
AuthError::Internal(anyhow::anyhow!("Error interno al crear usuario"))
}
})?;
tracing::info!(
"User created: {} with ID: {}",
payload.email,
register_result.id
);
Ok((
axum::http::StatusCode::CREATED,
Json(json!({ "id": register_result.id, "email": payload.email })),
))
} }

View File

@@ -43,12 +43,14 @@ pub async fn create_project(
let mut tx = pool.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let mut tx = pool.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let project = sqlx::query_as::<_, EdgeProject>( let project = sqlx::query_as::<_, EdgeProject>(
r#"INSERT INTO edge_projects (name, client, description, metadata) r#"INSERT INTO edge_projects (name, client, operator_name, contract_number, description, metadata)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *"#, RETURNING *"#,
) )
.bind(&req.name) .bind(&req.name)
.bind(&req.client) .bind(&req.client)
.bind(&req.operator_name)
.bind(&req.contract_number)
.bind(&req.description) .bind(&req.description)
.bind(req.metadata.unwrap_or(serde_json::json!({}))) .bind(req.metadata.unwrap_or(serde_json::json!({})))
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
@@ -327,10 +329,10 @@ pub async fn download_installer(
// Configurar credenciales explícitas // Configurar credenciales explícitas
let access_key = std::env::var("AWS_ACCESS_KEY_ID") let access_key = std::env::var("AWS_ACCESS_KEY_ID")
.or_else(|_| std::env::var("MINIO_USER")) .or_else(|_| std::env::var("MINIO_USER"))
.unwrap_or_else(|_| "admin_s3".to_string()); .expect("S3/MinIO credentials (AWS_ACCESS_KEY_ID or MINIO_USER) must be set");
let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY") let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY")
.or_else(|_| std::env::var("MINIO_PASSWORD")) .or_else(|_| std::env::var("MINIO_PASSWORD"))
.unwrap_or_else(|_| "otra_clave_segura_minio".to_string()); .expect("S3/MinIO credentials (AWS_SECRET_ACCESS_KEY or MINIO_PASSWORD) must be set");
let credentials = aws_sdk_s3::config::Credentials::new( let credentials = aws_sdk_s3::config::Credentials::new(
access_key, access_key,

View File

@@ -15,6 +15,7 @@ uuid = { version = "1.23.0", features = ["v4", "serde"] }
chrono = { version = "0.4.44", features = ["serde"] } chrono = { version = "0.4.44", features = ["serde"] }
tracing = "0.1.44" tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
tracing-appender = "0.2.3"
axum = { version = "0.8.8", features = ["macros"] } axum = { version = "0.8.8", features = ["macros"] }
tower = { version = "0.5.3", features = ["util"] } tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.6.8", features = ["trace", "cors"] } tower-http = { version = "0.6.8", features = ["trace", "cors"] }

View File

@@ -62,18 +62,6 @@ while true; do
# Solo nos aseguramos de que el icono esté accesible para wixl # Solo nos aseguramos de que el icono esté accesible para wixl
cp -f icon.ico target/x86_64-pc-windows-msvc/release/icon.ico || true cp -f icon.ico target/x86_64-pc-windows-msvc/release/icon.ico || true
# Crear un agent.toml base si no existe (clímax para evitar fallo de servicio)
if [ ! -f "agent.toml" ]; then
cat <<EOF > agent.toml
[agent]
project_id = "00000000-0000-0000-0000-000000000000"
[mqtt]
host = "localhost"
[docker]
[server]
EOF
fi
# 2. Ejecutar WiXl para generar el MSI # 2. Ejecutar WiXl para generar el MSI
wixl -v -o "$OUTPUT_DIR/$MSI_NAME" "wix/main.wxs" wixl -v -o "$OUTPUT_DIR/$MSI_NAME" "wix/main.wxs"

View File

@@ -24,7 +24,7 @@ use log_reporter::LogReporter;
use mqtt_listener::AgentCommand; use mqtt_listener::AgentCommand;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::{RwLock, mpsc}; use tokio::sync::{RwLock, mpsc};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, Layer};
use uuid::Uuid; use uuid::Uuid;
const DEFAULT_CONFIG_PATH: &str = "agent.toml"; const DEFAULT_CONFIG_PATH: &str = "agent.toml";
@@ -32,15 +32,42 @@ const VERSION: &str = env!("CARGO_PKG_VERSION");
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
// Inicializar tracing // Inicializar tracing con salida a consola y a archivo para errores
let exe_path = std::env::current_exe().unwrap_or_default();
let log_dir = exe_path.parent().unwrap_or(std::path::Path::new(".")).join("logs");
let file_appender = tracing_appender::rolling::daily(log_dir, "edge-agent.log");
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
let console_layer = tracing_subscriber::fmt::layer()
.with_writer(std::io::stdout);
let file_layer = tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_writer(non_blocking);
tracing_subscriber::registry() tracing_subscriber::registry()
.with( .with(
tracing_subscriber::EnvFilter::try_from_default_env() tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,edge_agent=debug".into()), .unwrap_or_else(|_| "info,edge_agent=debug".into()),
) )
.with(tracing_subscriber::fmt::layer()) .with(console_layer)
.with(file_layer.with_filter(tracing_subscriber::filter::LevelFilter::WARN))
.init(); .init();
// Capturar Panics en el log
std::panic::set_hook(Box::new(|panic_info| {
let location = panic_info.location().map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())).unwrap_or_else(|| "unknown".to_string());
let payload = panic_info.payload().downcast_ref::<&str>().cloned()
.or_else(|| panic_info.payload().downcast_ref::<String>().map(|s| s.as_str()))
.unwrap_or("no message");
tracing::error!("CRITICAL PANIC at {}: {}", location, payload);
}));
// Guardar el _guard para que los logs sigan fluyendo
// Como _guard se destruye al final de main, está bien.
// Pero main.rs es async, así que lo mantenemos en el scope de main.
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
// Command: --generate-config // Command: --generate-config
@@ -85,7 +112,11 @@ async fn main() -> Result<()> {
let config = AgentConfig::load_default(std::path::Path::new(&config_path)) let config = AgentConfig::load_default(std::path::Path::new(&config_path))
.with_context(|| "Failed to load agent configuration (baked or external)")?; .with_context(|| "Failed to load agent configuration (baked or external)")?;
run_agent(config).await if let Err(e) = run_agent(config).await {
tracing::error!("Fatal error: {}", e);
return Err(e);
}
Ok(())
} }
pub async fn run_agent(config: AgentConfig) -> Result<()> { pub async fn run_agent(config: AgentConfig) -> Result<()> {

View File

@@ -32,7 +32,7 @@ impl StoreAndForward {
// 2. Preparar Cifrado // 2. Preparar Cifrado
let master_secret = std::env::var("OMNIOIL_MASTER_SECRET") let master_secret = std::env::var("OMNIOIL_MASTER_SECRET")
.unwrap_or_else(|_| "dev_master_secret_omnioil_2026".to_string()); .expect("OMNIOIL_MASTER_SECRET must be set for Edge Agent database encryption");
let mut key = [0u8; 32]; let mut key = [0u8; 32];
pbkdf2_hmac::<Sha256>(master_secret.as_bytes(), SALT, 1000, &mut key); pbkdf2_hmac::<Sha256>(master_secret.as_bytes(), SALT, 1000, &mut key);

View File

@@ -31,7 +31,7 @@ pub fn run_service() -> Result<()> {
#[cfg(windows)] #[cfg(windows)]
fn service_main(_args: Vec<OsString>) { fn service_main(_args: Vec<OsString>) {
if let Err(e) = service_handler() { if let Err(e) = service_handler() {
eprintln!("Service error: {}", e); tracing::error!("Service critical error: {}", e);
} }
} }
@@ -68,19 +68,24 @@ fn service_handler() -> Result<()> {
let exe_path = std::env::current_exe().unwrap(); let exe_path = std::env::current_exe().unwrap();
let config_path = exe_path.with_file_name("agent.toml"); let config_path = exe_path.with_file_name("agent.toml");
if let Ok(config) = crate::config::AgentConfig::load_default(&config_path) { match crate::config::AgentConfig::load_default(&config_path) {
tokio::select! { Ok(config) => {
res = crate::run_agent(config) => { tokio::select! {
if let Err(e) = res { res = crate::run_agent(config) => {
tracing::error!("Agent exited with error: {}", e); if let Err(e) = res {
tracing::error!("Agent exited with error: {}", e);
}
},
_ = shutdown_rx.recv() => {
tracing::info!("Service stopping via SCM signal");
} }
},
_ = shutdown_rx.recv() => {
tracing::info!("Service stopping via SCM signal");
} }
},
Err(e) => {
tracing::error!("Failed to load configuration at {:?}: {}", config_path, e);
// Esperar un poco antes de salir para que el log se escriba
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
} }
} else {
shutdown_rx.recv().await;
} }
}); });

View File

@@ -29,7 +29,6 @@
<Component Id='MainExecutable' Guid='B278D874-45B5-4CBA-A840-3F02AB62F9FD'> <Component Id='MainExecutable' Guid='B278D874-45B5-4CBA-A840-3F02AB62F9FD'>
<File Id='EdgeAgentEXE' Source='target/x86_64-pc-windows-msvc/release/edge-agent.exe' KeyPath='yes' /> <File Id='EdgeAgentEXE' Source='target/x86_64-pc-windows-msvc/release/edge-agent.exe' KeyPath='yes' />
<File Id='AgentConfigTOML' Source='agent.toml' />
<!-- Servicio de Windows --> <!-- Servicio de Windows -->
<ServiceInstall Id="ServiceInstaller" <ServiceInstall Id="ServiceInstaller"

View File

@@ -30,27 +30,39 @@ RUN pnpm build
FROM nginx:alpine FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
# Configuración Nginx optimizada para SPA y Proxy Inverso # Configuración Nginx optimizada para SPA y Cache Control
RUN echo 'server { \ RUN echo 'server { \
listen 80; \ listen 80; \
\
# Ruta principal para la SPA (React/Vue) \
location / { \
root /usr/share/nginx/html; \ root /usr/share/nginx/html; \
index index.html index.htm; \ index index.html; \
try_files $uri $uri/ /index.html; \ \
# Cache Control para el index.html (NUNCA cachear) \
location = /index.html { \
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; \
} \ } \
\ \
# Proxy para evitar errores de conexión y CORS \ # Cache Control para assets estáticos (Vite usa hashes, así que es seguro cachear) \
location /api/ { \ location /assets/ { \
proxy_pass http://backend-api:8000; \ expires 1y; \
proxy_http_version 1.1; \ add_header Cache-Control "public, immutable"; \
proxy_set_header Upgrade $http_upgrade; \
proxy_set_header Connection "upgrade"; \
proxy_set_header Host $host; \
proxy_cache_bypass $http_upgrade; \
} \ } \
}' > /etc/nginx/conf.d/default.conf \
# Ruta principal para la SPA \
location / { \
try_files $uri $uri/ /index.html; \
} \
\
# Proxy para la API con re-resolución DNS automática \
location /api/ { \
resolver 127.0.0.11 valid=30s; \
set $backend_upstream backend-api; \
proxy_pass http://$backend_upstream:8000; \
proxy_http_version 1.1; \
proxy_set_header Upgrade $http_upgrade; \
proxy_set_header Connection "upgrade"; \
proxy_set_header Host $host; \
} \
}' > /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,71 @@
import { useEffect } from 'react';
// Declaration for the global variable injected by Vite
declare global {
const __BUILD_TIME__: number;
}
/**
* AutoUpdateChecker component
* Periodically checks if a new version of the app is available on the server.
* If a new version is detected, it reloads the page to ensure the user has the latest version.
*/
export const AutoUpdateChecker = () => {
useEffect(() => {
// Only run this in production
if (import.meta.env.DEV) return;
const checkVersion = async () => {
try {
// Fetch the version.json file from the server
// Add a cache-busting timestamp to the request itself to ensure we get the latest file
const response = await fetch(`/version.json?t=${new Date().getTime()}`, {
cache: 'no-store',
});
if (response.ok) {
const data = await response.json();
const serverVersion = data.buildTime;
// Compare server version with the build time injected at compile time
// Use a small 1-second threshold to avoid issues with slight build variances
if (serverVersion && serverVersion > (__BUILD_TIME__ + 1000)) {
const lastReloadedVersion = localStorage.getItem('last_reloaded_version');
if (lastReloadedVersion !== String(serverVersion)) {
console.log('New version detected, reloading...');
localStorage.setItem('last_reloaded_version', String(serverVersion));
window.location.reload();
} else {
console.warn('New version detected but already reloaded once. Stopping loop.');
}
}
}
} catch (error) {
console.error('Error checking version:', error);
}
};
// Check version on mount
checkVersion();
// Check version when the page becomes visible again (e.g., user returns to the tab)
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
checkVersion();
}
};
// Check version every 5 minutes
const interval = setInterval(checkVersion, 1000 * 60 * 5);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
clearInterval(interval);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
return null; // This component doesn't render anything
};

View File

@@ -5,6 +5,8 @@ export interface EdgeProject {
id: string id: string
name: string name: string
client: string client: string
operator_name: string
contract_number: string
description?: string description?: string
is_active: boolean is_active: boolean
installer_status?: 'NOT_STARTED' | 'BUILDING' | 'READY' | 'FAILED' installer_status?: 'NOT_STARTED' | 'BUILDING' | 'READY' | 'FAILED'
@@ -16,6 +18,8 @@ export interface EdgeProject {
export interface CreateProjectRequest { export interface CreateProjectRequest {
name: string name: string
client: string client: string
operator_name: string
contract_number: string
description?: string description?: string
} }

View File

@@ -11,7 +11,9 @@ export default function CreateProjectPage() {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
client: 'OmniOil', client: '',
operator_name: '',
contract_number: '',
description: '', description: '',
}) })
@@ -66,17 +68,43 @@ export default function CreateProjectPage() {
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground"> <label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
Cliente / Operador Cliente / Razón Social
</label> </label>
<input <input
required required
placeholder="Ej. Ecopetrol" placeholder="Ej. Ecopetrol S.A."
className="w-full rounded-lg border border-border bg-input px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/50 transition-all" className="w-full rounded-lg border border-border bg-input px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/50 transition-all"
value={formData.client} value={formData.client}
onChange={(e) => setFormData({ ...formData, client: e.target.value })} onChange={(e) => setFormData({ ...formData, client: e.target.value })}
/> />
</div> </div>
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
Operador (Identificador ANH)
</label>
<input
required
placeholder="Ej. ECOPETROL_S_A"
className="w-full rounded-lg border border-border bg-input px-4 py-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/50 transition-all"
value={formData.operator_name}
onChange={(e) => setFormData({ ...formData, operator_name: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
Número de Contrato
</label>
<input
required
placeholder="Ej. CONT-2026-XYZ"
className="w-full rounded-lg border border-border bg-input px-4 py-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/50 transition-all"
value={formData.contract_number}
onChange={(e) => setFormData({ ...formData, contract_number: e.target.value })}
/>
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground"> <label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
Descripción <span className="normal-case font-normal">(Opcional)</span> Descripción <span className="normal-case font-normal">(Opcional)</span>

View File

@@ -201,7 +201,13 @@ export default function ProjectDetailsPage() {
<Button <Button
size="sm" size="sm"
onClick={() => setShowCreateModal(true)} onClick={() => {
setNewWell({
name: '',
code: '',
});
setShowCreateModal(true);
}}
className="bg-primary text-primary-foreground hover:bg-primary/90 text-xs h-8 gap-1.5 shadow-lg shadow-primary/20" className="bg-primary text-primary-foreground hover:bg-primary/90 text-xs h-8 gap-1.5 shadow-lg shadow-primary/20"
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />

View File

@@ -4,6 +4,7 @@ import { RouterProvider } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { router } from './app/router' import { router } from './app/router'
import SessionBootstrap from './app/bootstrap/SessionBootstrap' import SessionBootstrap from './app/bootstrap/SessionBootstrap'
import { AutoUpdateChecker } from './components/common/AutoUpdateChecker'
import './styles/globals.css' import './styles/globals.css'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -18,6 +19,7 @@ const queryClient = new QueryClient({
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AutoUpdateChecker />
<SessionBootstrap> <SessionBootstrap>
<RouterProvider router={router} /> <RouterProvider router={router} />
</SessionBootstrap> </SessionBootstrap>

View File

@@ -2,12 +2,25 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import path from 'node:path' import path from 'node:path'
import fs from 'node:fs'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const buildTime = new Date().getTime()
export default defineConfig({ export default defineConfig({
define: {
__BUILD_TIME__: buildTime,
},
plugins: [ plugins: [
react() react(),
{
name: 'generate-version',
writeBundle() {
const version = { buildTime };
fs.writeFileSync(path.resolve(__dirname, 'dist/version.json'), JSON.stringify(version));
}
}
], ],
test: { test: {
environment: 'jsdom', environment: 'jsdom',

View File

@@ -30,22 +30,37 @@ RUN pnpm build
FROM nginx:alpine FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
# Optimized Nginx config for PWA (with API proxy) # Optimized Nginx config for PWA (with API proxy and Cache Control)
RUN echo 'server { \ RUN echo 'server { \
listen 80; \ listen 80; \
location / { \
root /usr/share/nginx/html; \ root /usr/share/nginx/html; \
index index.html index.htm; \ index index.html; \
try_files $uri $uri/ /index.html; \ \
# Cache Control para el index.html y sw.js (NUNCA cachear) \
location ~* ^/(index\.html|sw\.js)$ { \
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; \
} \ } \
\
# Cache Control para assets estáticos (Vite usa hashes) \
location /assets/ { \
expires 1y; \
add_header Cache-Control "public, immutable"; \
} \
\
location / { \
try_files $uri $uri/ /index.html; \
} \
\
location /api/ { \ location /api/ { \
proxy_pass http://backend-api:8000; \ resolver 127.0.0.11 valid=30s; \
proxy_http_version 1.1; \ set $backend_upstream backend-api; \
proxy_set_header Upgrade $http_upgrade; \ proxy_pass http://$backend_upstream:8000; \
proxy_set_header Connection "upgrade"; \ proxy_http_version 1.1; \
proxy_set_header Host $host; \ proxy_set_header Upgrade $http_upgrade; \
proxy_set_header Connection "upgrade"; \
proxy_set_header Host $host; \
} \ } \
}' > /etc/nginx/conf.d/default.conf }' > /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,7 +1,6 @@
import { lazy, Suspense, useEffect } from 'react' import { lazy, Suspense, useEffect } from 'react'
import { createBrowserRouter, Navigate, useLocation } from 'react-router-dom' import { createBrowserRouter, Navigate, useLocation } from 'react-router-dom'
import LoginPage from '@/features/auth/pages/LoginPage' import LoginPage from '@/features/auth/pages/LoginPage'
import RegisterPage from '@/features/auth/pages/RegisterPage'
import FieldLayout from '@/app/layouts/FieldLayout' import FieldLayout from '@/app/layouts/FieldLayout'
import OfflinePage from '@/features/offline/OfflinePage' import OfflinePage from '@/features/offline/OfflinePage'
import { AuthTransitionScreen } from '@/features/auth/components/AuthTransitionScreen' import { AuthTransitionScreen } from '@/features/auth/components/AuthTransitionScreen'
@@ -64,10 +63,6 @@ export const router = createBrowserRouter([
path: '/login', path: '/login',
element: <LoginPage />, element: <LoginPage />,
}, },
{
path: '/register',
element: <RegisterPage />,
},
{ {
path: '/offline', path: '/offline',
element: <OfflinePage />, element: <OfflinePage />,

View File

@@ -0,0 +1,63 @@
import { useEffect } from 'react';
// Declaration for the global variable injected by Vite
declare global {
const __BUILD_TIME__: number;
}
/**
* AutoUpdateChecker component for PWA
* Work together with Service Worker to ensure the app is always up to date.
*/
export const AutoUpdateChecker = () => {
useEffect(() => {
if (import.meta.env.DEV) return;
const checkVersion = async () => {
try {
const response = await fetch(`/version.json?t=${new Date().getTime()}`, {
cache: 'no-store',
});
if (response.ok) {
const data = await response.json();
const serverVersion = data.buildTime;
// Use a small 1-second threshold to avoid issues with slight build variances
if (serverVersion && serverVersion > (__BUILD_TIME__ + 1000)) {
const lastReloadedVersion = localStorage.getItem('last_reloaded_version');
if (lastReloadedVersion !== String(serverVersion)) {
console.log('New version detected for PWA, reloading...');
localStorage.setItem('last_reloaded_version', String(serverVersion));
window.location.reload();
} else {
console.warn('New version detected but already reloaded once. Stopping loop.');
}
}
}
} catch (error) {
console.error('Error checking version:', error);
}
};
checkVersion();
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
checkVersion();
}
};
const interval = setInterval(checkVersion, 1000 * 60 * 15); // Check less frequently in PWA to save battery
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
clearInterval(interval);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
return null;
};

View File

@@ -1,6 +1,5 @@
import { apiClient } from '@/lib/api/client' import { apiClient } from '@/lib/api/client'
import type { LoginFormData } from '../schemas/login-schema' import type { LoginFormData } from '../schemas/login-schema'
import type { RegisterFormData } from '../schemas/register-schema'
interface ProjectSummary { interface ProjectSummary {
id: string id: string
@@ -21,10 +20,3 @@ export async function loginApi(data: LoginFormData): Promise<LoginResponse> {
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
} }
export async function registerApi(data: RegisterFormData): Promise<{ id: string, email: string }> {
return apiClient<{ id: string, email: string }>('/auth/register', {
method: 'POST',
body: JSON.stringify(data),
})
}

View File

@@ -49,9 +49,11 @@ describe('PWA LoginPage', () => {
expect(container?.textContent).toContain('Iniciar turno y cargar parte') expect(container?.textContent).toContain('Iniciar turno y cargar parte')
expect(container?.textContent).toContain('Offline primero') expect(container?.textContent).toContain('Offline primero')
expect(container?.textContent).toContain('Pozo, bateria o ruta') expect(container?.textContent).toContain('Pozo, bateria o ruta')
expect(container?.textContent).toContain('Si todavia no te habilitaron para la cuadrilla, pedi el alta a un administrador.')
expect(container?.textContent).not.toContain('OmniOil Admin') expect(container?.textContent).not.toContain('OmniOil Admin')
expect(container?.textContent).not.toContain('Consola administrativa') expect(container?.textContent).not.toContain('Consola administrativa')
expect(container?.textContent).not.toContain('Acceso administrativo') expect(container?.textContent).not.toContain('Acceso administrativo')
expect(container?.textContent).not.toContain('Ingresar al panel admin') expect(container?.textContent).not.toContain('Ingresar al panel admin')
expect(container?.textContent).not.toContain('Pedir alta de campo')
}) })
}) })

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate, useLocation, Link } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { loginSchema, type LoginFormData } from '../schemas/login-schema' import { loginSchema, type LoginFormData } from '../schemas/login-schema'
@@ -358,11 +358,8 @@ export default function LoginPage() {
</button> </button>
<div className="pt-4 border-t border-border text-center"> <div className="pt-4 border-t border-border text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
¿Todavia no te habilitaron para la cuadrilla?{' '} Si todavia no te habilitaron para la cuadrilla, pedi el alta a un administrador.
<Link to="/register" className="text-emerald-300 font-semibold hover:text-emerald-200 transition-colors">
Pedir alta de campo
</Link>
</p> </p>
</div> </div>
</form> </form>

View File

@@ -1,155 +0,0 @@
import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { registerSchema, type RegisterFormData } from '../schemas/register-schema'
import { registerApi } from '../api/auth-api'
import { Loader2, Droplets } from 'lucide-react'
export default function RegisterPage() {
const navigate = useNavigate()
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
})
const onSubmit = async (data: RegisterFormData) => {
setError(null)
setIsLoading(true)
try {
await registerApi(data)
setIsSuccess(true)
setTimeout(() => navigate('/login'), 2000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al registrar usuario')
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--background)] px-4">
<div className="w-full max-w-md space-y-8">
{/* Logo */}
<div className="text-center">
<div className="flex items-center justify-center gap-3 mb-2">
<Droplets className="h-10 w-10 text-cyan-400" />
<h1 className="text-3xl font-bold tracking-tight text-white">
OMNIOIL
</h1>
</div>
<p className="text-sm text-[var(--muted-foreground)]">
Crea tu cuenta de operador
</p>
</div>
{/* Card */}
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-8 shadow-2xl shadow-cyan-500/5">
<h2 className="text-xl font-semibold text-white mb-6">
Registro de Usuario
</h2>
{isSuccess ? (
<div className="rounded-lg bg-emerald-500/10 border border-emerald-500/20 px-4 py-6 text-center">
<p className="text-emerald-400 font-medium">¡Cuenta creada con éxito!</p>
<p className="text-xs text-emerald-400/70 mt-2">Redirigiendo al login...</p>
</div>
) : (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{/* Full Name */}
<div className="space-y-2">
<label htmlFor="full_name" className="text-sm font-medium text-[var(--foreground)]">
Nombre completo
</label>
<input
id="full_name"
type="text"
placeholder="Juan Pérez"
className="w-full rounded-lg border border-[var(--border)] bg-[var(--input)] px-4 py-2.5 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition-all"
{...register('full_name')}
/>
{errors.full_name && (
<p className="text-xs text-red-400">{errors.full_name.message}</p>
)}
</div>
{/* Email */}
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium text-[var(--foreground)]">
Correo electrónico
</label>
<input
id="email"
type="email"
placeholder="ejemplo@omnioil.com"
className="w-full rounded-lg border border-[var(--border)] bg-[var(--input)] px-4 py-2.5 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition-all"
{...register('email')}
/>
{errors.email && (
<p className="text-xs text-red-400">{errors.email.message}</p>
)}
</div>
{/* Password */}
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium text-[var(--foreground)]">
Contraseña
</label>
<input
id="password"
type="password"
placeholder="••••••••"
className="w-full rounded-lg border border-[var(--border)] bg-[var(--input)] px-4 py-2.5 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition-all"
{...register('password')}
/>
{errors.password && (
<p className="text-xs text-red-400">{errors.password.message}</p>
)}
</div>
{/* Error */}
{error && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
{/* Submit */}
<button
type="submit"
disabled={isLoading}
className="w-full flex items-center justify-center gap-2 rounded-lg bg-cyan-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2 focus:ring-offset-[var(--background)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Creando cuenta...
</>
) : (
'Registrarse'
)}
</button>
<div className="text-center pt-2">
<Link to="/login" className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors">
¿Ya tienes cuenta? Inicia sesión aquí
</Link>
</div>
</form>
)}
</div>
<p className="text-center text-xs text-gray-600">
OMNIOIL ANALYTICS v1.0 ANH Colombia
</p>
</div>
</div>
)
}

View File

@@ -1,9 +0,0 @@
import { z } from 'zod'
export const registerSchema = z.object({
email: z.string().email('Correo electrónico inválido'),
password: z.string().min(6, 'La contraseña debe tener al menos 6 caracteres'),
full_name: z.string().min(3, 'El nombre debe tener al menos 3 caracteres'),
})
export type RegisterFormData = z.infer<typeof registerSchema>

View File

@@ -3,7 +3,7 @@ import { resolveAuthRoute } from '@/features/auth/lib/domain-routing'
const API_BASE = import.meta.env.VITE_API_BASE || '/api' const API_BASE = import.meta.env.VITE_API_BASE || '/api'
const LOGIN_PATH = '/login' const LOGIN_PATH = '/login'
const AUTH_BYPASS_ENDPOINTS = ['/auth/login', '/auth/register', '/auth/refresh'] const AUTH_BYPASS_ENDPOINTS = ['/auth/login', '/auth/refresh']
interface FetchOptions extends RequestInit { interface FetchOptions extends RequestInit {
token?: string | null token?: string | null

View File

@@ -5,6 +5,7 @@ import { RouterProvider } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { router } from './app/router' import { router } from './app/router'
import SessionBootstrap from './app/bootstrap/SessionBootstrap' import SessionBootstrap from './app/bootstrap/SessionBootstrap'
import { AutoUpdateChecker } from './components/common/AutoUpdateChecker'
import './styles/globals.css' import './styles/globals.css'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -21,6 +22,7 @@ registerSW({ immediate: true })
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AutoUpdateChecker />
<SessionBootstrap> <SessionBootstrap>
<RouterProvider router={router} /> <RouterProvider router={router} />
</SessionBootstrap> </SessionBootstrap>

View File

@@ -3,12 +3,25 @@ import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import path from 'node:path' import path from 'node:path'
import fs from 'node:fs'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const buildTime = new Date().getTime()
export default defineConfig({ export default defineConfig({
define: {
__BUILD_TIME__: buildTime,
},
plugins: [ plugins: [
react(), react(),
{
name: 'generate-version',
writeBundle() {
const version = { buildTime };
fs.writeFileSync(path.resolve(__dirname, 'dist/version.json'), JSON.stringify(version));
}
},
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',
injectRegister: 'auto', injectRegister: 'auto',

View File

@@ -29,22 +29,27 @@ impl Generator {
date: NaiveDate, date: NaiveDate,
operador: String, operador: String,
contrato: String, contrato: String,
) -> Result<(String, String), Box<dyn std::error::Error>> { ) -> Result<(String, String), Box<dyn std::error::Error + Send + Sync>> {
let start_time = date.and_hms_opt(0, 0, 0).unwrap().and_utc(); let start_time = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
let end_time = date.and_hms_opt(23, 59, 59).unwrap().and_utc(); let end_time = date.and_hms_opt(23, 59, 59).unwrap().and_utc();
// 1. Query High Frequency Data // 1. Query High Frequency Data — Filtrado por Operador y Contrato (Multitenant via Projects)
let high_freq_rows = sqlx::query( let high_freq_rows = sqlx::query(
r#" r#"
SELECT t.time, a.code, t.data SELECT t.time, a.code, t.data
FROM telemetry_raw t FROM telemetry_raw t
JOIN edge_assets a ON t.asset_id = a.id JOIN edge_assets a ON t.asset_id = a.id
JOIN edge_projects p ON a.project_id = p.id
WHERE t.time BETWEEN $1 AND $2 WHERE t.time BETWEEN $1 AND $2
AND p.operator_name = $3
AND p.contract_number = $4
ORDER BY t.time ASC ORDER BY t.time ASC
"#, "#,
) )
.bind(&start_time) .bind(&start_time)
.bind(&end_time) .bind(&end_time)
.bind(&operador)
.bind(&contrato)
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;

View File

@@ -3,7 +3,7 @@ mod uploader;
use tokio_cron_scheduler::{Job, JobScheduler}; use tokio_cron_scheduler::{Job, JobScheduler};
use std::time::Duration; use std::time::Duration;
use sqlx::postgres::PgPoolOptions; use sqlx::{Row, postgres::PgPoolOptions};
use std::env; use std::env;
use dotenv::dotenv; use dotenv::dotenv;
use chrono::Utc; use chrono::Utc;
@@ -11,7 +11,7 @@ use generator::Generator;
use uploader::Uploader; use uploader::Uploader;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
dotenv().ok(); dotenv().ok();
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
@@ -48,41 +48,61 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
async fn run_generation_task() -> Result<(), Box<dyn std::error::Error>> { async fn run_generation_task() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new() let pool = PgPoolOptions::new()
.connect(&database_url) .connect(&database_url)
.await?; .await?;
let operador = env::var("OPERADOR_NOMBRE").unwrap_or_else(|_| "OMNIOIL".to_string()); // Obtener todos los pares (operador, contrato) desde los proyectos activos
let contrato = env::var("CONTRATO_NUMERO").unwrap_or_else(|_| "C-123".to_string()); let groups = sqlx::query(
"SELECT DISTINCT operator_name, contract_number FROM edge_projects WHERE is_active = true"
)
.fetch_all(&pool)
.await?;
if groups.is_empty() {
tracing::warn!("No active assets with operator_name found in DB. Skipping generation.");
return Ok(());
}
// Fecha reporte = Ayer // Fecha reporte = Ayer
let date = Utc::now().naive_utc().date().pred_opt().unwrap(); let date = Utc::now().naive_utc().date().pred_opt().unwrap();
tracing::info!("Generating report for date: {}", date); for row in groups {
let operador: String = row.get("operator_name");
let contrato: String = row.get("contract_number");
let (json_content, hash) = Generator::generate_daily_report( tracing::info!("Generating report for operator: {}, contract: {}, date: {}", operador, contrato, date);
&pool,
date,
operador.clone(),
contrato.clone()
).await?;
// Nomenclatura: NOMBREOPERADOR_CONTRATONUMERO_FECHA(dd-mmm-yyyy).json // El generador filtrará internamente por estos valores
let filename = format!("{}_{}_{}.json", match Generator::generate_daily_report(
operador, &pool,
contrato, date,
date.format("%d-%b-%Y").to_string().to_uppercase() operador.clone(),
); contrato.clone()
).await {
Ok((json_content, hash)) => {
// Nomenclatura: NOMBREOPERADOR_CONTRATONUMERO_FECHA(dd-mmm-yyyy).json
let filename = format!("{}_{}_{}.json",
operador.replace(" ", "_"),
contrato.replace(" ", "_"),
date.format("%d-%b-%Y").to_string().to_uppercase()
);
tracing::info!("Report generated. Hash: {}. Uploading {}...", hash, filename); tracing::info!("Report generated. Hash: {}. Uploading {}...", hash, filename);
let uploader = Uploader::new().await; let uploader = Uploader::new().await;
uploader.ensure_bucket().await?; uploader.ensure_bucket().await?;
uploader.upload_json(&filename, json_content).await?; uploader.upload_json(&filename, json_content).await?;
tracing::info!("Upload successful!"); tracing::info!("Upload successful for {}!", filename);
},
Err(e) => {
tracing::error!("Failed to generate report for {} / {}: {}", operador, contrato, e);
}
}
}
Ok(()) Ok(())
} }

View File

@@ -24,7 +24,7 @@ impl Uploader {
Self { client, bucket } Self { client, bucket }
} }
pub async fn ensure_bucket(&self) -> Result<(), Box<dyn std::error::Error>> { pub async fn ensure_bucket(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let _ = self.client.create_bucket() let _ = self.client.create_bucket()
.bucket(&self.bucket) .bucket(&self.bucket)
.send() .send()
@@ -32,7 +32,7 @@ impl Uploader {
Ok(()) Ok(())
} }
pub async fn upload_json(&self, filename: &str, content: String) -> Result<(), Box<dyn std::error::Error>> { pub async fn upload_json(&self, filename: &str, content: String) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.client.put_object() self.client.put_object()
.bucket(&self.bucket) .bucket(&self.bucket)
.key(filename) .key(filename)

View File

@@ -13,6 +13,8 @@ pub struct EdgeProject {
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
pub client: String, pub client: String,
pub operator_name: String,
pub contract_number: String,
pub description: Option<String>, pub description: Option<String>,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub is_active: bool, pub is_active: bool,
@@ -28,6 +30,8 @@ pub struct EdgeProject {
pub struct CreateProjectRequest { pub struct CreateProjectRequest {
pub name: String, pub name: String,
pub client: String, pub client: String,
pub operator_name: String,
pub contract_number: String,
pub description: Option<String>, pub description: Option<String>,
pub metadata: Option<serde_json::Value>, pub metadata: Option<serde_json::Value>,
} }

View File

@@ -27,7 +27,11 @@ pub fn generate_custom_binary(
output_path: &Path, output_path: &Path,
config: &Value, config: &Value,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// 1. Convertir JSON config a TOML simple // 1. Obtener la clave obligatoria del entorno (solo requerida en el servidor para generar)
let master_secret = std::env::var("OMNIOIL_MASTER_SECRET")
.map_err(|_| "OMNIOIL_MASTER_SECRET must be set for binary generation")?;
// 2. Convertir JSON config a TOML simple
let mut config_toml = String::new(); let mut config_toml = String::new();
if let Some(obj) = config.as_object() { if let Some(obj) = config.as_object() {
for (section, values) in obj { for (section, values) in obj {
@@ -53,14 +57,11 @@ pub fn generate_custom_binary(
} }
} }
// 2. Encriptar con AES-256-GCM // 3. Encriptar con AES-256-GCM usando la clave original
let master_secret = std::env::var("OMNIOIL_MASTER_SECRET")
.unwrap_or_else(|_| "dev_master_secret_omnioil_2026".to_string());
let key_bytes = derive_key(&master_secret); let key_bytes = derive_key(&master_secret);
let cipher = Aes256Gcm::new_from_slice(&key_bytes) let cipher = Aes256Gcm::new_from_slice(&key_bytes)
.map_err(|e| format!("Cipher Initialization Error: {}", e))?; .map_err(|e| format!("Cipher Initialization Error: {}", e))?;
// Generar un Nonce único por cada binario generado
let mut nonce_bytes = [0u8; 12]; let mut nonce_bytes = [0u8; 12];
rand::thread_rng().fill_bytes(&mut nonce_bytes); rand::thread_rng().fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes);
@@ -69,20 +70,28 @@ pub fn generate_custom_binary(
.encrypt(nonce, config_toml.as_bytes()) .encrypt(nonce, config_toml.as_bytes())
.map_err(|e| format!("Encryption failed: {}", e))?; .map_err(|e| format!("Encryption failed: {}", e))?;
// Estructura del Payload: [Nonce (12)] + [Ciphertext (N)] // 4. Ofuscar el Master Secret para viajar en el binario sin estar en texto plano
// Usamos XOR con 0x5A para ofuscación simple (mismo patrón que BAKED_CONFIG)
let obfuscated_secret: Vec<u8> = master_secret.as_bytes().iter().map(|b| b ^ 0x5A).collect();
let secret_len = obfuscated_secret.len();
if secret_len > 255 {
return Err("Master secret is too long".into());
}
// Estructura del Payload: [Nonce (12)] + [SecretLen (1)] + [ObfuscatedSecret (N)] + [Ciphertext (M)]
let mut payload = Vec::new(); let mut payload = Vec::new();
payload.extend_from_slice(&nonce_bytes); payload.extend_from_slice(&nonce_bytes);
payload.push(secret_len as u8);
payload.extend_from_slice(&obfuscated_secret);
payload.extend_from_slice(&ciphertext); payload.extend_from_slice(&ciphertext);
let config_len = payload.len() as u32; let total_payload_len = payload.len() as u32;
// 3. Leer plantilla original // 5. Leer plantilla y construir binario final
let mut template_content = Vec::new(); let mut template_content = Vec::new();
fs::File::open(template_path)?.read_to_end(&mut template_content)?; fs::File::open(template_path)?.read_to_end(&mut template_content)?;
// 4. Construir binario final let length_bytes = total_payload_len.to_le_bytes();
// Estructura Footer: [payload] + [4 bytes len] + [8 bytes magic "OMNIOIL\0"]
let length_bytes = config_len.to_le_bytes();
if let Some(parent) = output_path.parent() { if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
@@ -90,21 +99,20 @@ pub fn generate_custom_binary(
let mut out_file = fs::File::create(output_path)?; let mut out_file = fs::File::create(output_path)?;
out_file.write_all(&template_content)?; out_file.write_all(&template_content)?;
out_file.write_all(&payload)?; out_file.write_all(&payload)?; // Todo el bloque (nonce, secret, ciphertext)
out_file.write_all(&length_bytes)?; out_file.write_all(&length_bytes)?;
out_file.write_all(MAGIC)?; out_file.write_all(MAGIC)?;
Ok(()) Ok(())
} }
/// Versión de la función que permite especificar la ruta (útil para tests) /// Versión de la función que permite especificar la ruta (útil para tests)
pub fn extract_overlay_from_file(path: &Path) -> Option<String> { pub fn extract_overlay_from_file(path: &Path) -> Option<String> {
let mut file = fs::File::open(path).ok()?; let mut file = fs::File::open(path).ok()?;
let mut buffer = Vec::new(); let mut buffer = Vec::new();
file.read_to_end(&mut buffer).ok()?; file.read_to_end(&mut buffer).ok()?;
if buffer.len() < 12 + 4 + 8 { if buffer.len() < 12 + 1 + 4 + 8 {
return None; return None;
} }
@@ -114,30 +122,37 @@ pub fn extract_overlay_from_file(path: &Path) -> Option<String> {
return None; return None;
} }
// Leer longitud (4 bytes antes del magic) // Leer longitud total del payload (4 bytes antes del magic)
let len_start = footer_start - 4; let len_start = footer_start - 4;
let mut len_bytes = [0u8; 4]; let mut len_bytes = [0u8; 4];
len_bytes.copy_from_slice(&buffer[len_start..footer_start]); len_bytes.copy_from_slice(&buffer[len_start..footer_start]);
let payload_len = u32::from_le_bytes(len_bytes) as usize; let total_payload_len = u32::from_le_bytes(len_bytes) as usize;
if len_start < payload_len { let payload_start = len_start - total_payload_len;
if payload_start >= len_start {
return None; return None;
} }
// Leer payload encriptado // --- Extracción del Payload por segmentos ---
let payload_start = len_start - payload_len; // [Nonce (12)] + [SecretLen (1)] + [ObfuscatedSecret (N)] + [Ciphertext (M)]
let payload = &buffer[payload_start..len_start];
if payload.len() < 12 { // Nonce
return None; // Mínimo nonce (12) let nonce_bytes = &buffer[payload_start..payload_start + 12];
// Secret Length & Secret
let secret_len = buffer[payload_start + 12] as usize;
if payload_start + 13 + secret_len > len_start {
return None;
} }
let obfuscated_secret = &buffer[payload_start + 13..payload_start + 13 + secret_len];
let nonce_bytes = &payload[..12]; // Ciphertext
let ciphertext = &payload[12..]; let ciphertext = &buffer[payload_start + 13 + secret_len..len_start];
// Desencriptar // 1. Reconstruir Master Secret desde el ofuscado (XOR 0x5A)
let master_secret = std::env::var("OMNIOIL_MASTER_SECRET") let master_secret: String = obfuscated_secret.iter().map(|b| (b ^ 0x5A) as char).collect();
.unwrap_or_else(|_| "dev_master_secret_omnioil_2026".to_string());
// 2. Derivar llave y desencriptar
let key_bytes = derive_key(&master_secret); let key_bytes = derive_key(&master_secret);
let cipher = Aes256Gcm::new_from_slice(&key_bytes).ok()?; let cipher = Aes256Gcm::new_from_slice(&key_bytes).ok()?;
let nonce = Nonce::from_slice(nonce_bytes); let nonce = Nonce::from_slice(nonce_bytes);