Skip to main content

kellnr_web_ui/
group.rs

1use axum::Json;
2use axum::extract::{Path, State};
3use kellnr_common::account_name::{GroupName, UserName};
4use kellnr_db::{self, Group, UserId};
5use kellnr_http::error::ApiError;
6use kellnr_http::identity::AdminUser;
7use kellnr_http::state::DbState;
8use serde::{Deserialize, Serialize};
9use utoipa::ToSchema;
10
11/// List all groups (admin only)
12#[utoipa::path(
13    get,
14    path = "/",
15    tag = "groups",
16    responses(
17        (status = 200, description = "List of all groups", body = Vec<Group>),
18        (status = 403, description = "Admin access required")
19    ),
20    security(("session_cookie" = []))
21)]
22pub async fn list_groups(
23    _user: AdminUser,
24    State(db): DbState,
25) -> Result<Json<Vec<Group>>, ApiError> {
26    Ok(Json(db.get_groups().await?))
27}
28
29/// Delete a group (admin only)
30#[utoipa::path(
31    delete,
32    path = "/{name}",
33    tag = "groups",
34    params(
35        ("name" = String, Path, description = "Group name to delete")
36    ),
37    responses(
38        (status = 200, description = "Group deleted successfully"),
39        (status = 403, description = "Admin access required")
40    ),
41    security(("session_cookie" = []))
42)]
43pub async fn delete(
44    _user: AdminUser,
45    Path(name): Path<GroupName>,
46    State(db): DbState,
47) -> Result<(), ApiError> {
48    Ok(db.delete_group(&name).await?)
49}
50
51#[derive(Deserialize, ToSchema)]
52pub struct NewGroup {
53    pub name: String,
54}
55
56impl NewGroup {
57    /// Returns the name of the new group.
58    pub fn validate(&self) -> Result<GroupName, ApiError> {
59        Ok(GroupName::for_new_group(self.name.as_str())?)
60    }
61}
62
63/// Create a new group (admin only)
64#[utoipa::path(
65    post,
66    path = "/",
67    tag = "groups",
68    request_body = NewGroup,
69    responses(
70        (status = 200, description = "Group created successfully"),
71        (status = 400, description = "Validation failed"),
72        (status = 403, description = "Admin access required")
73    ),
74    security(("session_cookie" = []))
75)]
76pub async fn add(
77    _user: AdminUser,
78    State(db): DbState,
79    Json(new_group): Json<NewGroup>,
80) -> Result<(), ApiError> {
81    let name = new_group.validate()?;
82
83    Ok(db.add_group(&name).await?)
84}
85
86#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
87pub struct GroupUser {
88    pub id: UserId,
89    pub name: UserName,
90}
91
92#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
93pub struct GroupUserList {
94    pub users: Vec<GroupUser>,
95}
96
97impl From<Vec<GroupUser>> for GroupUserList {
98    fn from(users: Vec<GroupUser>) -> Self {
99        Self { users }
100    }
101}
102
103/// List members of a group (admin only)
104#[utoipa::path(
105    get,
106    path = "/{group_name}/members",
107    tag = "groups",
108    params(
109        ("group_name" = String, Path, description = "Group name")
110    ),
111    responses(
112        (status = 200, description = "List of group members", body = GroupUserList),
113        (status = 403, description = "Admin access required")
114    ),
115    security(("session_cookie" = []))
116)]
117pub async fn list_users(
118    _user: AdminUser,
119    Path(group_name): Path<GroupName>,
120    State(db): DbState,
121) -> Result<Json<GroupUserList>, ApiError> {
122    let users: Vec<GroupUser> = db
123        .get_group_users(&group_name)
124        .await?
125        .iter()
126        .map(|u| GroupUser {
127            id: u.id,
128            name: u.name.clone(),
129        })
130        .collect();
131
132    Ok(Json(GroupUserList::from(users)))
133}
134
135/// Add a user to a group (admin only)
136#[utoipa::path(
137    put,
138    path = "/{group_name}/members/{name}",
139    tag = "groups",
140    params(
141        ("group_name" = String, Path, description = "Group name"),
142        ("name" = String, Path, description = "Username to add")
143    ),
144    responses(
145        (status = 200, description = "User added to group successfully"),
146        (status = 403, description = "Admin access required")
147    ),
148    security(("session_cookie" = []))
149)]
150pub async fn add_user(
151    _user: AdminUser,
152    Path((group_name, name)): Path<(GroupName, UserName)>,
153    State(db): DbState,
154) -> Result<(), ApiError> {
155    if !db.is_group_user(&group_name, &name).await? {
156        db.add_group_user(&group_name, &name).await?;
157    }
158
159    Ok(())
160}
161
162/// Remove a user from a group (admin only)
163#[utoipa::path(
164    delete,
165    path = "/{group_name}/members/{name}",
166    tag = "groups",
167    params(
168        ("group_name" = String, Path, description = "Group name"),
169        ("name" = String, Path, description = "Username to remove")
170    ),
171    responses(
172        (status = 200, description = "User removed from group successfully"),
173        (status = 403, description = "Admin access required")
174    ),
175    security(("session_cookie" = []))
176)]
177pub async fn delete_user(
178    _user: AdminUser,
179    Path((group_name, name)): Path<(GroupName, UserName)>,
180    State(db): DbState,
181) -> Result<(), ApiError> {
182    Ok(db.delete_group_user(&group_name, &name).await?)
183}
184
185#[cfg(test)]
186mod tests {
187    use axum::http::StatusCode;
188
189    use super::*;
190
191    #[tokio::test]
192    async fn add_group_with_invalid_name_is_bad_request() {
193        for name in ["", " ", "dev\tteam", &"a".repeat(65)] {
194            let result = add(
195                AdminUser(kellnr_http::test_support::session_identity("admin", true)),
196                State(kellnr_http::test_support::test_state().db),
197                Json(NewGroup {
198                    name: name.to_string(),
199                }),
200            )
201            .await;
202
203            assert!(
204                result
205                    .as_ref()
206                    .is_err_and(|e| e.status() == StatusCode::BAD_REQUEST),
207                "{name:?}"
208            );
209        }
210    }
211}