feat(credentials): scope Tuleap credentials by project

closes #8
This commit is contained in:
thibaud-lclr 2026-04-16 17:58:48 +02:00
parent f97e075ee6
commit d75695ffe6
11 changed files with 487 additions and 141 deletions

View file

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

View file

@ -8,6 +8,7 @@ use tauri::State;
#[tauri::command] #[tauri::command]
pub fn set_tuleap_credentials( pub fn set_tuleap_credentials(
state: State<'_, AppState>, state: State<'_, AppState>,
project_id: Option<String>,
tuleap_url: String, tuleap_url: String,
username: String, username: String,
password: String, password: String,
@ -24,44 +25,64 @@ pub fn set_tuleap_credentials(
.lock() .lock()
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; .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()) Ok(creds.to_safe())
} }
#[tauri::command] #[tauri::command]
pub fn get_tuleap_credentials( pub fn get_tuleap_credentials(
state: State<'_, AppState>, state: State<'_, AppState>,
project_id: Option<String>,
) -> Result<Option<TuleapCredentialsSafe>, AppError> { ) -> Result<Option<TuleapCredentialsSafe>, AppError> {
let db = state let db = state
.db .db
.lock() .lock()
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; .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) Ok(result)
} }
#[tauri::command] #[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 let db = state
.db .db
.lock() .lock()
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
TuleapCredentials::delete(&db)?; TuleapCredentials::delete_for_scope(&db, project_id.as_deref())?;
Ok(()) Ok(())
} }
#[tauri::command] #[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 (tuleap_url, username, password) = {
let db = state let db = state
.db .db
.lock() .lock()
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
let creds = TuleapCredentials::get(&db)? let creds = match &project_id {
.ok_or_else(|| AppError::from("No credentials configured".to_string()))?; 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) let password = crypto::decrypt(&state.encryption_key, &creds.password_encrypted)
.map_err(AppError::from)?; .map_err(AppError::from)?;

View file

@ -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()))?; .ok_or_else(|| AppError::from("No Tuleap credentials configured".to_string()))?;
let password = crypto::decrypt(&state.encryption_key, &cred.password_encrypted) let password = crypto::decrypt(&state.encryption_key, &cred.password_encrypted)

View file

@ -9,13 +9,16 @@ use crate::AppState;
use serde::Deserialize; use serde::Deserialize;
use tauri::State; 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 let db = state
.db .db
.lock() .lock()
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; .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()))?; .ok_or_else(|| AppError::from("No Tuleap credentials configured".to_string()))?;
let password = let password =
@ -133,9 +136,10 @@ pub fn remove_tracker(state: State<'_, AppState>, id: String) -> Result<(), AppE
#[tauri::command] #[tauri::command]
pub async fn get_tracker_fields( pub async fn get_tracker_fields(
state: State<'_, AppState>, state: State<'_, AppState>,
project_id: String,
tracker_id: i32, tracker_id: i32,
) -> Result<Vec<crate::services::tuleap_client::TrackerField>, AppError> { ) -> 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 let mut fields = client
.get_tracker_fields(tracker_id) .get_tracker_fields(tracker_id)
.await .await

View file

@ -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_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_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_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> { pub fn init(db_path: &Path) -> Result<Connection> {
let conn = Connection::open(db_path)?; let conn = Connection::open(db_path)?;
@ -61,6 +62,10 @@ fn migrate(conn: &Connection) -> Result<()> {
conn.execute_batch(MIGRATION_007)?; conn.execute_batch(MIGRATION_007)?;
conn.pragma_update(None, "user_version", 7)?; conn.pragma_update(None, "user_version", 7)?;
} }
if version < 8 {
conn.execute_batch(MIGRATION_008)?;
conn.pragma_update(None, "user_version", 8)?;
}
Ok(()) Ok(())
} }
@ -116,7 +121,7 @@ mod tests {
let version: i32 = conn let version: i32 = conn
.pragma_query_value(None, "user_version", |row| row.get(0)) .pragma_query_value(None, "user_version", |row| row.get(0))
.unwrap(); .unwrap();
assert_eq!(version, 7); assert_eq!(version, 8);
} }
#[test] #[test]

View file

@ -1,10 +1,11 @@
use rusqlite::{params, Connection, Result}; use rusqlite::{params, Connection, OptionalExtension, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TuleapCredentials { pub struct TuleapCredentials {
pub id: String, pub id: String,
pub project_id: Option<String>,
pub tuleap_url: String, pub tuleap_url: String,
pub username: String, pub username: String,
pub password_encrypted: String, pub password_encrypted: String,
@ -13,6 +14,7 @@ pub struct TuleapCredentials {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TuleapCredentialsSafe { pub struct TuleapCredentialsSafe {
pub id: String, pub id: String,
pub project_id: Option<String>,
pub tuleap_url: String, pub tuleap_url: String,
pub username: String, pub username: String,
} }
@ -51,55 +53,131 @@ impl TuleapCredentials {
Ok((normalized_url, normalized_username, password.to_string())) Ok((normalized_url, normalized_username, password.to_string()))
} }
pub fn upsert( pub fn upsert_for_scope(
conn: &Connection, conn: &Connection,
project_id: Option<&str>,
tuleap_url: &str, tuleap_url: &str,
username: &str, username: &str,
password_encrypted: &str, password_encrypted: &str,
) -> Result<TuleapCredentials> { ) -> Result<TuleapCredentials> {
conn.execute("DELETE FROM tuleap_credentials", [])?;
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO tuleap_credentials (id, tuleap_url, username, password_encrypted) VALUES (?1, ?2, ?3, ?4)", match project_id {
params![id, tuleap_url, username, password_encrypted], 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 { Ok(TuleapCredentials {
id, id,
project_id: project_id.map(String::from),
tuleap_url: tuleap_url.to_string(), tuleap_url: tuleap_url.to_string(),
username: username.to_string(), username: username.to_string(),
password_encrypted: password_encrypted.to_string(), password_encrypted: password_encrypted.to_string(),
}) })
} }
pub fn get(conn: &Connection) -> Result<Option<TuleapCredentials>> { pub fn get_by_scope(
let mut stmt = conn.prepare( conn: &Connection,
"SELECT id, tuleap_url, username, password_encrypted FROM tuleap_credentials LIMIT 1", project_id: Option<&str>,
)?; ) -> Result<Option<TuleapCredentials>> {
let mut rows = stmt.query_map([], |row| { match project_id {
Ok(TuleapCredentials { Some(project_id) => conn
id: row.get(0)?, .query_row(
tuleap_url: row.get(1)?, "SELECT id, project_id, tuleap_url, username, password_encrypted \
username: row.get(2)?, FROM tuleap_credentials \
password_encrypted: row.get(3)?, WHERE project_id = ?1 \
}) LIMIT 1",
})?; params![project_id],
|row| {
match rows.next() { Ok(TuleapCredentials {
Some(row) => Ok(Some(row?)), id: row.get(0)?,
None => Ok(None), 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<()> { pub fn get_global(conn: &Connection) -> Result<Option<TuleapCredentials>> {
conn.execute("DELETE FROM tuleap_credentials", [])?; 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(()) Ok(())
} }
pub fn to_safe(&self) -> TuleapCredentialsSafe { pub fn to_safe(&self) -> TuleapCredentialsSafe {
TuleapCredentialsSafe { TuleapCredentialsSafe {
id: self.id.clone(), id: self.id.clone(),
project_id: self.project_id.clone(),
tuleap_url: self.tuleap_url.clone(), tuleap_url: self.tuleap_url.clone(),
username: self.username.clone(), username: self.username.clone(),
} }
@ -110,22 +188,30 @@ impl TuleapCredentials {
mod tests { mod tests {
use super::*; use super::*;
use crate::db; use crate::db;
use crate::models::project::Project;
fn setup() -> Connection { fn setup() -> Connection {
db::init_in_memory().expect("db init should succeed") 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] #[test]
fn test_upsert_creates_credentials() { fn test_upsert_global_creates_credentials() {
let conn = setup(); let conn = setup();
let creds = TuleapCredentials::upsert( let creds = TuleapCredentials::upsert_for_scope(
&conn, &conn,
None,
"https://tuleap.example.com", "https://tuleap.example.com",
"alice", "alice",
"encrypted_password", "encrypted_password",
) )
.expect("upsert should succeed"); .expect("upsert should succeed");
assert_eq!(creds.project_id, None);
assert_eq!(creds.tuleap_url, "https://tuleap.example.com"); assert_eq!(creds.tuleap_url, "https://tuleap.example.com");
assert_eq!(creds.username, "alice"); assert_eq!(creds.username, "alice");
assert_eq!(creds.password_encrypted, "encrypted_password"); assert_eq!(creds.password_encrypted, "encrypted_password");
@ -133,59 +219,136 @@ mod tests {
} }
#[test] #[test]
fn test_upsert_replaces_existing() { fn test_upsert_for_scope_replaces_only_target_scope() {
let conn = setup(); let conn = setup();
TuleapCredentials::upsert(&conn, "https://old.example.com", "old_user", "old_enc") let project_a = create_project(&conn, "project-a");
.expect("first upsert should succeed"); let project_b = create_project(&conn, "project-b");
let second = TuleapCredentials::upsert_for_scope(
TuleapCredentials::upsert(&conn, "https://new.example.com", "new_user", "new_enc") &conn,
.expect("second upsert should succeed"); Some(&project_a.id),
"https://a.example.com",
"alice",
"a1",
)
.expect("upsert for project A should succeed");
// Only one record should exist TuleapCredentials::upsert_for_scope(
let creds = TuleapCredentials::get(&conn) &conn,
.expect("get should succeed") Some(&project_b.id),
.expect("should have credentials"); "https://b.example.com",
"bob",
"b1",
)
.expect("upsert for project B should succeed");
assert_eq!(creds.id, second.id); TuleapCredentials::upsert_for_scope(
assert_eq!(creds.tuleap_url, "https://new.example.com"); &conn,
assert_eq!(creds.username, "new_user"); 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] #[test]
fn test_get_returns_none_when_empty() { fn test_get_for_project_falls_back_to_global() {
let conn = setup(); let conn = setup();
let result = TuleapCredentials::get(&conn).expect("get should succeed"); let project = create_project(&conn, "project-with-fallback");
assert!(result.is_none());
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] #[test]
fn test_get_returns_credentials() { fn test_get_for_project_prefers_scoped_credentials() {
let conn = setup(); let conn = setup();
let created = let project = create_project(&conn, "project-scoped");
TuleapCredentials::upsert(&conn, "https://tuleap.example.com", "bob", "enc_pass")
.expect("upsert should succeed");
let fetched = TuleapCredentials::get(&conn) TuleapCredentials::upsert_for_scope(
.expect("get should succeed") &conn,
.expect("should have credentials"); None,
"https://global.example.com",
"global",
"global",
)
.expect("global upsert should succeed");
assert_eq!(fetched.id, created.id); TuleapCredentials::upsert_for_scope(
assert_eq!(fetched.tuleap_url, "https://tuleap.example.com"); &conn,
assert_eq!(fetched.username, "bob"); Some(&project.id),
assert_eq!(fetched.password_encrypted, "enc_pass"); "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] #[test]
fn test_delete_removes_credentials() { fn test_delete_for_scope_removes_only_target_scope() {
let conn = setup(); let conn = setup();
TuleapCredentials::upsert(&conn, "https://tuleap.example.com", "carol", "enc") let project = create_project(&conn, "project-delete");
.expect("upsert should succeed");
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"); TuleapCredentials::delete_for_scope(&conn, Some(&project.id))
assert!(result.is_none()); .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] #[test]

View file

@ -29,8 +29,8 @@ async fn poll_all_trackers(
http_client: &reqwest::Client, http_client: &reqwest::Client,
app_handle: &AppHandle, app_handle: &AppHandle,
) { ) {
// 1. Read all enabled trackers and credentials from DB // 1. Read all enabled trackers from DB
let (trackers, client) = { let trackers = {
let conn = match db.lock() { let conn = match db.lock() {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
@ -47,34 +47,57 @@ async fn poll_all_trackers(
} }
}; };
// 2. Read credentials; bail silently if none trackers
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)
}; // lock released }; // lock released
// 3. For each tracker that should_poll, poll it // 2. For each tracker that should_poll, poll it
for tracker in &trackers { for tracker in &trackers {
if should_poll(tracker) { if !should_poll(tracker) {
poll_single_tracker(db, &client, tracker, app_handle).await; 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;
} }
} }

View file

@ -1,35 +1,103 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import { import {
listProjects,
getTuleapCredentials, getTuleapCredentials,
setTuleapCredentials, setTuleapCredentials,
deleteTuleapCredentials, deleteTuleapCredentials,
testTuleapConnection, testTuleapConnection,
} from "../../lib/api"; } from "../../lib/api";
import { getErrorMessage } from "../../lib/errors"; import { getErrorMessage } from "../../lib/errors";
import type { TuleapCredentialsSafe } from "../../lib/types"; import type { Project, TuleapCredentialsSafe } from "../../lib/types";
import ConfirmModal from "../ui/ConfirmModal"; import ConfirmModal from "../ui/ConfirmModal";
function normalizeScope(value: string): string | null {
return value === "" ? null : value;
}
export default function SettingsPage() { 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 [tuleapUrl, setTuleapUrl] = useState("");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [existing, setExisting] = useState<TuleapCredentialsSafe | null>(null); const [existing, setExisting] = useState<TuleapCredentialsSafe | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const [loadingScope, setLoadingScope] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = 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(() => { useEffect(() => {
getTuleapCredentials().then((creds) => { listProjects()
if (creds) { .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); setExisting(creds);
setTuleapUrl(creds.tuleap_url); if (creds) {
setUsername(creds.username); 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() { function clearMessages() {
setError(null); setError(null);
@ -41,10 +109,10 @@ export default function SettingsPage() {
clearMessages(); clearMessages();
setSaving(true); setSaving(true);
try { try {
const creds = await setTuleapCredentials(tuleapUrl, username, password); const creds = await setTuleapCredentials(selectedProjectId, tuleapUrl, username, password);
setExisting(creds); setExisting(creds);
setPassword(""); setPassword("");
setSuccess("Credentials saved."); setSuccess(selectedProjectId ? "Project credentials saved." : "Global credentials saved.");
} catch (err: unknown) { } catch (err: unknown) {
setError(getErrorMessage(err)); setError(getErrorMessage(err));
} finally { } finally {
@ -56,7 +124,7 @@ export default function SettingsPage() {
clearMessages(); clearMessages();
setTesting(true); setTesting(true);
try { try {
const msg = await testTuleapConnection(); const msg = await testTuleapConnection(selectedProjectId);
setSuccess(msg); setSuccess(msg);
} catch (err: unknown) { } catch (err: unknown) {
setError(getErrorMessage(err)); setError(getErrorMessage(err));
@ -70,12 +138,19 @@ export default function SettingsPage() {
clearMessages(); clearMessages();
setDeleting(true); setDeleting(true);
try { try {
await deleteTuleapCredentials(); await deleteTuleapCredentials(selectedProjectId);
setExisting(null);
setTuleapUrl(""); const refreshed = await getTuleapCredentials(selectedProjectId);
setUsername(""); setExisting(refreshed);
setTuleapUrl(refreshed?.tuleap_url ?? "");
setUsername(refreshed?.username ?? "");
setPassword(""); 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) { } catch (err: unknown) {
setError(getErrorMessage(err)); setError(getErrorMessage(err));
} finally { } finally {
@ -90,11 +165,40 @@ export default function SettingsPage() {
<div className="bg-white rounded-lg border border-gray-200 p-6"> <div className="bg-white rounded-lg border border-gray-200 p-6">
<h3 className="text-base font-semibold mb-4">Tuleap credentials</h3> <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"> <form onSubmit={handleSave} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">Tuleap URL</label>
Tuleap URL
</label>
<input <input
type="url" type="url"
value={tuleapUrl} value={tuleapUrl}
@ -106,9 +210,7 @@ export default function SettingsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">Username</label>
Username
</label>
<input <input
type="text" type="text"
value={username} value={username}
@ -119,14 +221,12 @@ export default function SettingsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
Password
</label>
<input <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} 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" 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> </div>
@ -146,7 +246,7 @@ export default function SettingsPage() {
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<button <button
type="submit" 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" className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
> >
{saving ? "Saving..." : "Save"} {saving ? "Saving..." : "Save"}
@ -154,12 +254,12 @@ export default function SettingsPage() {
<button <button
type="button" type="button"
onClick={handleTest} 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" className="px-4 py-2 bg-gray-200 rounded text-sm hover:bg-gray-300 disabled:opacity-50"
> >
{testing ? "Testing..." : "Test connection"} {testing ? "Testing..." : "Test connection"}
</button> </button>
{existing && ( {hasScopedCredentials && (
<button <button
type="button" type="button"
onClick={() => setIsDeleteModalOpen(true)} onClick={() => setIsDeleteModalOpen(true)}

View file

@ -93,7 +93,7 @@ export default function TrackerConfig() {
setEnabled(tracker.enabled); setEnabled(tracker.enabled);
setTrackerStatus(tracker.status === "invalid" ? "invalid" : "valid"); setTrackerStatus(tracker.status === "invalid" ? "invalid" : "valid");
const trackerFields = await getTrackerFields(tracker.tracker_id); const trackerFields = await getTrackerFields(projectId, tracker.tracker_id);
setFields(sortTrackerFields(trackerFields)); setFields(sortTrackerFields(trackerFields));
setFieldsLoaded(true); setFieldsLoaded(true);
} catch (err: unknown) { } catch (err: unknown) {
@ -107,11 +107,11 @@ export default function TrackerConfig() {
}, [projectId, trackerConfigId]); }, [projectId, trackerConfigId]);
async function handleLoadFields() { async function handleLoadFields() {
if (!trackerId) return; if (!trackerId || !projectId) return;
setFieldsLoading(true); setFieldsLoading(true);
setError(null); setError(null);
try { try {
const result = await getTrackerFields(Number(trackerId)); const result = await getTrackerFields(projectId, Number(trackerId));
setFields(sortTrackerFields(result)); setFields(sortTrackerFields(result));
setFieldsLoaded(true); setFieldsLoaded(true);
} catch (err: unknown) { } catch (err: unknown) {

View file

@ -89,17 +89,22 @@ export async function deleteAgent(id: string): Promise<void> {
} }
// Credentials // Credentials
export async function setTuleapCredentials(tuleapUrl: string, username: string, password: string): Promise<TuleapCredentialsSafe> { export async function setTuleapCredentials(
return invoke("set_tuleap_credentials", { tuleapUrl, username, password }); 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> { export async function getTuleapCredentials(projectId: string | null): Promise<TuleapCredentialsSafe | null> {
return invoke("get_tuleap_credentials"); return invoke("get_tuleap_credentials", { projectId });
} }
export async function deleteTuleapCredentials(): Promise<void> { export async function deleteTuleapCredentials(projectId: string | null): Promise<void> {
return invoke("delete_tuleap_credentials"); return invoke("delete_tuleap_credentials", { projectId });
} }
export async function testTuleapConnection(): Promise<string> { export async function testTuleapConnection(projectId: string | null): Promise<string> {
return invoke("test_tuleap_connection"); return invoke("test_tuleap_connection", { projectId });
} }
// Trackers // Trackers
@ -153,8 +158,8 @@ export async function updateTracker(
export async function removeTracker(id: string): Promise<void> { export async function removeTracker(id: string): Promise<void> {
return invoke("remove_tracker", { id }); return invoke("remove_tracker", { id });
} }
export async function getTrackerFields(trackerId: number): Promise<TrackerField[]> { export async function getTrackerFields(projectId: string, trackerId: number): Promise<TrackerField[]> {
return invoke("get_tracker_fields", { trackerId }); return invoke("get_tracker_fields", { projectId, trackerId });
} }
// Tickets & Polling // Tickets & Polling

View file

@ -9,6 +9,7 @@ export interface Project {
export interface TuleapCredentialsSafe { export interface TuleapCredentialsSafe {
id: string; id: string;
project_id: string | null;
tuleap_url: string; tuleap_url: string;
username: string; username: string;
} }