feat: add private repository support with SSH key and HTTPS token auth
Some checks failed
CI / Clippy (push) Failing after 2m39s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Format (pull_request) Failing after 3s
CI / Clippy (pull_request) Failing after 2m33s
CI / Security Audit (pull_request) Has been skipped
CI / Tests (pull_request) Has been skipped
CI / Detect Changes (push) Has been skipped
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (push) Has been skipped
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Format (push) Failing after 4s
CI / Deploy MCP (pull_request) Has been skipped
CI / Deploy MCP (push) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped

- Generate SSH ed25519 key pair on agent startup for cloning private repos via SSH
- Add GET /api/v1/settings/ssh-public-key endpoint to expose deploy key
- Add auth_token and auth_username fields to TrackedRepository model
- Wire git2 credential callbacks for both SSH and HTTPS authentication
- Validate repository access before saving (test-connect on add)
- Update dashboard add form with optional auth section showing deploy key and token fields
- Show error toast if private repo cannot be accessed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-03-09 11:53:17 +01:00
parent 23ba52276b
commit 492a93a83e
13 changed files with 338 additions and 18 deletions

View File

@@ -34,19 +34,29 @@ pub async fn add_repository(
name: String,
git_url: String,
default_branch: String,
auth_token: Option<String>,
auth_username: Option<String>,
) -> Result<(), ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!("{}/api/v1/repositories", state.agent_api_url);
let mut body = serde_json::json!({
"name": name,
"git_url": git_url,
"default_branch": default_branch,
});
if let Some(token) = auth_token.filter(|t| !t.is_empty()) {
body["auth_token"] = serde_json::Value::String(token);
}
if let Some(username) = auth_username.filter(|u| !u.is_empty()) {
body["auth_username"] = serde_json::Value::String(username);
}
let client = reqwest::Client::new();
let resp = client
.post(&url)
.json(&serde_json::json!({
"name": name,
"git_url": git_url,
"default_branch": default_branch,
}))
.json(&body)
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
@@ -61,6 +71,32 @@ pub async fn add_repository(
Ok(())
}
#[server]
pub async fn fetch_ssh_public_key() -> Result<String, ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!("{}/api/v1/settings/ssh-public-key", state.agent_api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
if !resp.status().is_success() {
return Err(ServerFnError::new("SSH key not available".to_string()));
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(body
.get("public_key")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string())
}
#[server]
pub async fn delete_repository(repo_id: String) -> Result<(), ServerFnError> {
let state: super::server_state::ServerState =

View File

@@ -23,6 +23,12 @@ pub fn RepositoriesPage() -> Element {
let mut name = use_signal(String::new);
let mut git_url = use_signal(String::new);
let mut branch = use_signal(|| "main".to_string());
let mut auth_token = use_signal(String::new);
let mut auth_username = use_signal(String::new);
let mut show_auth = use_signal(|| false);
let mut show_ssh_key = use_signal(|| false);
let mut ssh_public_key = use_signal(String::new);
let mut adding = use_signal(|| false);
let mut toasts = use_context::<Toasts>();
let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name)
let mut scanning_ids = use_signal(Vec::<String>::new);
@@ -66,7 +72,7 @@ pub fn RepositoriesPage() -> Element {
label { "Git URL" }
input {
r#type: "text",
placeholder: "https://github.com/org/repo.git",
placeholder: "https://github.com/org/repo.git or git@github.com:org/repo.git",
value: "{git_url}",
oninput: move |e| git_url.set(e.value()),
}
@@ -80,26 +86,105 @@ pub fn RepositoriesPage() -> Element {
oninput: move |e| branch.set(e.value()),
}
}
// Private repo auth section
div { style: "margin-top: 8px;",
button {
class: "btn btn-ghost",
style: "font-size: 12px; padding: 4px 8px;",
onclick: move |_| {
show_auth.toggle();
if !show_ssh_key() {
// Fetch SSH key on first open
show_ssh_key.set(true);
spawn(async move {
match crate::infrastructure::repositories::fetch_ssh_public_key().await {
Ok(key) => ssh_public_key.set(key),
Err(_) => ssh_public_key.set("(not available)".to_string()),
}
});
}
},
if show_auth() { "Hide auth options" } else { "Private repository?" }
}
}
if show_auth() {
div { class: "auth-section", style: "margin-top: 12px; padding: 12px; border: 1px solid var(--border-subtle); border-radius: 8px;",
// SSH deploy key display
div { style: "margin-bottom: 12px;",
label { style: "font-size: 12px; color: var(--text-secondary);",
"For SSH URLs: add this deploy key (read-only) to your repository"
}
div {
style: "margin-top: 4px; padding: 8px; background: var(--bg-secondary); border-radius: 4px; font-family: monospace; font-size: 11px; word-break: break-all; user-select: all;",
if ssh_public_key().is_empty() {
"Loading..."
} else {
"{ssh_public_key}"
}
}
}
// HTTPS auth fields
p { style: "font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;",
"For HTTPS URLs: provide an access token (PAT) or username/password"
}
div { class: "form-group",
label { "Auth Token / Password" }
input {
r#type: "password",
placeholder: "ghp_xxxx or personal access token",
value: "{auth_token}",
oninput: move |e| auth_token.set(e.value()),
}
}
div { class: "form-group",
label { "Username (optional, defaults to x-access-token)" }
input {
r#type: "text",
placeholder: "x-access-token",
value: "{auth_username}",
oninput: move |e| auth_username.set(e.value()),
}
}
}
}
button {
class: "btn btn-primary",
disabled: adding(),
onclick: move |_| {
let n = name();
let u = git_url();
let b = branch();
let tok = {
let v = auth_token();
if v.is_empty() { None } else { Some(v) }
};
let usr = {
let v = auth_username();
if v.is_empty() { None } else { Some(v) }
};
adding.set(true);
spawn(async move {
match crate::infrastructure::repositories::add_repository(n, u, b).await {
match crate::infrastructure::repositories::add_repository(n, u, b, tok, usr).await {
Ok(_) => {
toasts.push(ToastType::Success, "Repository added");
repos.restart();
}
Err(e) => toasts.push(ToastType::Error, e.to_string()),
}
adding.set(false);
});
show_add_form.set(false);
show_auth.set(false);
name.set(String::new());
git_url.set(String::new());
auth_token.set(String::new());
auth_username.set(String::new());
},
"Add"
if adding() { "Validating..." } else { "Add" }
}
}
}