diff --git a/.gitignore b/.gitignore index ca7135e..eb4c8b0 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ next-env.d.ts /server/target/ # Vite /dist/ +/.claude/worktrees/ diff --git a/server/src/admin/api_keys.rs b/server/src/admin/api_keys.rs new file mode 100644 index 0000000..a85ac4e --- /dev/null +++ b/server/src/admin/api_keys.rs @@ -0,0 +1,115 @@ +//! Gestion des clés de l'API `/api/v1` (administrateurs) — port de `api-key-actions.ts`. +//! +//! La clé en clair n'existe qu'une fois : renvoyée par `createApiKey`, jamais +//! restituée ensuite (seul son hachage SHA-256 est stocké). Une clé révoquée +//! conserve sa ligne, pour garder la trace de son usage passé. + +use crate::auth::api_key::generate_api_key; +use crate::error::AppResult; +use crate::rpc::{json, Ctx}; +use chrono::NaiveDateTime; +use serde::Serialize; +use serde_json::{json as j, Value}; +use sqlx::Row; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ApiKeyRow { + id: i32, + name: String, + key_prefix: String, + role: String, + created_by: Option, + created_at: Option, + last_used_at: Option, + revoked_at: Option, +} + +fn ts(r: &sqlx::postgres::PgRow, col: &str) -> Option { + r.try_get::, _>(col).ok().flatten().map(crate::db::iso) +} + +/// Liste les clés. Jamais de secret — seulement le préfixe lisible. +pub async fn get_api_keys(ctx: Ctx) -> AppResult { + ctx.admin().await?; + let pool = crate::db::pool()?; + let rows = sqlx::query( + "SELECT id, name, key_prefix, role, created_by, created_at, last_used_at, revoked_at FROM api_keys ORDER BY created_at DESC", + ) + .fetch_all(&pool) + .await?; + let out: Vec = rows + .iter() + .map(|r| ApiKeyRow { + id: r.try_get("id").unwrap_or_default(), + name: r.try_get("name").unwrap_or_default(), + key_prefix: r.try_get("key_prefix").unwrap_or_default(), + role: r.try_get("role").unwrap_or_default(), + created_by: r.try_get("created_by").ok().flatten(), + created_at: ts(r, "created_at"), + last_used_at: ts(r, "last_used_at"), + revoked_at: ts(r, "revoked_at"), + }) + .collect(); + json(out) +} + +/// Crée une clé et renvoie sa valeur **en clair, une seule et unique fois**. +pub async fn create_api_key(ctx: Ctx) -> AppResult { + let moi = ctx.admin().await?; + let name: String = ctx.arg::>(0)?.unwrap_or_default(); + let role: String = ctx.arg::>(1)?.unwrap_or_else(|| "user".into()); + let trimmed = name.trim(); + if trimmed.is_empty() { + return Ok(j!({ "success": false, "error": "Le nom de la clé est obligatoire." })); + } + if trimmed.chars().count() > 100 { + return Ok(j!({ "success": false, "error": "Nom trop long (100 caractères max)." })); + } + let k = generate_api_key(); + let pool = crate::db::pool()?; + let created_by = Some(moi.username.clone()).filter(|s| !s.is_empty()); + match sqlx::query("INSERT INTO api_keys (name, key_prefix, key_hash, role, created_by) VALUES ($1, $2, $3, $4, $5)") + .bind(trimmed) + .bind(&k.key_prefix) + .bind(&k.key_hash) + .bind(&role) + .bind(created_by) + .execute(&pool) + .await + { + Ok(_) => Ok(j!({ "success": true, "key": k.key, "keyPrefix": k.key_prefix })), + Err(e) => { + tracing::error!("[api-keys] création KO: {e}"); + Ok(j!({ "success": false, "error": "Erreur technique lors de la création de la clé." })) + } + } +} + +/// Révoque une clé : refusée dès l'appel suivant, mais toujours listée. +pub async fn revoke_api_key(ctx: Ctx) -> AppResult { + ctx.admin().await?; + let id: i64 = ctx.arg(0)?; + let pool = crate::db::pool()?; + match sqlx::query("UPDATE api_keys SET revoked_at = now() WHERE id = $1").bind(id as i32).execute(&pool).await { + Ok(_) => Ok(j!({ "success": true })), + Err(e) => { + tracing::error!("[api-keys] révocation KO: {e}"); + Ok(j!({ "success": false, "error": "Erreur lors de la révocation." })) + } + } +} + +/// Supprime définitivement une clé (nettoyage de la liste). +pub async fn delete_api_key(ctx: Ctx) -> AppResult { + ctx.admin().await?; + let id: i64 = ctx.arg(0)?; + let pool = crate::db::pool()?; + match sqlx::query("DELETE FROM api_keys WHERE id = $1").bind(id as i32).execute(&pool).await { + Ok(_) => Ok(j!({ "success": true })), + Err(e) => { + tracing::error!("[api-keys] suppression KO: {e}"); + Ok(j!({ "success": false, "error": "Erreur lors de la suppression." })) + } + } +} diff --git a/server/src/admin/logs.rs b/server/src/admin/logs.rs new file mode 100644 index 0000000..25723b0 --- /dev/null +++ b/server/src/admin/logs.rs @@ -0,0 +1,41 @@ +//! Journaux serveur des jobs (liste + téléchargement). +//! +//! `GET /api/logs` → liste des journaux disponibles (JSON) +//! `GET /api/logs?id=` → le journal complet en pièce jointe (text/plain) +//! +//! Réservé aux admins : un journal d'extraction contient les diagnostics du +//! modèle Qlik et les codes articles manipulés (jamais de secret). + +use crate::auth::AdminUser; +use crate::logcapture::{list_captures, read_capture}; +use axum::extract::Query; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Deserialize; +use serde_json::json; + +#[derive(Deserialize)] +pub struct LogsQuery { + id: Option, +} + +pub async fn get_logs(_admin: AdminUser, Query(q): Query) -> Response { + let Some(id) = q.id.filter(|s| !s.is_empty()) else { + return Json(json!({ "success": true, "logs": list_captures() })).into_response(); + }; + let Some(contenu) = read_capture(&id) else { + return (StatusCode::NOT_FOUND, Json(json!({ "error": "Journal introuvable ou expiré" }))).into_response(); + }; + // Nom de fichier assaini : l'id finit dans un en-tête HTTP. + let nom: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || "_.-".contains(c) { c } else { '_' }).collect(); + ( + [ + (header::CONTENT_TYPE, "text/plain; charset=utf-8".to_string()), + (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{nom}.log\"")), + (header::CACHE_CONTROL, "no-store".to_string()), + ], + contenu, + ) + .into_response() +} diff --git a/server/src/admin/mod.rs b/server/src/admin/mod.rs index 2306925..abca81b 100644 --- a/server/src/admin/mod.rs +++ b/server/src/admin/mod.rs @@ -1,16 +1,56 @@ -//! Module `admin` — à porter. +//! Module `admin` — comptes, clés d'API, Paramètres, synchronisation nocturne. -use crate::rpc::Handler; +pub mod api_keys; +pub mod logs; +mod qlik_bridge; +pub mod settings; +pub mod sync_settings; +pub mod users; + +use crate::error::AppResult; +use crate::handler; +use crate::rpc::{json, Ctx, Handler}; +use axum::routing::get; use axum::Router; +use serde_json::{Map, Value}; /// Routes HTTP du module. pub fn router() -> Router { - Router::new() + Router::new().route("/api/logs", get(logs::get_logs)) +} + +async fn read_sync_settings_action(ctx: Ctx) -> AppResult { + ctx.admin().await?; + json(sync_settings::read_sync_settings()) +} + +async fn save_sync_settings_action(ctx: Ctx) -> AppResult { + ctx.admin().await?; + let patch: Map = ctx.arg::>>(0)?.unwrap_or_default(); + json(sync_settings::save_sync_settings(&patch)?) } /// Actions (`POST /api/actions/{nom}`) du module. pub fn actions() -> Vec<(&'static str, Handler)> { - vec![] + vec![ + ("getUsers", handler!(users::get_users)), + ("createUser", handler!(users::create_user)), + ("deleteUser", handler!(users::delete_user)), + ("updatePassword", handler!(users::update_password)), + ("getApiKeys", handler!(api_keys::get_api_keys)), + ("createApiKey", handler!(api_keys::create_api_key)), + ("revokeApiKey", handler!(api_keys::revoke_api_key)), + ("deleteApiKey", handler!(api_keys::delete_api_key)), + ("getParametresAffichables", handler!(settings::get_parametres_affichables)), + ("testDatabaseConnection", handler!(settings::test_database_connection)), + ("saveDatabaseSettings", handler!(settings::save_database_settings)), + ("saveQlikSettings", handler!(settings::save_qlik_settings)), + ("testQlikConnection", handler!(settings::test_qlik_connection)), + ("saveFfApiSettings", handler!(settings::save_ff_api_settings)), + ("testFfApiConnection", handler!(settings::test_ff_api_connection)), + ("readSyncSettings", handler!(read_sync_settings_action)), + ("saveSyncSettings", handler!(save_sync_settings_action)), + ] } /// Données de pages (`GET /api/pages/{nom}`) du module. diff --git a/server/src/admin/qlik_bridge.rs b/server/src/admin/qlik_bridge.rs new file mode 100644 index 0000000..5621280 --- /dev/null +++ b/server/src/admin/qlik_bridge.rs @@ -0,0 +1,17 @@ +//! Points d'appel vers les modules `qlik` et `ff` utilisés par les Paramètres. +//! Branchés après l'intégration de ces modules. + +use serde_json::{json, Value}; + +/// Ouvre une session Qlik (NTLM → ticket → cookie) avec les identifiants donnés. +pub async fn tester_session(_host: String, _user: String, _password: Option) -> anyhow::Result<()> { + anyhow::bail!("Test Qlik indisponible : module en cours de portage") +} + +/// Oublie l'URL mémorisée de l'API FF Nancy. +pub fn reset_ff_api_base() {} + +/// Diagnostic de l'API FF Nancy. +pub async fn diagnostiquer_api_ff(base: Option) -> Value { + json!({ "success": false, "url": base.unwrap_or_default(), "error": "Diagnostic indisponible : module en cours de portage" }) +} diff --git a/server/src/admin/settings.rs b/server/src/admin/settings.rs new file mode 100644 index 0000000..cec07fe --- /dev/null +++ b/server/src/admin/settings.rs @@ -0,0 +1,279 @@ +//! Actions des Paramètres — réservées aux administrateurs. +//! +//! Aucun secret ne repart vers le navigateur : les mots de passe (base, Qlik) +//! et les clés d'IA restent dans `data/.db-config.json`. Un mot de passe laissé +//! vide dans le formulaire signifie « conserver celui qui est enregistré ». + +use crate::config::{read_config, write_config}; +use crate::error::AppResult; +use crate::rpc::{json, Ctx}; +use serde::{Deserialize, Serialize}; +use serde_json::{json as j, Map, Value}; +use std::time::Duration; + +#[derive(Debug, Clone, Deserialize)] +pub struct ChampsBaseDeDonnees { + #[serde(default)] + pub host: String, + #[serde(default)] + pub port: String, + #[serde(default)] + pub database: String, + #[serde(default)] + pub user: String, + /// Vide ou absent : le mot de passe enregistré est conservé. + #[serde(default)] + pub password: Option, + #[serde(default)] + pub ssl: bool, +} + +struct UrlPostgres { + host: String, + port: String, + database: String, + user: String, + password: String, + ssl: bool, +} + +fn decoder(v: &str) -> String { + urlencoding::decode(v).map(|c| c.into_owned()).unwrap_or_else(|_| v.to_string()) +} + +/// Découpe l'URL `postgres://` enregistrée en champs du formulaire. +fn lire_url_postgres(url: &str) -> Option { + let re = regex::Regex::new(r"^[a-zA-Z][a-zA-Z0-9+.-]*://(?:([^:@/]*)(?::([^@/]*))?@)?(\[[^\]]+\]|[^:/?#]*)(?::(\d+))?(?:/([^?#]*))?").unwrap(); + let Some(c) = re.captures(url) else { + tracing::error!("[Settings] URL de base enregistrée illisible"); + return None; + }; + Some(UrlPostgres { + host: c.get(3).map(|m| m.as_str().to_string()).unwrap_or_default(), + port: c.get(4).map(|m| m.as_str().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "5432".into()), + database: decoder(c.get(5).map(|m| m.as_str()).unwrap_or_default()), + user: decoder(c.get(1).map(|m| m.as_str()).unwrap_or_default()), + password: decoder(c.get(2).map(|m| m.as_str()).unwrap_or_default()), + ssl: url.contains("sslmode=require"), + }) +} + +fn construire_url_postgres(champs: &ChampsBaseDeDonnees, mot_de_passe: &str) -> String { + let user = urlencoding::encode(champs.user.trim()).into_owned(); + let identifiants = if mot_de_passe.is_empty() { + user + } else { + format!("{user}:{}", urlencoding::encode(mot_de_passe)) + }; + let port = Some(champs.port.trim()).filter(|p| !p.is_empty()).unwrap_or("5432"); + format!( + "postgres://{identifiants}@{}:{port}/{}{}", + champs.host.trim(), + champs.database.trim(), + if champs.ssl { "?sslmode=require" } else { "" } + ) +} + +/// Mot de passe saisi, sinon celui de l'URL enregistrée. +fn mot_de_passe_base(champs: &ChampsBaseDeDonnees) -> String { + if let Some(p) = champs.password.as_ref().filter(|p| !p.is_empty()) { + return p.clone(); + } + read_config().url.as_deref().and_then(lire_url_postgres).map(|u| u.password).unwrap_or_default() +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DbAffichable { + host: String, + port: String, + database: String, + user: String, + ssl: bool, + mot_de_passe_enregistre: bool, +} + +/// Réglages enregistrés, sans aucun secret. +pub async fn get_parametres_affichables(ctx: Ctx) -> AppResult { + ctx.admin().await?; + let config = read_config(); + let base = config.url.as_deref().and_then(lire_url_postgres); + let db = base.map(|b| DbAffichable { + host: b.host, + port: b.port, + database: b.database, + user: b.user, + ssl: b.ssl, + mot_de_passe_enregistre: !b.password.is_empty(), + }); + let qlik_pwd_env = std::env::var("QLIK_PWD").map(|v| !v.is_empty()).unwrap_or(false); + json(j!({ + "db": db, + "qlik": { + "host": config.qlik_host.clone().unwrap_or_default(), + "user": config.qlik_user.clone().unwrap_or_default(), + // Même ordre de priorité que la config Qlik : réglage enregistré, puis variable d'environnement. + "motDePasseEnregistre": config.qlik_password.as_deref().map(|p| !p.is_empty()).unwrap_or(false) || qlik_pwd_env, + }, + "ffApiBaseUrl": config.ff_api_base_url.clone().unwrap_or_default(), + })) +} + +fn refus(e: crate::error::AppError) -> Value { + j!({ "success": false, "error": e.message }) +} + +pub async fn test_database_connection(ctx: Ctx) -> AppResult { + if let Err(e) = ctx.admin().await { + return Ok(refus(e)); + } + let champs: ChampsBaseDeDonnees = ctx.arg(0)?; + let url = construire_url_postgres(&champs, &mot_de_passe_base(&champs)); + tracing::info!("Testing connection to: {}", crate::db::mask_url(&url)); + let essai = async { + let opts = crate::db::connect_options(&url)?; + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_secs(5)) + .connect_with(opts) + .await?; + sqlx::query("SELECT 1").execute(&pool).await?; + pool.close().await; + Ok::<_, sqlx::Error>(()) + }; + match tokio::time::timeout(Duration::from_secs(8), essai).await { + Ok(Ok(())) => Ok(j!({ "success": true })), + Ok(Err(e)) => { + tracing::error!("Database connection test failed: {e}"); + Ok(j!({ "success": false, "error": e.to_string() })) + } + Err(_) => Ok(j!({ "success": false, "error": "timeout exceeded when trying to connect" })), + } +} + +pub async fn save_database_settings(ctx: Ctx) -> AppResult { + if let Err(e) = ctx.admin().await { + return Ok(refus(e)); + } + let champs: ChampsBaseDeDonnees = ctx.arg(0)?; + let url = construire_url_postgres(&champs, &mot_de_passe_base(&champs)); + // Seule l'URL change : les clés d'IA et les réglages Qlik / API FF sont conservés. + tracing::info!("[Settings] Saving config to {}", crate::config::config_file().display()); + let mut patch = Map::new(); + patch.insert("url".into(), Value::String(url)); + match write_config(patch) { + Ok(()) => { + tracing::info!("[Settings] Database configuration saved successfully."); + crate::db::refresh(); + // Nouvelle base : schéma et compte admin à (re)vérifier. + tokio::spawn(async { + crate::migrations::run().await; + crate::auth::ensure_admin_exists().await; + }); + Ok(j!({ "success": true })) + } + Err(e) => { + tracing::error!("Failed to save database configuration: {e}"); + Ok(j!({ "success": false, "error": e.to_string() })) + } + } +} + +/// Mot de passe vide ou absent : celui qui est enregistré est conservé. +pub async fn save_qlik_settings(ctx: Ctx) -> AppResult { + if let Err(e) = ctx.admin().await { + return Ok(refus(e)); + } + let host: String = ctx.arg::>(0)?.unwrap_or_default(); + let user: String = ctx.arg::>(1)?.unwrap_or_default(); + let password: Option = ctx.arg::>(2)?.filter(|p| !p.is_empty()); + let mut patch = Map::new(); + patch.insert("qlikHost".into(), Value::String(host)); + patch.insert("qlikUser".into(), Value::String(user)); + if let Some(p) = password { + patch.insert("qlikPassword".into(), Value::String(p)); + } + match write_config(patch) { + Ok(()) => { + tracing::info!("[Settings] Qlik configuration saved."); + Ok(j!({ "success": true })) + } + Err(e) => { + tracing::error!("[Settings] Failed to save Qlik config: {e}"); + Ok(j!({ "success": false, "error": e.to_string() })) + } + } +} + +/// Mot de passe vide ou absent : on teste avec celui qui est enregistré. +pub async fn test_qlik_connection(ctx: Ctx) -> AppResult { + if let Err(e) = ctx.admin().await { + return Ok(refus(e)); + } + let host: String = ctx.arg::>(0)?.unwrap_or_default(); + let user: String = ctx.arg::>(1)?.unwrap_or_default(); + let password: Option = ctx.arg::>(2)?.filter(|p| !p.is_empty()); + match super::qlik_bridge::tester_session(host, user, password).await { + Ok(()) => Ok(j!({ "success": true })), + Err(e) => Ok(j!({ "success": false, "error": format!("{e:#}") })), + } +} + +/// Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut. +pub async fn save_ff_api_settings(ctx: Ctx) -> AppResult { + if let Err(e) = ctx.admin().await { + return Ok(refus(e)); + } + let brut: String = ctx.arg::>(0)?.unwrap_or_default(); + let cleaned = brut.trim().trim_end_matches('/').to_string(); + let lower = cleaned.to_ascii_lowercase(); + if !cleaned.is_empty() && !(lower.starts_with("http://") || lower.starts_with("https://")) { + return Ok(j!({ "success": false, "error": "L'URL doit commencer par http:// ou https://" })); + } + let mut patch = Map::new(); + patch.insert("ffApiBaseUrl".into(), if cleaned.is_empty() { Value::Null } else { Value::String(cleaned.clone()) }); + match write_config(patch) { + Ok(()) => { + // Sans cela, l'ancienne URL resterait servie jusqu'à 30 s après la sauvegarde. + super::qlik_bridge::reset_ff_api_base(); + tracing::info!("[Settings] FF API base URL saved: {}", if cleaned.is_empty() { "(défaut)" } else { &cleaned }); + Ok(j!({ "success": true })) + } + Err(e) => { + tracing::error!("[Settings] Failed to save FF API config: {e}"); + Ok(j!({ "success": false, "error": e.to_string() })) + } + } +} + +/// Teste l'API FF Nancy. +pub async fn test_ff_api_connection(ctx: Ctx) -> AppResult { + if let Err(e) = ctx.admin().await { + return Ok(j!({ "success": false, "url": "", "error": e.message })); + } + let base: Option = ctx.arg(0)?; + Ok(super::qlik_bridge::diagnostiquer_api_ff(base).await) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn url_roundtrip() { + let champs = ChampsBaseDeDonnees { + host: "db".into(), + port: "".into(), + database: "cf".into(), + user: "u@x".into(), + password: Some("p:ss/w".into()), + ssl: true, + }; + let url = construire_url_postgres(&champs, "p:ss/w"); + assert_eq!(url, "postgres://u%40x:p%3Ass%2Fw@db:5432/cf?sslmode=require"); + let u = lire_url_postgres(&url).unwrap(); + assert_eq!((u.host.as_str(), u.port.as_str(), u.database.as_str(), u.user.as_str(), u.password.as_str(), u.ssl), ("db", "5432", "cf", "u@x", "p:ss/w", true)); + let u = lire_url_postgres("postgres://postgres:secret@postgresbase:5432/collectflow").unwrap(); + assert_eq!((u.host.as_str(), u.user.as_str(), u.password.as_str(), u.database.as_str()), ("postgresbase", "postgres", "secret", "collectflow")); + } +} diff --git a/server/src/admin/sync_settings.rs b/server/src/admin/sync_settings.rs new file mode 100644 index 0000000..f6168e7 --- /dev/null +++ b/server/src/admin/sync_settings.rs @@ -0,0 +1,121 @@ +//! Réglages de la synchronisation nocturne, rangés dans `data/.db-config.json` +//! (clé `syncNuit`) comme les autres réglages de l'application. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SyncSettings { + /// Désactivé par défaut : un déploiement ne doit pas se mettre à solliciter + /// Qlik et la base la nuit suivante sans que personne l'ait décidé. + pub actif: bool, + /// Heure d'ouverture de la fenêtre (0-23). + pub heure_debut: i64, + /// Heure de fermeture. Aucun fournisseur n'est **démarré** après. + pub heure_fin: i64, + /// Nombre maximum d'extractions Qlik par nuit (chacune peut prendre 8 min). + pub qlik_par_nuit: i64, + /// Une extraction Qlik est intercalée tous les N fournisseurs SQL. + pub sql_par_qlik: i64, + /// Âge minimal des données Qlik avant de les refaire. + pub qlik_min_jours: i64, +} + +pub const SYNC_SETTINGS_DEFAUT: SyncSettings = + SyncSettings { actif: false, heure_debut: 0, heure_fin: 5, qlik_par_nuit: 6, sql_par_qlik: 20, qlik_min_jours: 7 }; + +fn borne(v: Option<&Value>, min: i64, max: i64, defaut: i64) -> i64 { + let n = match v { + Some(Value::Number(n)) => n.as_f64(), + Some(Value::String(s)) => s.trim().parse::().ok(), + Some(Value::Bool(b)) => Some(if *b { 1.0 } else { 0.0 }), + Some(Value::Null) => Some(0.0), + _ => None, + }; + match n { + Some(f) if f.is_finite() => (f.trunc() as i64).clamp(min, max), + _ => defaut, + } +} + +fn truthy(v: Option<&Value>) -> bool { + match v { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(Value::Number(n)) => n.as_f64().map(|f| f != 0.0 && !f.is_nan()).unwrap_or(false), + Some(Value::String(s)) => !s.is_empty(), + Some(_) => true, + } +} + +/// Normalise ce qui vient du fichier : il est éditable à la main. +pub fn normaliser(brut: Option<&Map>) -> SyncSettings { + let d = SYNC_SETTINGS_DEFAUT; + let g = |k: &str| brut.and_then(|m| m.get(k)); + let mut s = SyncSettings { + actif: truthy(g("actif")), + heure_debut: borne(g("heureDebut"), 0, 23, d.heure_debut), + heure_fin: borne(g("heureFin"), 0, 23, d.heure_fin), + qlik_par_nuit: borne(g("qlikParNuit"), 0, 200, d.qlik_par_nuit), + sql_par_qlik: borne(g("sqlParQlik"), 1, 500, d.sql_par_qlik), + qlik_min_jours: borne(g("qlikMinJours"), 0, 365, d.qlik_min_jours), + }; + // Une fenêtre de durée nulle ne s'ouvrirait jamais : retour au défaut. + if s.heure_debut == s.heure_fin { + s.heure_debut = d.heure_debut; + s.heure_fin = d.heure_fin; + } + s +} + +fn lire_fichier() -> Option> { + let raw = std::fs::read_to_string(crate::config::config_file()).ok()?; + match serde_json::from_str::(&raw).ok()? { + Value::Object(m) => Some(m), + _ => None, + } +} + +pub fn read_sync_settings() -> SyncSettings { + match lire_fichier() { + Some(cfg) => normaliser(cfg.get("syncNuit").and_then(|v| v.as_object())), + None => SYNC_SETTINGS_DEFAUT, + } +} + +/// Fusionne `patch` (champs camelCase partiels) dans les réglages enregistrés. +pub fn save_sync_settings(patch: &Map) -> std::io::Result { + let existant = lire_fichier().unwrap_or_default(); + let courant = normaliser(existant.get("syncNuit").and_then(|v| v.as_object())); + let mut fusion = match serde_json::to_value(courant) { + Ok(Value::Object(m)) => m, + _ => Map::new(), + }; + for (k, v) in patch { + fusion.insert(k.clone(), v.clone()); + } + let suivant = normaliser(Some(&fusion)); + let mut p = Map::new(); + p.insert("syncNuit".into(), serde_json::to_value(suivant).unwrap_or(Value::Null)); + crate::config::write_config(p)?; + tracing::info!("[sync-nuit] réglages enregistrés : {}", serde_json::to_string(&suivant).unwrap_or_default()); + Ok(suivant) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn normalise() { + let m = json!({ "actif": 1, "heureDebut": "2", "heureFin": 30, "qlikParNuit": -4, "sqlParQlik": 0 }); + let s = normaliser(m.as_object()); + assert!(s.actif); + assert_eq!((s.heure_debut, s.heure_fin, s.qlik_par_nuit, s.sql_par_qlik, s.qlik_min_jours), (2, 23, 0, 1, 7)); + let m = json!({ "heureDebut": 4, "heureFin": 4 }); + let s = normaliser(m.as_object()); + assert_eq!((s.heure_debut, s.heure_fin), (0, 5)); + } +} diff --git a/server/src/admin/users.rs b/server/src/admin/users.rs new file mode 100644 index 0000000..554c7c3 --- /dev/null +++ b/server/src/admin/users.rs @@ -0,0 +1,91 @@ +//! Gestion des comptes (administrateurs) — port de `user-actions.ts`. + +use crate::auth::password::hash_password; +use crate::error::AppResult; +use crate::rpc::{json, Ctx}; +use serde::Serialize; +use serde_json::{json as j, Value}; +use sqlx::Row; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct UserRow { + id: i32, + username: String, + role: String, + created_at: Option, +} + +/// Tous les utilisateurs. +pub async fn get_users(ctx: Ctx) -> AppResult { + ctx.admin().await?; + let pool = crate::db::pool()?; + let rows = sqlx::query("SELECT id, username, role, created_at FROM users").fetch_all(&pool).await?; + let users: Vec = rows + .iter() + .map(|r| UserRow { + id: r.try_get("id").unwrap_or_default(), + username: r.try_get("username").unwrap_or_default(), + role: r.try_get("role").unwrap_or_default(), + created_at: r.try_get::, _>("created_at").ok().flatten().map(crate::db::iso), + }) + .collect(); + json(users) +} + +/// Crée un utilisateur. +pub async fn create_user(ctx: Ctx) -> AppResult { + ctx.admin().await?; + let username: String = ctx.arg(0)?; + let password: String = ctx.arg(1)?; + let role: Option = ctx.arg(2)?; + let role = role.unwrap_or_else(|| "user".into()); + let pool = crate::db::pool()?; + match sqlx::query("INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3)") + .bind(&username) + .bind(hash_password(&password)) + .bind(&role) + .execute(&pool) + .await + { + Ok(_) => Ok(j!({ "success": true })), + Err(e) => { + tracing::error!("CreateUser Error: {e}"); + Ok(j!({ "success": false, "error": "L'utilisateur existe déjà ou une erreur technique est survenue." })) + } + } +} + +/// Supprime un utilisateur (jamais son propre compte). +pub async fn delete_user(ctx: Ctx) -> AppResult { + let moi = ctx.admin().await?; + let id: i64 = ctx.arg(0)?; + if moi.id.parse::().ok() == Some(id) { + return Ok(j!({ "success": false, "error": "Vous ne pouvez pas supprimer votre propre compte." })); + } + let pool = crate::db::pool()?; + match sqlx::query("DELETE FROM users WHERE id = $1").bind(id as i32).execute(&pool).await { + Ok(_) => { + crate::auth::authz::invalider(&id.to_string()); + Ok(j!({ "success": true })) + } + Err(_) => Ok(j!({ "success": false, "error": "Erreur lors de la suppression." })), + } +} + +/// Change le mot de passe d'un utilisateur. +pub async fn update_password(ctx: Ctx) -> AppResult { + ctx.admin().await?; + let id: i64 = ctx.arg(0)?; + let new_password: String = ctx.arg(1)?; + let pool = crate::db::pool()?; + match sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2") + .bind(hash_password(&new_password)) + .bind(id as i32) + .execute(&pool) + .await + { + Ok(_) => Ok(j!({ "success": true })), + Err(_) => Ok(j!({ "success": false, "error": "Erreur lors de la mise à jour du mot de passe." })), + } +} diff --git a/server/src/ff_cache.rs b/server/src/ff_cache.rs index 6741920..410d504 100644 --- a/server/src/ff_cache.rs +++ b/server/src/ff_cache.rs @@ -62,7 +62,7 @@ where Fut: Future> + Send + 'static, { let pending = { - let mut store = STORE.lock(); + let store = STORE.lock(); match store.map.get(key) { Some(Entry::Ready { value, expires_at, .. }) if *expires_at > Instant::now() => { if let Some(v) = value.downcast_ref::() { diff --git a/server/src/rpc.rs b/server/src/rpc.rs index 40fd73b..ab3e198 100644 --- a/server/src/rpc.rs +++ b/server/src/rpc.rs @@ -98,9 +98,14 @@ fn no_store(v: Value) -> Response { ([(header::CACHE_CONTROL, "no-store")], Json(v)).into_response() } -async fn call_action(Path(name): Path, headers: HeaderMap, body: Option>) -> AppResult { +async fn call_action(Path(name): Path, headers: HeaderMap, body: axum::body::Bytes) -> AppResult { let h = ACTIONS.get(name.as_str()).ok_or_else(|| AppError::not_found(format!("Action inconnue : {name}")))?; - let args = match body.map(|b| b.0) { + let body: Option = if body.iter().all(|b| b.is_ascii_whitespace()) { + None + } else { + Some(serde_json::from_slice(&body).map_err(|e| AppError::bad_request(format!("Corps JSON invalide : {e}")))?) + }; + let args = match body { Some(Value::Array(a)) => a, Some(Value::Null) | None => vec![], Some(other) => vec![other],