fabrica

hanna/fabrica

feat(web): admin dashboard

2abf849 · hanna committed on 2026-07-26

Add a tabbed admin dashboard (gated by RequireAdmin; non-admins 404):

- Overview: instance stats — user/repo/group/issue/PR/key/token/session
  counts, repository storage (disk-walk, floored at tracked size), and
  the SQLite DB file size.
- Users: list, create, verify, enable/disable, grant/revoke admin,
  delete, and reset passwords.
- Repositories and Groups: list and delete.
- Invites: create sign-up URLs (raw token shown once) that permit
  registration even when self-registration is disabled; list and revoke.
  /register/{token} validates and redeems the invite.
- Settings: override config-file values (name, description, allow
  registration/anonymous, default visibility). Overrides live in
  instance_settings and are cached in AppState (DB override > config >
  default), refreshed at boot and after each change. A blank field
  clears the override.

Store gains admin_counts/total_repo_bytes, the signup_invites and
instance_settings tables (migration 0012), list_groups, and
set_admin. allow_anonymous/instance_name/default_visibility reads
across the web crate now route through the override layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
18 files changed · +1486 −24UnifiedSplit
assets/base.css +35 −0
@@ -973,6 +973,41 @@ body.drawer-open .drawer-backdrop {
973973 margin-left: auto;
974974 }
975975
976+/* Admin dashboard: stat grid and management lists. */
977+.stat-grid {
978+ display: grid;
979+ grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
980+ gap: 0.75rem;
981+}
982+.stat-card {
983+ padding: 1rem;
984+ background: var(--fb-bg-raised);
985+ border: 1px solid var(--fb-border);
986+ border-radius: var(--fb-radius);
987+}
988+.stat-value {
989+ font-size: 1.6rem;
990+ font-weight: 600;
991+}
992+.stat-label {
993+ font-size: 0.85em;
994+}
995+.admin-actions {
996+ flex-wrap: wrap;
997+ gap: 0.35rem;
998+}
999+.admin-form-row {
1000+ display: flex;
1001+ gap: 0.5rem;
1002+ flex-wrap: wrap;
1003+ margin-bottom: 0.5rem;
1004+}
1005+.admin-form-row input,
1006+.admin-form-row select {
1007+ flex: 1;
1008+ min-width: 9rem;
1009+}
1010+
9761011 /* Settings: a left tab rail and the active tab's content. */
9771012 .settings-layout {
9781013 display: flex;
crates/store/src/admin.rs +297 −0
@@ -0,0 +1,297 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Admin-dashboard queries: instance statistics, open sign-up invites, and
6+//! config-overriding instance settings.
7+
8+use std::collections::HashMap;
9+
10+use sqlx::Row;
11+
12+use crate::{Store, StoreError, new_id, now_ms};
13+
14+/// Row counts for the admin overview.
15+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
16+pub struct AdminCounts {
17+ /// Total user accounts.
18+ pub users: i64,
19+ /// Disabled accounts.
20+ pub disabled_users: i64,
21+ /// Site administrators.
22+ pub admins: i64,
23+ /// Total repositories.
24+ pub repos: i64,
25+ /// Total groups.
26+ pub groups: i64,
27+ /// Total issues (all states, excludes PRs).
28+ pub issues: i64,
29+ /// Open issues.
30+ pub open_issues: i64,
31+ /// Total pull requests.
32+ pub pulls: i64,
33+ /// Registered SSH/GPG keys.
34+ pub keys: i64,
35+ /// Active (unrevoked) API tokens.
36+ pub tokens: i64,
37+ /// Live sessions.
38+ pub sessions: i64,
39+}
40+
41+/// An open sign-up invite (a token that permits registration).
42+#[derive(Debug, Clone, PartialEq, Eq)]
43+pub struct SignupInvite {
44+ /// ULID primary key.
45+ pub id: String,
46+ /// Optional admin note (who/what it's for).
47+ pub note: Option<String>,
48+ /// Expiry, if any.
49+ pub expires_at: Option<i64>,
50+ /// When it was redeemed, if it has been.
51+ pub used_at: Option<i64>,
52+ /// The user who redeemed it.
53+ pub used_by: Option<String>,
54+ /// Creation time.
55+ pub created_at: i64,
56+}
57+
58+/// Fields to create a sign-up invite. The caller supplies the SHA-256 `token_hash`
59+/// (the raw token lives only in the emailed/copied URL).
60+#[derive(Debug, Clone)]
61+pub struct NewSignupInvite {
62+ /// SHA-256 of the token.
63+ pub token_hash: String,
64+ /// Optional note.
65+ pub note: Option<String>,
66+ /// Expiry, if any.
67+ pub expires_at: Option<i64>,
68+ /// The admin who created it.
69+ pub created_by: String,
70+}
71+
72+impl Store {
73+ /// Count the main entities for the admin overview.
74+ ///
75+ /// # Errors
76+ ///
77+ /// Returns [`StoreError::Query`] on a database failure.
78+ pub async fn admin_counts(&self) -> Result<AdminCounts, StoreError> {
79+ let count = |sql: &'static str| async move {
80+ let n: i64 = sqlx::query(sql).fetch_one(&self.pool).await?.try_get("n")?;
81+ Ok::<i64, StoreError>(n)
82+ };
83+ // Boolean filters are bound (not literals) to stay portable across
84+ // SQLite (INTEGER) and Postgres (BOOLEAN).
85+ let count_b = |sql: &'static str, b: bool| async move {
86+ let n: i64 = sqlx::query(sql)
87+ .bind(b)
88+ .fetch_one(&self.pool)
89+ .await?
90+ .try_get("n")?;
91+ Ok::<i64, StoreError>(n)
92+ };
93+ Ok(AdminCounts {
94+ users: count("SELECT COUNT(*) AS n FROM users").await?,
95+ disabled_users: count("SELECT COUNT(*) AS n FROM users WHERE disabled_at IS NOT NULL")
96+ .await?,
97+ admins: count_b("SELECT COUNT(*) AS n FROM users WHERE is_admin = $1", true).await?,
98+ repos: count("SELECT COUNT(*) AS n FROM repos").await?,
99+ groups: count("SELECT COUNT(*) AS n FROM groups").await?,
100+ issues: count_b("SELECT COUNT(*) AS n FROM issues WHERE is_pull = $1", false).await?,
101+ open_issues: count_b(
102+ "SELECT COUNT(*) AS n FROM issues WHERE state = 'open' AND is_pull = $1",
103+ false,
104+ )
105+ .await?,
106+ pulls: count_b("SELECT COUNT(*) AS n FROM issues WHERE is_pull = $1", true).await?,
107+ keys: count("SELECT COUNT(*) AS n FROM keys").await?,
108+ tokens: count("SELECT COUNT(*) AS n FROM api_tokens WHERE revoked_at IS NULL").await?,
109+ sessions: count("SELECT COUNT(*) AS n FROM sessions").await?,
110+ })
111+ }
112+
113+ /// The sum of every repository's tracked size in bytes.
114+ ///
115+ /// # Errors
116+ ///
117+ /// Returns [`StoreError::Query`] on a database failure.
118+ pub async fn total_repo_bytes(&self) -> Result<i64, StoreError> {
119+ let n: i64 = sqlx::query("SELECT COALESCE(SUM(size_bytes), 0) AS n FROM repos")
120+ .fetch_one(&self.pool)
121+ .await?
122+ .try_get("n")?;
123+ Ok(n)
124+ }
125+
126+ // ---- Sign-up invites ----
127+
128+ /// Create a sign-up invite.
129+ ///
130+ /// # Errors
131+ ///
132+ /// Returns [`StoreError::Query`] on a database failure.
133+ pub async fn create_signup_invite(
134+ &self,
135+ new: NewSignupInvite,
136+ ) -> Result<SignupInvite, StoreError> {
137+ let invite = SignupInvite {
138+ id: new_id(),
139+ note: new.note,
140+ expires_at: new.expires_at,
141+ used_at: None,
142+ used_by: None,
143+ created_at: now_ms(),
144+ };
145+ sqlx::query(
146+ "INSERT INTO signup_invites \
147+ (id, token_hash, note, expires_at, used_at, used_by, created_by, created_at) \
148+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
149+ )
150+ .bind(&invite.id)
151+ .bind(&new.token_hash)
152+ .bind(&invite.note)
153+ .bind(invite.expires_at)
154+ .bind(invite.used_at)
155+ .bind(&invite.used_by)
156+ .bind(&new.created_by)
157+ .bind(invite.created_at)
158+ .execute(&self.pool)
159+ .await?;
160+ Ok(invite)
161+ }
162+
163+ /// List every sign-up invite, newest first.
164+ ///
165+ /// # Errors
166+ ///
167+ /// Returns [`StoreError::Query`] on a database failure.
168+ pub async fn list_signup_invites(&self) -> Result<Vec<SignupInvite>, StoreError> {
169+ let rows = sqlx::query(
170+ "SELECT id, note, expires_at, used_at, used_by, created_at \
171+ FROM signup_invites ORDER BY created_at DESC",
172+ )
173+ .fetch_all(&self.pool)
174+ .await?;
175+ rows.iter()
176+ .map(|r| {
177+ Ok(SignupInvite {
178+ id: r.try_get("id")?,
179+ note: r.try_get("note")?,
180+ expires_at: r.try_get("expires_at")?,
181+ used_at: r.try_get("used_at")?,
182+ used_by: r.try_get("used_by")?,
183+ created_at: r.try_get("created_at")?,
184+ })
185+ })
186+ .collect::<Result<_, sqlx::Error>>()
187+ .map_err(Into::into)
188+ }
189+
190+ /// Whether a token hash names a currently valid (unused, unexpired) invite.
191+ ///
192+ /// # Errors
193+ ///
194+ /// Returns [`StoreError::Query`] on a database failure.
195+ pub async fn signup_invite_valid(
196+ &self,
197+ token_hash: &str,
198+ ) -> Result<Option<String>, StoreError> {
199+ let row = sqlx::query(
200+ "SELECT id FROM signup_invites \
201+ WHERE token_hash = $1 AND used_at IS NULL \
202+ AND (expires_at IS NULL OR expires_at > $2)",
203+ )
204+ .bind(token_hash)
205+ .bind(now_ms())
206+ .fetch_optional(&self.pool)
207+ .await?;
208+ Ok(row.map(|r| r.try_get("id")).transpose()?)
209+ }
210+
211+ /// Mark a sign-up invite redeemed by `user_id`.
212+ ///
213+ /// # Errors
214+ ///
215+ /// Returns [`StoreError::Query`] on a database failure.
216+ pub async fn redeem_signup_invite(
217+ &self,
218+ token_hash: &str,
219+ user_id: &str,
220+ ) -> Result<(), StoreError> {
221+ sqlx::query(
222+ "UPDATE signup_invites SET used_at = $1, used_by = $2 \
223+ WHERE token_hash = $3 AND used_at IS NULL",
224+ )
225+ .bind(now_ms())
226+ .bind(user_id)
227+ .bind(token_hash)
228+ .execute(&self.pool)
229+ .await?;
230+ Ok(())
231+ }
232+
233+ /// Delete a sign-up invite.
234+ ///
235+ /// # Errors
236+ ///
237+ /// Returns [`StoreError::Query`] on a database failure.
238+ pub async fn delete_signup_invite(&self, id: &str) -> Result<bool, StoreError> {
239+ let n = sqlx::query("DELETE FROM signup_invites WHERE id = $1")
240+ .bind(id)
241+ .execute(&self.pool)
242+ .await?
243+ .rows_affected();
244+ Ok(n > 0)
245+ }
246+
247+ // ---- Instance settings (config overrides) ----
248+
249+ /// Load every instance setting as a `key -> value` map.
250+ ///
251+ /// # Errors
252+ ///
253+ /// Returns [`StoreError::Query`] on a database failure.
254+ pub async fn all_settings(&self) -> Result<HashMap<String, String>, StoreError> {
255+ let rows = sqlx::query("SELECT key, value FROM instance_settings")
256+ .fetch_all(&self.pool)
257+ .await?;
258+ let mut out = HashMap::new();
259+ for r in &rows {
260+ out.insert(
261+ r.try_get::<String, _>("key")?,
262+ r.try_get::<String, _>("value")?,
263+ );
264+ }
265+ Ok(out)
266+ }
267+
268+ /// Set (upsert) an instance setting.
269+ ///
270+ /// # Errors
271+ ///
272+ /// Returns [`StoreError::Query`] on a database failure.
273+ pub async fn set_setting(&self, key: &str, value: &str) -> Result<(), StoreError> {
274+ sqlx::query(
275+ "INSERT INTO instance_settings (key, value) VALUES ($1, $2) \
276+ ON CONFLICT (key) DO UPDATE SET value = $2",
277+ )
278+ .bind(key)
279+ .bind(value)
280+ .execute(&self.pool)
281+ .await?;
282+ Ok(())
283+ }
284+
285+ /// Remove an instance setting (reverting to the config-file value).
286+ ///
287+ /// # Errors
288+ ///
289+ /// Returns [`StoreError::Query`] on a database failure.
290+ pub async fn delete_setting(&self, key: &str) -> Result<(), StoreError> {
291+ sqlx::query("DELETE FROM instance_settings WHERE key = $1")
292+ .bind(key)
293+ .execute(&self.pool)
294+ .await?;
295+ Ok(())
296+ }
297+}
crates/store/src/groups.rs +16 −0
@@ -128,6 +128,22 @@ impl Store {
128128 .map_err(Into::into)
129129 }
130130
131+ /// List every group across all owners, ordered by path. For the admin view.
132+ ///
133+ /// # Errors
134+ ///
135+ /// Returns [`StoreError::Query`] on a database failure.
136+ pub async fn list_groups(&self) -> Result<Vec<Group>, StoreError> {
137+ let sql = format!("SELECT {GROUP_COLUMNS} FROM groups ORDER BY owner_id ASC, path ASC");
138+ let rows = sqlx::query(AssertSqlSafe(sql))
139+ .fetch_all(&self.pool)
140+ .await?;
141+ rows.iter()
142+ .map(map_group)
143+ .collect::<Result<_, _>>()
144+ .map_err(Into::into)
145+ }
146+
131147 /// Delete a group by id. Child groups cascade (`ON DELETE CASCADE`); repos in
132148 /// the group have their `group_id` set null by the schema, so they survive as
133149 /// ungrouped repos. Returns `true` if a row was deleted.
crates/store/src/lib.rs +2 −0
@@ -25,6 +25,7 @@
2525 //! Migrations live in `migrations/{sqlite,postgres}/NNNN_name.sql`; the matching
2626 //! set is embedded at build time and selected at runtime by [`Store::migrate`].
2727
28+mod admin;
2829 mod groups;
2930 mod invites;
3031 mod issues;
@@ -43,6 +44,7 @@ use sqlx::Row;
4344 use sqlx::any::{AnyPoolOptions, AnyRow};
4445 use sqlx::migrate::Migrator;
4546
47+pub use crate::admin::{AdminCounts, NewSignupInvite, SignupInvite};
4648 pub use crate::invites::NewInvite;
4749 pub use crate::issues::StateCounts;
4850 pub use crate::keys::NewKey;
crates/store/src/users.rs +16 −0
@@ -571,6 +571,22 @@ impl Store {
571571 Ok(updated > 0)
572572 }
573573
574+ /// Grant or revoke a user's site-administrator flag.
575+ ///
576+ /// # Errors
577+ ///
578+ /// Returns [`StoreError::Query`] on a database failure.
579+ pub async fn set_admin(&self, user_id: &str, is_admin: bool) -> Result<bool, StoreError> {
580+ let updated = sqlx::query("UPDATE users SET is_admin = $1, updated_at = $2 WHERE id = $3")
581+ .bind(is_admin)
582+ .bind(now_ms())
583+ .bind(user_id)
584+ .execute(&self.pool)
585+ .await?
586+ .rows_affected();
587+ Ok(updated > 0)
588+ }
589+
574590 /// Delete a user by id, cascading to their repositories, keys, tokens, and
575591 /// sessions (via `ON DELETE CASCADE`). The caller is responsible for any
576592 /// policy that should *prevent* the delete (e.g. refusing when repos exist
crates/web/src/admin.rs +827 −0
@@ -0,0 +1,827 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! The admin dashboard: instance overview, and management of users, repositories,
6+//! groups, sign-up invites, and config overrides. Every route is gated by the
7+//! [`RequireAdmin`] extractor (non-admins get a 404).
8+
9+use std::path::{Path as FsPath, PathBuf};
10+
11+use axum::Form;
12+use axum::extract::{Path, State};
13+use axum::http::{HeaderMap, Uri};
14+use axum::response::{IntoResponse, Redirect, Response};
15+use axum_extra::extract::cookie::CookieJar;
16+use maud::{Markup, html};
17+use model::User;
18+use serde::Deserialize;
19+
20+use crate::error::{AppError, AppResult};
21+use crate::layout::page;
22+use crate::pages::build_chrome;
23+use crate::session::{RequireAdmin, verify_csrf};
24+use crate::{AppState, setting_keys};
25+
26+/// The admin dashboard shell: a left tab rail and the active tab's content.
27+#[allow(clippy::needless_pass_by_value)] // `content` is embedded once.
28+fn admin_shell(active: &str, content: Markup) -> Markup {
29+ let tab = |href: &str, key: &str, label: &str| {
30+ html! { a href=(href) aria-current=[(active == key).then_some("page")] { (label) } }
31+ };
32+ html! {
33+ div class="settings-layout" {
34+ aside class="settings-nav" {
35+ h1 { "Admin" }
36+ nav {
37+ (tab("/admin", "overview", "Overview"))
38+ (tab("/admin/users", "users", "Users"))
39+ (tab("/admin/repos", "repos", "Repositories"))
40+ (tab("/admin/groups", "groups", "Groups"))
41+ (tab("/admin/invites", "invites", "Invites"))
42+ (tab("/admin/settings", "settings", "Settings"))
43+ }
44+ }
45+ div class="settings-content" { (content) }
46+ }
47+ }
48+}
49+
50+/// Render an admin page with the shell and chrome.
51+fn render(
52+ state: &AppState,
53+ jar: CookieJar,
54+ user: User,
55+ uri: &Uri,
56+ active: &str,
57+ content: Markup,
58+) -> Response {
59+ let (jar, chrome) = build_chrome(state, jar, Some(user), uri.path().to_string());
60+ (jar, page(&chrome, "Admin", admin_shell(active, content))).into_response()
61+}
62+
63+// ---- Overview ----
64+
65+/// `GET /admin` — instance statistics.
66+pub async fn overview(
67+ State(state): State<AppState>,
68+ RequireAdmin(user): RequireAdmin,
69+ jar: CookieJar,
70+ uri: Uri,
71+) -> AppResult<Response> {
72+ let counts = state.store.admin_counts().await?;
73+ let tracked = state.store.total_repo_bytes().await.unwrap_or(0);
74+ // Disk usage of the repo tree (best-effort) and the SQLite DB file, if any.
75+ let repo_dir = state.config.storage.repo_dir.clone();
76+ let disk = tokio::task::spawn_blocking(move || dir_size(&repo_dir))
77+ .await
78+ .unwrap_or(0);
79+ let db_bytes = sqlite_db_size(&state.config.database.url);
80+
81+ let stat = |label: &str, value: String| {
82+ html! { div class="stat-card" { div class="stat-value" { (value) } div class="stat-label muted" { (label) } } }
83+ };
84+ let content = html! {
85+ h2 { "Overview" }
86+ div class="stat-grid" {
87+ (stat("Users", counts.users.to_string()))
88+ (stat("Administrators", counts.admins.to_string()))
89+ (stat("Disabled", counts.disabled_users.to_string()))
90+ (stat("Repositories", counts.repos.to_string()))
91+ (stat("Groups", counts.groups.to_string()))
92+ (stat("Issues (open)", format!("{} ({})", counts.issues, counts.open_issues)))
93+ (stat("Pull requests", counts.pulls.to_string()))
94+ (stat("SSH/GPG keys", counts.keys.to_string()))
95+ (stat("Active API tokens", counts.tokens.to_string()))
96+ (stat("Live sessions", counts.sessions.to_string()))
97+ (stat("Repository storage", fmt_bytes(disk.max(u64::try_from(tracked).unwrap_or(0)))))
98+ @if let Some(db) = db_bytes { (stat("Database size", fmt_bytes(db))) }
99+ }
100+ };
101+ Ok(render(&state, jar, user, &uri, "overview", content))
102+}
103+
104+// ---- Users ----
105+
106+/// `GET /admin/users` — the user list and a create form.
107+pub async fn users(
108+ State(state): State<AppState>,
109+ RequireAdmin(user): RequireAdmin,
110+ jar: CookieJar,
111+ uri: Uri,
112+) -> AppResult<Response> {
113+ let all = state.store.list_users().await?;
114+ let (_, chrome) = build_chrome(
115+ &state,
116+ jar.clone(),
117+ Some(user.clone()),
118+ uri.path().to_string(),
119+ );
120+ let csrf = chrome.csrf.clone();
121+ let content = html! {
122+ h2 { "Users" }
123+ section class="listing" {
124+ div class="card" {
125+ ul class="entry-list admin-list" {
126+ @for u in &all {
127+ li class="entry-row" {
128+ div class="entry-row-body" {
129+ span class="entry-row-title" {
130+ a href={ "/" (u.username) } { (u.username) }
131+ @if u.is_admin { " " span class="badge" { "admin" } }
132+ @if u.disabled_at.is_some() { " " span class="badge badge-private" { "disabled" } }
133+ }
134+ div class="entry-row-meta muted" { (u.email) }
135+ }
136+ div class="entry-row-actions admin-actions" {
137+ (post_button(&format!("/admin/users/{}/verify", u.id), &csrf, "Verify", "btn"))
138+ (toggle_button(&format!("/admin/users/{}/admin", u.id), &csrf, "admin", !u.is_admin,
139+ if u.is_admin { "Revoke admin" } else { "Make admin" }))
140+ (toggle_button(&format!("/admin/users/{}/disable", u.id), &csrf, "disabled", u.disabled_at.is_none(),
141+ if u.disabled_at.is_some() { "Enable" } else { "Disable" }))
142+ @if u.id != user.id {
143+ (post_button(&format!("/admin/users/{}/delete", u.id), &csrf, "Delete", "btn btn-danger"))
144+ }
145+ }
146+ }
147+ }
148+ }
149+ }
150+ }
151+ section class="listing" {
152+ h3 { "Create a user" }
153+ div class="card" {
154+ form method="post" action="/admin/users" {
155+ input type="hidden" name="_csrf" value=(csrf);
156+ div class="admin-form-row" {
157+ input type="text" name="username" placeholder="username" required;
158+ input type="email" name="email" placeholder="email" required;
159+ input type="password" name="password" placeholder="password (optional)";
160+ }
161+ label class="checkbox" { input type="checkbox" name="admin" value="1"; " Administrator" }
162+ button class="btn btn-primary inline-btn" type="submit" { "Create user" }
163+ }
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+ }
166+ }
167+ section class="listing" {
168+ h3 { "Reset a password" }
169+ div class="card" {
170+ form method="post" action="/admin/users/password" {
171+ input type="hidden" name="_csrf" value=(csrf);
172+ div class="admin-form-row" {
173+ select name="user_id" aria-label="User" {
174+ @for u in &all { option value=(u.id) { (u.username) } }
175+ }
176+ input type="password" name="password" placeholder="new password" required minlength="8";
177+ }
178+ button class="btn inline-btn" type="submit" { "Set password" }
179+ }
180+ }
181+ }
182+ };
183+ Ok(render(&state, jar, user, &uri, "users", content))
184+}
185+
186+/// Create-user form.
187+#[derive(Debug, Deserialize)]
188+pub struct CreateUserForm {
189+ #[serde(default)]
190+ username: String,
191+ #[serde(default)]
192+ email: String,
193+ #[serde(default)]
194+ password: String,
195+ #[serde(default)]
196+ admin: Option<String>,
197+ #[serde(rename = "_csrf")]
198+ csrf: String,
199+}
200+
201+/// `POST /admin/users` — create a user.
202+pub async fn user_create(
203+ State(state): State<AppState>,
204+ RequireAdmin(_): RequireAdmin,
205+ jar: CookieJar,
206+ headers: HeaderMap,
207+ Form(form): Form<CreateUserForm>,
208+) -> Response {
209+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
210+ return AppError::Forbidden.into_response();
211+ }
212+ let hash = if form.password.trim().is_empty() {
213+ None
214+ } else {
215+ match hash_password(&state, form.password.trim()) {
216+ Ok(h) => Some(h),
217+ Err(e) => return e.into_response(),
218+ }
219+ };
220+ match state
221+ .store
222+ .create_user(store::NewUser {
223+ username: form.username.trim().to_string(),
224+ email: form.email.trim().to_string(),
225+ display_name: None,
226+ password_hash: hash,
227+ is_admin: form.admin.is_some(),
228+ must_change_password: false,
229+ })
230+ .await
231+ {
232+ Ok(_) => Redirect::to("/admin/users").into_response(),
233+ Err(store::StoreError::Conflict { .. }) => {
234+ AppError::BadRequest("That username or email is already taken.".to_string())
235+ .into_response()
236+ }
237+ Err(store::StoreError::Name(_)) => {
238+ AppError::BadRequest("Invalid username.".to_string()).into_response()
239+ }
240+ Err(err) => AppError::from(err).into_response(),
241+ }
242+}
243+
244+/// A `{disabled|admin}=true/false` toggle form.
245+#[derive(Debug, Deserialize)]
246+pub struct ToggleForm {
247+ #[serde(default)]
248+ disabled: String,
249+ #[serde(default)]
250+ admin: String,
251+ #[serde(rename = "_csrf")]
252+ csrf: String,
253+}
254+
255+/// A single-field password form.
256+#[derive(Debug, Deserialize)]
257+pub struct PasswordForm {
258+ #[serde(default)]
259+ user_id: String,
260+ #[serde(default)]
261+ password: String,
262+ #[serde(rename = "_csrf")]
263+ csrf: String,
264+}
265+
266+/// A bare CSRF form for parameter-less actions.
267+#[derive(Debug, Deserialize)]
268+pub struct BareForm {
269+ #[serde(rename = "_csrf")]
270+ csrf: String,
271+}
272+
273+/// `POST /admin/users/{id}/verify`.
274+pub async fn user_verify(
275+ State(state): State<AppState>,
276+ RequireAdmin(_): RequireAdmin,
277+ jar: CookieJar,
278+ headers: HeaderMap,
279+ Path(id): Path<String>,
280+ Form(form): Form<BareForm>,
281+) -> Response {
282+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
283+ return AppError::Forbidden.into_response();
284+ }
285+ let _ = state.store.verify_primary_email(&id).await;
286+ Redirect::to("/admin/users").into_response()
287+}
288+
289+/// `POST /admin/users/{id}/disable`.
290+pub async fn user_disable(
291+ State(state): State<AppState>,
292+ RequireAdmin(_): RequireAdmin,
293+ jar: CookieJar,
294+ headers: HeaderMap,
295+ Path(id): Path<String>,
296+ Form(form): Form<ToggleForm>,
297+) -> Response {
298+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
299+ return AppError::Forbidden.into_response();
300+ }
301+ let _ = state.store.set_disabled(&id, form.disabled == "true").await;
302+ Redirect::to("/admin/users").into_response()
303+}
304+
305+/// `POST /admin/users/{id}/admin`.
306+pub async fn user_toggle_admin(
307+ State(state): State<AppState>,
308+ RequireAdmin(_): RequireAdmin,
309+ jar: CookieJar,
310+ headers: HeaderMap,
311+ Path(id): Path<String>,
312+ Form(form): Form<ToggleForm>,
313+) -> Response {
314+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
315+ return AppError::Forbidden.into_response();
316+ }
317+ let _ = state.store.set_admin(&id, form.admin == "true").await;
318+ Redirect::to("/admin/users").into_response()
319+}
320+
321+/// `POST /admin/users/{id}/delete`.
322+pub async fn user_delete(
323+ State(state): State<AppState>,
324+ RequireAdmin(admin): RequireAdmin,
325+ jar: CookieJar,
326+ headers: HeaderMap,
327+ Path(id): Path<String>,
328+ Form(form): Form<BareForm>,
329+) -> Response {
330+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
331+ return AppError::Forbidden.into_response();
332+ }
333+ if id == admin.id {
334+ return AppError::BadRequest("You cannot delete your own account here.".to_string())
335+ .into_response();
336+ }
337+ let _ = state.store.delete_user(&id).await;
338+ Redirect::to("/admin/users").into_response()
339+}
340+
341+/// `POST /admin/users/password` — set a user's password.
342+pub async fn user_set_password(
343+ State(state): State<AppState>,
344+ RequireAdmin(_): RequireAdmin,
345+ jar: CookieJar,
346+ headers: HeaderMap,
347+ Form(form): Form<PasswordForm>,
348+) -> Response {
349+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
350+ return AppError::Forbidden.into_response();
351+ }
352+ let hash = match hash_password(&state, form.password.trim()) {
353+ Ok(h) => h,
354+ Err(e) => return e.into_response(),
355+ };
356+ let _ = state.store.set_password(&form.user_id, Some(&hash)).await;
357+ Redirect::to("/admin/users").into_response()
358+}
359+
360+// ---- Repositories ----
361+
362+/// `GET /admin/repos`.
363+pub async fn repos(
364+ State(state): State<AppState>,
365+ RequireAdmin(user): RequireAdmin,
366+ jar: CookieJar,
367+ uri: Uri,
368+) -> AppResult<Response> {
369+ let repos = state.store.list_repos().await?;
370+ // Resolve owner names for links.
371+ let mut owners = std::collections::HashMap::new();
372+ for r in &repos {
373+ if !owners.contains_key(&r.owner_id)
374+ && let Ok(Some(u)) = state.store.user_by_id(&r.owner_id).await
375+ {
376+ owners.insert(r.owner_id.clone(), u.username);
377+ }
378+ }
379+ let (_, chrome) = build_chrome(
380+ &state,
381+ jar.clone(),
382+ Some(user.clone()),
383+ uri.path().to_string(),
384+ );
385+ let csrf = chrome.csrf.clone();
386+ let content = html! {
387+ h2 { "Repositories" }
388+ section class="listing" {
389+ div class="card" {
390+ @if repos.is_empty() { p class="muted" { "No repositories." } }
391+ ul class="entry-list admin-list" {
392+ @for r in &repos {
393+ @let owner = owners.get(&r.owner_id).cloned().unwrap_or_else(|| r.owner_id.clone());
394+ li class="entry-row" {
395+ div class="entry-row-body" {
396+ span class="entry-row-title" { a href={ "/" (owner) "/" (r.path) } { (owner) "/" (r.path) } }
397+ div class="entry-row-meta muted" { (r.visibility.as_str()) " · " (r.object_format) }
398+ }
399+ div class="entry-row-actions admin-actions" {
400+ a class="btn" href={ "/" (owner) "/" (r.path) "/-/settings" } { "Manage" }
401+ (post_button(&format!("/admin/repos/{}/delete", r.id), &csrf, "Delete", "btn btn-danger"))
402+ }
403+ }
404+ }
405+ }
406+ }
407+ }
408+ };
409+ Ok(render(&state, jar, user, &uri, "repos", content))
410+}
411+
412+/// `POST /admin/repos/{id}/delete`.
413+pub async fn repo_delete(
414+ State(state): State<AppState>,
415+ RequireAdmin(_): RequireAdmin,
416+ jar: CookieJar,
417+ headers: HeaderMap,
418+ Path(id): Path<String>,
419+ Form(form): Form<BareForm>,
420+) -> Response {
421+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
422+ return AppError::Forbidden.into_response();
423+ }
424+ let _ = state.store.delete_repo(&id).await;
425+ Redirect::to("/admin/repos").into_response()
426+}
427+
428+// ---- Groups ----
429+
430+/// `GET /admin/groups`.
431+pub async fn groups(
432+ State(state): State<AppState>,
433+ RequireAdmin(user): RequireAdmin,
434+ jar: CookieJar,
435+ uri: Uri,
436+) -> AppResult<Response> {
437+ let groups = state.store.list_groups().await?;
438+ let mut owners = std::collections::HashMap::new();
439+ for g in &groups {
440+ if !owners.contains_key(&g.owner_id)
441+ && let Ok(Some(u)) = state.store.user_by_id(&g.owner_id).await
442+ {
443+ owners.insert(g.owner_id.clone(), u.username);
444+ }
445+ }
446+ let (_, chrome) = build_chrome(
447+ &state,
448+ jar.clone(),
449+ Some(user.clone()),
450+ uri.path().to_string(),
451+ );
452+ let csrf = chrome.csrf.clone();
453+ let content = html! {
454+ h2 { "Groups" }
455+ section class="listing" {
456+ div class="card" {
457+ @if groups.is_empty() { p class="muted" { "No groups." } }
458+ ul class="entry-list admin-list" {
459+ @for g in &groups {
460+ @let owner = owners.get(&g.owner_id).cloned().unwrap_or_else(|| g.owner_id.clone());
461+ li class="entry-row" {
462+ div class="entry-row-body" {
463+ span class="entry-row-title" { a href={ "/" (owner) "/" (g.path) } { (owner) "/" (g.path) } }
464+ }
465+ div class="entry-row-actions admin-actions" {
466+ (post_button(&format!("/admin/groups/{}/delete", g.id), &csrf, "Delete", "btn btn-danger"))
467+ }
468+ }
469+ }
470+ }
471+ p class="muted field-hint" { "Deleting a group ungroups its repositories (they are not deleted)." }
472+ }
473+ }
474+ };
475+ Ok(render(&state, jar, user, &uri, "groups", content))
476+}
477+
478+/// `POST /admin/groups/{id}/delete`.
479+pub async fn group_delete(
480+ State(state): State<AppState>,
481+ RequireAdmin(_): RequireAdmin,
482+ jar: CookieJar,
483+ headers: HeaderMap,
484+ Path(id): Path<String>,
485+ Form(form): Form<BareForm>,
486+) -> Response {
487+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
488+ return AppError::Forbidden.into_response();
489+ }
490+ let _ = state.store.delete_group(&id).await;
491+ Redirect::to("/admin/groups").into_response()
492+}
493+
494+// ---- Invites ----
495+
496+/// `GET /admin/invites`.
497+pub async fn invites(
498+ State(state): State<AppState>,
499+ RequireAdmin(user): RequireAdmin,
500+ jar: CookieJar,
501+ uri: Uri,
502+) -> AppResult<Response> {
503+ let invites = state.store.list_signup_invites().await?;
504+ let base = state.config.instance.url.trim_end_matches('/').to_string();
505+ let (_, chrome) = build_chrome(
506+ &state,
507+ jar.clone(),
508+ Some(user.clone()),
509+ uri.path().to_string(),
510+ );
511+ let csrf = chrome.csrf.clone();
512+ let content = html! {
513+ h2 { "Sign-up invites" }
514+ p class="muted field-hint" {
515+ "Invite links let people register even when self-registration is disabled. The link is shown once at creation."
516+ }
517+ section class="listing" {
518+ div class="card" {
519+ @if invites.is_empty() { p class="muted" { "No invites." } }
520+ ul class="entry-list admin-list" {
521+ @for iv in &invites {
522+ li class="entry-row" {
523+ div class="entry-row-body" {
524+ span class="entry-row-title" { (iv.note.clone().unwrap_or_else(|| "(no note)".to_string())) }
525+ div class="entry-row-meta muted" {
526+ @if iv.used_at.is_some() { "used" } @else { "unused" }
527+ @if let Some(exp) = iv.expires_at { " · expires " (crate::repo::fmt_date(exp)) }
528+ }
529+ }
530+ div class="entry-row-actions admin-actions" {
531+ (post_button(&format!("/admin/invites/{}/delete", iv.id), &csrf, "Delete", "btn btn-danger"))
532+ }
533+ }
534+ }
535+ }
536+ form method="post" action="/admin/invites" {
537+ input type="hidden" name="_csrf" value=(csrf);
538+ div class="admin-form-row" {
539+ input type="text" name="note" placeholder="note (optional, e.g. who it's for)";
540+ }
541+ button class="btn btn-primary inline-btn" type="submit" { "Create invite" }
542+ }
543+ }
544+ }
545+ @if let Some(link) = jar.get("fabrica_flash_invite").map(|c| c.value().to_string()) {
546+ div class="card notice notice-success" {
547+ p { strong { "Invite link created." } " Copy it now:" }
548+ pre class="token-secret" { code { (base) "/register/" (link) } }
549+ }
550+ }
551+ };
552+ // Clear the flash cookie after showing it.
553+ let jar = jar.remove(axum_extra::extract::cookie::Cookie::from(
554+ "fabrica_flash_invite",
555+ ));
556+ Ok(render(&state, jar, user, &uri, "invites", content))
557+}
558+
559+/// Create-invite form.
560+#[derive(Debug, Deserialize)]
561+pub struct CreateInviteForm {
562+ #[serde(default)]
563+ note: String,
564+ #[serde(rename = "_csrf")]
565+ csrf: String,
566+}
567+
568+/// `POST /admin/invites` — create a sign-up invite and show its link once.
569+pub async fn invite_create(
570+ State(state): State<AppState>,
571+ RequireAdmin(admin): RequireAdmin,
572+ jar: CookieJar,
573+ headers: HeaderMap,
574+ Form(form): Form<CreateInviteForm>,
575+) -> Response {
576+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
577+ return AppError::Forbidden.into_response();
578+ }
579+ let token = auth::new_session_token();
580+ let note = form.note.trim();
581+ let created = state
582+ .store
583+ .create_signup_invite(store::NewSignupInvite {
584+ token_hash: token.id,
585+ note: (!note.is_empty()).then(|| note.to_string()),
586+ expires_at: None,
587+ created_by: admin.id,
588+ })
589+ .await;
590+ if created.is_err() {
591+ return AppError::internal("could not create invite").into_response();
592+ }
593+ // Stash the raw token in a short-lived cookie so the list page can show it once.
594+ let flash = axum_extra::extract::cookie::Cookie::build(("fabrica_flash_invite", token.cookie))
595+ .http_only(true)
596+ .same_site(axum_extra::extract::cookie::SameSite::Lax)
597+ .secure(state.config.auth.cookie_secure)
598+ .path("/admin/invites")
599+ .build();
600+ (jar.add(flash), Redirect::to("/admin/invites")).into_response()
601+}
602+
603+/// `POST /admin/invites/{id}/delete`.
604+pub async fn invite_delete(
605+ State(state): State<AppState>,
606+ RequireAdmin(_): RequireAdmin,
607+ jar: CookieJar,
608+ headers: HeaderMap,
609+ Path(id): Path<String>,
610+ Form(form): Form<BareForm>,
611+) -> Response {
612+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
613+ return AppError::Forbidden.into_response();
614+ }
615+ let _ = state.store.delete_signup_invite(&id).await;
616+ Redirect::to("/admin/invites").into_response()
617+}
618+
619+// ---- Settings ----
620+
621+/// `GET /admin/settings` — override config values.
622+pub async fn settings(
623+ State(state): State<AppState>,
624+ RequireAdmin(user): RequireAdmin,
625+ jar: CookieJar,
626+ uri: Uri,
627+) -> AppResult<Response> {
628+ let (_, chrome) = build_chrome(
629+ &state,
630+ jar.clone(),
631+ Some(user.clone()),
632+ uri.path().to_string(),
633+ );
634+ let csrf = chrome.csrf.clone();
635+ let name = state.instance_name();
636+ let desc = state.setting_str(
637+ setting_keys::DESCRIPTION,
638+ &state.config.instance.description,
639+ );
640+ let reg = state.allow_registration();
641+ let anon = state.allow_anonymous();
642+ let vis = state.default_visibility_token();
643+ let vis_opt = |v: &str, label: &str| html! { option value=(v) selected[v == vis] { (label) } };
644+ let content = html! {
645+ h2 { "Instance settings" }
646+ p class="muted field-hint" {
647+ "These override the config file. Leaving a field at its default keeps the config value; each row can be reset."
648+ }
649+ div class="card" {
650+ form method="post" action="/admin/settings" {
651+ 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"))
661+ }
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+ }
666+ }
667+ };
668+ Ok(render(&state, jar, user, &uri, "settings", content))
669+}
670+
671+/// Settings form.
672+#[derive(Debug, Deserialize)]
673+pub struct SettingsForm {
674+ #[serde(default)]
675+ name: String,
676+ #[serde(default)]
677+ description: String,
678+ #[serde(default)]
679+ default_visibility: String,
680+ #[serde(default)]
681+ allow_registration: Option<String>,
682+ #[serde(default)]
683+ allow_anonymous: Option<String>,
684+ #[serde(rename = "_csrf")]
685+ csrf: String,
686+}
687+
688+/// `POST /admin/settings` — persist overrides (a blank name/description clears it).
689+pub async fn settings_save(
690+ State(state): State<AppState>,
691+ RequireAdmin(_): RequireAdmin,
692+ jar: CookieJar,
693+ headers: HeaderMap,
694+ Form(form): Form<SettingsForm>,
695+) -> Response {
696+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
697+ return AppError::Forbidden.into_response();
698+ }
699+ // A blank text field reverts to the config value (delete the override).
700+ for (key, value) in [
701+ (setting_keys::NAME, form.name.trim()),
702+ (setting_keys::DESCRIPTION, form.description.trim()),
703+ ] {
704+ if value.is_empty() {
705+ let _ = state.store.delete_setting(key).await;
706+ } else {
707+ let _ = state.store.set_setting(key, value).await;
708+ }
709+ }
710+ if matches!(
711+ form.default_visibility.as_str(),
712+ "public" | "internal" | "private"
713+ ) {
714+ let _ = state
715+ .store
716+ .set_setting(setting_keys::DEFAULT_VISIBILITY, &form.default_visibility)
717+ .await;
718+ }
719+ let _ = state
720+ .store
721+ .set_setting(
722+ setting_keys::ALLOW_REGISTRATION,
723+ if form.allow_registration.is_some() {
724+ "true"
725+ } else {
726+ "false"
727+ },
728+ )
729+ .await;
730+ let _ = state
731+ .store
732+ .set_setting(
733+ setting_keys::ALLOW_ANONYMOUS,
734+ if form.allow_anonymous.is_some() {
735+ "true"
736+ } else {
737+ "false"
738+ },
739+ )
740+ .await;
741+ state.reload_settings().await;
742+ Redirect::to("/admin/settings").into_response()
743+}
744+
745+// ---- Helpers ----
746+
747+/// A small POST form rendering a single button.
748+fn post_button(action: &str, csrf: &str, label: &str, class: &str) -> Markup {
749+ html! {
750+ form method="post" action=(action) class="inline-form" {
751+ input type="hidden" name="_csrf" value=(csrf);
752+ button class=(class) type="submit" { (label) }
753+ }
754+ }
755+}
756+
757+/// A POST form that sets `field` to `value` (for enable/disable, admin toggles).
758+fn toggle_button(action: &str, csrf: &str, field: &str, value: bool, label: &str) -> Markup {
759+ html! {
760+ form method="post" action=(action) class="inline-form" {
761+ input type="hidden" name="_csrf" value=(csrf);
762+ input type="hidden" name=(field) value=(if value { "true" } else { "false" });
763+ button class="btn" type="submit" { (label) }
764+ }
765+ }
766+}
767+
768+/// Hash a password with the instance Argon2 cost.
769+fn hash_password(state: &AppState, password: &str) -> Result<String, AppError> {
770+ let params = auth::HashParams {
771+ m_cost: state.config.auth.argon2_m_cost,
772+ t_cost: state.config.auth.argon2_t_cost,
773+ p_cost: state.config.auth.argon2_p_cost,
774+ };
775+ auth::hash_password(password, params).map_err(|_| AppError::internal("hash failed"))
776+}
777+
778+/// Recursively sum the sizes of regular files under `dir` (best-effort).
779+fn dir_size(dir: &FsPath) -> u64 {
780+ let mut total = 0u64;
781+ let mut stack: Vec<PathBuf> = vec![dir.to_path_buf()];
782+ while let Some(path) = stack.pop() {
783+ let Ok(entries) = std::fs::read_dir(&path) else {
784+ continue;
785+ };
786+ for entry in entries.flatten() {
787+ let Ok(ft) = entry.file_type() else { continue };
788+ if ft.is_dir() {
789+ stack.push(entry.path());
790+ } else if ft.is_file()
791+ && let Ok(meta) = entry.metadata()
792+ {
793+ total += meta.len();
794+ }
795+ }
796+ }
797+ total
798+}
799+
800+/// The on-disk size of a `sqlite:` database file, if the URL names one.
801+fn sqlite_db_size(url: &str) -> Option<u64> {
802+ let path = url
803+ .strip_prefix("sqlite://")
804+ .or_else(|| url.strip_prefix("sqlite:"))?;
805+ let path = path.split('?').next().unwrap_or(path);
806+ if path.is_empty() || path == ":memory:" {
807+ return None;
808+ }
809+ std::fs::metadata(path).ok().map(|m| m.len())
810+}
811+
812+/// Format a byte count as a human-readable string.
813+fn fmt_bytes(bytes: u64) -> String {
814+ const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
815+ #[allow(clippy::cast_precision_loss)]
816+ let mut value = bytes as f64;
817+ let mut unit = 0;
818+ while value >= 1024.0 && unit < UNITS.len() - 1 {
819+ value /= 1024.0;
820+ unit += 1;
821+ }
822+ if unit == 0 {
823+ format!("{bytes} B")
824+ } else {
825+ format!("{value:.1} {}", UNITS[unit])
826+ }
827+}
crates/web/src/git_http.rs +1 −1
@@ -227,7 +227,7 @@ async fn authorize(
227227 viewer_id.as_ref(),
228228 &repo,
229229 collaborator,
230- state.config.instance.allow_anonymous,
230+ state.allow_anonymous(),
231231 );
232232 if access >= auth::Access::Read {
233233 Ok(repo)
crates/web/src/issues.rs +1 −1
@@ -633,7 +633,7 @@ pub(crate) async fn access_for(
633633 viewer_id.as_ref(),
634634 repo,
635635 collab,
636- state.config.instance.allow_anonymous,
636+ state.allow_anonymous(),
637637 ))
638638 }
639639
crates/web/src/layout.rs +4 −1
@@ -129,7 +129,10 @@ fn drawer(chrome: &Chrome) -> Markup {
129129 hr;
130130 }
131131 nav {
132- @if chrome.user.is_some() {
132+ @if let Some(user) = &chrome.user {
133+ @if user.is_admin {
134+ a href="/admin" { (icon(Icon::Dashboard)) span { "Admin" } }
135+ }
133136 a href="/settings" { (icon(Icon::Settings)) span { "Settings" } }
134137 form method="post" action="/logout" {
135138 input type="hidden" name="_csrf" value=(chrome.csrf);
crates/web/src/lib.rs +100 −0
@@ -11,6 +11,7 @@
1111 //! without JavaScript; htmx only enhances.
1212
1313 mod activity;
14+mod admin;
1415 mod assets;
1516 mod avatar;
1617 mod diff;
@@ -78,6 +79,23 @@ pub struct AppState {
7879 pub signatures: git::SignatureCache,
7980 /// Theme/asset registry, behind a lock so `SIGHUP` can re-scan it.
8081 assets: Arc<RwLock<Assets>>,
82+ /// Admin-set instance settings that override the config file, cached in
83+ /// memory and refreshed via [`AppState::reload_settings`].
84+ settings: Arc<RwLock<std::collections::HashMap<String, String>>>,
85+}
86+
87+/// Keys for the admin-overridable instance settings.
88+pub(crate) mod setting_keys {
89+ /// Instance display name.
90+ pub const NAME: &str = "instance.name";
91+ /// Instance description.
92+ pub const DESCRIPTION: &str = "instance.description";
93+ /// Allow web self-registration.
94+ pub const ALLOW_REGISTRATION: &str = "instance.allow_registration";
95+ /// Allow anonymous browse/clone.
96+ pub const ALLOW_ANONYMOUS: &str = "instance.allow_anonymous";
97+ /// Default visibility for new repositories.
98+ pub const DEFAULT_VISIBILITY: &str = "instance.default_visibility";
8199 }
82100
83101 impl AppState {
@@ -96,9 +114,69 @@ impl AppState {
96114 secret: Arc::new(secret),
97115 signatures: git::SignatureCache::new(4096),
98116 assets: Arc::new(RwLock::new(assets)),
117+ settings: Arc::new(RwLock::new(std::collections::HashMap::new())),
99118 })
100119 }
101120
121+ /// Refresh the cached instance settings from the database. Call at startup and
122+ /// after any admin settings change.
123+ pub async fn reload_settings(&self) {
124+ if let Ok(map) = self.store.all_settings().await {
125+ *self
126+ .settings
127+ .write()
128+ .unwrap_or_else(PoisonError::into_inner) = map;
129+ }
130+ }
131+
132+ /// A settings override for `key`, if an admin has set one.
133+ fn setting(&self, key: &str) -> Option<String> {
134+ self.settings
135+ .read()
136+ .unwrap_or_else(PoisonError::into_inner)
137+ .get(key)
138+ .cloned()
139+ }
140+
141+ /// A string setting: admin override, else `fallback` (the config-file value).
142+ pub(crate) fn setting_str(&self, key: &str, fallback: &str) -> String {
143+ self.setting(key).unwrap_or_else(|| fallback.to_string())
144+ }
145+
146+ /// A boolean setting: admin override (`"true"`/`"false"`), else `fallback`.
147+ pub(crate) fn setting_bool(&self, key: &str, fallback: bool) -> bool {
148+ self.setting(key).map_or(fallback, |v| v == "true")
149+ }
150+
151+ /// Whether web self-registration is currently allowed (override or config).
152+ pub(crate) fn allow_registration(&self) -> bool {
153+ self.setting_bool(
154+ setting_keys::ALLOW_REGISTRATION,
155+ self.config.instance.allow_registration,
156+ )
157+ }
158+
159+ /// Whether anonymous browse/clone is currently allowed (override or config).
160+ pub(crate) fn allow_anonymous(&self) -> bool {
161+ self.setting_bool(
162+ setting_keys::ALLOW_ANONYMOUS,
163+ self.config.instance.allow_anonymous,
164+ )
165+ }
166+
167+ /// The effective instance display name (override or config).
168+ pub(crate) fn instance_name(&self) -> String {
169+ self.setting_str(setting_keys::NAME, &self.config.instance.name)
170+ }
171+
172+ /// The effective default-visibility token for new repos (override or config).
173+ pub(crate) fn default_visibility_token(&self) -> String {
174+ self.setting_str(
175+ setting_keys::DEFAULT_VISIBILITY,
176+ self.config.instance.default_visibility.as_token(),
177+ )
178+ }
179+
102180 /// A read guard over the asset registry, recovering from a poisoned lock.
103181 fn assets(&self) -> RwLockReadGuard<'_, Assets> {
104182 self.assets.read().unwrap_or_else(PoisonError::into_inner)
@@ -147,6 +225,7 @@ pub fn build_router(state: AppState) -> Router {
147225 "/register",
148226 get(pages::register_form).post(pages::register_submit),
149227 )
228+ .route("/register/{token}", get(pages::register_invite_form))
150229 .route("/logout", post(pages::logout))
151230 .route(
152231 "/invite/{token}",
@@ -178,6 +257,27 @@ pub fn build_router(state: AppState) -> Router {
178257 .route("/settings/keys/delete", post(pages::key_delete))
179258 .route("/settings/keys/verify", post(pages::key_verify))
180259 .route("/settings/avatar", post(avatar::upload))
260+ // Admin dashboard (RequireAdmin-gated in each handler).
261+ .route("/admin", get(admin::overview))
262+ .route("/admin/users", get(admin::users).post(admin::user_create))
263+ .route("/admin/users/password", post(admin::user_set_password))
264+ .route("/admin/users/{id}/verify", post(admin::user_verify))
265+ .route("/admin/users/{id}/disable", post(admin::user_disable))
266+ .route("/admin/users/{id}/admin", post(admin::user_toggle_admin))
267+ .route("/admin/users/{id}/delete", post(admin::user_delete))
268+ .route("/admin/repos", get(admin::repos))
269+ .route("/admin/repos/{id}/delete", post(admin::repo_delete))
270+ .route("/admin/groups", get(admin::groups))
271+ .route("/admin/groups/{id}/delete", post(admin::group_delete))
272+ .route(
273+ "/admin/invites",
274+ get(admin::invites).post(admin::invite_create),
275+ )
276+ .route("/admin/invites/{id}/delete", post(admin::invite_delete))
277+ .route(
278+ "/admin/settings",
279+ get(admin::settings).post(admin::settings_save),
280+ )
181281 .route("/settings/theme", get(pages::set_theme))
182282 .route("/avatar/{username}", get(avatar::show))
183283 // Repo administration (owner/admin only; enforced in the handlers). A
crates/web/src/pages.rs +74 −7
@@ -46,13 +46,13 @@ pub(crate) fn build_chrome(
4646 drop(assets);
4747
4848 let chrome = Chrome {
49- instance_name: state.config.instance.name.clone(),
49+ instance_name: state.instance_name(),
5050 user,
5151 active_theme: active,
5252 active_scheme: scheme,
5353 themes,
5454 allow_theme_choice: state.config.ui.allow_theme_choice,
55- allow_registration: state.config.instance.allow_registration,
55+ allow_registration: state.allow_registration(),
5656 path,
5757 csrf,
5858 };
@@ -406,7 +406,7 @@ async fn create_repo_from_form(
406406 }
407407 };
408408 let visibility = model::Visibility::from_token(&form.visibility).unwrap_or_else(|| {
409- model::Visibility::from_token(state.config.instance.default_visibility.as_token())
409+ model::Visibility::from_token(&state.default_visibility_token())
410410 .unwrap_or(model::Visibility::Private)
411411 });
412412 let description = {
@@ -459,7 +459,7 @@ async fn create_repo_from_form(
459459
460460 /// Render the new-repository form with an optional error and prefilled name.
461461 fn new_repo_body(state: &AppState, csrf: &str, error: Option<&str>, name: &str) -> Markup {
462- let default_vis = state.config.instance.default_visibility.as_token();
462+ let default_vis = state.default_visibility_token();
463463 let vis_option = |value: &str, label: &str| {
464464 html! { option value=(value) selected[value == default_vis] { (label) } }
465465 };
@@ -654,6 +654,9 @@ pub struct RegisterForm {
654654 /// reCAPTCHA solved token (populated by the reCAPTCHA widget).
655655 #[serde(default, rename = "g-recaptcha-response")]
656656 grecaptcha_response: String,
657+ /// A sign-up invite token, when registering via an invite link.
658+ #[serde(default)]
659+ invite: String,
657660 /// CSRF token.
658661 #[serde(rename = "_csrf")]
659662 csrf: String,
@@ -674,7 +677,7 @@ pub async fn register_form(
674677 jar: CookieJar,
675678 uri: Uri,
676679 ) -> Response {
677- if !state.config.instance.allow_registration {
680+ if !state.allow_registration() {
678681 return AppError::NotFound.into_response();
679682 }
680683 if user.is_some() {
@@ -687,6 +690,42 @@ pub async fn register_form(
687690 "",
688691 "",
689692 captcha_view(&state.config).as_ref(),
693+ None,
694+ );
695+ (jar, page(&chrome, "Sign up", body)).into_response()
696+}
697+
698+/// `GET /register/{token}` — the sign-up form reached via an admin invite link.
699+/// Works even when self-registration is disabled, provided the token is valid.
700+pub async fn register_invite_form(
701+ State(state): State<AppState>,
702+ MaybeUser(user): MaybeUser,
703+ jar: CookieJar,
704+ uri: Uri,
705+ Path(token): Path<String>,
706+) -> Response {
707+ if user.is_some() {
708+ return Redirect::to("/").into_response();
709+ }
710+ let hash = auth::session_id(&token);
711+ match state.store.signup_invite_valid(&hash).await {
712+ Ok(Some(_)) => {}
713+ Ok(None) => {
714+ return AppError::BadRequest(
715+ "That invite link is invalid or has been used.".to_string(),
716+ )
717+ .into_response();
718+ }
719+ Err(err) => return AppError::from(err).into_response(),
720+ }
721+ let (jar, chrome) = build_chrome(&state, jar, None, uri.path().to_string());
722+ let body = register_body(
723+ &chrome.csrf,
724+ None,
725+ "",
726+ "",
727+ captcha_view(&state.config).as_ref(),
728+ Some(&token),
690729 );
691730 (jar, page(&chrome, "Sign up", body)).into_response()
692731 }
@@ -699,16 +738,23 @@ fn register_body(
699738 username: &str,
700739 email: &str,
701740 captcha: Option<&(config::CaptchaProvider, String)>,
741+ invite: Option<&str>,
702742 ) -> Markup {
703743 html! {
704744 div class="auth-wrap" {
705745 div class="auth-card" {
706746 h1 { "Sign up" }
747+ @if invite.is_some() {
748+ div class="notice notice-success" { "You've been invited to create an account." }
749+ }
707750 @if let Some(err) = error {
708751 div class="notice notice-error" { (err) }
709752 }
710753 form method="post" action="/register" {
711754 input type="hidden" name="_csrf" value=(csrf);
755+ @if let Some(token) = invite {
756+ input type="hidden" name="invite" value=(token);
757+ }
712758 label for="username" { "Username" }
713759 input type="text" id="username" name="username" value=(username)
714760 autocomplete="username" required;
@@ -736,13 +782,21 @@ fn register_body(
736782 }
737783
738784 /// `POST /register` — create an account and sign in.
785+#[allow(clippy::too_many_lines)] // Validation, invite redemption, and session setup.
739786 pub async fn register_submit(
740787 State(state): State<AppState>,
741788 jar: CookieJar,
742789 headers: HeaderMap,
743790 Form(form): Form<RegisterForm>,
744791 ) -> Response {
745- if !state.config.instance.allow_registration {
792+ // Registration is allowed either globally or via a valid invite token.
793+ let invite_hash =
794+ (!form.invite.trim().is_empty()).then(|| auth::session_id(form.invite.trim()));
795+ let invite_ok = match &invite_hash {
796+ Some(h) => matches!(state.store.signup_invite_valid(h).await, Ok(Some(_))),
797+ None => false,
798+ };
799+ if !state.allow_registration() && !invite_ok {
746800 return AppError::NotFound.into_response();
747801 }
748802 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
@@ -750,11 +804,19 @@ pub async fn register_submit(
750804 }
751805 let username = form.username.trim().to_string();
752806 let email = form.email.trim().to_string();
807+ let invite_field = (!form.invite.trim().is_empty()).then_some(form.invite.trim());
753808
754809 let captcha = captcha_view(&state.config);
755810 let reject = |msg: &str, username: &str, email: &str| {
756811 let (jar, chrome) = build_chrome(&state, jar.clone(), None, "/register".to_string());
757- let body = register_body(&chrome.csrf, Some(msg), username, email, captcha.as_ref());
812+ let body = register_body(
813+ &chrome.csrf,
814+ Some(msg),
815+ username,
816+ email,
817+ captcha.as_ref(),
818+ invite_field,
819+ );
758820 (
759821 axum::http::StatusCode::BAD_REQUEST,
760822 jar,
@@ -829,6 +891,11 @@ pub async fn register_submit(
829891 Err(err) => return AppError::from(err).into_response(),
830892 };
831893
894+ // Consume the invite (if any) now that the account exists.
895+ if let Some(h) = &invite_hash {
896+ let _ = state.store.redeem_signup_invite(h, &user.id).await;
897+ }
898+
832899 // Send a verification email for the new primary address (best-effort).
833900 if let Ok(emails) = state.store.emails_by_user(&user.id).await
834901 && let Some(primary) = emails.into_iter().find(|e| e.is_primary)
crates/web/src/repo.rs +3 −13
@@ -139,7 +139,7 @@ pub(crate) async fn resolve(
139139 viewer_id.as_ref(),
140140 &repo,
141141 collaborator,
142- state.config.instance.allow_anonymous,
142+ state.allow_anonymous(),
143143 );
144144 if access == auth::Access::None {
145145 return Err(AppError::NotFound);
@@ -1277,12 +1277,7 @@ async fn require_admin_repo(
12771277 .and_then(|p| auth::Permission::parse(&p)),
12781278 None => None,
12791279 };
1280- let access = auth::access(
1281- viewer_id.as_ref(),
1282- &repo,
1283- collab,
1284- state.config.instance.allow_anonymous,
1285- );
1280+ let access = auth::access(viewer_id.as_ref(), &repo, collab, state.allow_anonymous());
12861281 if access == auth::Access::Admin {
12871282 Ok(repo)
12881283 } else {
@@ -2044,12 +2039,7 @@ async fn visible_repos(
20442039 .and_then(|p| auth::Permission::parse(&p)),
20452040 None => None,
20462041 };
2047- let access = auth::access(
2048- viewer_id.as_ref(),
2049- &repo,
2050- collab,
2051- state.config.instance.allow_anonymous,
2052- );
2042+ let access = auth::access(viewer_id.as_ref(), &repo, collab, state.allow_anonymous());
20532043 if access >= auth::Access::Read {
20542044 out.push(repo);
20552045 }
crates/web/src/search.rs +1 −1
@@ -511,7 +511,7 @@ async fn can_read(state: &AppState, repo: &Repo, viewer: Option<&User>) -> AppRe
511511 viewer_id.as_ref(),
512512 repo,
513513 collaborator,
514- state.config.instance.allow_anonymous,
514+ state.allow_anonymous(),
515515 );
516516 Ok(access >= auth::Access::Read)
517517 }
crates/web/src/session.rs +22 −0
@@ -74,6 +74,28 @@ impl FromRequestParts<AppState> for RequireUser {
7474 }
7575 }
7676
77+/// A signed-in **administrator**. Non-admins get a 404 (the admin area does not
78+/// acknowledge its existence to them); anonymous requests are redirected to login.
79+pub struct RequireAdmin(pub User);
80+
81+impl FromRequestParts<AppState> for RequireAdmin {
82+ type Rejection = Response;
83+
84+ async fn from_request_parts(
85+ parts: &mut Parts,
86+ state: &AppState,
87+ ) -> Result<Self, Self::Rejection> {
88+ let MaybeUser(user) = MaybeUser::from_request_parts(parts, state)
89+ .await
90+ .unwrap_or(MaybeUser(None));
91+ match user {
92+ Some(user) if user.is_admin => Ok(Self(user)),
93+ Some(_) => Err(crate::error::AppError::NotFound.into_response()),
94+ None => Err(Redirect::to("/login").into_response()),
95+ }
96+ }
97+}
98+
7799 /// Ensure the CSRF cookie exists, returning the (possibly updated) jar and the
78100 /// token to embed in forms and htmx headers. A stable token avoids invalidating
79101 /// forms opened in other tabs.
crates/web/src/tests.rs +49 −0
@@ -60,6 +60,55 @@ fn get(uri: &str) -> Request<Body> {
6060 }
6161
6262 #[tokio::test]
63+async fn admin_dashboard_gating_and_settings_override() {
64+ let (state, app) = app().await;
65+ let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
66+ let cookie = login(&app).await;
67+ let token = cookie
68+ .split("fabrica_csrf=")
69+ .nth(1)
70+ .and_then(|s| s.split(';').next())
71+ .unwrap()
72+ .to_string();
73+
74+ // Non-admin: the admin area does not acknowledge itself (404).
75+ let req = Request::builder()
76+ .uri("/admin")
77+ .header(header::COOKIE, cookie.clone())
78+ .body(Body::empty())
79+ .unwrap();
80+ assert_eq!(
81+ app.clone().oneshot(req).await.unwrap().status(),
82+ StatusCode::NOT_FOUND
83+ );
84+
85+ // Promote and retry (the session re-reads the user each request).
86+ state.store.set_admin(&ada.id, true).await.unwrap();
87+ let req = Request::builder()
88+ .uri("/admin")
89+ .header(header::COOKIE, cookie.clone())
90+ .body(Body::empty())
91+ .unwrap();
92+ let res = app.clone().oneshot(req).await.unwrap();
93+ assert_eq!(res.status(), StatusCode::OK);
94+ assert!(body_string(res).await.contains("Overview"));
95+
96+ // Override the instance name via the settings tab.
97+ let req = Request::builder()
98+ .method("POST")
99+ .uri("/admin/settings")
100+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
101+ .header(header::COOKIE, cookie)
102+ .body(Body::from(format!(
103+ "name=Renamed&description=&default_visibility=private&_csrf={token}"
104+ )))
105+ .unwrap();
106+ let res = app.oneshot(req).await.unwrap();
107+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
108+ assert_eq!(state.instance_name(), "Renamed", "override took effect");
109+}
110+
111+#[tokio::test]
63112 async fn home_renders_the_shell() {
64113 let (_state, app) = app().await;
65114 let res = app.oneshot(get("/")).await.unwrap();
migrations/postgres/0012_admin.sql +18 −0
@@ -0,0 +1,18 @@
1+-- Admin dashboard: open sign-up invites (independent of a pre-created account)
2+-- and instance settings that override the config file.
3+CREATE TABLE signup_invites (
4+ id TEXT PRIMARY KEY,
5+ token_hash TEXT NOT NULL,
6+ note TEXT,
7+ expires_at BIGINT,
8+ used_at BIGINT,
9+ used_by TEXT REFERENCES users(id) ON DELETE SET NULL,
10+ created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
11+ created_at BIGINT NOT NULL
12+);
13+CREATE UNIQUE INDEX idx_signup_invites_token ON signup_invites (token_hash);
14+
15+CREATE TABLE instance_settings (
16+ key TEXT PRIMARY KEY,
17+ value TEXT NOT NULL
18+);
migrations/sqlite/0012_admin.sql +18 −0
@@ -0,0 +1,18 @@
1+-- Admin dashboard: open sign-up invites (independent of a pre-created account)
2+-- and instance settings that override the config file.
3+CREATE TABLE signup_invites (
4+ id TEXT PRIMARY KEY,
5+ token_hash TEXT NOT NULL,
6+ note TEXT,
7+ expires_at BIGINT,
8+ used_at BIGINT,
9+ used_by TEXT REFERENCES users(id) ON DELETE SET NULL,
10+ created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
11+ created_at BIGINT NOT NULL
12+);
13+CREATE UNIQUE INDEX idx_signup_invites_token ON signup_invites (token_hash);
14+
15+CREATE TABLE instance_settings (
16+ key TEXT PRIMARY KEY,
17+ value TEXT NOT NULL
18+);
src/serve.rs +2 −0
@@ -68,6 +68,8 @@ pub fn run(req: ServeRequest) -> anyhow::Result<ExitCode> {
6868 });
6969
7070 let state = web::AppState::build(config, store, secret)?;
71+ // Load admin config overrides from the database before serving.
72+ state.reload_settings().await;
7173 let result = web::serve(state, addr).await;
7274 ssh_task.abort();
7375 result?;