fabrica

hanna/fabrica

feat(web): paginate admin lists and tidy admin forms

604537e · hanna committed on 2026-07-26

- Users, Repositories, and Groups lists paginate at 20 rows (?page=N),
  using new store methods list_{users,repos,groups}_paged and the
  existing admin_counts totals for the page count.
- Create-a-user: move the password hint above a bottom row that pairs
  the Administrator checkbox with the Create button.
- Reset-a-password and Create-invite: the submit button now sits inline
  to the right of its input.
- Instance settings: wrap each label+control in a .form-field for even
  vertical rhythm, with the toggles and Save button in a bottom row.

The password picker still lists every account (not just the visible
page) so any user can be reset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
6 files changed · +207 −24UnifiedSplit
assets/base.css +22 −0
@@ -1007,6 +1007,28 @@ body.drawer-open .drawer-backdrop {
1007 flex: 1;1007 flex: 1;
1008 min-width: 9rem;1008 min-width: 9rem;
1009}1009}
1010/* A control row that is the last thing in its form carries no trailing gap. */
1011.admin-form-row-last {
1012 margin-bottom: 0;
1013}
1014/* A bottom row pairing option toggles/checkboxes with the submit button. */
1015.admin-form-actions {
1016 display: flex;
1017 align-items: center;
1018 flex-wrap: wrap;
1019 gap: 0.75rem;
1020 margin-top: 0.5rem;
1021}
1022.admin-form-actions label.checkbox {
1023 margin: 0;
1024}
1025/* Stacked label + control groups (admin settings). */
1026.form-field {
1027 margin-bottom: 0.9rem;
1028}
1029.form-field label {
1030 margin-top: 0;
1031}
10101032
1011/* Settings: a left tab rail and the active tab's content. */1033/* Settings: a left tab rail and the active tab's content. */
1012.settings-layout {1034.settings-layout {
crates/store/src/groups.rs +24 −0
@@ -144,6 +144,30 @@ impl Store {
144 .map_err(Into::into)144 .map_err(Into::into)
145 }145 }
146146
147 /// One page of groups, ordered by owner then path. For the admin view.
148 ///
149 /// # Errors
150 ///
151 /// Returns [`StoreError::Query`] on a database failure.
152 pub async fn list_groups_paged(
153 &self,
154 limit: i64,
155 offset: i64,
156 ) -> Result<Vec<Group>, StoreError> {
157 let sql = format!(
158 "SELECT {GROUP_COLUMNS} FROM groups ORDER BY owner_id ASC, path ASC LIMIT $1 OFFSET $2"
159 );
160 let rows = sqlx::query(AssertSqlSafe(sql))
161 .bind(limit)
162 .bind(offset)
163 .fetch_all(&self.pool)
164 .await?;
165 rows.iter()
166 .map(map_group)
167 .collect::<Result<_, _>>()
168 .map_err(Into::into)
169 }
170
147 /// Delete a group by id. Child groups cascade (`ON DELETE CASCADE`); repos in171 /// Delete a group by id. Child groups cascade (`ON DELETE CASCADE`); repos in
148 /// the group have their `group_id` set null by the schema, so they survive as172 /// the group have their `group_id` set null by the schema, so they survive as
149 /// ungrouped repos. Returns `true` if a row was deleted.173 /// ungrouped repos. Returns `true` if a row was deleted.
crates/store/src/repos.rs +20 −0
@@ -190,6 +190,26 @@ impl Store {
190 .map_err(Into::into)190 .map_err(Into::into)
191 }191 }
192192
193 /// One page of repositories, ordered by owner then path. For the admin view.
194 ///
195 /// # Errors
196 ///
197 /// Returns [`StoreError::Query`] on a database failure.
198 pub async fn list_repos_paged(&self, limit: i64, offset: i64) -> Result<Vec<Repo>, StoreError> {
199 let sql = format!(
200 "SELECT {REPO_COLUMNS} FROM repos ORDER BY owner_id ASC, path ASC LIMIT $1 OFFSET $2"
201 );
202 let rows = sqlx::query(AssertSqlSafe(sql))
203 .bind(limit)
204 .bind(offset)
205 .fetch_all(&self.pool)
206 .await?;
207 rows.iter()
208 .map(|r| self.map_repo(r))
209 .collect::<Result<_, _>>()
210 .map_err(Into::into)
211 }
212
193 /// Rename a repository: update its leaf `name` and materialized `path`213 /// Rename a repository: update its leaf `name` and materialized `path`
194 /// (metadata only — the on-disk directory is keyed by id and never moves).214 /// (metadata only — the on-disk directory is keyed by id and never moves).
195 ///215 ///
crates/store/src/tests.rs +32 −0
@@ -475,6 +475,38 @@ async fn list_users_is_sorted_case_insensitively() {
475}475}
476476
477#[tokio::test]477#[tokio::test]
478async fn list_users_paged_slices_by_limit_and_offset() {
479 for fx in sqlite_fixtures().await {
480 for name in ["alice", "Bob", "Charlie", "dave"] {
481 fx.store
482 .create_user(sample_user(name, &format!("{name}@example.com")))
483 .await
484 .unwrap();
485 }
486 let page1: Vec<String> = fx
487 .store
488 .list_users_paged(2, 0)
489 .await
490 .unwrap()
491 .into_iter()
492 .map(|u| u.username)
493 .collect();
494 assert_eq!(page1, ["alice", "Bob"]);
495 let page2: Vec<String> = fx
496 .store
497 .list_users_paged(2, 2)
498 .await
499 .unwrap()
500 .into_iter()
501 .map(|u| u.username)
502 .collect();
503 assert_eq!(page2, ["Charlie", "dave"]);
504 // Past the end yields nothing.
505 assert!(fx.store.list_users_paged(2, 4).await.unwrap().is_empty());
506 }
507}
508
509#[tokio::test]
478async fn delete_user_cascades_to_repos() {510async fn delete_user_cascades_to_repos() {
479 for fx in sqlite_fixtures().await {511 for fx in sqlite_fixtures().await {
480 let user = fx512 let user = fx
crates/store/src/users.rs +20 −0
@@ -199,6 +199,26 @@ impl Store {
199 .map_err(Into::into)199 .map_err(Into::into)
200 }200 }
201201
202 /// One page of users, ordered case-insensitively by username.
203 ///
204 /// # Errors
205 ///
206 /// Returns [`StoreError::Query`] on a database failure.
207 pub async fn list_users_paged(&self, limit: i64, offset: i64) -> Result<Vec<User>, StoreError> {
208 let sql = format!(
209 "SELECT {USER_COLUMNS} FROM users ORDER BY username_lower ASC LIMIT $1 OFFSET $2"
210 );
211 let rows = sqlx::query(AssertSqlSafe(sql))
212 .bind(limit)
213 .bind(offset)
214 .fetch_all(&self.pool)
215 .await?;
216 rows.iter()
217 .map(|r| self.map_user(r))
218 .collect::<Result<_, _>>()
219 .map_err(Into::into)
220 }
221
202 /// Change a user's username (case-preserved, case-insensitively unique).222 /// Change a user's username (case-preserved, case-insensitively unique).
203 ///223 ///
204 /// # Errors224 /// # Errors
crates/web/src/admin.rs +89 −24
@@ -9,7 +9,7 @@
9use std::path::{Path as FsPath, PathBuf};9use std::path::{Path as FsPath, PathBuf};
1010
11use axum::Form;11use axum::Form;
12use axum::extract::{Path, State};12use axum::extract::{Path, Query, State};
13use axum::http::{HeaderMap, Uri};13use axum::http::{HeaderMap, Uri};
14use axum::response::{IntoResponse, Redirect, Response};14use axum::response::{IntoResponse, Redirect, Response};
15use axum_extra::extract::cookie::CookieJar;15use axum_extra::extract::cookie::CookieJar;
@@ -60,6 +60,47 @@ fn render(
60 (jar, page(&chrome, "Admin", admin_shell(active, content))).into_response()60 (jar, page(&chrome, "Admin", admin_shell(active, content))).into_response()
61}61}
6262
63/// Rows shown per page in the admin list views.
64const PAGE_SIZE: i64 = 20;
65
66/// The `?page=N` (1-based) query parameter shared by the paginated lists.
67#[derive(Debug, Default, Deserialize)]
68pub struct Pager {
69 #[serde(default)]
70 page: Option<i64>,
71}
72
73impl Pager {
74 /// The clamped 1-based page number and its row offset.
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/// Previous/next controls under a list, shown only when `total` spans more than
82/// one page. `base` is the list path (e.g. `/admin/users`).
83fn 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// ---- Overview ----104// ---- Overview ----
64105
65/// `GET /admin` — instance statistics.106/// `GET /admin` — instance statistics.
@@ -109,8 +150,13 @@ pub async fn users(
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 // The password picker offers every account, not just the current page.
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,6 +192,7 @@ pub async fn users(
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,10 +205,12 @@ pub async fn users(
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,11 +220,11 @@ pub async fn users(
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,8 +414,11 @@ pub async fn repos(
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 // Resolve owner names for links.422 // Resolve owner names for links.
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,6 +455,7 @@ pub async fn repos(
403 }455 }
404 }456 }
405 }457 }
458 (pager_nav("/admin/repos", page, total))
406 }459 }
407 }460 }
408 };461 };
@@ -433,8 +486,11 @@ pub async fn groups(
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,6 +524,7 @@ pub async fn groups(
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,10 +592,10 @@ pub async fn invites(
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,21 +704,29 @@ pub async fn settings(
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 };