Tahinli/forge
public0 watching
★ 0
| 1 | //! Page: admin user management (`/admin/users`). Non-admins get 404. |
| 2 | |
| 3 | use forge_core::names::{normalize_name, validate_username}; |
| 4 | use forge_core::store::user_delete::{DeletionReport, GHOST_USERNAME}; |
| 5 | use forge_core::{AuditAction, Session, Store, User}; |
| 6 | use serde::Deserialize; |
| 7 | use topcoat::Result; |
| 8 | use topcoat::context::{Cx, app_context}; |
| 9 | use topcoat::router::content::Form; |
| 10 | use topcoat::router::error::{forbidden, not_found, see_other}; |
| 11 | use topcoat::router::{page, path_param_segment}; |
| 12 | use topcoat::view::{View, component, view}; |
| 13 | |
| 14 | use crate::audit::{self, Actor, Target}; |
| 15 | use crate::auth::{check_csrf, current_user, hash_password_async}; |
| 16 | use crate::i18n::admin::{self as g, K}; |
| 17 | use crate::i18n::{self, Key, Lang}; |
| 18 | use crate::state::AppState; |
| 19 | use crate::views::{alert, enc, shell, user_facing_message}; |
| 20 | |
| 21 | const MIN_PASSWORD_CHARS: usize = 12; |
| 22 | const MAX_PASSWORD_BYTES: usize = 1024; |
| 23 | |
| 24 | #[derive(Deserialize)] |
| 25 | struct CreateUserForm { |
| 26 | #[serde(default)] |
| 27 | csrf: String, |
| 28 | #[serde(default)] |
| 29 | username: String, |
| 30 | #[serde(default)] |
| 31 | email: String, |
| 32 | #[serde(default)] |
| 33 | password: String, |
| 34 | /// Checkbox: present with any value when checked. |
| 35 | #[serde(default)] |
| 36 | is_admin: Option<String>, |
| 37 | } |
| 38 | |
| 39 | #[derive(Deserialize)] |
| 40 | struct DeleteForm { |
| 41 | #[serde(default)] |
| 42 | csrf: String, |
| 43 | #[serde(default)] |
| 44 | confirm_username: String, |
| 45 | } |
| 46 | |
| 47 | /// The ghost account is reserved and never shown as a deletable user. |
| 48 | fn is_ghost(user: &User) -> bool { |
| 49 | normalize_name(&user.username) == GHOST_USERNAME |
| 50 | } |
| 51 | |
| 52 | #[component] |
| 53 | async fn users_view( |
| 54 | lang: Lang, |
| 55 | csrf: &str, |
| 56 | viewer_id: &str, |
| 57 | users: Vec<User>, |
| 58 | error: Option<String>, |
| 59 | ) -> Result<impl View> { |
| 60 | Ok(view! { |
| 61 | <section class="page-head"> |
| 62 | <h1>(g::t(lang, K::Users))</h1> |
| 63 | <p><a href="/admin/audit">(g::t(lang, K::AuditLog))</a> " · " <a href="/admin/backups">(g::t(lang, K::Backups))</a> " · " <a href="/admin/replication">(g::t(lang, K::Replication))</a> " · " <a href="/admin/mail">(g::t(lang, K::Mail))</a> " · " <a href="/admin/runners">(g::t(lang, K::Runners))</a> " · " <a href="/admin/invites">(g::t(lang, K::Invites))</a></p> |
| 64 | </section> |
| 65 | <section class="card"> |
| 66 | <div class="table-wrap"> |
| 67 | <table class="table"> |
| 68 | <thead> |
| 69 | <tr><th>(i18n::t(lang, Key::Username))</th><th>(i18n::t(lang, Key::Email))</th><th>(g::t(lang, K::Role))</th><th>(i18n::t(lang, Key::Created))</th><th></th></tr> |
| 70 | </thead> |
| 71 | <tbody> |
| 72 | for user in users.into_iter() { |
| 73 | <tr> |
| 74 | <td>(user.username.clone())</td> |
| 75 | <td>(user.email.clone().unwrap_or_default())</td> |
| 76 | <td>(if user.is_org() { g::t(lang, K::RoleOrganization) } else if user.is_admin { g::t(lang, K::RoleAdmin) } else { g::t(lang, K::RoleUser) })</td> |
| 77 | <td class="small">(user.created_at.clone())</td> |
| 78 | <td> |
| 79 | if user.id != viewer_id && !is_ghost(&user) { |
| 80 | <a class="danger" href=(format!("/admin/users/{}/delete", enc(&user.id)))>(i18n::t(lang, Key::Delete))</a> |
| 81 | } |
| 82 | </td> |
| 83 | </tr> |
| 84 | } |
| 85 | </tbody> |
| 86 | </table> |
| 87 | </div> |
| 88 | </section> |
| 89 | <section class="card"> |
| 90 | <h2>(g::t(lang, K::CreateAUser))</h2> |
| 91 | if let Some(message) = error { |
| 92 | alert(message: message.as_str()) |
| 93 | } |
| 94 | <form method="post" action="/admin/users" class="stack"> |
| 95 | <input type="hidden" name="csrf" value=(csrf)> |
| 96 | <label> |
| 97 | <span>(i18n::t(lang, Key::Username))</span> |
| 98 | <input type="text" name="username" required=(true) autocomplete="off"> |
| 99 | </label> |
| 100 | <label> |
| 101 | <span>(i18n::t(lang, Key::EmailOptional))</span> |
| 102 | <input type="email" name="email" autocomplete="off"> |
| 103 | </label> |
| 104 | <label> |
| 105 | <span>(i18n::t(lang, Key::Password))</span> |
| 106 | <input type="password" name="password" required=(true) autocomplete="new-password"> |
| 107 | <small class="muted">(g::t(lang, K::AtLeast12Chars))</small> |
| 108 | </label> |
| 109 | <label class="inline"> |
| 110 | <input type="checkbox" name="is_admin" value="yes"> |
| 111 | <span>(g::t(lang, K::Administrator))</span> |
| 112 | </label> |
| 113 | <button type="submit" class="primary">(g::t(lang, K::CreateUser))</button> |
| 114 | </form> |
| 115 | </section> |
| 116 | }) |
| 117 | } |
| 118 | |
| 119 | #[page("/admin/users")] |
| 120 | async fn users_page(cx: &Cx) -> Result<impl View> { |
| 121 | let viewer = admin_only(cx).await?; |
| 122 | let lang = i18n::lang(cx, Some(&viewer.0)); |
| 123 | let users = app_context::<AppState>(cx).store.list_users().await?; |
| 124 | let csrf = viewer.1.csrf_token.clone(); |
| 125 | let title = g::t(lang, K::Users); |
| 126 | Ok(view! { |
| 127 | shell(title: title, viewer: Some(&viewer), users_view(lang: lang, csrf: csrf.as_str(), viewer_id: viewer.0.id.as_str(), users: users, error: None)) |
| 128 | }) |
| 129 | } |
| 130 | |
| 131 | #[page(POST "/admin/users")] |
| 132 | async fn users_create(cx: &Cx, Form(form): Form<CreateUserForm>) -> Result<impl View> { |
| 133 | let viewer = admin_only(cx).await?; |
| 134 | if !check_csrf(&viewer.1, &form.csrf) { |
| 135 | return Err(forbidden().into()); |
| 136 | } |
| 137 | let lang = i18n::lang(cx, Some(&viewer.0)); |
| 138 | let store = &app_context::<AppState>(cx).store; |
| 139 | |
| 140 | // Ok(Ok(user)) created; Ok(Err(message)) rejected with a user-facing message. |
| 141 | let outcome: Result<std::result::Result<User, String>> = create_user(store, &form, lang).await; |
| 142 | match outcome? { |
| 143 | Ok(created) => { |
| 144 | audit::record( |
| 145 | store, |
| 146 | Some(cx), |
| 147 | Actor::User(&viewer.0), |
| 148 | AuditAction::UserCreated, |
| 149 | Target { |
| 150 | kind: "user", |
| 151 | id: &created.id, |
| 152 | name: &created.username, |
| 153 | }, |
| 154 | serde_json::json!({ "admin": created.is_admin }), |
| 155 | ) |
| 156 | .await; |
| 157 | Err(see_other("/admin/users").into()) |
| 158 | } |
| 159 | Err(message) => { |
| 160 | let users = store.list_users().await?; |
| 161 | let csrf = viewer.1.csrf_token.clone(); |
| 162 | let title = g::t(lang, K::Users); |
| 163 | Ok(view! { |
| 164 | shell(title: title, viewer: Some(&viewer), users_view(lang: lang, csrf: csrf.as_str(), viewer_id: viewer.0.id.as_str(), users: users, error: Some(message))) |
| 165 | }) |
| 166 | } |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | /// The signed-in admin, or 404 for anonymous users and non-admins alike. |
| 171 | pub(crate) async fn admin_only(cx: &Cx) -> Result<(User, Session)> { |
| 172 | match current_user(cx).await? { |
| 173 | Some(viewer) if viewer.0.is_admin => Ok(viewer), |
| 174 | _ => Err(not_found().into()), |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | /// Validates the form and creates the user. The inner `Err` is a user-facing rejection. |
| 179 | async fn create_user( |
| 180 | store: &forge_core::Store, |
| 181 | form: &CreateUserForm, |
| 182 | lang: Lang, |
| 183 | ) -> Result<std::result::Result<User, String>> { |
| 184 | let username = match validate_username(&form.username) { |
| 185 | Ok((name, _)) => name, |
| 186 | Err(e) => { |
| 187 | return Ok(Err(user_facing_message(&e) |
| 188 | .unwrap_or_else(|| g::t(lang, K::ErrInvalidUsername).to_owned()))); |
| 189 | } |
| 190 | }; |
| 191 | if form.password.chars().count() < MIN_PASSWORD_CHARS { |
| 192 | return Ok(Err(g::password_min_error(lang, MIN_PASSWORD_CHARS))); |
| 193 | } |
| 194 | if form.password.len() > MAX_PASSWORD_BYTES { |
| 195 | return Ok(Err(g::t(lang, K::ErrPasswordTooLong).to_owned())); |
| 196 | } |
| 197 | let email = form.email.trim(); |
| 198 | let email = (!email.is_empty()).then_some(email); |
| 199 | let hash = hash_password_async(form.password.clone()).await?; |
| 200 | match store |
| 201 | .create_user(&username, email, &hash, form.is_admin.is_some()) |
| 202 | .await |
| 203 | { |
| 204 | Ok(created) => Ok(Ok(created)), |
| 205 | Err(e) => match user_facing_message(&e) { |
| 206 | Some(message) => Ok(Err(message)), |
| 207 | None => Err(e.into()), |
| 208 | }, |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | /// Rows moved and removed by a deletion, as `"<n> <label>"` lines, skipping empty kinds. |
| 213 | fn impact_lines(rows: &[(&'static str, u64)]) -> Vec<String> { |
| 214 | rows.iter() |
| 215 | .filter(|(_, n)| *n > 0) |
| 216 | .map(|(label, n)| format!("{n} {label}")) |
| 217 | .collect() |
| 218 | } |
| 219 | |
| 220 | /// What a deletion would move and remove, or the reason it is refused. |
| 221 | struct Impact { |
| 222 | moved: Vec<String>, |
| 223 | removed: Vec<String>, |
| 224 | } |
| 225 | |
| 226 | /// The owned repositories of `target` (listed when non-empty) and either its impact or the |
| 227 | /// refusal message. Changes nothing and does not create the ghost account. |
| 228 | async fn review_target( |
| 229 | store: &Store, |
| 230 | target: &User, |
| 231 | lang: Lang, |
| 232 | ) -> Result<(Vec<String>, std::result::Result<Impact, String>)> { |
| 233 | let repos = store |
| 234 | .list_repos_by_owner(&target.id) |
| 235 | .await? |
| 236 | .into_iter() |
| 237 | .map(|repo| format!("{}/{}", repo.owner_name, repo.name)) |
| 238 | .collect(); |
| 239 | let ghost_id = store.user_by_username(GHOST_USERNAME).await?.map(|g| g.id); |
| 240 | let outcome = match store |
| 241 | .deletion_preview(&target.id, ghost_id.as_deref()) |
| 242 | .await |
| 243 | { |
| 244 | Ok(DeletionReport { |
| 245 | reassigned, |
| 246 | removed, |
| 247 | }) => Ok(Impact { |
| 248 | moved: impact_lines(&reassigned), |
| 249 | removed: impact_lines(&removed), |
| 250 | }), |
| 251 | Err(e) => Err(user_facing_message(&e) |
| 252 | .unwrap_or_else(|| g::t(lang, K::ErrAccountCannotBeDeleted).to_owned())), |
| 253 | }; |
| 254 | Ok((repos, outcome)) |
| 255 | } |
| 256 | |
| 257 | #[component] |
| 258 | async fn delete_body( |
| 259 | lang: Lang, |
| 260 | csrf: &str, |
| 261 | target: User, |
| 262 | repos: Vec<String>, |
| 263 | impact: Option<Impact>, |
| 264 | refusal: Option<String>, |
| 265 | error: Option<String>, |
| 266 | ) -> Result<impl View> { |
| 267 | let has_repos = !repos.is_empty(); |
| 268 | let action = format!("/admin/users/{}/delete", enc(&target.id)); |
| 269 | let prompt = g::type_to_confirm(lang, &target.username); |
| 270 | Ok(view! { |
| 271 | <section class="page-head"> |
| 272 | <h1>(g::t(lang, K::DeleteUser))</h1> |
| 273 | </section> |
| 274 | <section class="card"> |
| 275 | if let Some(message) = error { |
| 276 | alert(message: message.as_str()) |
| 277 | } |
| 278 | if let Some(message) = refusal { |
| 279 | alert(message: message.as_str()) |
| 280 | } |
| 281 | <p>(g::t(lang, K::DeletesAccount))<strong>(target.username.as_str())</strong>(g::t(lang, K::DeleteAccountRest))</p> |
| 282 | if has_repos { |
| 283 | <p>(g::t(lang, K::TransferOrDeleteFirst))</p> |
| 284 | <ul class="item-list"> |
| 285 | for name in repos.into_iter() { |
| 286 | <li>(name)</li> |
| 287 | } |
| 288 | </ul> |
| 289 | } |
| 290 | if let Some(impact) = impact { |
| 291 | <h2>(g::t(lang, K::WhatHappens))</h2> |
| 292 | <p>(g::t(lang, K::MovedToGhost))</p> |
| 293 | <ul class="item-list"> |
| 294 | for line in impact.moved.into_iter() { |
| 295 | <li>(line)</li> |
| 296 | } |
| 297 | </ul> |
| 298 | <p>(g::t(lang, K::RemovedWithAccount))</p> |
| 299 | <ul class="item-list"> |
| 300 | for line in impact.removed.into_iter() { |
| 301 | <li>(line)</li> |
| 302 | } |
| 303 | </ul> |
| 304 | <form method="post" action=(action.as_str()) class="stack"> |
| 305 | <input type="hidden" name="csrf" value=(csrf)> |
| 306 | <label> |
| 307 | <span>(prompt.as_str())</span> |
| 308 | <input type="text" name="confirm_username" autocomplete="off" required=(true)> |
| 309 | </label> |
| 310 | <button type="submit" class="danger">(g::t(lang, K::DeleteUser))</button> |
| 311 | </form> |
| 312 | } |
| 313 | <p><a href="/admin/users">(g::t(lang, K::BackToUsers))</a></p> |
| 314 | </section> |
| 315 | }) |
| 316 | } |
| 317 | |
| 318 | #[page("/admin/users/{user_id}/delete")] |
| 319 | async fn user_delete_page(cx: &Cx) -> Result<impl View> { |
| 320 | let viewer = admin_only(cx).await?; |
| 321 | let lang = i18n::lang(cx, Some(&viewer.0)); |
| 322 | let store = &app_context::<AppState>(cx).store; |
| 323 | let target_id = path_param_segment(cx, "user_id").to_owned(); |
| 324 | let Some(target) = store.user_by_id(&target_id).await? else { |
| 325 | return Err(not_found().into()); |
| 326 | }; |
| 327 | if target.id == viewer.0.id { |
| 328 | return Err(see_other("/settings/security").into()); |
| 329 | } |
| 330 | if is_ghost(&target) { |
| 331 | return Err(not_found().into()); |
| 332 | } |
| 333 | let (repos, outcome) = review_target(store, &target, lang).await?; |
| 334 | let (impact, refusal) = match outcome { |
| 335 | Ok(impact) => (Some(impact), None), |
| 336 | Err(message) => (None, Some(message)), |
| 337 | }; |
| 338 | let csrf = viewer.1.csrf_token.clone(); |
| 339 | let title = g::t(lang, K::DeleteUser); |
| 340 | Ok(view! { |
| 341 | shell(title: title, viewer: Some(&viewer), delete_body(lang: lang, csrf: csrf.as_str(), target: target, repos: repos, impact: impact, refusal: refusal, error: None)) |
| 342 | }) |
| 343 | } |
| 344 | |
| 345 | #[page(POST "/admin/users/{user_id}/delete")] |
| 346 | async fn user_delete(cx: &Cx, Form(form): Form<DeleteForm>) -> Result<impl View> { |
| 347 | let viewer = admin_only(cx).await?; |
| 348 | if !check_csrf(&viewer.1, &form.csrf) { |
| 349 | return Err(forbidden().into()); |
| 350 | } |
| 351 | let lang = i18n::lang(cx, Some(&viewer.0)); |
| 352 | let store = &app_context::<AppState>(cx).store; |
| 353 | let target_id = path_param_segment(cx, "user_id").to_owned(); |
| 354 | let Some(target) = store.user_by_id(&target_id).await? else { |
| 355 | return Err(not_found().into()); |
| 356 | }; |
| 357 | if target.id == viewer.0.id { |
| 358 | return Err(see_other("/settings/security").into()); |
| 359 | } |
| 360 | if is_ghost(&target) { |
| 361 | return Err(not_found().into()); |
| 362 | } |
| 363 | let error = if form.confirm_username != target.username { |
| 364 | Some(g::t(lang, K::ErrTypeUsernameExactly).to_owned()) |
| 365 | } else { |
| 366 | let ghost = store.ensure_ghost_user().await?; |
| 367 | match store.delete_user(&target.id, &ghost.id).await { |
| 368 | Ok(_) => { |
| 369 | audit::record( |
| 370 | store, |
| 371 | Some(cx), |
| 372 | Actor::User(&viewer.0), |
| 373 | AuditAction::UserDeleted, |
| 374 | Target { |
| 375 | kind: "user", |
| 376 | id: &target.id, |
| 377 | name: &target.username, |
| 378 | }, |
| 379 | serde_json::json!({ "reassigned_to": GHOST_USERNAME }), |
| 380 | ) |
| 381 | .await; |
| 382 | return Err(see_other("/admin/users").into()); |
| 383 | } |
| 384 | Err(e) => match user_facing_message(&e) { |
| 385 | Some(message) => Some(message), |
| 386 | None => return Err(e.into()), |
| 387 | }, |
| 388 | } |
| 389 | }; |
| 390 | let (repos, outcome) = review_target(store, &target, lang).await?; |
| 391 | let (impact, refusal) = match outcome { |
| 392 | Ok(impact) => (Some(impact), None), |
| 393 | Err(message) => (None, Some(message)), |
| 394 | }; |
| 395 | let csrf = viewer.1.csrf_token.clone(); |
| 396 | let title = g::t(lang, K::DeleteUser); |
| 397 | Ok(view! { |
| 398 | shell(title: title, viewer: Some(&viewer), delete_body(lang: lang, csrf: csrf.as_str(), target: target, repos: repos, impact: impact, refusal: refusal, error: error)) |
| 399 | }) |
| 400 | } |