parent
f97e075ee6
commit
d75695ffe6
11 changed files with 487 additions and 141 deletions
|
|
@ -0,0 +1,24 @@
|
|||
ALTER TABLE tuleap_credentials RENAME TO tuleap_credentials_legacy;
|
||||
|
||||
CREATE TABLE tuleap_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
||||
tuleap_url TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password_encrypted TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
INSERT INTO tuleap_credentials (id, project_id, tuleap_url, username, password_encrypted, created_at)
|
||||
SELECT id, NULL, tuleap_url, username, password_encrypted, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
FROM tuleap_credentials_legacy;
|
||||
|
||||
DROP TABLE tuleap_credentials_legacy;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tuleap_credentials_project_unique
|
||||
ON tuleap_credentials(project_id)
|
||||
WHERE project_id IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tuleap_credentials_global_unique
|
||||
ON tuleap_credentials((project_id IS NULL))
|
||||
WHERE project_id IS NULL;
|
||||
|
|
@ -8,6 +8,7 @@ use tauri::State;
|
|||
#[tauri::command]
|
||||
pub fn set_tuleap_credentials(
|
||||
state: State<'_, AppState>,
|
||||
project_id: Option<String>,
|
||||
tuleap_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
|
|
@ -24,44 +25,64 @@ pub fn set_tuleap_credentials(
|
|||
.lock()
|
||||
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
|
||||
let creds = TuleapCredentials::upsert(&db, &tuleap_url, &username, &password_encrypted)?;
|
||||
let creds = TuleapCredentials::upsert_for_scope(
|
||||
&db,
|
||||
project_id.as_deref(),
|
||||
&tuleap_url,
|
||||
&username,
|
||||
&password_encrypted,
|
||||
)?;
|
||||
Ok(creds.to_safe())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_tuleap_credentials(
|
||||
state: State<'_, AppState>,
|
||||
project_id: Option<String>,
|
||||
) -> Result<Option<TuleapCredentialsSafe>, AppError> {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
|
||||
let result = TuleapCredentials::get(&db)?.map(|c| c.to_safe());
|
||||
let result = match project_id {
|
||||
Some(project_id) => TuleapCredentials::get_for_project(&db, &project_id)?,
|
||||
None => TuleapCredentials::get_global(&db)?,
|
||||
}
|
||||
.map(|c| c.to_safe());
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_tuleap_credentials(state: State<'_, AppState>) -> Result<(), AppError> {
|
||||
pub fn delete_tuleap_credentials(
|
||||
state: State<'_, AppState>,
|
||||
project_id: Option<String>,
|
||||
) -> Result<(), AppError> {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
|
||||
TuleapCredentials::delete(&db)?;
|
||||
TuleapCredentials::delete_for_scope(&db, project_id.as_deref())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn test_tuleap_connection(state: State<'_, AppState>) -> Result<String, AppError> {
|
||||
pub async fn test_tuleap_connection(
|
||||
state: State<'_, AppState>,
|
||||
project_id: Option<String>,
|
||||
) -> Result<String, AppError> {
|
||||
let (tuleap_url, username, password) = {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
|
||||
let creds = TuleapCredentials::get(&db)?
|
||||
.ok_or_else(|| AppError::from("No credentials configured".to_string()))?;
|
||||
let creds = match &project_id {
|
||||
Some(project_id) => TuleapCredentials::get_for_project(&db, project_id)?,
|
||||
None => TuleapCredentials::get_global(&db)?,
|
||||
}
|
||||
.ok_or_else(|| AppError::from("No credentials configured".to_string()))?;
|
||||
|
||||
let password = crypto::decrypt(&state.encryption_key, &creds.password_encrypted)
|
||||
.map_err(AppError::from)?;
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ pub async fn manual_poll(
|
|||
));
|
||||
}
|
||||
|
||||
let cred = TuleapCredentials::get(&db)?
|
||||
let cred = TuleapCredentials::get_for_project(&db, &tracker.project_id)?
|
||||
.ok_or_else(|| AppError::from("No Tuleap credentials configured".to_string()))?;
|
||||
|
||||
let password = crypto::decrypt(&state.encryption_key, &cred.password_encrypted)
|
||||
|
|
|
|||
|
|
@ -9,13 +9,16 @@ use crate::AppState;
|
|||
use serde::Deserialize;
|
||||
use tauri::State;
|
||||
|
||||
fn build_tuleap_client(state: &State<AppState>) -> Result<TuleapClient, AppError> {
|
||||
fn build_tuleap_client(
|
||||
state: &State<AppState>,
|
||||
project_id: &str,
|
||||
) -> Result<TuleapClient, AppError> {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||
|
||||
let cred = TuleapCredentials::get(&db)?
|
||||
let cred = TuleapCredentials::get_for_project(&db, project_id)?
|
||||
.ok_or_else(|| AppError::from("No Tuleap credentials configured".to_string()))?;
|
||||
|
||||
let password =
|
||||
|
|
@ -133,9 +136,10 @@ pub fn remove_tracker(state: State<'_, AppState>, id: String) -> Result<(), AppE
|
|||
#[tauri::command]
|
||||
pub async fn get_tracker_fields(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
tracker_id: i32,
|
||||
) -> Result<Vec<crate::services::tuleap_client::TrackerField>, AppError> {
|
||||
let client = build_tuleap_client(&state)?;
|
||||
let client = build_tuleap_client(&state, &project_id)?;
|
||||
let mut fields = client
|
||||
.get_tracker_fields(tracker_id)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const MIGRATION_004: &str = include_str!("../migrations/004_default_agents.sql")
|
|||
const MIGRATION_005: &str = include_str!("../migrations/005_orchestration_modules_chat_tasks.sql");
|
||||
const MIGRATION_006: &str = include_str!("../migrations/006_processed_tickets_unique_index.sql");
|
||||
const MIGRATION_007: &str = include_str!("../migrations/007_normalize_timestamps_rfc3339.sql");
|
||||
const MIGRATION_008: &str = include_str!("../migrations/008_project_scoped_tuleap_credentials.sql");
|
||||
|
||||
pub fn init(db_path: &Path) -> Result<Connection> {
|
||||
let conn = Connection::open(db_path)?;
|
||||
|
|
@ -61,6 +62,10 @@ fn migrate(conn: &Connection) -> Result<()> {
|
|||
conn.execute_batch(MIGRATION_007)?;
|
||||
conn.pragma_update(None, "user_version", 7)?;
|
||||
}
|
||||
if version < 8 {
|
||||
conn.execute_batch(MIGRATION_008)?;
|
||||
conn.pragma_update(None, "user_version", 8)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -116,7 +121,7 @@ mod tests {
|
|||
let version: i32 = conn
|
||||
.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(version, 7);
|
||||
assert_eq!(version, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
use rusqlite::{params, Connection, Result};
|
||||
use rusqlite::{params, Connection, OptionalExtension, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TuleapCredentials {
|
||||
pub id: String,
|
||||
pub project_id: Option<String>,
|
||||
pub tuleap_url: String,
|
||||
pub username: String,
|
||||
pub password_encrypted: String,
|
||||
|
|
@ -13,6 +14,7 @@ pub struct TuleapCredentials {
|
|||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TuleapCredentialsSafe {
|
||||
pub id: String,
|
||||
pub project_id: Option<String>,
|
||||
pub tuleap_url: String,
|
||||
pub username: String,
|
||||
}
|
||||
|
|
@ -51,55 +53,131 @@ impl TuleapCredentials {
|
|||
Ok((normalized_url, normalized_username, password.to_string()))
|
||||
}
|
||||
|
||||
pub fn upsert(
|
||||
pub fn upsert_for_scope(
|
||||
conn: &Connection,
|
||||
project_id: Option<&str>,
|
||||
tuleap_url: &str,
|
||||
username: &str,
|
||||
password_encrypted: &str,
|
||||
) -> Result<TuleapCredentials> {
|
||||
conn.execute("DELETE FROM tuleap_credentials", [])?;
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO tuleap_credentials (id, tuleap_url, username, password_encrypted) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![id, tuleap_url, username, password_encrypted],
|
||||
)?;
|
||||
|
||||
match project_id {
|
||||
Some(project_id) => {
|
||||
conn.execute(
|
||||
"DELETE FROM tuleap_credentials WHERE project_id = ?1",
|
||||
params![project_id],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO tuleap_credentials (id, project_id, tuleap_url, username, password_encrypted) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![id, project_id, tuleap_url, username, password_encrypted],
|
||||
)?;
|
||||
}
|
||||
None => {
|
||||
conn.execute(
|
||||
"DELETE FROM tuleap_credentials WHERE project_id IS NULL",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO tuleap_credentials (id, project_id, tuleap_url, username, password_encrypted) VALUES (?1, NULL, ?2, ?3, ?4)",
|
||||
params![id, tuleap_url, username, password_encrypted],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(TuleapCredentials {
|
||||
id,
|
||||
project_id: project_id.map(String::from),
|
||||
tuleap_url: tuleap_url.to_string(),
|
||||
username: username.to_string(),
|
||||
password_encrypted: password_encrypted.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(conn: &Connection) -> Result<Option<TuleapCredentials>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, tuleap_url, username, password_encrypted FROM tuleap_credentials LIMIT 1",
|
||||
)?;
|
||||
let mut rows = stmt.query_map([], |row| {
|
||||
Ok(TuleapCredentials {
|
||||
id: row.get(0)?,
|
||||
tuleap_url: row.get(1)?,
|
||||
username: row.get(2)?,
|
||||
password_encrypted: row.get(3)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
match rows.next() {
|
||||
Some(row) => Ok(Some(row?)),
|
||||
None => Ok(None),
|
||||
pub fn get_by_scope(
|
||||
conn: &Connection,
|
||||
project_id: Option<&str>,
|
||||
) -> Result<Option<TuleapCredentials>> {
|
||||
match project_id {
|
||||
Some(project_id) => conn
|
||||
.query_row(
|
||||
"SELECT id, project_id, tuleap_url, username, password_encrypted \
|
||||
FROM tuleap_credentials \
|
||||
WHERE project_id = ?1 \
|
||||
LIMIT 1",
|
||||
params![project_id],
|
||||
|row| {
|
||||
Ok(TuleapCredentials {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
tuleap_url: row.get(2)?,
|
||||
username: row.get(3)?,
|
||||
password_encrypted: row.get(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional(),
|
||||
None => conn
|
||||
.query_row(
|
||||
"SELECT id, project_id, tuleap_url, username, password_encrypted \
|
||||
FROM tuleap_credentials \
|
||||
WHERE project_id IS NULL \
|
||||
LIMIT 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(TuleapCredentials {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
tuleap_url: row.get(2)?,
|
||||
username: row.get(3)?,
|
||||
password_encrypted: row.get(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(conn: &Connection) -> Result<()> {
|
||||
conn.execute("DELETE FROM tuleap_credentials", [])?;
|
||||
pub fn get_global(conn: &Connection) -> Result<Option<TuleapCredentials>> {
|
||||
Self::get_by_scope(conn, None)
|
||||
}
|
||||
|
||||
pub fn get_for_project(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> Result<Option<TuleapCredentials>> {
|
||||
if let Some(project_scoped) = Self::get_by_scope(conn, Some(project_id))? {
|
||||
return Ok(Some(project_scoped));
|
||||
}
|
||||
|
||||
Self::get_global(conn)
|
||||
}
|
||||
|
||||
pub fn delete_for_scope(conn: &Connection, project_id: Option<&str>) -> Result<()> {
|
||||
match project_id {
|
||||
Some(project_id) => {
|
||||
conn.execute(
|
||||
"DELETE FROM tuleap_credentials WHERE project_id = ?1",
|
||||
params![project_id],
|
||||
)?;
|
||||
}
|
||||
None => {
|
||||
conn.execute(
|
||||
"DELETE FROM tuleap_credentials WHERE project_id IS NULL",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn to_safe(&self) -> TuleapCredentialsSafe {
|
||||
TuleapCredentialsSafe {
|
||||
id: self.id.clone(),
|
||||
project_id: self.project_id.clone(),
|
||||
tuleap_url: self.tuleap_url.clone(),
|
||||
username: self.username.clone(),
|
||||
}
|
||||
|
|
@ -110,22 +188,30 @@ impl TuleapCredentials {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::db;
|
||||
use crate::models::project::Project;
|
||||
|
||||
fn setup() -> Connection {
|
||||
db::init_in_memory().expect("db init should succeed")
|
||||
}
|
||||
|
||||
fn create_project(conn: &Connection, name: &str) -> Project {
|
||||
Project::insert(conn, name, &format!("/tmp/{}", name), None, "main")
|
||||
.expect("project insert should succeed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_creates_credentials() {
|
||||
fn test_upsert_global_creates_credentials() {
|
||||
let conn = setup();
|
||||
let creds = TuleapCredentials::upsert(
|
||||
let creds = TuleapCredentials::upsert_for_scope(
|
||||
&conn,
|
||||
None,
|
||||
"https://tuleap.example.com",
|
||||
"alice",
|
||||
"encrypted_password",
|
||||
)
|
||||
.expect("upsert should succeed");
|
||||
|
||||
assert_eq!(creds.project_id, None);
|
||||
assert_eq!(creds.tuleap_url, "https://tuleap.example.com");
|
||||
assert_eq!(creds.username, "alice");
|
||||
assert_eq!(creds.password_encrypted, "encrypted_password");
|
||||
|
|
@ -133,59 +219,136 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_replaces_existing() {
|
||||
fn test_upsert_for_scope_replaces_only_target_scope() {
|
||||
let conn = setup();
|
||||
TuleapCredentials::upsert(&conn, "https://old.example.com", "old_user", "old_enc")
|
||||
.expect("first upsert should succeed");
|
||||
let project_a = create_project(&conn, "project-a");
|
||||
let project_b = create_project(&conn, "project-b");
|
||||
|
||||
let second =
|
||||
TuleapCredentials::upsert(&conn, "https://new.example.com", "new_user", "new_enc")
|
||||
.expect("second upsert should succeed");
|
||||
TuleapCredentials::upsert_for_scope(
|
||||
&conn,
|
||||
Some(&project_a.id),
|
||||
"https://a.example.com",
|
||||
"alice",
|
||||
"a1",
|
||||
)
|
||||
.expect("upsert for project A should succeed");
|
||||
|
||||
// Only one record should exist
|
||||
let creds = TuleapCredentials::get(&conn)
|
||||
.expect("get should succeed")
|
||||
.expect("should have credentials");
|
||||
TuleapCredentials::upsert_for_scope(
|
||||
&conn,
|
||||
Some(&project_b.id),
|
||||
"https://b.example.com",
|
||||
"bob",
|
||||
"b1",
|
||||
)
|
||||
.expect("upsert for project B should succeed");
|
||||
|
||||
assert_eq!(creds.id, second.id);
|
||||
assert_eq!(creds.tuleap_url, "https://new.example.com");
|
||||
assert_eq!(creds.username, "new_user");
|
||||
TuleapCredentials::upsert_for_scope(
|
||||
&conn,
|
||||
Some(&project_a.id),
|
||||
"https://a2.example.com",
|
||||
"alice2",
|
||||
"a2",
|
||||
)
|
||||
.expect("second upsert for project A should succeed");
|
||||
|
||||
let creds_a = TuleapCredentials::get_by_scope(&conn, Some(&project_a.id))
|
||||
.expect("get project A should succeed")
|
||||
.expect("project A should have credentials");
|
||||
let creds_b = TuleapCredentials::get_by_scope(&conn, Some(&project_b.id))
|
||||
.expect("get project B should succeed")
|
||||
.expect("project B should have credentials");
|
||||
|
||||
assert_eq!(creds_a.tuleap_url, "https://a2.example.com");
|
||||
assert_eq!(creds_a.username, "alice2");
|
||||
assert_eq!(creds_b.tuleap_url, "https://b.example.com");
|
||||
assert_eq!(creds_b.username, "bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_returns_none_when_empty() {
|
||||
fn test_get_for_project_falls_back_to_global() {
|
||||
let conn = setup();
|
||||
let result = TuleapCredentials::get(&conn).expect("get should succeed");
|
||||
assert!(result.is_none());
|
||||
let project = create_project(&conn, "project-with-fallback");
|
||||
|
||||
TuleapCredentials::upsert_for_scope(
|
||||
&conn,
|
||||
None,
|
||||
"https://global.example.com",
|
||||
"global",
|
||||
"enc",
|
||||
)
|
||||
.expect("global upsert should succeed");
|
||||
|
||||
let creds = TuleapCredentials::get_for_project(&conn, &project.id)
|
||||
.expect("get for project should succeed")
|
||||
.expect("fallback global credentials should be returned");
|
||||
|
||||
assert_eq!(creds.project_id, None);
|
||||
assert_eq!(creds.tuleap_url, "https://global.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_returns_credentials() {
|
||||
fn test_get_for_project_prefers_scoped_credentials() {
|
||||
let conn = setup();
|
||||
let created =
|
||||
TuleapCredentials::upsert(&conn, "https://tuleap.example.com", "bob", "enc_pass")
|
||||
.expect("upsert should succeed");
|
||||
let project = create_project(&conn, "project-scoped");
|
||||
|
||||
let fetched = TuleapCredentials::get(&conn)
|
||||
.expect("get should succeed")
|
||||
.expect("should have credentials");
|
||||
TuleapCredentials::upsert_for_scope(
|
||||
&conn,
|
||||
None,
|
||||
"https://global.example.com",
|
||||
"global",
|
||||
"global",
|
||||
)
|
||||
.expect("global upsert should succeed");
|
||||
|
||||
assert_eq!(fetched.id, created.id);
|
||||
assert_eq!(fetched.tuleap_url, "https://tuleap.example.com");
|
||||
assert_eq!(fetched.username, "bob");
|
||||
assert_eq!(fetched.password_encrypted, "enc_pass");
|
||||
TuleapCredentials::upsert_for_scope(
|
||||
&conn,
|
||||
Some(&project.id),
|
||||
"https://project.example.com",
|
||||
"project-user",
|
||||
"project-enc",
|
||||
)
|
||||
.expect("project scoped upsert should succeed");
|
||||
|
||||
let creds = TuleapCredentials::get_for_project(&conn, &project.id)
|
||||
.expect("get for project should succeed")
|
||||
.expect("project credentials should exist");
|
||||
|
||||
assert_eq!(creds.project_id, Some(project.id));
|
||||
assert_eq!(creds.tuleap_url, "https://project.example.com");
|
||||
assert_eq!(creds.username, "project-user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_removes_credentials() {
|
||||
fn test_delete_for_scope_removes_only_target_scope() {
|
||||
let conn = setup();
|
||||
TuleapCredentials::upsert(&conn, "https://tuleap.example.com", "carol", "enc")
|
||||
.expect("upsert should succeed");
|
||||
let project = create_project(&conn, "project-delete");
|
||||
|
||||
TuleapCredentials::delete(&conn).expect("delete should succeed");
|
||||
TuleapCredentials::upsert_for_scope(
|
||||
&conn,
|
||||
None,
|
||||
"https://global.example.com",
|
||||
"global",
|
||||
"enc",
|
||||
)
|
||||
.expect("global upsert should succeed");
|
||||
TuleapCredentials::upsert_for_scope(
|
||||
&conn,
|
||||
Some(&project.id),
|
||||
"https://project.example.com",
|
||||
"project-user",
|
||||
"project-enc",
|
||||
)
|
||||
.expect("project scoped upsert should succeed");
|
||||
|
||||
let result = TuleapCredentials::get(&conn).expect("get should succeed");
|
||||
assert!(result.is_none());
|
||||
TuleapCredentials::delete_for_scope(&conn, Some(&project.id))
|
||||
.expect("delete for project scope should succeed");
|
||||
|
||||
let scoped = TuleapCredentials::get_by_scope(&conn, Some(&project.id))
|
||||
.expect("scoped fetch should succeed");
|
||||
let global = TuleapCredentials::get_global(&conn).expect("global fetch should succeed");
|
||||
|
||||
assert!(scoped.is_none());
|
||||
assert!(global.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ async fn poll_all_trackers(
|
|||
http_client: &reqwest::Client,
|
||||
app_handle: &AppHandle,
|
||||
) {
|
||||
// 1. Read all enabled trackers and credentials from DB
|
||||
let (trackers, client) = {
|
||||
// 1. Read all enabled trackers from DB
|
||||
let trackers = {
|
||||
let conn = match db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
|
|
@ -47,34 +47,57 @@ async fn poll_all_trackers(
|
|||
}
|
||||
};
|
||||
|
||||
// 2. Read credentials; bail silently if none
|
||||
let creds = match TuleapCredentials::get(&conn) {
|
||||
Ok(Some(c)) => c,
|
||||
Ok(None) => return,
|
||||
Err(e) => {
|
||||
eprintln!("poller: failed to read credentials: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let password = match crypto::decrypt(encryption_key, &creds.password_encrypted) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("poller: failed to decrypt password: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let client = TuleapClient::new(http_client, &creds.tuleap_url, &creds.username, &password);
|
||||
|
||||
(trackers, client)
|
||||
trackers
|
||||
}; // lock released
|
||||
|
||||
// 3. For each tracker that should_poll, poll it
|
||||
// 2. For each tracker that should_poll, poll it
|
||||
for tracker in &trackers {
|
||||
if should_poll(tracker) {
|
||||
poll_single_tracker(db, &client, tracker, app_handle).await;
|
||||
if !should_poll(tracker) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let client = {
|
||||
let conn = match db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("poller: failed to lock db while preparing client: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let creds = match TuleapCredentials::get_for_project(&conn, &tracker.project_id) {
|
||||
Ok(Some(c)) => c,
|
||||
Ok(None) => {
|
||||
eprintln!(
|
||||
"poller: no credentials configured for project {} (tracker {})",
|
||||
tracker.project_id, tracker.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"poller: failed to read credentials for project {}: {}",
|
||||
tracker.project_id, e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let password = match crypto::decrypt(encryption_key, &creds.password_encrypted) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"poller: failed to decrypt password for project {}: {}",
|
||||
tracker.project_id, e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
TuleapClient::new(http_client, &creds.tuleap_url, &creds.username, &password)
|
||||
};
|
||||
|
||||
poll_single_tracker(db, &client, tracker, app_handle).await;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,35 +1,103 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
listProjects,
|
||||
getTuleapCredentials,
|
||||
setTuleapCredentials,
|
||||
deleteTuleapCredentials,
|
||||
testTuleapConnection,
|
||||
} from "../../lib/api";
|
||||
import { getErrorMessage } from "../../lib/errors";
|
||||
import type { TuleapCredentialsSafe } from "../../lib/types";
|
||||
import type { Project, TuleapCredentialsSafe } from "../../lib/types";
|
||||
import ConfirmModal from "../ui/ConfirmModal";
|
||||
|
||||
function normalizeScope(value: string): string | null {
|
||||
return value === "" ? null : value;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialProjectId = searchParams.get("projectId");
|
||||
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(initialProjectId);
|
||||
|
||||
const [tuleapUrl, setTuleapUrl] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [existing, setExisting] = useState<TuleapCredentialsSafe | null>(null);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [loadingScope, setLoadingScope] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
|
||||
const selectedProject =
|
||||
selectedProjectId === null
|
||||
? null
|
||||
: projects.find((project) => project.id === selectedProjectId) ?? null;
|
||||
|
||||
const hasScopedCredentials =
|
||||
existing !== null && (existing.project_id ?? null) === selectedProjectId;
|
||||
|
||||
const usingGlobalFallback =
|
||||
selectedProjectId !== null && existing !== null && existing.project_id !== selectedProjectId;
|
||||
|
||||
useEffect(() => {
|
||||
getTuleapCredentials().then((creds) => {
|
||||
if (creds) {
|
||||
listProjects()
|
||||
.then((items) => {
|
||||
setProjects(items);
|
||||
if (initialProjectId && !items.some((project) => project.id === initialProjectId)) {
|
||||
setSelectedProjectId(null);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(getErrorMessage(err));
|
||||
});
|
||||
}, [initialProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
setLoadingScope(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
getTuleapCredentials(selectedProjectId)
|
||||
.then((creds) => {
|
||||
if (cancelled) return;
|
||||
|
||||
setExisting(creds);
|
||||
setTuleapUrl(creds.tuleap_url);
|
||||
setUsername(creds.username);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
if (creds) {
|
||||
setTuleapUrl(creds.tuleap_url);
|
||||
setUsername(creds.username);
|
||||
} else {
|
||||
setTuleapUrl("");
|
||||
setUsername("");
|
||||
}
|
||||
setPassword("");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
setExisting(null);
|
||||
setTuleapUrl("");
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setError(getErrorMessage(err));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoadingScope(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedProjectId]);
|
||||
|
||||
function clearMessages() {
|
||||
setError(null);
|
||||
|
|
@ -41,10 +109,10 @@ export default function SettingsPage() {
|
|||
clearMessages();
|
||||
setSaving(true);
|
||||
try {
|
||||
const creds = await setTuleapCredentials(tuleapUrl, username, password);
|
||||
const creds = await setTuleapCredentials(selectedProjectId, tuleapUrl, username, password);
|
||||
setExisting(creds);
|
||||
setPassword("");
|
||||
setSuccess("Credentials saved.");
|
||||
setSuccess(selectedProjectId ? "Project credentials saved." : "Global credentials saved.");
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
|
|
@ -56,7 +124,7 @@ export default function SettingsPage() {
|
|||
clearMessages();
|
||||
setTesting(true);
|
||||
try {
|
||||
const msg = await testTuleapConnection();
|
||||
const msg = await testTuleapConnection(selectedProjectId);
|
||||
setSuccess(msg);
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err));
|
||||
|
|
@ -70,12 +138,19 @@ export default function SettingsPage() {
|
|||
clearMessages();
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteTuleapCredentials();
|
||||
setExisting(null);
|
||||
setTuleapUrl("");
|
||||
setUsername("");
|
||||
await deleteTuleapCredentials(selectedProjectId);
|
||||
|
||||
const refreshed = await getTuleapCredentials(selectedProjectId);
|
||||
setExisting(refreshed);
|
||||
setTuleapUrl(refreshed?.tuleap_url ?? "");
|
||||
setUsername(refreshed?.username ?? "");
|
||||
setPassword("");
|
||||
setSuccess("Credentials deleted.");
|
||||
|
||||
if (selectedProjectId && refreshed && refreshed.project_id === null) {
|
||||
setSuccess("Project credentials deleted. Global fallback credentials are now used.");
|
||||
} else {
|
||||
setSuccess("Credentials deleted.");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
|
|
@ -90,11 +165,40 @@ export default function SettingsPage() {
|
|||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 className="text-base font-semibold mb-4">Tuleap credentials</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Scope</label>
|
||||
<select
|
||||
value={selectedProjectId ?? ""}
|
||||
onChange={(e) => setSelectedProjectId(normalizeScope(e.target.value))}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Global fallback</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
{selectedProject
|
||||
? `Les trackers du projet \"${selectedProject.name}\" utilisent d'abord ce scope, puis le fallback global.`
|
||||
: "Credentials par défaut pour tous les projets sans credentials dédiés."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loadingScope ? (
|
||||
<div className="text-sm text-gray-500 mb-4">Loading credentials...</div>
|
||||
) : null}
|
||||
|
||||
{usingGlobalFallback && (
|
||||
<div className="mb-4 rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700">
|
||||
Aucun credential spécifique au projet n'est configuré. Le fallback global est utilisé.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tuleap URL
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Tuleap URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={tuleapUrl}
|
||||
|
|
@ -106,9 +210,7 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
|
|
@ -119,14 +221,12 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={existing ? "Leave empty to keep current" : ""}
|
||||
required
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -146,7 +246,7 @@ export default function SettingsPage() {
|
|||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
disabled={saving || loadingScope}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
|
|
@ -154,12 +254,12 @@ export default function SettingsPage() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={testing || !existing}
|
||||
disabled={testing || !existing || loadingScope}
|
||||
className="px-4 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300 disabled:opacity-50"
|
||||
>
|
||||
{testing ? "Testing..." : "Test connection"}
|
||||
</button>
|
||||
{existing && (
|
||||
{hasScopedCredentials && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export default function TrackerConfig() {
|
|||
setEnabled(tracker.enabled);
|
||||
setTrackerStatus(tracker.status === "invalid" ? "invalid" : "valid");
|
||||
|
||||
const trackerFields = await getTrackerFields(tracker.tracker_id);
|
||||
const trackerFields = await getTrackerFields(projectId, tracker.tracker_id);
|
||||
setFields(sortTrackerFields(trackerFields));
|
||||
setFieldsLoaded(true);
|
||||
} catch (err: unknown) {
|
||||
|
|
@ -107,11 +107,11 @@ export default function TrackerConfig() {
|
|||
}, [projectId, trackerConfigId]);
|
||||
|
||||
async function handleLoadFields() {
|
||||
if (!trackerId) return;
|
||||
if (!trackerId || !projectId) return;
|
||||
setFieldsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getTrackerFields(Number(trackerId));
|
||||
const result = await getTrackerFields(projectId, Number(trackerId));
|
||||
setFields(sortTrackerFields(result));
|
||||
setFieldsLoaded(true);
|
||||
} catch (err: unknown) {
|
||||
|
|
|
|||
|
|
@ -89,17 +89,22 @@ export async function deleteAgent(id: string): Promise<void> {
|
|||
}
|
||||
|
||||
// Credentials
|
||||
export async function setTuleapCredentials(tuleapUrl: string, username: string, password: string): Promise<TuleapCredentialsSafe> {
|
||||
return invoke("set_tuleap_credentials", { tuleapUrl, username, password });
|
||||
export async function setTuleapCredentials(
|
||||
projectId: string | null,
|
||||
tuleapUrl: string,
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<TuleapCredentialsSafe> {
|
||||
return invoke("set_tuleap_credentials", { projectId, tuleapUrl, username, password });
|
||||
}
|
||||
export async function getTuleapCredentials(): Promise<TuleapCredentialsSafe | null> {
|
||||
return invoke("get_tuleap_credentials");
|
||||
export async function getTuleapCredentials(projectId: string | null): Promise<TuleapCredentialsSafe | null> {
|
||||
return invoke("get_tuleap_credentials", { projectId });
|
||||
}
|
||||
export async function deleteTuleapCredentials(): Promise<void> {
|
||||
return invoke("delete_tuleap_credentials");
|
||||
export async function deleteTuleapCredentials(projectId: string | null): Promise<void> {
|
||||
return invoke("delete_tuleap_credentials", { projectId });
|
||||
}
|
||||
export async function testTuleapConnection(): Promise<string> {
|
||||
return invoke("test_tuleap_connection");
|
||||
export async function testTuleapConnection(projectId: string | null): Promise<string> {
|
||||
return invoke("test_tuleap_connection", { projectId });
|
||||
}
|
||||
|
||||
// Trackers
|
||||
|
|
@ -153,8 +158,8 @@ export async function updateTracker(
|
|||
export async function removeTracker(id: string): Promise<void> {
|
||||
return invoke("remove_tracker", { id });
|
||||
}
|
||||
export async function getTrackerFields(trackerId: number): Promise<TrackerField[]> {
|
||||
return invoke("get_tracker_fields", { trackerId });
|
||||
export async function getTrackerFields(projectId: string, trackerId: number): Promise<TrackerField[]> {
|
||||
return invoke("get_tracker_fields", { projectId, trackerId });
|
||||
}
|
||||
|
||||
// Tickets & Polling
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export interface Project {
|
|||
|
||||
export interface TuleapCredentialsSafe {
|
||||
id: string;
|
||||
project_id: string | null;
|
||||
tuleap_url: string;
|
||||
username: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue