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 {
10071007 flex: 1;
10081008 min-width: 9rem;
10091009 }
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
10111033 /* Settings: a left tab rail and the active tab's content. */
10121034 .settings-layout {
crates/store/src/groups.rs +24 −0
@@ -144,6 +144,30 @@ impl Store {
144144 .map_err(Into::into)
145145 }
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+
147171 /// Delete a group by id. Child groups cascade (`ON DELETE CASCADE`); repos in
148172 /// the group have their `group_id` set null by the schema, so they survive as
149173 /// ungrouped repos. Returns `true` if a row was deleted.
crates/store/src/repos.rs +20 −0
@@ -190,6 +190,26 @@ impl Store {
190190 .map_err(Into::into)
191191 }
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+
193213 /// Rename a repository: update its leaf `name` and materialized `path`
194214 /// (metadata only — the on-disk directory is keyed by id and never moves).
195215 ///
crates/store/src/tests.rs +32 −0
@@ -475,6 +475,38 @@ async fn list_users_is_sorted_case_insensitively() {
475475 }
476476
477477 #[tokio::test]
478+async 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]
478510 async fn delete_user_cascades_to_repos() {
479511 for fx in sqlite_fixtures().await {
480512 let user = fx
crates/store/src/users.rs +20 −0
@@ -199,6 +199,26 @@ impl Store {
199199 .map_err(Into::into)
200200 }
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+
202222 /// Change a user's username (case-preserved, case-insensitively unique).
203223 ///
204224 /// # Errors
crates/web/src/admin.rs +89 −24
@@ -9,7 +9,7 @@
99 use std::path::{Path as FsPath, PathBuf};
1010
1111 use axum::Form;
12-use axum::extract::{Path, State};
12+use axum::extract::{Path, Query, State};
1313 use axum::http::{HeaderMap, Uri};
1414 use axum::response::{IntoResponse, Redirect, Response};
1515 use axum_extra::extract::cookie::CookieJar;
@@ -60,6 +60,47 @@ fn render(
6060 (jar, page(&chrome, "Admin", admin_shell(active, content))).into_response()
6161 }
6262
63+/// Rows shown per page in the admin list views.
64+const PAGE_SIZE: i64 = 20;
65+
66+/// The `?page=N` (1-based) query parameter shared by the paginated lists.
67+#[derive(Debug, Default, Deserialize)]
68+pub struct Pager {
69+ #[serde(default)]
70+ page: Option<i64>,
71+}
72+
73+impl 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`).
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+
63104 // ---- Overview ----
64105
65106 /// `GET /admin` — instance statistics.
@@ -109,8 +150,13 @@ pub async fn users(
109150 RequireAdmin(user): RequireAdmin,
110151 jar: CookieJar,
111152 uri: Uri,
153+ Query(pager): Query<Pager>,
112154 ) -> 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?;
114160 let (_, chrome) = build_chrome(
115161 &state,
116162 jar.clone(),
@@ -146,6 +192,7 @@ pub async fn users(
146192 }
147193 }
148194 }
195+ (pager_nav("/admin/users", page, total))
149196 }
150197 }
151198 section class="listing" {
@@ -158,10 +205,12 @@ pub async fn users(
158205 input type="email" name="email" placeholder="email" required;
159206 input type="password" name="password" placeholder="password (optional)";
160207 }
161- label class="checkbox" { input type="checkbox" name="admin" value="1"; " Administrator" }
162- button class="btn btn-primary inline-btn" type="submit" { "Create user" }
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)." }
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+ }
163213 }
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)." }
165214 }
166215 }
167216 section class="listing" {
@@ -171,11 +220,11 @@ pub async fn users(
171220 input type="hidden" name="_csrf" value=(csrf);
172221 div class="admin-form-row" {
173222 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) } }
175224 }
176225 input type="password" name="password" placeholder="new password" required minlength="8";
226+ button class="btn inline-btn" type="submit" { "Set password" }
177227 }
178- button class="btn inline-btn" type="submit" { "Set password" }
179228 }
180229 }
181230 }
@@ -365,8 +414,11 @@ pub async fn repos(
365414 RequireAdmin(user): RequireAdmin,
366415 jar: CookieJar,
367416 uri: Uri,
417+ Query(pager): Query<Pager>,
368418 ) -> 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?;
370422 // Resolve owner names for links.
371423 let mut owners = std::collections::HashMap::new();
372424 for r in &repos {
@@ -403,6 +455,7 @@ pub async fn repos(
403455 }
404456 }
405457 }
458+ (pager_nav("/admin/repos", page, total))
406459 }
407460 }
408461 };
@@ -433,8 +486,11 @@ pub async fn groups(
433486 RequireAdmin(user): RequireAdmin,
434487 jar: CookieJar,
435488 uri: Uri,
489+ Query(pager): Query<Pager>,
436490 ) -> 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?;
438494 let mut owners = std::collections::HashMap::new();
439495 for g in &groups {
440496 if !owners.contains_key(&g.owner_id)
@@ -468,6 +524,7 @@ pub async fn groups(
468524 }
469525 }
470526 }
527+ (pager_nav("/admin/groups", page, total))
471528 p class="muted field-hint" { "Deleting a group ungroups its repositories (they are not deleted)." }
472529 }
473530 }
@@ -535,10 +592,10 @@ pub async fn invites(
535592 }
536593 form method="post" action="/admin/invites" {
537594 input type="hidden" name="_csrf" value=(csrf);
538- div class="admin-form-row" {
595+ div class="admin-form-row admin-form-row-last" {
539596 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" }
540598 }
541- button class="btn btn-primary inline-btn" type="submit" { "Create invite" }
542599 }
543600 }
544601 }
@@ -647,21 +704,29 @@ pub async fn settings(
647704 "These override the config file. Leaving a field at its default keeps the config value; each row can be reset."
648705 }
649706 div class="card" {
650- form method="post" action="/admin/settings" {
707+ form method="post" action="/admin/settings" class="stacked-form" {
651708 input type="hidden" name="_csrf" value=(csrf);
652- label for="s-name" { "Instance name" }
653- input type="text" id="s-name" name="name" value=(name);
654- label for="s-desc" { "Description" }
655- input type="text" id="s-desc" name="description" value=(desc);
656- label for="s-vis" { "Default repository visibility" }
657- select id="s-vis" name="default_visibility" {
658- (vis_opt("public", "Public"))
659- (vis_opt("internal", "Internal"))
660- (vis_opt("private", "Private"))
709+ div class="form-field" {
710+ label for="s-name" { "Instance name" }
711+ input type="text" id="s-name" name="name" value=(name);
712+ }
713+ div class="form-field" {
714+ label for="s-desc" { "Description" }
715+ input type="text" id="s-desc" name="description" value=(desc);
716+ }
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" }
661729 }
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" }
665730 }
666731 }
667732 };