diff --git a/Cargo.lock b/Cargo.lock index 352ec76d4..fe0195615 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,6 +79,16 @@ dependencies = [ "password-hash", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -559,6 +569,24 @@ dependencies = [ "cipher", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "der" version = "0.7.10" @@ -888,6 +916,7 @@ dependencies = [ "urlencoding", "uuid", "windows-sys 0.61.2", + "wiremock", "zeroize", ] @@ -1119,6 +1148,12 @@ dependencies = [ "spin", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -1293,6 +1328,25 @@ dependencies = [ "polyval", ] +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1425,6 +1479,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -2002,6 +2057,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "objc2" version = "0.6.4" @@ -3268,6 +3333,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -3912,6 +3978,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "write16" version = "1.0.0" diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml index e1bef7044..dd24d4f5d 100644 --- a/apps/server/Cargo.toml +++ b/apps/server/Cargo.toml @@ -59,6 +59,9 @@ hmac = "0.12" subtle = "2.6" png = "0.18" +[dev-dependencies] +wiremock = "0.6" + [target.'cfg(target_os = "linux")'.dependencies] tray-icon = { version = "0.25", default-features = false, features = ["ksni"] } diff --git a/apps/server/src/auth_routes.rs b/apps/server/src/auth_routes.rs index 24872f4cd..ebb693dd5 100644 --- a/apps/server/src/auth_routes.rs +++ b/apps/server/src/auth_routes.rs @@ -1635,3 +1635,15 @@ mod session_cookie_tests { assert_eq!(session_from_cookie_header("other=df_abc"), None); } } + +/// Utilisateur depuis `Authorization: Bearer` ou, à défaut, le cookie de session +/// (redirections navigateur depuis GitHub, sans en-tête). +pub async fn user_from_bearer_or_cookie( + state: &AppState, + headers: &HeaderMap, +) -> Result, (axum::http::StatusCode, Json)> { + let Some(token) = bearer_from(headers).or_else(|| session_cookie_from(headers)) else { + return Ok(None); + }; + session_user(state, &token).await +} diff --git a/apps/server/src/db.rs b/apps/server/src/db.rs index 5a4435c00..f6480a11d 100644 --- a/apps/server/src/db.rs +++ b/apps/server/src/db.rs @@ -1057,6 +1057,11 @@ pub async fn migrate_github_app(pool: &PgPool) -> Result<(), sqlx::Error> { ] { sqlx::query(sql).execute(pool).await?; } + // P1 : type du propriétaire (User/Organization) pour le lien de gestion GitHub. + let _ = + sqlx::query("ALTER TABLE github_app ADD COLUMN owner_type TEXT NOT NULL DEFAULT 'User'") + .execute(pool) + .await; // Échec toléré : colonne déjà présente. for (col, def) in [ ("github_installation_id", "BIGINT"), diff --git a/apps/server/src/github_app_routes.rs b/apps/server/src/github_app_routes.rs new file mode 100644 index 000000000..fafd70294 --- /dev/null +++ b/apps/server/src/github_app_routes.rs @@ -0,0 +1,775 @@ +//! GitHub App native — P1 : création en 1 clic (Manifest flow) et retour d'installation. +//! +//! Routes (admin d'instance uniquement) : +//! - `POST /api/v1/github-app/manifest` → `{ post_url, manifest, state }` +//! - `GET /api/v1/github-app/manifest/callback` → conversion du code, secrets scellés +//! - `GET /api/v1/github-app/installed` → installation vérifiée côté GitHub +//! - `GET /api/v1/github-app` → statut (jamais de secret) +//! - `DELETE /api/v1/github-app` → oubli local +//! +//! Aucun secret (clé privée, webhook secret, client secret, token) n'est renvoyé ni journalisé. + +use axum::{ + extract::{Query, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Redirect, Response}, + routing::{get, post}, + Json, Router, +}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL; +use base64::Engine; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sqlx::PgPool; + +use crate::auth_routes::UserRow; +use crate::secret_box::Redacted; +use crate::state::AppState; + +type ApiErr = (StatusCode, Json); + +pub const STATE_TTL_MINUTES: i64 = 10; +const SETTINGS_PATH: &str = "/app/settings?tab=github"; + +/// Permissions minimales demandées (liste blanche, cf. plan P1-01). +pub const REQUIRED_PERMISSIONS: &[(&str, &str)] = &[ + ("contents", "write"), + ("metadata", "read"), + ("pull_requests", "write"), + ("checks", "write"), + ("statuses", "write"), + ("deployments", "write"), +]; +pub const DEFAULT_EVENTS: &[&str] = &["push", "pull_request", "check_run", "check_suite"]; + +pub fn router() -> Router { + Router::new() + .route("/api/v1/github-app", get(status).delete(forget)) + .route("/api/v1/github-app/manifest", post(create_manifest)) + .route( + "/api/v1/github-app/manifest/callback", + get(manifest_callback), + ) + .route("/api/v1/github-app/installed", get(installed)) +} + +fn api_base() -> String { + std::env::var("DEVFORGE_GITHUB_API_URL") + .ok() + .map(|s| s.trim().trim_end_matches('/').to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "https://api.github.com".into()) +} + +fn web_base() -> String { + std::env::var("DEVFORGE_GITHUB_WEB_URL") + .ok() + .map(|s| s.trim().trim_end_matches('/').to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "https://github.com".into()) +} + +fn http() -> reqwest::Client { + reqwest::Client::builder() + .user_agent("DevForge") + .timeout(std::time::Duration::from_secs(20)) + .build() + .unwrap_or_default() +} + +fn err(code: StatusCode, msg: &str) -> ApiErr { + (code, Json(json!({ "error": msg }))) +} + +fn db_err(e: sqlx::Error) -> ApiErr { + tracing::error!("github-app : erreur base : {e}"); + err(StatusCode::INTERNAL_SERVER_ERROR, "erreur base de données") +} + +fn settings_redirect(extra: &str) -> Response { + Redirect::to(&format!("{SETTINGS_PATH}&{extra}")).into_response() +} + +async fn require_admin(state: &AppState, headers: &HeaderMap) -> Result { + let user = crate::auth_routes::user_from_bearer_or_cookie(state, headers) + .await? + .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "connexion requise"))?; + if user.role != "instance_admin" { + return Err(err(StatusCode::FORBIDDEN, "Réservé à l’admin instance")); + } + Ok(user) +} + +// ───────────────────────── Manifest ───────────────────────── + +/// Nom d'app GitHub : 34 caractères max, lettres/chiffres/tirets. +pub fn app_name(instance_name: &str) -> String { + let cleaned: String = instance_name + .trim() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + let cleaned = cleaned.trim_matches('-'); + let mut name = if cleaned.is_empty() { + "DevForge".to_string() + } else { + format!("DevForge-{cleaned}") + }; + name.truncate(34); + name.trim_end_matches('-').to_string() +} + +pub fn build_manifest(instance_name: &str, instance_url: &str, workflows: bool) -> Value { + let base = instance_url.trim().trim_end_matches('/'); + let mut perms = serde_json::Map::new(); + for (k, v) in REQUIRED_PERMISSIONS { + perms.insert((*k).into(), json!(v)); + } + if workflows { + perms.insert("workflows".into(), json!("write")); + } + json!({ + "name": app_name(instance_name), + "url": base, + "hook_attributes": { "url": format!("{base}/api/v1/webhooks/github-app"), "active": true }, + "redirect_url": format!("{base}/api/v1/github-app/manifest/callback"), + "setup_url": format!("{base}/api/v1/github-app/installed"), + "setup_on_update": true, + "callback_urls": [format!("{base}/api/v1/github-app/oauth/callback")], + "public": false, + "default_permissions": Value::Object(perms), + "default_events": DEFAULT_EVENTS, + }) +} + +/// URL de création : compte personnel ou organisation. +pub fn manifest_post_url(web: &str, org: Option<&str>, state: &str) -> String { + let st = urlencoding::encode(state); + match org.map(str::trim).filter(|o| !o.is_empty()) { + Some(o) => format!( + "{web}/organizations/{}/settings/apps/new?state={st}", + urlencoding::encode(o) + ), + None => format!("{web}/settings/apps/new?state={st}"), + } +} + +pub fn valid_org(org: &str) -> bool { + !org.is_empty() + && org.len() <= 39 + && org.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') + && !org.starts_with('-') +} + +fn new_state() -> String { + let mut b = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut b); + B64URL.encode(b) +} + +pub async fn issue_state(pool: &PgPool, user_uuid: &str) -> Result { + // Purge opportuniste des états expirés. + let _ = sqlx::query("DELETE FROM github_manifest_states WHERE expires_at < now()") + .execute(pool) + .await; + let s = new_state(); + sqlx::query( + "INSERT INTO github_manifest_states (state, user_uuid, expires_at) VALUES ($1, $2, now() + make_interval(mins => $3))", + ) + .bind(&s) + .bind(user_uuid) + .bind(STATE_TTL_MINUTES as i32) + .execute(pool) + .await?; + Ok(s) +} + +#[derive(Debug, PartialEq, Eq)] +pub enum StateError { + Unknown, + Expired, + OtherUser, +} + +/// Usage unique : l'état est supprimé quel que soit le résultat de la vérification. +pub async fn consume_state(pool: &PgPool, state: &str, user_uuid: &str) -> Result<(), StateError> { + let row: Option<(String, bool)> = sqlx::query_as( + "DELETE FROM github_manifest_states WHERE state = $1 RETURNING user_uuid, expires_at > now()", + ) + .bind(state) + .fetch_optional(pool) + .await + .ok() + .flatten(); + match row { + None => Err(StateError::Unknown), + Some((_, false)) => Err(StateError::Expired), + Some((u, true)) if u != user_uuid => Err(StateError::OtherUser), + Some(_) => Ok(()), + } +} + +#[derive(Deserialize, Default)] +struct ManifestBody { + org: Option, + #[serde(default)] + workflows: bool, +} + +async fn create_manifest( + State(state): State, + headers: HeaderMap, + body: Option>, +) -> Result, ApiErr> { + let user = require_admin(&state, &headers).await?; + let body = body.map(|b| b.0).unwrap_or_default(); + let org = body.org.as_deref().map(str::trim).filter(|o| !o.is_empty()); + if let Some(o) = org { + if !valid_org(o) { + return Err(err(StatusCode::BAD_REQUEST, "Nom d’organisation invalide")); + } + } + let (name, url): (String, String) = + sqlx::query_as("SELECT instance_name, instance_url FROM instance_settings WHERE id = 1") + .fetch_one(&state.pool) + .await + .map_err(db_err)?; + if !url.trim().starts_with("https://") && !url.trim().starts_with("http://") { + return Err(err( + StatusCode::BAD_REQUEST, + "Configure d’abord l’URL publique de l’instance (Admin → Serveur)", + )); + } + let st = issue_state(&state.pool, &user.uuid).await.map_err(db_err)?; + Ok(Json(json!({ + "ok": true, + "post_url": manifest_post_url(&web_base(), org, &st), + "manifest": build_manifest(&name, &url, body.workflows), + "state": st, + }))) +} + +// ───────────────────── Conversion du code ───────────────────── + +#[derive(Deserialize)] +struct ConvOwner { + login: String, + #[serde(rename = "type", default)] + kind: String, +} + +/// Réponse de `POST /app-manifests/{code}/conversions`. Les secrets restent masqués en Debug. +#[derive(Deserialize)] +pub struct ManifestConversion { + pub id: i64, + pub slug: String, + pub client_id: String, + pub html_url: String, + owner: ConvOwner, + client_secret: Redacted, + webhook_secret: Redacted, + pem: Redacted, +} + +impl<'de> Deserialize<'de> for Redacted { + fn deserialize>(d: D) -> Result { + Option::::deserialize(d).map(|v| Redacted(v.unwrap_or_default())) + } +} + +pub async fn convert_manifest( + client: &reqwest::Client, + api: &str, + code: &str, +) -> Result { + if code.is_empty() + || !code + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err("code invalide".into()); + } + let res = client + .post(format!("{api}/app-manifests/{code}/conversions")) + .header("Accept", "application/vnd.github+json") + .send() + .await + .map_err(|e| format!("GitHub injoignable : {}", e.without_url()))?; + let status = res.status(); + if !status.is_success() { + return Err(format!( + "conversion refusée par GitHub ({})", + status.as_u16() + )); + } + res.json::() + .await + .map_err(|_| "réponse GitHub inattendue".to_string()) +} + +pub async fn store_app(pool: &PgPool, c: &ManifestConversion) -> Result<(), String> { + let seal = |v: &str| crate::secret_box::seal(v.as_bytes()).map_err(|e| e.to_string()); + let cs = seal(c.client_secret.expose())?; + let ws = seal(c.webhook_secret.expose())?; + let pk = seal(c.pem.expose())?; + sqlx::query( + r#" + INSERT INTO github_app (id, app_id, slug, client_id, client_secret_enc, webhook_secret_enc, + private_key_enc, html_url, owner_login, owner_type) + VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET + app_id = EXCLUDED.app_id, slug = EXCLUDED.slug, client_id = EXCLUDED.client_id, + client_secret_enc = EXCLUDED.client_secret_enc, + webhook_secret_enc = EXCLUDED.webhook_secret_enc, + private_key_enc = EXCLUDED.private_key_enc, html_url = EXCLUDED.html_url, + owner_login = EXCLUDED.owner_login, owner_type = EXCLUDED.owner_type, + created_at = now() + "#, + ) + .bind(c.id) + .bind(&c.slug) + .bind(&c.client_id) + .bind(cs) + .bind(ws) + .bind(pk) + .bind(&c.html_url) + .bind(&c.owner.login) + .bind(if c.owner.kind.is_empty() { + "User" + } else { + &c.owner.kind + }) + .execute(pool) + .await + .map_err(|e| format!("enregistrement impossible : {e}"))?; + Ok(()) +} + +#[derive(Deserialize)] +struct CallbackQuery { + code: Option, + state: Option, +} + +fn state_error_response(e: StateError) -> Response { + let msg = match e { + StateError::Unknown => { + "Lien de création invalide ou déjà utilisé. Relance depuis DevForge." + } + StateError::Expired => "Lien de création expiré (10 min). Relance depuis DevForge.", + StateError::OtherUser => "Ce lien a été généré pour un autre compte.", + }; + err(StatusCode::BAD_REQUEST, msg).into_response() +} + +async fn manifest_callback( + State(state): State, + headers: HeaderMap, + Query(q): Query, +) -> Response { + let user = match require_admin(&state, &headers).await { + Ok(u) => u, + Err(e) => return e.into_response(), + }; + let (Some(code), Some(st)) = (q.code, q.state) else { + return err(StatusCode::BAD_REQUEST, "code ou state manquant").into_response(); + }; + if let Err(e) = consume_state(&state.pool, &st, &user.uuid).await { + return state_error_response(e); + } + let conv = match convert_manifest(&http(), &api_base(), &code).await { + Ok(c) => c, + Err(e) => { + tracing::warn!("github-app : {e}"); + return settings_redirect("error=conversion"); + } + }; + if let Err(e) = store_app(&state.pool, &conv).await { + tracing::error!("github-app : {e}"); + return settings_redirect("error=stockage"); + } + tracing::info!(app_id = conv.id, slug = %conv.slug, "github-app : app créée"); + let install_state = match issue_state(&state.pool, &user.uuid).await { + Ok(s) => s, + Err(e) => return db_err(e).into_response(), + }; + Redirect::to(&format!( + "{}/apps/{}/installations/new?state={}", + web_base(), + urlencoding::encode(&conv.slug), + urlencoding::encode(&install_state) + )) + .into_response() +} + +// ───────────────────── Installation ───────────────────── + +#[derive(Serialize)] +struct JwtClaims { + iat: i64, + exp: i64, + iss: String, +} + +/// JWT d'app RS256 (iat = now−60, exp = now+540). Sera déplacé dans `crates/github` en P2. +pub fn app_jwt(app_id: i64, pem: &[u8]) -> Result, String> { + let key = jsonwebtoken::EncodingKey::from_rsa_pem(pem).map_err(|_| "clé privée invalide")?; + let now = chrono::Utc::now().timestamp(); + let claims = JwtClaims { + iat: now - 60, + exp: now + 540, + iss: app_id.to_string(), + }; + jsonwebtoken::encode( + &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256), + &claims, + &key, + ) + .map(Redacted) + .map_err(|_| "signature JWT impossible".into()) +} + +async fn load_app_jwt(pool: &PgPool) -> Result, String> { + let row: Option<(i64, String)> = + sqlx::query_as("SELECT app_id, private_key_enc FROM github_app WHERE id = 1") + .fetch_optional(pool) + .await + .map_err(|e| e.to_string())?; + let (app_id, enc) = row.ok_or("aucune app GitHub configurée")?; + let pem = crate::secret_box::open(&enc).map_err(|e| e.to_string())?; + app_jwt(app_id, &pem) +} + +#[derive(Deserialize)] +struct InstAccount { + login: String, + #[serde(rename = "type", default)] + kind: String, +} + +#[derive(Deserialize)] +pub struct Installation { + pub id: i64, + account: InstAccount, + #[serde(default)] + repository_selection: String, + #[serde(default)] + permissions: Value, + #[serde(default)] + suspended_at: Option, +} + +#[derive(Deserialize)] +struct InstRepo { + id: i64, + full_name: String, + #[serde(default)] + private: bool, + default_branch: Option, +} + +#[derive(Deserialize)] +struct InstRepoPage { + repositories: Vec, +} + +#[derive(Deserialize)] +struct InstToken { + token: Redacted, +} + +async fn gh_get( + client: &reqwest::Client, + url: &str, + bearer: &str, +) -> Result { + let res = client + .get(url) + .bearer_auth(bearer) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await + .map_err(|e| format!("GitHub injoignable : {}", e.without_url()))?; + let s = res.status(); + if !s.is_success() { + return Err(format!("GitHub a répondu {}", s.as_u16())); + } + res.json::() + .await + .map_err(|_| "réponse GitHub inattendue".into()) +} + +/// Vérifie l'installation côté GitHub (on ne fait pas confiance à la query), puis upsert + repos. +pub async fn verify_and_store_installation( + pool: &PgPool, + client: &reqwest::Client, + api: &str, + jwt: &str, + installation_id: i64, + user_uuid: &str, +) -> Result { + let inst: Installation = gh_get( + client, + &format!("{api}/app/installations/{installation_id}"), + jwt, + ) + .await?; + if inst.id != installation_id { + return Err("installation incohérente".into()); + } + sqlx::query( + r#" + INSERT INTO github_installations (installation_id, account_login, account_type, + repository_selection, permissions, suspended_at, created_by_user_uuid) + VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7) + ON CONFLICT (installation_id) DO UPDATE SET + account_login = EXCLUDED.account_login, account_type = EXCLUDED.account_type, + repository_selection = EXCLUDED.repository_selection, + permissions = EXCLUDED.permissions, suspended_at = EXCLUDED.suspended_at, + updated_at = now() + "#, + ) + .bind(inst.id) + .bind(&inst.account.login) + .bind(&inst.account.kind) + .bind(&inst.repository_selection) + .bind(&inst.permissions) + .bind(inst.suspended_at.as_deref()) + .bind(user_uuid) + .execute(pool) + .await + .map_err(|e| format!("enregistrement installation : {e}"))?; + + // Synchronisation des dépôts accessibles (token d'installation éphémère, non mis en cache en P1). + let res = client + .post(format!( + "{api}/app/installations/{installation_id}/access_tokens" + )) + .bearer_auth(jwt) + .header("Accept", "application/vnd.github+json") + .send() + .await + .map_err(|e| format!("GitHub injoignable : {}", e.without_url()))?; + if !res.status().is_success() { + return Err(format!( + "token d’installation refusé ({})", + res.status().as_u16() + )); + } + let tok: InstToken = res.json().await.map_err(|_| "réponse GitHub inattendue")?; + let mut repos = Vec::new(); + for page in 1..=10 { + let p: InstRepoPage = gh_get( + client, + &format!("{api}/installation/repositories?per_page=100&page={page}"), + tok.token.expose(), + ) + .await?; + let n = p.repositories.len(); + repos.extend(p.repositories); + if n < 100 { + break; + } + } + let mut tx = pool.begin().await.map_err(|e| e.to_string())?; + sqlx::query("DELETE FROM github_installation_repos WHERE installation_id = $1") + .bind(installation_id) + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())?; + for r in &repos { + sqlx::query( + "INSERT INTO github_installation_repos (installation_id, repo_id, full_name, private, default_branch) VALUES ($1, $2, $3, $4, $5)", + ) + .bind(installation_id) + .bind(r.id) + .bind(&r.full_name) + .bind(r.private) + .bind(&r.default_branch) + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())?; + } + tx.commit().await.map_err(|e| e.to_string())?; + Ok(inst) +} + +#[derive(Deserialize)] +struct InstalledQuery { + installation_id: Option, + setup_action: Option, + state: Option, +} + +async fn installed( + State(state): State, + headers: HeaderMap, + Query(q): Query, +) -> Response { + let user = match require_admin(&state, &headers).await { + Ok(u) => u, + Err(e) => return e.into_response(), + }; + // `setup_on_update` : GitHub rappelle aussi lors d'une mise à jour, sans `state`. + if let Some(st) = q.state.as_deref().filter(|s| !s.is_empty()) { + if let Err(e) = consume_state(&state.pool, st, &user.uuid).await { + return state_error_response(e); + } + } + let Some(id) = q.installation_id.and_then(|s| s.trim().parse::().ok()) else { + return err(StatusCode::BAD_REQUEST, "installation_id manquant").into_response(); + }; + let jwt = match load_app_jwt(&state.pool).await { + Ok(j) => j, + Err(e) => { + tracing::warn!("github-app : {e}"); + return settings_redirect("error=app"); + } + }; + match verify_and_store_installation( + &state.pool, + &http(), + &api_base(), + jwt.expose(), + id, + &user.uuid, + ) + .await + { + Ok(inst) => { + tracing::info!( + installation_id = inst.id, + account = %inst.account.login, + action = q.setup_action.as_deref().unwrap_or(""), + "github-app : installation enregistrée" + ); + settings_redirect("installed=1") + } + Err(e) => { + tracing::warn!( + installation_id = id, + "github-app : vérification impossible : {e}" + ); + settings_redirect("error=installation") + } + } +} + +// ───────────────────── Statut / oubli ───────────────────── + +/// Permissions requises absentes ou insuffisantes (`write` couvre `read`). +pub fn missing_permissions(granted: &Value) -> Vec { + REQUIRED_PERMISSIONS + .iter() + .filter(|(k, need)| { + let have = granted.get(*k).and_then(Value::as_str).unwrap_or(""); + !(have == *need || (have == "write" && *need == "read") || have == "admin") + }) + .map(|(k, need)| format!("{k}:{need}")) + .collect() +} + +pub fn manage_url(web: &str, slug: &str, owner_login: &str, owner_type: &str) -> String { + if owner_type.eq_ignore_ascii_case("Organization") { + format!("{web}/organizations/{owner_login}/settings/apps/{slug}/advanced") + } else { + format!("{web}/settings/apps/{slug}/advanced") + } +} + +pub async fn status_json(pool: &PgPool) -> Result { + let app: Option<(i64, String, String, String, String, String)> = sqlx::query_as( + "SELECT app_id, slug, client_id, html_url, owner_login, owner_type FROM github_app WHERE id = 1", + ) + .fetch_optional(pool) + .await?; + let insts: Vec<(i64, String, String, String, Value, bool)> = sqlx::query_as( + r#"SELECT i.installation_id, i.account_login, i.account_type, i.repository_selection, + i.permissions, i.suspended_at IS NOT NULL + FROM github_installations i ORDER BY i.created_at"#, + ) + .fetch_all(pool) + .await?; + let counts: Vec<(i64, i64)> = sqlx::query_as( + "SELECT installation_id, count(*) FROM github_installation_repos GROUP BY installation_id", + ) + .fetch_all(pool) + .await?; + let web = web_base(); + let installations: Vec = insts + .into_iter() + .map(|(id, login, kind, sel, perms, suspended)| { + let repos = counts + .iter() + .find(|(i, _)| *i == id) + .map(|c| c.1) + .unwrap_or(0); + json!({ + "installation_id": id, + "account_login": login, + "account_type": kind, + "repository_selection": sel, + "repositories": repos, + "suspended": suspended, + "permissions": perms, + "missing_permissions": missing_permissions(&perms), + }) + }) + .collect(); + let required: serde_json::Map = REQUIRED_PERMISSIONS + .iter() + .map(|(k, v)| ((*k).to_string(), json!(v))) + .collect(); + Ok(match app { + None => json!({ + "ok": true, "configured": false, "required_permissions": required, + "installations": installations, + }), + Some((app_id, slug, client_id, html_url, owner, owner_type)) => json!({ + "ok": true, + "configured": true, + "app_id": app_id, + "slug": slug, + "client_id": client_id, + "html_url": html_url, + "owner_login": owner, + "owner_type": owner_type, + "install_url": format!("{web}/apps/{slug}/installations/new"), + "manage_url": manage_url(&web, &slug, &owner, &owner_type), + "required_permissions": required, + "installations": installations, + }), + }) +} + +async fn status(State(state): State, headers: HeaderMap) -> Result, ApiErr> { + require_admin(&state, &headers).await?; + status_json(&state.pool).await.map(Json).map_err(db_err) +} + +async fn forget(State(state): State, headers: HeaderMap) -> Result, ApiErr> { + require_admin(&state, &headers).await?; + let st = status_json(&state.pool).await.map_err(db_err)?; + let mut tx = state.pool.begin().await.map_err(db_err)?; + sqlx::query("DELETE FROM github_installations") + .execute(&mut *tx) + .await + .map_err(db_err)?; + sqlx::query("DELETE FROM github_app WHERE id = 1") + .execute(&mut *tx) + .await + .map_err(db_err)?; + tx.commit().await.map_err(db_err)?; + Ok(Json(json!({ + "ok": true, + "configured": false, + // L'app existe toujours chez GitHub : lien pour la supprimer là-bas. + "manage_url": st.get("manage_url").cloned().unwrap_or(Value::Null), + }))) +} + +#[cfg(test)] +#[path = "github_app_routes_tests.rs"] +mod tests; diff --git a/apps/server/src/github_app_routes_tests.rs b/apps/server/src/github_app_routes_tests.rs new file mode 100644 index 000000000..c81b74ca4 --- /dev/null +++ b/apps/server/src/github_app_routes_tests.rs @@ -0,0 +1,344 @@ +use super::*; +use wiremock::matchers::{header, method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const FAKE_PEM: &str = + "-----BEGIN RSA PRIVATE KEY-----\nMIIEtestonly\n-----END RSA PRIVATE KEY-----\n"; + +fn ensure_key() { + if std::env::var(crate::secret_box::KEY_ENV).is_err() { + std::env::set_var( + crate::secret_box::KEY_ENV, + base64::engine::general_purpose::STANDARD.encode([5u8; 32]), + ); + } + crate::secret_box::global().expect("coffre de test"); +} + +async fn pool() -> PgPool { + ensure_key(); + let pool = devforge_database::ephemeral_pg().await; + crate::db::migrate(&pool).await.unwrap(); + pool +} + +#[test] +fn manifest_urls_and_permissions_whitelist() { + let m = build_manifest("NAS Maison", "https://web.jeser.app/", false); + assert_eq!(m["name"], "DevForge-NAS-Maison"); + assert_eq!(m["url"], "https://web.jeser.app"); + assert_eq!( + m["hook_attributes"]["url"], + "https://web.jeser.app/api/v1/webhooks/github-app" + ); + assert_eq!(m["hook_attributes"]["active"], true); + assert_eq!( + m["redirect_url"], + "https://web.jeser.app/api/v1/github-app/manifest/callback" + ); + assert_eq!( + m["setup_url"], + "https://web.jeser.app/api/v1/github-app/installed" + ); + assert_eq!(m["setup_on_update"], true); + assert_eq!(m["public"], false); + assert_eq!( + m["callback_urls"], + json!(["https://web.jeser.app/api/v1/github-app/oauth/callback"]) + ); + assert_eq!( + m["default_permissions"], + json!({ + "contents": "write", "metadata": "read", "pull_requests": "write", + "checks": "write", "statuses": "write", "deployments": "write" + }) + ); + assert_eq!( + m["default_events"], + json!(["push", "pull_request", "check_run", "check_suite"]) + ); + let perms = m["default_permissions"].as_object().unwrap(); + for forbidden in ["administration", "workflows", "secrets", "members"] { + assert!(!perms.contains_key(forbidden), "{forbidden}"); + } +} + +#[test] +fn workflows_is_opt_in_only() { + let m = build_manifest("x", "https://a.b", true); + assert_eq!(m["default_permissions"]["workflows"], "write"); + assert_eq!(m["default_permissions"].as_object().unwrap().len(), 7); +} + +#[test] +fn app_name_is_sanitized_and_bounded() { + assert_eq!(app_name(""), "DevForge"); + assert!(app_name(&"a".repeat(80)).len() <= 34); + assert_eq!(app_name(" été/prod "), "DevForge-t--prod"); +} + +#[test] +fn post_url_personal_or_org() { + assert_eq!( + manifest_post_url("https://github.com", None, "s1"), + "https://github.com/settings/apps/new?state=s1" + ); + assert_eq!( + manifest_post_url("https://github.com", Some("my-org"), "s1"), + "https://github.com/organizations/my-org/settings/apps/new?state=s1" + ); + assert!(valid_org("bobdivx")); + assert!(!valid_org("../evil")); + assert!(!valid_org("-x")); +} + +#[test] +fn missing_permissions_detects_gaps() { + let all = json!({"contents":"write","metadata":"read","pull_requests":"write","checks":"write","statuses":"write","deployments":"write"}); + assert!(missing_permissions(&all).is_empty()); + let partial = json!({"contents":"read","metadata":"write"}); + let miss = missing_permissions(&partial); + assert!(miss.contains(&"contents:write".to_string())); + assert!(!miss.contains(&"metadata:read".to_string())); + assert_eq!(miss.len(), 5); +} + +#[test] +fn manage_url_user_vs_org() { + assert_eq!( + manage_url("https://github.com", "s", "bob", "User"), + "https://github.com/settings/apps/s/advanced" + ); + assert_eq!( + manage_url("https://github.com", "s", "acme", "Organization"), + "https://github.com/organizations/acme/settings/apps/s/advanced" + ); +} + +#[tokio::test] +async fn state_single_use_expiry_and_user_binding() { + let pool = pool().await; + let s = issue_state(&pool, "u1").await.unwrap(); + assert!(s.len() >= 43); + assert_eq!(consume_state(&pool, &s, "u1").await, Ok(())); + assert_eq!( + consume_state(&pool, &s, "u1").await, + Err(StateError::Unknown), + "réutilisé" + ); + + let s = issue_state(&pool, "u1").await.unwrap(); + assert_eq!( + consume_state(&pool, &s, "u2").await, + Err(StateError::OtherUser) + ); + assert_eq!( + consume_state(&pool, &s, "u1").await, + Err(StateError::Unknown), + "consommé même si refusé" + ); + + sqlx::query("INSERT INTO github_manifest_states (state, user_uuid, expires_at) VALUES ('old', 'u1', now() - interval '1 minute')") + .execute(&pool).await.unwrap(); + assert_eq!( + consume_state(&pool, "old", "u1").await, + Err(StateError::Expired) + ); + assert_eq!( + consume_state(&pool, "nope", "u1").await, + Err(StateError::Unknown) + ); + // Réponse HTTP : 400. + assert_eq!( + state_error_response(StateError::Expired).status(), + StatusCode::BAD_REQUEST + ); + assert_eq!( + state_error_response(StateError::OtherUser).status(), + StatusCode::BAD_REQUEST + ); + assert_eq!( + state_error_response(StateError::Unknown).status(), + StatusCode::BAD_REQUEST + ); +} + +fn conversion_body() -> Value { + json!({ + "id": 4242, "slug": "devforge-nas", "client_id": "Iv1.abc", "node_id": "x", + "html_url": "https://github.com/apps/devforge-nas", + "owner": {"login": "bobdivx", "type": "User"}, + "client_secret": "cs_secret_value", "webhook_secret": "wh_secret_value", + "pem": FAKE_PEM + }) +} + +#[tokio::test] +async fn manifest_conversion_is_stored_sealed() { + let pool = pool().await; + let gh = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/app-manifests/abc123/conversions")) + .respond_with(ResponseTemplate::new(201).set_body_json(conversion_body())) + .expect(1) + .mount(&gh) + .await; + let c = convert_manifest(&http(), &gh.uri(), "abc123") + .await + .unwrap(); + assert_eq!(c.id, 4242); + store_app(&pool, &c).await.unwrap(); + + let (pk, cs, ws): (String, String, String) = sqlx::query_as( + "SELECT private_key_enc, client_secret_enc, webhook_secret_enc FROM github_app", + ) + .fetch_one(&pool) + .await + .unwrap(); + // Validation P0/P1 : jamais de clé en clair en base. + assert!(!pk.contains("BEGIN RSA") && pk.starts_with("v1:")); + assert!(!cs.contains("cs_secret") && !ws.contains("wh_secret")); + assert_eq!( + crate::secret_box::open(&pk).unwrap().as_slice(), + FAKE_PEM.as_bytes() + ); + + // Statut : aucun secret exposé. + let st = status_json(&pool).await.unwrap().to_string(); + assert!(st.contains("devforge-nas") && st.contains("\"configured\":true")); + for s in ["BEGIN RSA", "cs_secret_value", "wh_secret_value", "v1:"] { + assert!(!st.contains(s), "{s} exposé"); + } +} + +#[tokio::test] +async fn conversion_failure_and_bad_code() { + let gh = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/app-manifests/gone/conversions")) + .respond_with(ResponseTemplate::new(404)) + .mount(&gh) + .await; + let e = convert_manifest(&http(), &gh.uri(), "gone") + .await + .err() + .unwrap(); + assert!(e.contains("404")); + assert!(convert_manifest(&http(), &gh.uri(), "../x").await.is_err()); +} + +#[tokio::test] +async fn installation_is_verified_then_stored_with_repos() { + let pool = pool().await; + let gh = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/app/installations/77")) + .and(header("authorization", "Bearer app.jwt")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": 77, "account": {"login": "bobdivx", "type": "User"}, + "repository_selection": "selected", + "permissions": {"contents": "write", "metadata": "read"}, + "suspended_at": null + }))) + .mount(&gh) + .await; + Mock::given(method("POST")) + .and(path("/app/installations/77/access_tokens")) + .respond_with( + ResponseTemplate::new(201) + .set_body_json(json!({"token": "ghs_inst", "expires_at": "2026-10-13T23:00:00Z"})), + ) + .mount(&gh) + .await; + Mock::given(method("GET")) + .and(path("/installation/repositories")) + .and(query_param("page", "1")) + .and(header("authorization", "Bearer ghs_inst")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "total_count": 1, + "repositories": [{"id": 9, "full_name": "bobdivx/devforge", "private": true, "default_branch": "main"}] + }))) + .mount(&gh) + .await; + Mock::given(method("GET")) + .and(path("/app/installations/666")) + .respond_with(ResponseTemplate::new(404)) + .mount(&gh) + .await; + + // Installation inventée dans la query ⇒ refusée, rien en base. + assert!( + verify_and_store_installation(&pool, &http(), &gh.uri(), "app.jwt", 666, "u1") + .await + .is_err() + ); + let inst = verify_and_store_installation(&pool, &http(), &gh.uri(), "app.jwt", 77, "u1") + .await + .unwrap(); + assert_eq!(inst.id, 77); + // Idempotent (setup_on_update). + verify_and_store_installation(&pool, &http(), &gh.uri(), "app.jwt", 77, "u1") + .await + .unwrap(); + + let (n,): (i64,) = sqlx::query_as("SELECT count(*) FROM github_installations") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(n, 1); + let (full, private): (String, bool) = sqlx::query_as( + "SELECT full_name, private FROM github_installation_repos WHERE installation_id = 77", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!((full.as_str(), private), ("bobdivx/devforge", true)); + let st = status_json(&pool).await.unwrap(); + let i = &st["installations"][0]; + assert_eq!(i["repositories"], 1); + assert_eq!(i["missing_permissions"].as_array().unwrap().len(), 4); + assert!(!st.to_string().contains("ghs_inst")); +} + +#[test] +fn app_jwt_rs256_claims() { + let dir = std::env::temp_dir().join(format!("df-jwt-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let key = dir.join("k.pem"); + let pubk = dir.join("k.pub"); + let ok = std::process::Command::new("openssl") + .args(["genrsa", "-traditional", "-out"]) + .arg(&key) + .arg("2048") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + && std::process::Command::new("openssl") + .args(["rsa", "-pubout", "-in"]) + .arg(&key) + .arg("-out") + .arg(&pubk) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if !ok { + eprintln!("openssl indisponible — test JWT ignoré"); + return; + } + let pem = std::fs::read(&key).unwrap(); + let jwt = app_jwt(4242, &pem).unwrap(); + assert_eq!(format!("{jwt:?}"), "***"); + let mut v = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256); + v.set_issuer(&["4242"]); + v.set_required_spec_claims(&["exp", "iat", "iss"]); + let data = jsonwebtoken::decode::( + jwt.expose(), + &jsonwebtoken::DecodingKey::from_rsa_pem(&std::fs::read(&pubk).unwrap()).unwrap(), + &v, + ) + .unwrap(); + let c = data.claims; + assert!(c["exp"].as_i64().unwrap() - c["iat"].as_i64().unwrap() <= 600); + let _ = std::fs::remove_dir_all(&dir); + assert!(app_jwt(1, b"not a key").is_err()); +} diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index 1ba0008ee..71dfc8e3d 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -27,6 +27,7 @@ mod draft; mod draft_origin; mod draft_routes; mod git_routes; +mod github_app_routes; mod group_routes; mod infra_routes; mod infra_sqlite; @@ -365,6 +366,7 @@ async fn main() -> Result<(), Box> { .merge(runner_routes::router()) .merge(actions_routes::router()) .merge(git_routes::router()) + .merge(github_app_routes::router()) .merge(draft_routes::router()) .merge(correction::router()) .merge(group_routes::router()) diff --git a/apps/web/src/components/GithubAppSettingsPanel.test.tsx b/apps/web/src/components/GithubAppSettingsPanel.test.tsx new file mode 100644 index 000000000..73bf9d644 --- /dev/null +++ b/apps/web/src/components/GithubAppSettingsPanel.test.tsx @@ -0,0 +1,68 @@ +import { fireEvent, render, waitFor } from '@testing-library/preact'; +import { describe, expect, it, vi } from 'vitest'; +import type { GithubAppStatus } from '../lib/api'; +import { GithubAppSettingsPanel, submitManifestForm, type GithubAppClient } from './GithubAppSettingsPanel'; + +const empty: GithubAppStatus = { ok: true, configured: false, required_permissions: {}, installations: [] }; + +function client(status: GithubAppStatus): GithubAppClient { + return { + githubAppStatus: vi.fn(async () => status), + githubAppManifest: vi.fn(async () => ({ + ok: true, + post_url: 'https://github.com/settings/apps/new?state=s1', + manifest: { name: 'DevForge-x' }, + state: 's1', + })), + githubAppForget: vi.fn(async () => ({ ok: true, configured: false })), + }; +} + +describe('GithubAppSettingsPanel', () => { + it('crée le manifest puis le soumet à GitHub', async () => { + const c = client(empty); + const submit = vi.fn(); + const r = render(); + const btn = await r.findByText('Créer l’app GitHub DevForge'); + fireEvent.click(btn); + await waitFor(() => expect(submit).toHaveBeenCalledTimes(1)); + expect(c.githubAppManifest).toHaveBeenCalledWith({}); + expect(submit.mock.calls[0][0].post_url).toContain('/settings/apps/new'); + }); + + it('liste les installations et signale les permissions manquantes', async () => { + const c = client({ + ...empty, + configured: true, + slug: 'devforge-nas', + owner_login: 'bobdivx', + install_url: 'https://github.com/apps/devforge-nas/installations/new', + installations: [ + { + installation_id: 1, + account_login: 'bobdivx', + account_type: 'User', + repository_selection: 'selected', + repositories: 3, + suspended: false, + permissions: {}, + missing_permissions: ['checks:write'], + }, + ], + }); + const r = render(); + await r.findByText(/3 dépôt\(s\)/); + expect(r.getByText(/checks:write/)).toBeTruthy(); + expect(r.getByText('Installer sur un autre compte')).toBeTruthy(); + }); + + it('formulaire caché : POST vers GitHub avec le champ manifest JSON', () => { + const orig = HTMLFormElement.prototype.submit; + HTMLFormElement.prototype.submit = vi.fn(); + const f = submitManifestForm({ ok: true, post_url: 'https://github.com/settings/apps/new?state=s', manifest: { a: 1 }, state: 's' }); + expect(f.method).toBe('post'); + expect(f.action).toBe('https://github.com/settings/apps/new?state=s'); + expect((f.querySelector('input[name=manifest]') as HTMLInputElement).value).toBe('{"a":1}'); + HTMLFormElement.prototype.submit = orig; + }); +}); diff --git a/apps/web/src/components/GithubAppSettingsPanel.tsx b/apps/web/src/components/GithubAppSettingsPanel.tsx new file mode 100644 index 000000000..2204ce8be --- /dev/null +++ b/apps/web/src/components/GithubAppSettingsPanel.tsx @@ -0,0 +1,159 @@ +import { useEffect, useState } from 'preact/hooks'; +import { api, type GithubAppManifest, type GithubAppStatus } from '../lib/api'; +import { Alert, Badge, Button, Card, CardHeader, Input } from './ui'; + +export type GithubAppClient = Pick; + +const ERRORS: Record = { + conversion: 'GitHub a refusé la création de l’app (lien expiré ?). Relance la création.', + stockage: 'L’app a été créée sur GitHub mais n’a pas pu être enregistrée. Relance la création.', + app: 'Aucune app GitHub DevForge n’est configurée sur cette instance.', + installation: 'Installation non vérifiée auprès de GitHub. Réessaie depuis « Installer ».', +}; + +/** Soumet le manifest à GitHub via un formulaire POST caché (méthode exigée par GitHub). */ +export function submitManifestForm(m: GithubAppManifest, doc: Document = document): HTMLFormElement { + const form = doc.createElement('form'); + form.method = 'post'; + form.action = m.post_url; + form.style.display = 'none'; + const input = doc.createElement('input'); + input.type = 'hidden'; + input.name = 'manifest'; + input.value = JSON.stringify(m.manifest); + form.appendChild(input); + doc.body.appendChild(form); + form.submit(); + return form; +} + +export function GithubAppSettingsPanel({ + client = api, + submit = submitManifestForm, +}: { + client?: GithubAppClient; + submit?: (m: GithubAppManifest) => void; +}) { + const [status, setStatus] = useState(null); + const [org, setOrg] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + async function load() { + try { + setStatus(await client.githubAppStatus()); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + } + + useEffect(() => { + const q = new URLSearchParams(window.location.search); + const err = q.get('error'); + if (err) setError(ERRORS[err] ?? 'Erreur GitHub App.'); + if (q.get('installed') === '1') setNotice('Installation GitHub enregistrée.'); + void load(); + }, []); + + async function create() { + setBusy(true); + setError(null); + try { + const m = await client.githubAppManifest(org.trim() ? { org: org.trim() } : {}); + submit(m); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setBusy(false); + } + } + + async function forget() { + if (!window.confirm('Oublier l’app GitHub sur DevForge ? Elle restera à supprimer côté GitHub.')) return; + setBusy(true); + try { + const r = await client.githubAppForget(); + setNotice( + r.manage_url ? `App oubliée localement. Supprime-la aussi sur GitHub : ${r.manage_url}` : 'App oubliée.', + ); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + } + + const missing = (status?.installations ?? []).flatMap((i) => i.missing_permissions); + + return ( + + app créée : non configurée + } + /> + {error && ( + + {error} + + )} + {notice && ( + + {notice} + + )} + {status && !status.configured && ( +
+ setOrg((e.target as HTMLInputElement).value)} + /> + +
+ )} + {status?.configured && ( +
+

+ + {status.slug} + {' '} + · @{status.owner_login} +

+ {missing.length > 0 && ( + Permissions manquantes à accepter sur GitHub : {[...new Set(missing)].join(', ')} + )} + {status.installations.length === 0 ? ( +

Aucune installation pour l’instant.

+ ) : ( +
    + {status.installations.map((i) => ( +
  • + @{i.account_login} — {i.repository_selection === 'all' ? 'tous les dépôts' : `${i.repositories} dépôt(s)`} + {i.suspended && suspendue} +
  • + ))} +
+ )} +
+ {status.install_url && ( + + )} + +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/SettingsPage.tsx b/apps/web/src/components/SettingsPage.tsx index db5734087..32da082ff 100644 --- a/apps/web/src/components/SettingsPage.tsx +++ b/apps/web/src/components/SettingsPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'preact/hooks'; import { api, type InstanceDomain } from '../lib/api'; import { AcmeEmailTile } from './AcmeEmailTile'; import { AppShell } from './AppShell'; +import { GithubAppSettingsPanel } from './GithubAppSettingsPanel'; import { LlmProvidersPanel } from './LlmProvidersPanel'; import { MemorySettingsPanel } from './MemorySettingsPanel'; import { @@ -583,6 +584,11 @@ function SettingsPageInner() { )} + {isAdmin && ( +
+ +
+ )} )} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index f61714e8d..c3cc8775b 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -494,6 +494,38 @@ export type DraftSummary = { advice?: DraftAdvice | null; }; +/** GitHub App native (P1) : statut sans aucun secret. */ +export type GithubInstallation = { + installation_id: number; + account_login: string; + account_type: string; + repository_selection: string; + repositories: number; + suspended: boolean; + permissions: Record; + missing_permissions: string[]; +}; +export type GithubAppStatus = { + ok: boolean; + configured: boolean; + app_id?: number; + slug?: string; + client_id?: string; + html_url?: string; + owner_login?: string; + owner_type?: string; + install_url?: string; + manage_url?: string; + required_permissions: Record; + installations: GithubInstallation[]; +}; +export type GithubAppManifest = { + ok: boolean; + post_url: string; + manifest: Record; + state: string; +}; + export const api = { bootstrap: () => request('/bootstrap'), register: async (body: { @@ -1403,6 +1435,13 @@ export const api = { method: 'POST', body: JSON.stringify(body), }), + githubAppStatus: () => request('/github-app'), + githubAppManifest: (body: { org?: string; workflows?: boolean } = {}) => + request('/github-app/manifest', { method: 'POST', body: JSON.stringify(body) }), + githubAppForget: () => + request<{ ok: boolean; configured: boolean; manage_url?: string | null }>('/github-app', { + method: 'DELETE', + }), githubStatus: () => request<{ ok: boolean;