repos: sidebar actions (sync/edit/remove) + edit modal
Sidebar: - Each connection header now has three inline icon buttons: Sync now (spins while in flight), Edit (opens the modal), Remove (opens an inline confirm strip). Removes cascade repos via ON DELETE CASCADE. - The connection's last_sync_error surfaces as a red inline banner under the header — no more 'error status with nowhere to see why'. - Sync is POST /api/repos/connections/:id/sync (already existed); after either sync or delete the sidebar re-fetches so state stays consistent. Edit modal (RepoConnectionEditModal): - Loads GET /api/repos/connections/:id, pre-fills owner/base_url/label - PATCHes only the fields that actually changed; empty string on a Some(&str) field sends explicit null so the backend clears it - Sync-now + Remove reachable from inside the modal too - Rotating the token is out of scope: the modal says as much and points the user at delete + re-create through the wizard (the broker doesn't expose an update path, and rotating in place would require duplicating the whole broker->store_secret flow here) Backend: - GET /api/repos/connections/:id — same ConnectionSummary shape - PATCH /api/repos/connections/:id — owner/base_url use Option<Option<T>> double-nesting so 'omit = leave alone' and 'null = clear' round-trip distinctly through serde - repo_connections::update with COALESCE-per-field so the SQL matches the double-Option semantics without an OR-chain per field
This commit is contained in:
@@ -187,6 +187,92 @@ pub async fn list_connections(
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
/// `GET /api/repos/connections/:id` — full detail for the edit modal.
|
||||
pub async fn get_connection(
|
||||
State(state): State<AppState>,
|
||||
Authed(user): Authed,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ConnectionSummary>, ApiError> {
|
||||
let c = cm_db::repo::repo_connections::get(&state.pool, id, user.workspace_id).await?;
|
||||
Ok(Json(ConnectionSummary {
|
||||
id: c.id.to_string(),
|
||||
provider: c.provider,
|
||||
owner: c.owner,
|
||||
base_url: c.base_url,
|
||||
label: c.label,
|
||||
status: match c.last_sync_error.as_deref() {
|
||||
Some(_) => "error".into(),
|
||||
None if c.last_synced_at.is_some() => "connected".into(),
|
||||
None => "pending".into(),
|
||||
},
|
||||
last_synced_at: c.last_synced_at.and_then(|t| t.format(&Rfc3339).ok()),
|
||||
last_sync_error: c.last_sync_error,
|
||||
created_at: c.created_at.format(&Rfc3339).unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// `PATCH /api/repos/connections/:id` — edit owner / base_url / label on an
|
||||
/// existing connection. Any field omitted from the body is left as-is;
|
||||
/// explicit `null` on `owner` or `base_url` clears the value. Rotating the
|
||||
/// PAT is out-of-band: delete + re-create through the wizard.
|
||||
///
|
||||
/// The response is the freshly-loaded connection so the client can react to
|
||||
/// derived fields (`status`, `last_sync_error` cleared by a preceding sync).
|
||||
pub async fn update_connection(
|
||||
State(state): State<AppState>,
|
||||
Authed(user): Authed,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<UpdateConnectionRequest>,
|
||||
) -> Result<Json<ConnectionSummary>, ApiError> {
|
||||
let owner = body
|
||||
.owner
|
||||
.map(|opt| opt.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()));
|
||||
let base_url = body
|
||||
.base_url
|
||||
.map(|opt| opt.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()));
|
||||
let label = body
|
||||
.label
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
let owner_ref = owner.as_ref().map(|opt| opt.as_deref());
|
||||
let base_url_ref = base_url.as_ref().map(|opt| opt.as_deref());
|
||||
let ok = cm_db::repo::repo_connections::update(
|
||||
&state.pool,
|
||||
id,
|
||||
user.workspace_id,
|
||||
owner_ref,
|
||||
base_url_ref,
|
||||
label,
|
||||
)
|
||||
.await?;
|
||||
if !ok {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
get_connection(State(state), Authed(user), Path(id)).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateConnectionRequest {
|
||||
/// `Some(None)` clears; `None` leaves unchanged.
|
||||
#[serde(default, deserialize_with = "de_double_option")]
|
||||
pub owner: Option<Option<String>>,
|
||||
#[serde(default, deserialize_with = "de_double_option")]
|
||||
pub base_url: Option<Option<String>>,
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
// serde default treats a missing field as `None` and an explicit `null` as
|
||||
// `Some(None)` when the target type is Option<Option<T>>. Manual deserializer
|
||||
// is needed because serde otherwise conflates the two.
|
||||
fn de_double_option<'de, D>(d: D) -> Result<Option<Option<String>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Option::<Option<String>>::deserialize(d)
|
||||
}
|
||||
|
||||
/// `DELETE /api/repos/connections/:id` — remove the repo_connections row.
|
||||
/// `ON DELETE CASCADE` cleans out its repos; the underlying app_connections
|
||||
/// row + broker secret stay (the workspace may reuse the token elsewhere).
|
||||
|
||||
Reference in New Issue
Block a user