Add manage section to repos with admin permissions

This commit is contained in:
Luke Street
2025-04-21 23:31:11 -06:00
parent cb2f04fb75
commit 8eb275fbac
8 changed files with 183 additions and 17 deletions
Generated
+1
View File
@@ -1028,6 +1028,7 @@ dependencies = [
"octocrab",
"rand 0.9.1",
"serde",
"serde_json",
"time",
"tower-sessions",
"tracing",
+4 -3
View File
@@ -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
+66 -11
View File
@@ -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<CurrentUserRepo>,
}
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::<Permissions>(r#"{"push":false,"pull":false}"#).unwrap()
}
impl From<Repository> 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<Current
let oauth = StoredOAuth::from(oauth);
let client = Octocrab::builder().oauth(oauth.clone().into()).build()?;
let profile = client.current().user().await.context("Failed to fetch current user")?;
tracing::info!("Logged in as @{}", profile.login);
Ok(CurrentUser { oauth, profile })
let repos = client
.all_pages(
client
.current()
.list_repos_for_authenticated_user()
.per_page(100)
.send()
.await
.context("Failed to fetch current user repositories")?,
)
.await
.context("Failed to fetch current user repositories")?
.into_iter()
.map(CurrentUserRepo::from)
.collect::<Vec<_>>();
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<CurrentUser, anyhow::Error> {
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<S> FromRequestParts<S> 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);
}
+23
View File
@@ -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<String>,
) -> 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! {
+7
View File
@@ -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" }
}
}
}
}
}
+1
View File
@@ -27,6 +27,7 @@ pub fn build_router() -> Router<AppState> {
.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))
}
+80 -2
View File
@@ -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<bool, D::Error>
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<String>,
}
pub async fn save_project(
Path(params): Path<ReportParams>,
State(state): State<AppState>,
current_user: CurrentUser,
Form(form): Form<ProjectForm>,
) -> Result<Response, AppError> {
let Some(project_info) = state.db.get_project_info(&params.owner, &params.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())
}
+1 -1
View File
@@ -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,