diff --git a/src-tauri/migrations/008_project_scoped_tuleap_credentials.sql b/src-tauri/migrations/008_project_scoped_tuleap_credentials.sql new file mode 100644 index 0000000..77bcf73 --- /dev/null +++ b/src-tauri/migrations/008_project_scoped_tuleap_credentials.sql @@ -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; diff --git a/src-tauri/src/commands/credential.rs b/src-tauri/src/commands/credential.rs index 16a9bf8..e837fab 100644 --- a/src-tauri/src/commands/credential.rs +++ b/src-tauri/src/commands/credential.rs @@ -8,6 +8,7 @@ use tauri::State; #[tauri::command] pub fn set_tuleap_credentials( state: State<'_, AppState>, + project_id: Option, 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, ) -> Result, 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, +) -> 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 { +pub async fn test_tuleap_connection( + state: State<'_, AppState>, + project_id: Option, +) -> Result { 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)?; diff --git a/src-tauri/src/commands/poller.rs b/src-tauri/src/commands/poller.rs index 1932930..10c68a2 100644 --- a/src-tauri/src/commands/poller.rs +++ b/src-tauri/src/commands/poller.rs @@ -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) diff --git a/src-tauri/src/commands/tracker.rs b/src-tauri/src/commands/tracker.rs index bb368fd..0917d06 100644 --- a/src-tauri/src/commands/tracker.rs +++ b/src-tauri/src/commands/tracker.rs @@ -9,13 +9,16 @@ use crate::AppState; use serde::Deserialize; use tauri::State; -fn build_tuleap_client(state: &State) -> Result { +fn build_tuleap_client( + state: &State, + project_id: &str, +) -> Result { 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, 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 diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index cc37589..0b330f2 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -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 { 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] diff --git a/src-tauri/src/models/credential.rs b/src-tauri/src/models/credential.rs index c2f4879..bae3211 100644 --- a/src-tauri/src/models/credential.rs +++ b/src-tauri/src/models/credential.rs @@ -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, 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, 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 { - 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> { - 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> { + 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> { + Self::get_by_scope(conn, None) + } + + pub fn get_for_project( + conn: &Connection, + project_id: &str, + ) -> Result> { + 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] diff --git a/src-tauri/src/services/poller.rs b/src-tauri/src/services/poller.rs index 46e9477..e68e83e 100644 --- a/src-tauri/src/services/poller.rs +++ b/src-tauri/src/services/poller.rs @@ -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; } } diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 5cab35c..7c273c0 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -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([]); + const [selectedProjectId, setSelectedProjectId] = useState(initialProjectId); + const [tuleapUrl, setTuleapUrl] = useState(""); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [existing, setExisting] = useState(null); + const [error, setError] = useState(null); const [success, setSuccess] = useState(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() {

Tuleap credentials

+
+ + +

+ {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."} +

+
+ + {loadingScope ? ( +
Loading credentials...
+ ) : null} + + {usingGlobalFallback && ( +
+ Aucun credential spécifique au projet n'est configuré. Le fallback global est utilisé. +
+ )} +
- +
- +
- + 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" />
@@ -146,7 +246,7 @@ export default function SettingsPage() {
- {existing && ( + {hasScopedCredentials && (