//! Organisations and teams (hub spec ยง5.2). use super::settings::{csrf, require}; use super::{Error, Page, Shell}; use crate::auth::{roles, Viewer}; use crate::db::accounts; use crate::Shared; use askama::Template; use axum::extract::{Form, Path, State}; use axum::response::{IntoResponse, Redirect, Response}; use serde::Deserialize; #[derive(Template)] #[template(path = "org_new.html")] pub struct OrgNewPage { shell: Shell, error: Option, } pub async fn new_form(State(app): State, viewer: Viewer) -> Result, Error> { let shell = Shell::new(&app, "/orgs/new", &viewer); require(&viewer, &shell)?; Ok(Page(OrgNewPage { shell, error: None })) } #[derive(Deserialize)] pub struct OrgNewForm { #[serde(rename = "_csrf")] csrf_token: String, name: String, display_name: String, } pub async fn create(State(app): State, viewer: Viewer, Form(f): Form) -> Result { let shell = Shell::new(&app, "/orgs/new", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let name = f.name.trim().to_string(); if !crate::repos::names::valid_owner(&name) { return Ok(Page(OrgNewPage { shell, error: Some("An organisation name is letters, digits, dots, dashes and underscores.".into()), }) .into_response()); } let taken = accounts::account_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some() || accounts::org_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some(); if taken { return Ok(Page(OrgNewPage { shell, error: Some("That name is taken.".into()) }).into_response()); } let org = accounts::create_org(&app.db, &name, f.display_name.trim(), me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, Some(&me.account), "org.created", &org.name, "").await.ok(); Ok(Redirect::to(&format!("/orgs/{}/settings", org.name)).into_response()) } #[derive(Template)] #[template(path = "org_settings.html")] pub struct OrgSettingsPage { shell: Shell, org: accounts::Organisation, members: Vec<(accounts::Account, String)>, teams: Vec<(accounts::Team, Vec)>, notice: Option, error: Option, } async fn org_page( app: &Shared, shell: Shell, org: accounts::Organisation, notice: Option, error: Option, ) -> Result, Error> { let members = accounts::org_members(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; let mut teams = Vec::new(); for t in accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))? { let members = accounts::team_members(&app.db, t.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; teams.push((t, members)); } Ok(Page(OrgSettingsPage { shell, org, members, teams, notice, error })) } async fn owned_org(app: &Shared, shell: &Shell, viewer: &Viewer, name: &str) -> Result<(accounts::Account, accounts::Organisation), Error> { let me = require(viewer, shell)?; let org = accounts::org_by_name(&app.db, name) .await .map_err(|e| Error::from_db(shell.clone(), e))? .ok_or_else(|| Error::not_found(shell.clone()))?; if !roles::is_org_owner(&app.db, &me.account, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))? { return Err(Error::not_found(shell.clone())); } Ok((me.account, org)) } pub async fn settings(State(app): State, viewer: Viewer, Path(name): Path) -> Result, Error> { let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); let (_, org) = owned_org(&app, &shell, &viewer, &name).await?; org_page(&app, shell, org, None, None).await } #[derive(Deserialize)] pub struct OrgForm { #[serde(rename = "_csrf")] csrf_token: String, display_name: String, description: String, default_role: String, } pub async fn update( State(app): State, viewer: Viewer, Path(name): Path, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); let (me, org) = owned_org(&app, &shell, &viewer, &name).await?; csrf(&viewer, &f.csrf_token, &shell)?; let role = if f.default_role == "none" || roles::Role::parse(&f.default_role).is_some() { f.default_role.as_str() } else { "read" }; accounts::set_org(&app.db, org.id, f.display_name.trim(), f.description.trim(), role) .await .map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, Some(&me), "org.updated", &org.name, "").await.ok(); let org = accounts::org_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(org); org_page(&app, shell, org, Some("Saved.".into()), None).await } #[derive(Deserialize)] pub struct MemberForm { #[serde(rename = "_csrf")] csrf_token: String, username: String, #[serde(default)] role: String, #[serde(default)] remove: Option, } pub async fn member( State(app): State, viewer: Viewer, Path(name): Path, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); let (me, org) = owned_org(&app, &shell, &viewer, &name).await?; csrf(&viewer, &f.csrf_token, &shell)?; let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else { return org_page(&app, shell, org, None, Some("No such account.".into())).await; }; if f.remove.is_some() { let owners = accounts::org_members(&app.db, org.id) .await .map_err(|e| Error::from_db(shell.clone(), e))? .iter() .filter(|(_, r)| r == "owner") .count(); let is_owner = accounts::org_role(&app.db, org.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.as_deref() == Some("owner"); if is_owner && owners <= 1 { return org_page(&app, shell, org, None, Some("An organisation keeps at least one owner.".into())).await; } accounts::remove_org_member(&app.db, org.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, Some(&me), "org.member.removed", &org.name, &target.username).await.ok(); return org_page(&app, shell, org, Some(format!("Removed {}.", target.username)), None).await; } let role = if f.role == "owner" { "owner" } else { "member" }; accounts::set_org_member(&app.db, org.id, target.id, role).await.map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, Some(&me), "org.member.set", &org.name, &format!("{} {role}", target.username)).await.ok(); org_page(&app, shell, org, Some(format!("{} is now {role}.", target.username)), None).await } #[derive(Deserialize)] pub struct TeamForm { #[serde(rename = "_csrf")] csrf_token: String, team: String, #[serde(default)] username: String, #[serde(default)] action: String, } pub async fn team( State(app): State, viewer: Viewer, Path(name): Path, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); let (me, org) = owned_org(&app, &shell, &viewer, &name).await?; csrf(&viewer, &f.csrf_token, &shell)?; let team_name = f.team.trim().to_string(); if !crate::repos::names::valid_segment(&team_name) { return org_page(&app, shell, org, None, Some("A team name is letters, digits, dots, dashes and underscores.".into())).await; } let existing = accounts::team_by_name(&app.db, org.id, &team_name).await.map_err(|e| Error::from_db(shell.clone(), e))?; let notice = match f.action.as_str() { "create" => { if existing.is_some() { return org_page(&app, shell, org, None, Some("That team exists.".into())).await; } accounts::create_team(&app.db, org.id, &team_name).await.map_err(|e| Error::from_db(shell.clone(), e))?; format!("Team {team_name} created.") } "delete" => { if let Some(t) = existing { accounts::delete_team(&app.db, t.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; } format!("Team {team_name} deleted.") } "add" | "remove" => { let Some(t) = existing else { return org_page(&app, shell, org, None, Some("No such team.".into())).await }; let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else { return org_page(&app, shell, org, None, Some("No such account.".into())).await; }; if f.action == "add" { accounts::set_org_member(&app.db, org.id, target.id, "member").await.ok(); accounts::add_team_member(&app.db, t.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; format!("{} added to {team_name}.", target.username) } else { accounts::remove_team_member(&app.db, t.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; format!("{} removed from {team_name}.", target.username) } } _ => return Err(Error::bad(shell, "unknown team action")), }; accounts::audit(&app.db, Some(&me), "org.team", &org.name, &format!("{} {team_name} {}", f.action, f.username.trim())).await.ok(); org_page(&app, shell, org, Some(notice), None).await }