//! Organisations and teams (hub spec ยง5.2). use super::settings::{csrf, require}; use super::{filters, 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() || accounts::username_retired(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?; 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)>, hooks: Vec, notice: Option, error: Option, /// Runners serving the organisation: (runner, what it serves, whether /// it is the organisation's own). runners: Vec<(crate::db::runners::Runner, String, bool)>, /// A registration token just made, shown once. runner_token: Option, /// The organisation's AI assistants and prompt (#97). assistants: super::assistants::Panel, /// The organisation's Flow variables (#102). variables: super::variables::Panel, /// The organisation's Flow secrets (#107). secrets: super::secrets::Panel, } 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)); } let hooks = crate::db::hooks::hooks_for_org(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?; let mut runners = Vec::new(); for r in crate::db::runners::serving_owner(&app.db, &org.name).await.map_err(|e| Error::from_db(shell.clone(), e))? { let own = r.scope_owner == org.name; let serves = match r.scope_repo_id { Some(id) => crate::db::repos::get_by_id(&app.db, id).await.ok().flatten().map(|p| p.slug()).unwrap_or_default(), None if own => "the organisation's repositories".into(), None => "the whole site".into(), }; runners.push((r, serves, own)); } let assistants = super::assistants::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?; let variables = super::variables::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?; let secrets = super::secrets::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?; Ok(Page(OrgSettingsPage { shell, org, members, teams, hooks, notice, error, runners, runner_token: None, assistants, variables, secrets, })) } 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 } /// `POST /orgs/{name}/settings/avatar`: upload or remove the organisation's logo. pub async fn avatar( State(app): State, viewer: Viewer, Path(name): Path, multipart: axum::extract::Multipart, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); let (me, org) = owned_org(&app, &shell, &viewer, &name).await?; let upload = super::avatar::read_upload(multipart).await; csrf(&viewer, &upload.csrf, &shell)?; let result = super::avatar::apply(&app, "org", org.id, upload).await; if result.is_ok() { accounts::audit(&app.db, Some(&me), "org.picture", &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); match result { Ok(m) => org_page(&app, shell, org, Some(m), None).await, Err(e) => org_page(&app, shell, org, None, Some(e)).await, } } /// `POST /orgs/{name}/settings/hooks`: webhooks that hear every repository of the organisation. pub async fn hooks( State(app): State, viewer: Viewer, Path(name): Path, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); let (_, org) = owned_org(&app, &shell, &viewer, &name).await?; csrf(&viewer, &f.csrf_token, &shell)?; let scope = super::webhooks::Scope::Org { id: org.id, name: org.name.clone() }; match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? { Ok(notice) => org_page(&app, shell, org, Some(notice), None).await, Err(error) => org_page(&app, shell, org, None, Some(error)).await, } } pub async fn deliveries( State(app): State, viewer: Viewer, Path((name, id)): Path<(String, i64)>, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/orgs/{name}/settings/hooks/{id}/deliveries"), &viewer); let (_, org) = owned_org(&app, &shell, &viewer, &name).await?; let scope = super::webhooks::Scope::Org { id: org.id, name: org.name }; super::webhooks::deliveries_page(&app, shell, &scope, None, id).await } pub async fn redeliver( State(app): State, viewer: Viewer, Path((name, id, delivery)): Path<(String, i64, i64)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/orgs/{name}/settings/hooks/{id}/deliveries"), &viewer); let (_, org) = owned_org(&app, &shell, &viewer, &name).await?; csrf(&viewer, &f.csrf, &shell)?; let scope = super::webhooks::Scope::Org { id: org.id, name: org.name }; super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?; super::webhooks::deliveries_page(&app, shell, &scope, None, id).await } /// `POST /orgs/{name}/settings/runners/token`: a one-time registration /// token for a runner that serves the organisation's repositories /// (oxygenhub#41). pub async fn runner_token( 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, &shell)?; let token = format!("flowreg_{}", crate::auth::random_hex(24)); let scope = crate::db::runners::Scope { owner: org.name.clone(), repo_id: None }; crate::db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me.username, &scope) .await .map_err(|e| Error::from_db(shell.clone(), e))?; accounts::audit(&app.db, Some(&me), "flow.runner.token", &org.name, "registration token for the organisation").await.ok(); let mut page = org_page(&app, shell, org, Some("Copy the token now; it is not shown again.".into()), None).await?; page.0.runner_token = Some(token); Ok(page) } /// `POST /orgs/{name}/settings/runners/{id}/remove`: remove a runner scoped /// to the organisation or one of its repositories, when it runs nothing. pub async fn runner_remove( State(app): State, viewer: Viewer, Path((name, id)): Path<(String, i64)>, 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, &shell)?; let e = |e| Error::from_db(shell.clone(), e); let Some(r) = crate::db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_owner == org.name) else { return Err(Error::not_found(shell)); }; if crate::db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) { return org_page(&app, shell, org, None, Some(format!("{} is running jobs: wait for them to finish.", r.name))).await; } crate::db::runners::remove(&app.db, id).await.map_err(e)?; accounts::audit(&app.db, Some(&me), "flow.runner.removed", &r.name, &org.name).await.ok(); org_page(&app, shell, org, Some(format!("{} removed: its credential no longer works.", r.name)), None).await } fn org_assistants(org: &accounts::Organisation) -> crate::levels::At { crate::levels::At::org(org) } /// `POST /orgs/{name}/settings/assistants`: the organisation's assistants /// and prompt (#97). pub async fn assistants( State(app): State, viewer: Viewer, Path(name): Path, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); let (_, org) = owned_org(&app, &shell, &viewer, &name).await?; csrf(&viewer, &f.csrf_token, &shell)?; match super::assistants::apply(&app, &viewer, &org_assistants(&org), f).await.map_err(|e| Error::from_db(shell.clone(), e))? { Ok(notice) => org_page(&app, shell, org, Some(notice), None).await, Err(error) => org_page(&app, shell, org, None, Some(error)).await, } } /// `POST /orgs/{name}/settings/variables`: the organisation's Flow /// variables (#102). pub async fn variables( State(app): State, viewer: Viewer, Path(name): Path, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); let (_, org) = owned_org(&app, &shell, &viewer, &name).await?; csrf(&viewer, &f.csrf_token, &shell)?; match super::variables::apply(&app, &viewer, &org_assistants(&org), f).await.map_err(|e| Error::from_db(shell.clone(), e))? { Ok(notice) => org_page(&app, shell, org, Some(notice), None).await, Err(error) => org_page(&app, shell, org, None, Some(error)).await, } } /// `POST /orgs/{name}/settings/secrets`: the organisation's Flow secrets (#107). pub async fn secrets( State(app): State, viewer: Viewer, Path(name): Path, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); let (_, org) = owned_org(&app, &shell, &viewer, &name).await?; csrf(&viewer, &f.csrf_token, &shell)?; match super::secrets::apply(&app, &viewer, &org_assistants(&org), f).await.map_err(|e| Error::from_db(shell.clone(), e))? { Ok(notice) => org_page(&app, shell, org, Some(notice), None).await, Err(error) => org_page(&app, shell, org, None, Some(error)).await, } } /// `POST /orgs/{name}/settings/assistants/models`: what a provider offers (#109). pub async fn assistant_models( State(app): State, viewer: Viewer, Path(name): Path, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); let (_, org) = owned_org(&app, &shell, &viewer, &name).await?; csrf(&viewer, &f.csrf_token, &shell)?; Ok(axum::Json(super::assistants::models(&app, &org_assistants(&org), f).await.map_err(|e| Error::from_db(shell.clone(), e))?)) }