forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
15.1 KiB
1//! Page: admin user management (`/admin/users`). Non-admins get 404.
2
3use forge_core::names::{normalize_name, validate_username};
4use forge_core::store::user_delete::{DeletionReport, GHOST_USERNAME};
5use forge_core::{AuditAction, Session, Store, User};
6use serde::Deserialize;
7use topcoat::Result;
8use topcoat::context::{Cx, app_context};
9use topcoat::router::content::Form;
10use topcoat::router::error::{forbidden, not_found, see_other};
11use topcoat::router::{page, path_param_segment};
12use topcoat::view::{View, component, view};
13
14use crate::audit::{self, Actor, Target};
15use crate::auth::{check_csrf, current_user, hash_password_async};
16use crate::i18n::admin::{self as g, K};
17use crate::i18n::{self, Key, Lang};
18use crate::state::AppState;
19use crate::views::{alert, enc, shell, user_facing_message};
20
21const MIN_PASSWORD_CHARS: usize = 12;
22const MAX_PASSWORD_BYTES: usize = 1024;
23
24#[derive(Deserialize)]
25struct 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)]
40struct 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.
48fn is_ghost(user: &User) -> bool {
49 normalize_name(&user.username) == GHOST_USERNAME
50}
51
52#[component]
53async 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")]
120async 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")]
132async 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.
171pub(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.
179async 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.
213fn 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.
221struct 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.
228async 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]
258async 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")]
319async 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")]
346async 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}