| 9 | use std::path::{Path as FsPath, PathBuf}; | 9 | use std::path::{Path as FsPath, PathBuf}; |
| 10 | | 10 | |
| 11 | use axum::Form; | 11 | use axum::Form; |
| 12 | use axum::extract::{Path, State}; | 12 | use axum::extract::{Path, Query, State}; |
| 13 | use axum::http::{HeaderMap, Uri}; | 13 | use axum::http::{HeaderMap, Uri}; |
| 14 | use axum::response::{IntoResponse, Redirect, Response}; | 14 | use axum::response::{IntoResponse, Redirect, Response}; |
| 15 | use axum_extra::extract::cookie::CookieJar; | 15 | use axum_extra::extract::cookie::CookieJar; |
| 60 | (jar, page(&chrome, "Admin", admin_shell(active, content))).into_response() | 60 | (jar, page(&chrome, "Admin", admin_shell(active, content))).into_response() |
| 61 | } | 61 | } |
| 62 | | 62 | |
| | 63 | |
| | 64 | const PAGE_SIZE: i64 = 20; |
| | 65 | |
| | 66 | |
| | 67 | #[derive(Debug, Default, Deserialize)] |
| | 68 | pub struct Pager { |
| | 69 | #[serde(default)] |
| | 70 | page: Option<i64>, |
| | 71 | } |
| | 72 | |
| | 73 | impl Pager { |
| | 74 | |
| | 75 | fn resolve(&self) -> (i64, i64) { |
| | 76 | let page = self.page.unwrap_or(1).max(1); |
| | 77 | (page, (page - 1) * PAGE_SIZE) |
| | 78 | } |
| | 79 | } |
| | 80 | |
| | 81 | |
| | 82 | |
| | 83 | fn pager_nav(base: &str, page: i64, total: i64) -> Markup { |
| | 84 | let pages = ((total + PAGE_SIZE - 1) / PAGE_SIZE).max(1); |
| | 85 | html! { |
| | 86 | @if pages > 1 { |
| | 87 | nav class="pagination" aria-label="Pagination" { |
| | 88 | @if page > 1 { |
| | 89 | a class="btn" href=(format!("{base}?page={}", page - 1)) { "← Previous" } |
| | 90 | } @else { |
| | 91 | span class="btn disabled" aria-disabled="true" { "← Previous" } |
| | 92 | } |
| | 93 | span class="muted pagination-page" { "Page " (page) " of " (pages) } |
| | 94 | @if page < pages { |
| | 95 | a class="btn" href=(format!("{base}?page={}", page + 1)) { "Next →" } |
| | 96 | } @else { |
| | 97 | span class="btn disabled" aria-disabled="true" { "Next →" } |
| | 98 | } |
| | 99 | } |
| | 100 | } |
| | 101 | } |
| | 102 | } |
| | 103 | |
| 63 | | 104 | |
| 64 | | 105 | |
| 65 | | 106 | |
| 109 | RequireAdmin(user): RequireAdmin, | 150 | RequireAdmin(user): RequireAdmin, |
| 110 | jar: CookieJar, | 151 | jar: CookieJar, |
| 111 | uri: Uri, | 152 | uri: Uri, |
| | 153 | Query(pager): Query<Pager>, |
| 112 | ) -> AppResult<Response> { | 154 | ) -> AppResult<Response> { |
| 113 | let all = state.store.list_users().await?; | 155 | let (page, offset) = pager.resolve(); |
| | 156 | let total = state.store.admin_counts().await?.users; |
| | 157 | let all = state.store.list_users_paged(PAGE_SIZE, offset).await?; |
| | 158 | |
| | 159 | let everyone = state.store.list_users().await?; |
| 114 | let (_, chrome) = build_chrome( | 160 | let (_, chrome) = build_chrome( |
| 115 | &state, | 161 | &state, |
| 116 | jar.clone(), | 162 | jar.clone(), |
| 146 | } | 192 | } |
| 147 | } | 193 | } |
| 148 | } | 194 | } |
| | 195 | (pager_nav("/admin/users", page, total)) |
| 149 | } | 196 | } |
| 150 | } | 197 | } |
| 151 | section class="listing" { | 198 | section class="listing" { |
| 158 | input type="email" name="email" placeholder="email" required; | 205 | input type="email" name="email" placeholder="email" required; |
| 159 | input type="password" name="password" placeholder="password (optional)"; | 206 | input type="password" name="password" placeholder="password (optional)"; |
| 160 | } | 207 | } |
| 161 | label class="checkbox" { input type="checkbox" name="admin" value="1"; " Administrator" } | 208 | p class="muted field-hint" { "Leaving the password blank creates an account that must be activated by the user (they'll need an invite/reset)." } |
| 162 | button class="btn btn-primary inline-btn" type="submit" { "Create user" } | 209 | div class="admin-form-actions" { |
| | 210 | label class="checkbox" { input type="checkbox" name="admin" value="1"; " Administrator" } |
| | 211 | button class="btn btn-primary inline-btn" type="submit" { "Create user" } |
| | 212 | } |
| 163 | } | 213 | } |
| 164 | p class="muted field-hint" { "Leaving the password blank creates an account that must be activated by the user (they'll need an invite/reset)." } | | |
| 165 | } | 214 | } |
| 166 | } | 215 | } |
| 167 | section class="listing" { | 216 | section class="listing" { |
| 171 | input type="hidden" name="_csrf" value=(csrf); | 220 | input type="hidden" name="_csrf" value=(csrf); |
| 172 | div class="admin-form-row" { | 221 | div class="admin-form-row" { |
| 173 | select name="user_id" aria-label="User" { | 222 | select name="user_id" aria-label="User" { |
| 174 | @for u in &all { option value=(u.id) { (u.username) } } | 223 | @for u in &everyone { option value=(u.id) { (u.username) } } |
| 175 | } | 224 | } |
| 176 | input type="password" name="password" placeholder="new password" required minlength="8"; | 225 | input type="password" name="password" placeholder="new password" required minlength="8"; |
| | 226 | button class="btn inline-btn" type="submit" { "Set password" } |
| 177 | } | 227 | } |
| 178 | button class="btn inline-btn" type="submit" { "Set password" } | | |
| 179 | } | 228 | } |
| 180 | } | 229 | } |
| 181 | } | 230 | } |
| 365 | RequireAdmin(user): RequireAdmin, | 414 | RequireAdmin(user): RequireAdmin, |
| 366 | jar: CookieJar, | 415 | jar: CookieJar, |
| 367 | uri: Uri, | 416 | uri: Uri, |
| | 417 | Query(pager): Query<Pager>, |
| 368 | ) -> AppResult<Response> { | 418 | ) -> AppResult<Response> { |
| 369 | let repos = state.store.list_repos().await?; | 419 | let (page, offset) = pager.resolve(); |
| | 420 | let total = state.store.admin_counts().await?.repos; |
| | 421 | let repos = state.store.list_repos_paged(PAGE_SIZE, offset).await?; |
| 370 | | 422 | |
| 371 | let mut owners = std::collections::HashMap::new(); | 423 | let mut owners = std::collections::HashMap::new(); |
| 372 | for r in &repos { | 424 | for r in &repos { |
| 403 | } | 455 | } |
| 404 | } | 456 | } |
| 405 | } | 457 | } |
| | 458 | (pager_nav("/admin/repos", page, total)) |
| 406 | } | 459 | } |
| 407 | } | 460 | } |
| 408 | }; | 461 | }; |
| 433 | RequireAdmin(user): RequireAdmin, | 486 | RequireAdmin(user): RequireAdmin, |
| 434 | jar: CookieJar, | 487 | jar: CookieJar, |
| 435 | uri: Uri, | 488 | uri: Uri, |
| | 489 | Query(pager): Query<Pager>, |
| 436 | ) -> AppResult<Response> { | 490 | ) -> AppResult<Response> { |
| 437 | let groups = state.store.list_groups().await?; | 491 | let (page, offset) = pager.resolve(); |
| | 492 | let total = state.store.admin_counts().await?.groups; |
| | 493 | let groups = state.store.list_groups_paged(PAGE_SIZE, offset).await?; |
| 438 | let mut owners = std::collections::HashMap::new(); | 494 | let mut owners = std::collections::HashMap::new(); |
| 439 | for g in &groups { | 495 | for g in &groups { |
| 440 | if !owners.contains_key(&g.owner_id) | 496 | if !owners.contains_key(&g.owner_id) |
| 468 | } | 524 | } |
| 469 | } | 525 | } |
| 470 | } | 526 | } |
| | 527 | (pager_nav("/admin/groups", page, total)) |
| 471 | p class="muted field-hint" { "Deleting a group ungroups its repositories (they are not deleted)." } | 528 | p class="muted field-hint" { "Deleting a group ungroups its repositories (they are not deleted)." } |
| 472 | } | 529 | } |
| 473 | } | 530 | } |
| 535 | } | 592 | } |
| 536 | form method="post" action="/admin/invites" { | 593 | form method="post" action="/admin/invites" { |
| 537 | input type="hidden" name="_csrf" value=(csrf); | 594 | input type="hidden" name="_csrf" value=(csrf); |
| 538 | div class="admin-form-row" { | 595 | div class="admin-form-row admin-form-row-last" { |
| 539 | input type="text" name="note" placeholder="note (optional, e.g. who it's for)"; | 596 | input type="text" name="note" placeholder="note (optional, e.g. who it's for)"; |
| | 597 | button class="btn btn-primary inline-btn" type="submit" { "Create invite" } |
| 540 | } | 598 | } |
| 541 | button class="btn btn-primary inline-btn" type="submit" { "Create invite" } | | |
| 542 | } | 599 | } |
| 543 | } | 600 | } |
| 544 | } | 601 | } |
| 647 | "These override the config file. Leaving a field at its default keeps the config value; each row can be reset." | 704 | "These override the config file. Leaving a field at its default keeps the config value; each row can be reset." |
| 648 | } | 705 | } |
| 649 | div class="card" { | 706 | div class="card" { |
| 650 | form method="post" action="/admin/settings" { | 707 | form method="post" action="/admin/settings" class="stacked-form" { |
| 651 | input type="hidden" name="_csrf" value=(csrf); | 708 | input type="hidden" name="_csrf" value=(csrf); |
| 652 | label for="s-name" { "Instance name" } | 709 | div class="form-field" { |
| 653 | input type="text" id="s-name" name="name" value=(name); | 710 | label for="s-name" { "Instance name" } |
| 654 | label for="s-desc" { "Description" } | 711 | input type="text" id="s-name" name="name" value=(name); |
| 655 | input type="text" id="s-desc" name="description" value=(desc); | 712 | } |
| 656 | label for="s-vis" { "Default repository visibility" } | 713 | div class="form-field" { |
| 657 | select id="s-vis" name="default_visibility" { | 714 | label for="s-desc" { "Description" } |
| 658 | (vis_opt("public", "Public")) | 715 | input type="text" id="s-desc" name="description" value=(desc); |
| 659 | (vis_opt("internal", "Internal")) | 716 | } |
| 660 | (vis_opt("private", "Private")) | 717 | div class="form-field" { |
| | 718 | label for="s-vis" { "Default repository visibility" } |
| | 719 | select id="s-vis" name="default_visibility" { |
| | 720 | (vis_opt("public", "Public")) |
| | 721 | (vis_opt("internal", "Internal")) |
| | 722 | (vis_opt("private", "Private")) |
| | 723 | } |
| | 724 | } |
| | 725 | div class="admin-form-actions" { |
| | 726 | label class="checkbox" { input type="checkbox" name="allow_registration" value="1" checked[reg]; " Allow self-registration" } |
| | 727 | label class="checkbox" { input type="checkbox" name="allow_anonymous" value="1" checked[anon]; " Allow anonymous browse/clone" } |
| | 728 | button class="btn btn-primary inline-btn" type="submit" { "Save settings" } |
| 661 | } | 729 | } |
| 662 | label class="checkbox" { input type="checkbox" name="allow_registration" value="1" checked[reg]; " Allow self-registration" } | | |
| 663 | label class="checkbox" { input type="checkbox" name="allow_anonymous" value="1" checked[anon]; " Allow anonymous browse/clone" } | | |
| 664 | button class="btn btn-primary inline-btn" type="submit" { "Save settings" } | | |
| 665 | } | 730 | } |
| 666 | } | 731 | } |
| 667 | }; | 732 | }; |