diff --git a/Cargo.lock b/Cargo.lock index 9056732..5d5ff90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1028,6 +1028,7 @@ dependencies = [ "octocrab", "rand 0.9.1", "serde", + "serde_json", "time", "tower-sessions", "tracing", diff --git a/crates/auth/Cargo.toml b/crates/auth/Cargo.toml index 7bf0bb8..692d700 100644 --- a/crates/auth/Cargo.toml +++ b/crates/auth/Cargo.toml @@ -8,11 +8,12 @@ publish = false anyhow.workspace = true axum.workspace = true base64 = "0.22" -rand = "0.9" decomp-dev-core = { path = "../core" } octocrab.workspace = true -time.workspace = true +rand = "0.9" serde.workspace = true +serde_json.workspace = true +time.workspace = true tower-sessions.workspace = true tracing.workspace = true -url.workspace = true +url.workspace = true \ No newline at end of file diff --git a/crates/auth/src/lib.rs b/crates/auth/src/lib.rs index 80bbe37..29f98e8 100644 --- a/crates/auth/src/lib.rs +++ b/crates/auth/src/lib.rs @@ -6,7 +6,10 @@ use axum::{ }; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use decomp_dev_core::{AppError, config::GitHubConfig}; -use octocrab::{Octocrab, models::Author}; +use octocrab::{ + Octocrab, + models::{Author, Permissions, Repository, RepositoryId}, +}; use rand::{TryRngCore, rngs::OsRng}; use time::{Duration, UtcDateTime}; use tower_sessions::Session; @@ -42,6 +45,41 @@ pub type Profile = Author; pub struct CurrentUser { pub oauth: StoredOAuth, pub profile: Profile, + #[serde(default)] + pub repos: Vec, +} + +impl CurrentUser { + pub fn permissions_for_repo(&self, id: u64) -> Permissions { + self.repos + .iter() + .find(|r| r.id.into_inner() == id) + .map(|r| r.permissions.clone()) + .unwrap_or_else(default_permissions) + } +} + +#[derive(Clone, serde::Serialize, serde::Deserialize)] +pub struct CurrentUserRepo { + pub id: RepositoryId, + pub owner: String, + pub repo: String, + pub permissions: Permissions, +} + +fn default_permissions() -> Permissions { + serde_json::from_str::(r#"{"push":false,"pull":false}"#).unwrap() +} + +impl From for CurrentUserRepo { + fn from(repo: Repository) -> Self { + Self { + id: repo.id, + owner: repo.owner.map(|o| o.login).unwrap_or_default(), + repo: repo.name, + permissions: repo.permissions.unwrap_or_else(default_permissions), + } + } } pub async fn login( @@ -182,13 +220,29 @@ async fn fetch_access_token(config: &GitHubConfig, code: &str) -> Result>(); + tracing::info!("Logged in as @{} ({} repos)", profile.login, repos.len()); + Ok(CurrentUser { oauth, profile, repos }) } async fn refresh_access_token( config: &GitHubConfig, refresh_token: &str, + prev_auth: &CurrentUser, ) -> Result { let Some(oauth_config) = &config.oauth else { tracing::warn!("No GitHub OAuth config found"); @@ -210,7 +264,7 @@ async fn refresh_access_token( let client = Octocrab::builder().oauth(oauth.clone().into()).build()?; let profile = client.current().user().await.context("Failed to fetch current user")?; tracing::info!("Refreshed token for @{}", profile.login); - Ok(CurrentUser { oauth, profile }) + Ok(CurrentUser { oauth, profile, repos: prev_auth.repos.clone() }) } impl FromRequestParts for CurrentUser @@ -256,13 +310,14 @@ where return Ok(None); } } - let current_user = match refresh_access_token(&config, refresh_token).await { - Ok(current_user) => current_user, - Err(e) => { - tracing::error!("Failed to refresh access token: {:?}", e); - return Ok(None); - } - }; + let current_user = + match refresh_access_token(&config, refresh_token, &user).await { + Ok(current_user) => current_user, + Err(e) => { + tracing::error!("Failed to refresh access token: {:?}", e); + return Ok(None); + } + }; if let Err(e) = session.insert(CURRENT_USER, current_user.clone()).await { tracing::error!("Failed to insert user into session: {}", e); } diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index 3c9310c..03d00d6 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -938,6 +938,29 @@ impl Database { .await?; Ok(()) } + + pub async fn update_project_settings( + &self, + project_id: u64, + enable_pr_comments: bool, + default_version: Option, + ) -> Result<()> { + let mut conn = self.pool.acquire().await?; + let project_id_db = project_id as i64; + sqlx::query!( + r#" + UPDATE projects + SET enable_pr_comments = ?, default_version = ? + WHERE id = ? + "#, + enable_pr_comments, + default_version, + project_id_db, + ) + .execute(&mut *conn) + .await?; + Ok(()) + } } thread_local! { diff --git a/crates/web/src/handlers/common.rs b/crates/web/src/handlers/common.rs index 29aa08f..01ea8aa 100644 --- a/crates/web/src/handlers/common.rs +++ b/crates/web/src/handlers/common.rs @@ -57,10 +57,17 @@ pub fn footer(start: Instant, current_user: Option<&CurrentUser>) -> Markup { "Logged in as " a href=(user.profile.html_url) { "@" (user.profile.login) } } + " | " form action="/logout" method="post" style="display: inline" { input type="submit" class="button outline secondary" value="Logout"; } } + } @else { + span class="section" { + small class="muted" { + a href="/login" { "Login" } + } + } } } } diff --git a/crates/web/src/handlers/mod.rs b/crates/web/src/handlers/mod.rs index 385f26a..452905d 100644 --- a/crates/web/src/handlers/mod.rs +++ b/crates/web/src/handlers/mod.rs @@ -27,6 +27,7 @@ pub fn build_router() -> Router { .route("/og.png", get(decomp_dev_images::get_og)) .route("/", get(project::get_projects)) .route("/{owner}/{repo}", get(report::get_report)) + .route("/{owner}/{repo}", post(report::save_project)) .route("/{owner}/{repo}/{version}", get(report::get_report)) .route("/{owner}/{repo}/{version}/{commit}", get(report::get_report)) } diff --git a/crates/web/src/handlers/report.rs b/crates/web/src/handlers/report.rs index 5e6d466..9fe6512 100644 --- a/crates/web/src/handlers/report.rs +++ b/crates/web/src/handlers/report.rs @@ -2,10 +2,10 @@ use std::{borrow::Cow, iter, time::Instant}; use anyhow::{Context, Result}; use axum::{ - Json, + Form, Json, extract::{Path, Query, State}, http::{HeaderMap, StatusCode, Uri, header}, - response::{IntoResponse, Response}, + response::{IntoResponse, Redirect, Response}, }; use decomp_dev_auth::CurrentUser; use decomp_dev_core::{ @@ -783,9 +783,87 @@ async fn render_template( noscript { img #treemap src=(image_url) alt="Progress graph"; } + @if current_user.as_ref().is_some_and(|u| u.permissions_for_repo(project_info.project.id).admin) { + (manage_form(project_info)) + } } } (footer(start, current_user.as_ref())) } }) } + +fn manage_form(project_info: &ProjectInfo) -> Markup { + let project_base_path = + format!("/{}/{}", project_info.project.owner, project_info.project.repo); + let default_version = project_info.default_version(); + html! { + h6 class="report-header" { "Manage" } + form action=(project_base_path) method="post" { + fieldset { + label { + "Default version" + select name="default_version" { + @for version in &project_info.report_versions { + @if default_version == Some(version.as_str()) { + option value=(version) selected { (version) } + } @else { + option value=(version) { (version) } + } + } + } + } + label { + @if project_info.project.enable_pr_comments { + input name="enable_pr_comments" type="checkbox" role="switch" checked; + } @else { + input name="enable_pr_comments" type="checkbox" role="switch"; + } + "Enable PR comments" + } + } + button type="submit" { "Save" } + } + } +} + +fn form_bool<'de, D>(deserializer: D) -> Result +where D: serde::Deserializer<'de> { + match <&str>::deserialize(deserializer)? { + "on" => Ok(true), + "off" => Ok(false), + other => Err(serde::de::Error::unknown_variant(other, &["on", "off"])), + } +} + +#[derive(Deserialize)] +pub struct ProjectForm { + #[serde(default, deserialize_with = "form_bool")] + pub enable_pr_comments: bool, + pub default_version: Option, +} + +pub async fn save_project( + Path(params): Path, + State(state): State, + current_user: CurrentUser, + Form(form): Form, +) -> Result { + let Some(project_info) = state.db.get_project_info(¶ms.owner, ¶ms.repo, None).await? + else { + return Err(AppError::Status(StatusCode::NOT_FOUND)); + }; + if !current_user.permissions_for_repo(project_info.project.id).admin { + return Err(AppError::Status(StatusCode::FORBIDDEN)); + } + state + .db + .update_project_settings( + project_info.project.id, + form.enable_pr_comments, + form.default_version, + ) + .await?; + let redirect_url = format!("/{}/{}", params.owner, params.repo); + Ok(Redirect::to(&redirect_url).into_response()) +} diff --git a/css/main.scss b/css/main.scss index 7ca775e..695a1f0 100644 --- a/css/main.scss +++ b/css/main.scss @@ -68,7 +68,7 @@ $breakpoints: ( // Forms "forms/basics": true, - "forms/checkbox-radio-switch": false, + "forms/checkbox-radio-switch": true, "forms/input-color": false, "forms/input-date": false, "forms/input-file": false,