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]
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)?;

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()))?;
let password = crypto::decrypt(&state.encryption_key, &cred.password_encrypted)

View file

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

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_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]

View file

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

View file

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

View file

@ -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)}

View file

@ -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) {

View file

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

View file

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