OXYGENHUB
# c0fac31d36fd oxygenhub / crates / hub / src / web / orgs.rs
9.8 KiB · Rust · last changed Milestone 0.2: accounts and access 1 month ago source blame history raw
1
//! Organisations and teams (hub spec §5.2).
2
3
use super::settings::{csrf, require};
4
use super::{Error, Page, Shell};
5
use crate::auth::{roles, Viewer};
6
use crate::db::accounts;
7
use crate::Shared;
8
use askama::Template;
9
use axum::extract::{Form, Path, State};
10
use axum::response::{IntoResponse, Redirect, Response};
11
use serde::Deserialize;
12
13
#[derive(Template)]
14
#[template(path = "org_new.html")]
15
pub struct OrgNewPage {
16
    shell: Shell,
17
    error: Option<String>,
18
}
19
20
pub async fn new_form(State(app): State<Shared>, viewer: Viewer) -> Result<Page<OrgNewPage>, Error> {
21
    let shell = Shell::new(&app, "/orgs/new", &viewer);
22
    require(&viewer, &shell)?;
23
    Ok(Page(OrgNewPage { shell, error: None }))
24
}
25
26
#[derive(Deserialize)]
27
pub struct OrgNewForm {
28
    #[serde(rename = "_csrf")]
29
    csrf_token: String,
30
    name: String,
31
    display_name: String,
32
}
33
34
pub async fn create(State(app): State<Shared>, viewer: Viewer, Form(f): Form<OrgNewForm>) -> Result<Response, Error> {
35
    let shell = Shell::new(&app, "/orgs/new", &viewer);
36
    let me = require(&viewer, &shell)?;
37
    csrf(&viewer, &f.csrf_token, &shell)?;
38
    let name = f.name.trim().to_string();
39
    if !crate::repos::names::valid_owner(&name) {
40
        return Ok(Page(OrgNewPage {
41
            shell,
42
            error: Some("An organisation name is letters, digits, dots, dashes and underscores.".into()),
43
        })
44
        .into_response());
45
    }
46
    let taken = accounts::account_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
47
        || accounts::org_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some();
48
    if taken {
49
        return Ok(Page(OrgNewPage { shell, error: Some("That name is taken.".into()) }).into_response());
50
    }
51
    let org =
52
        accounts::create_org(&app.db, &name, f.display_name.trim(), me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
53
    accounts::audit(&app.db, Some(&me.account), "org.created", &org.name, "").await.ok();
54
    Ok(Redirect::to(&format!("/orgs/{}/settings", org.name)).into_response())
55
}
56
57
#[derive(Template)]
58
#[template(path = "org_settings.html")]
59
pub struct OrgSettingsPage {
60
    shell: Shell,
61
    org: accounts::Organisation,
62
    members: Vec<(accounts::Account, String)>,
63
    teams: Vec<(accounts::Team, Vec<accounts::Account>)>,
64
    notice: Option<String>,
65
    error: Option<String>,
66
}
67
68
async fn org_page(
69
    app: &Shared,
70
    shell: Shell,
71
    org: accounts::Organisation,
72
    notice: Option<String>,
73
    error: Option<String>,
74
) -> Result<Page<OrgSettingsPage>, Error> {
75
    let members = accounts::org_members(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
76
    let mut teams = Vec::new();
77
    for t in accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
78
        let members = accounts::team_members(&app.db, t.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
79
        teams.push((t, members));
80
    }
81
    Ok(Page(OrgSettingsPage { shell, org, members, teams, notice, error }))
82
}
83
84
async fn owned_org(app: &Shared, shell: &Shell, viewer: &Viewer, name: &str) -> Result<(accounts::Account, accounts::Organisation), Error> {
85
    let me = require(viewer, shell)?;
86
    let org = accounts::org_by_name(&app.db, name)
87
        .await
88
        .map_err(|e| Error::from_db(shell.clone(), e))?
89
        .ok_or_else(|| Error::not_found(shell.clone()))?;
90
    if !roles::is_org_owner(&app.db, &me.account, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
91
        return Err(Error::not_found(shell.clone()));
92
    }
93
    Ok((me.account, org))
94
}
95
96
pub async fn settings(State(app): State<Shared>, viewer: Viewer, Path(name): Path<String>) -> Result<Page<OrgSettingsPage>, Error> {
97
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
98
    let (_, org) = owned_org(&app, &shell, &viewer, &name).await?;
99
    org_page(&app, shell, org, None, None).await
100
}
101
102
#[derive(Deserialize)]
103
pub struct OrgForm {
104
    #[serde(rename = "_csrf")]
105
    csrf_token: String,
106
    display_name: String,
107
    description: String,
108
    default_role: String,
109
}
110
111
pub async fn update(
112
    State(app): State<Shared>,
113
    viewer: Viewer,
114
    Path(name): Path<String>,
115
    Form(f): Form<OrgForm>,
116
) -> Result<Page<OrgSettingsPage>, Error> {
117
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
118
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
119
    csrf(&viewer, &f.csrf_token, &shell)?;
120
    let role = if f.default_role == "none" || roles::Role::parse(&f.default_role).is_some() { f.default_role.as_str() } else { "read" };
121
    accounts::set_org(&app.db, org.id, f.display_name.trim(), f.description.trim(), role)
122
        .await
123
        .map_err(|e| Error::from_db(shell.clone(), e))?;
124
    accounts::audit(&app.db, Some(&me), "org.updated", &org.name, "").await.ok();
125
    let org = accounts::org_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(org);
126
    org_page(&app, shell, org, Some("Saved.".into()), None).await
127
}
128
129
#[derive(Deserialize)]
130
pub struct MemberForm {
131
    #[serde(rename = "_csrf")]
132
    csrf_token: String,
133
    username: String,
134
    #[serde(default)]
135
    role: String,
136
    #[serde(default)]
137
    remove: Option<String>,
138
}
139
140
pub async fn member(
141
    State(app): State<Shared>,
142
    viewer: Viewer,
143
    Path(name): Path<String>,
144
    Form(f): Form<MemberForm>,
145
) -> Result<Page<OrgSettingsPage>, Error> {
146
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
147
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
148
    csrf(&viewer, &f.csrf_token, &shell)?;
149
    let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
150
        return org_page(&app, shell, org, None, Some("No such account.".into())).await;
151
    };
152
    if f.remove.is_some() {
153
        let owners = accounts::org_members(&app.db, org.id)
154
            .await
155
            .map_err(|e| Error::from_db(shell.clone(), e))?
156
            .iter()
157
            .filter(|(_, r)| r == "owner")
158
            .count();
159
        let is_owner =
160
            accounts::org_role(&app.db, org.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.as_deref() == Some("owner");
161
        if is_owner && owners <= 1 {
162
            return org_page(&app, shell, org, None, Some("An organisation keeps at least one owner.".into())).await;
163
        }
164
        accounts::remove_org_member(&app.db, org.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
165
        accounts::audit(&app.db, Some(&me), "org.member.removed", &org.name, &target.username).await.ok();
166
        return org_page(&app, shell, org, Some(format!("Removed {}.", target.username)), None).await;
167
    }
168
    let role = if f.role == "owner" { "owner" } else { "member" };
169
    accounts::set_org_member(&app.db, org.id, target.id, role).await.map_err(|e| Error::from_db(shell.clone(), e))?;
170
    accounts::audit(&app.db, Some(&me), "org.member.set", &org.name, &format!("{} {role}", target.username)).await.ok();
171
    org_page(&app, shell, org, Some(format!("{} is now {role}.", target.username)), None).await
172
}
173
174
#[derive(Deserialize)]
175
pub struct TeamForm {
176
    #[serde(rename = "_csrf")]
177
    csrf_token: String,
178
    team: String,
179
    #[serde(default)]
180
    username: String,
181
    #[serde(default)]
182
    action: String,
183
}
184
185
pub async fn team(
186
    State(app): State<Shared>,
187
    viewer: Viewer,
188
    Path(name): Path<String>,
189
    Form(f): Form<TeamForm>,
190
) -> Result<Page<OrgSettingsPage>, Error> {
191
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
192
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
193
    csrf(&viewer, &f.csrf_token, &shell)?;
194
    let team_name = f.team.trim().to_string();
195
    if !crate::repos::names::valid_segment(&team_name) {
196
        return org_page(&app, shell, org, None, Some("A team name is letters, digits, dots, dashes and underscores.".into())).await;
197
    }
198
    let existing = accounts::team_by_name(&app.db, org.id, &team_name).await.map_err(|e| Error::from_db(shell.clone(), e))?;
199
    let notice = match f.action.as_str() {
200
        "create" => {
201
            if existing.is_some() {
202
                return org_page(&app, shell, org, None, Some("That team exists.".into())).await;
203
            }
204
            accounts::create_team(&app.db, org.id, &team_name).await.map_err(|e| Error::from_db(shell.clone(), e))?;
205
            format!("Team {team_name} created.")
206
        }
207
        "delete" => {
208
            if let Some(t) = existing {
209
                accounts::delete_team(&app.db, t.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
210
            }
211
            format!("Team {team_name} deleted.")
212
        }
213
        "add" | "remove" => {
214
            let Some(t) = existing else { return org_page(&app, shell, org, None, Some("No such team.".into())).await };
215
            let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))?
216
            else {
217
                return org_page(&app, shell, org, None, Some("No such account.".into())).await;
218
            };
219
            if f.action == "add" {
220
                accounts::set_org_member(&app.db, org.id, target.id, "member").await.ok();
221
                accounts::add_team_member(&app.db, t.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
222
                format!("{} added to {team_name}.", target.username)
223
            } else {
224
                accounts::remove_team_member(&app.db, t.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
225
                format!("{} removed from {team_name}.", target.username)
226
            }
227
        }
228
        _ => return Err(Error::bad(shell, "unknown team action")),
229
    };
230
    accounts::audit(&app.db, Some(&me), "org.team", &org.name, &format!("{} {team_name} {}", f.action, f.username.trim())).await.ok();
231
    org_page(&app, shell, org, Some(notice), None).await
232
}