fabrica

hanna/fabrica

feat(web): repo settings tab (rename, visibility, delete)

dc5c1a3 · hanna committed on 2026-07-25

Add an owner/admin-only Settings tab to repositories with a General section
(rename + public/internal/private visibility) and a danger zone that deletes
the repo (row removed, on-disk copy moved to trash). Mutations post to a
fixed /repo-settings/{id}/... prefix that never collides with the repo
catch-all, verify CSRF, and re-check Admin access — a non-admin gets a 404,
never confirmation the repo exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
4 files changed · +277 −7UnifiedSplit
assets/base.css +13 −0
@@ -265,6 +265,19 @@ body.drawer-open .drawer-backdrop {
265265 border-color: var(--fb-danger);
266266 color: var(--fb-danger);
267267 }
268+.btn-danger:hover {
269+ background: var(--fb-danger);
270+ color: var(--fb-accent-fg, #fff);
271+ border-color: var(--fb-danger);
272+}
273+
274+/* Repo settings danger zone. */
275+.danger-zone {
276+ border-color: var(--fb-danger);
277+}
278+.danger-zone p {
279+ margin-top: 0;
280+}
268281
269282 label {
270283 display: block;
crates/web/src/lib.rs +4 −0
@@ -145,6 +145,10 @@ pub fn build_router(state: AppState) -> Router {
145145 .route("/settings/avatar", post(avatar::upload))
146146 .route("/settings/theme", get(pages::set_theme))
147147 .route("/avatar/{username}", get(avatar::show))
148+ // Repo administration (owner/admin only; enforced in the handlers). A
149+ // fixed prefix so it never collides with the `/{owner}/{*rest}` catch-all.
150+ .route("/repo-settings/{id}/general", post(repo::settings_general))
151+ .route("/repo-settings/{id}/delete", post(repo::settings_delete))
148152 .route("/healthz", get(serve_assets::healthz))
149153 .route("/version", get(serve_assets::version))
150154 .route("/theme.css", get(serve_assets::theme_default))
crates/web/src/repo.rs +217 −7
@@ -14,12 +14,13 @@
1414 use std::collections::HashMap;
1515 use std::path::Path as FsPath;
1616
17+use axum::Form;
1718 use axum::extract::{Path, Query, State};
1819 use axum::http::{HeaderMap, Uri, header};
19-use axum::response::{Html, IntoResponse, Response};
20+use axum::response::{Html, IntoResponse, Redirect, Response};
2021 use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
2122 use maud::{Markup, html};
22-use model::{Repo, User};
23+use model::{Repo, User, Visibility};
2324 use serde::Deserialize;
2425
2526 use crate::AppState;
@@ -29,7 +30,7 @@ use crate::icons::{Icon, icon};
2930 use crate::layout::page;
3031 use crate::markdown;
3132 use crate::pages::build_chrome;
32-use crate::session::MaybeUser;
33+use crate::session::{MaybeUser, verify_csrf};
3334
3435 /// The diff-view preference cookie (`unified` | `split`).
3536 const DIFF_COOKIE: &str = "fabrica_diffview";
@@ -63,15 +64,15 @@ enum Tab {
6364 Branches,
6465 Tags,
6566 Search,
67+ Settings,
6668 }
6769
6870 /// A resolved repository plus the viewer's access to it.
6971 pub(crate) struct RepoCtx {
7072 pub(crate) owner: User,
7173 pub(crate) repo: Repo,
72- /// The viewer's access level. Reserved for gating mutation UI (settings, edit)
73- /// in later phases; every browse view here requires only `Read`.
74- #[allow(dead_code)]
74+ /// The viewer's access level, used to gate the Settings tab and its actions.
75+ /// Every browse view requires only `Read`.
7576 pub(crate) access: auth::Access,
7677 }
7778
@@ -288,7 +289,8 @@ async fn dispatch_view(
288289 let raw_query = dq.q.clone().unwrap_or_default();
289290 crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await
290291 }
291- // Reserved `runs`/`settings` slots return 404 until built.
292+ "settings" => repo_settings_view(state, viewer, jar, uri, ctx).await,
293+ // Reserved `runs` slot returns 404 until built.
292294 _ => Err(AppError::NotFound),
293295 }
294296 }
@@ -707,6 +709,211 @@ async fn tags_view(
707709 Ok((jar, page(&chrome, &title, body)).into_response())
708710 }
709711
712+// ---- Settings (owner/admin only) ----
713+
714+/// `GET …/-/settings` — the repo admin page: rename, visibility, and delete.
715+/// Returns `404` for anyone without `Admin` access (existence never leaks).
716+async fn repo_settings_view(
717+ state: &AppState,
718+ viewer: Option<User>,
719+ jar: CookieJar,
720+ uri: &Uri,
721+ ctx: RepoCtx,
722+) -> AppResult<Response> {
723+ if ctx.access != auth::Access::Admin {
724+ return Err(AppError::NotFound);
725+ }
726+ let branches = branch_names(state, &ctx.repo.id).await;
727+ let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
728+
729+ let id = &ctx.repo.id;
730+ let vis = ctx.repo.visibility;
731+ let vis_option = |value: Visibility, label: &str| {
732+ html! {
733+ option value=(value.as_str()) selected[vis == value] { (label) }
734+ }
735+ };
736+ let body = html! {
737+ (repo_header(state, &ctx, Tab::Settings, &ctx.repo.default_branch, &branches))
738+ section class="listing" {
739+ h2 { "General" }
740+ div class="card" {
741+ form method="post" action=(format!("/repo-settings/{id}/general")) {
742+ input type="hidden" name="_csrf" value=(chrome.csrf);
743+ label for="name" { "Repository name" }
744+ input type="text" id="name" name="name" value=(ctx.repo.name) required;
745+ label for="visibility" { "Visibility" }
746+ select id="visibility" name="visibility" {
747+ (vis_option(Visibility::Public, "Public — visible to everyone"))
748+ (vis_option(Visibility::Internal, "Internal — any signed-in user"))
749+ (vis_option(Visibility::Private, "Private — only you and collaborators"))
750+ }
751+ button class="btn btn-primary" type="submit" { "Save changes" }
752+ }
753+ }
754+ }
755+ section class="listing" {
756+ h2 { "Danger zone" }
757+ div class="card danger-zone" {
758+ p { strong { "Delete this repository." } " This permanently removes it from fabrica; the on-disk copy is moved to trash." }
759+ form method="post" action=(format!("/repo-settings/{id}/delete")) {
760+ input type="hidden" name="_csrf" value=(chrome.csrf);
761+ button class="btn btn-danger" type="submit" { "Delete repository" }
762+ }
763+ }
764+ }
765+ };
766+ let title = format!("{}/{}: settings", ctx.owner.username, ctx.repo.path);
767+ Ok((jar, page(&chrome, &title, body)).into_response())
768+}
769+
770+/// Resolve a repo by id and require the viewer to have `Admin` access, else a
771+/// `404` (so a non-admin cannot even confirm the repo exists).
772+async fn require_admin_repo(
773+ state: &AppState,
774+ viewer: Option<&User>,
775+ repo_id: &str,
776+) -> AppResult<Repo> {
777+ let repo = state
778+ .store
779+ .repo_by_id(repo_id)
780+ .await?
781+ .ok_or(AppError::NotFound)?;
782+ let viewer_id = viewer.map(|u| auth::Viewer {
783+ id: u.id.clone(),
784+ is_admin: u.is_admin,
785+ });
786+ let collab = match viewer {
787+ Some(u) => state
788+ .store
789+ .collaborator_permission(&repo.id, &u.id)
790+ .await?
791+ .and_then(|p| auth::Permission::parse(&p)),
792+ None => None,
793+ };
794+ let access = auth::access(
795+ viewer_id.as_ref(),
796+ &repo,
797+ collab,
798+ state.config.instance.allow_anonymous,
799+ );
800+ if access == auth::Access::Admin {
801+ Ok(repo)
802+ } else {
803+ Err(AppError::NotFound)
804+ }
805+}
806+
807+/// The repo general-settings form (rename + visibility).
808+#[derive(Debug, Deserialize)]
809+pub struct RepoGeneralForm {
810+ /// The new leaf name (the group prefix is preserved).
811+ #[serde(default)]
812+ name: String,
813+ /// The chosen visibility token.
814+ #[serde(default)]
815+ visibility: String,
816+ /// CSRF token.
817+ #[serde(rename = "_csrf")]
818+ csrf: String,
819+}
820+
821+/// `POST /repo-settings/{id}/general` — rename and set visibility.
822+pub async fn settings_general(
823+ State(state): State<AppState>,
824+ MaybeUser(viewer): MaybeUser,
825+ jar: CookieJar,
826+ headers: HeaderMap,
827+ Path(id): Path<String>,
828+ Form(form): Form<RepoGeneralForm>,
829+) -> Response {
830+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
831+ return AppError::Forbidden.into_response();
832+ }
833+ let result = async {
834+ let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?;
835+ // Preserve any group prefix; only the leaf changes.
836+ let new_name = form.name.trim();
837+ let new_path = match repo.path.rsplit_once('/') {
838+ Some((group, _)) => format!("{group}/{new_name}"),
839+ None => new_name.to_string(),
840+ };
841+ state
842+ .store
843+ .rename_repo(&repo.id, new_name, &new_path)
844+ .await?;
845+ if let Some(vis) = Visibility::from_token(&form.visibility) {
846+ state.store.set_visibility(&repo.id, vis).await?;
847+ }
848+ let owner = state
849+ .store
850+ .user_by_id(&repo.owner_id)
851+ .await?
852+ .map_or_else(|| repo.owner_id.clone(), |u| u.username);
853+ Ok::<String, AppError>(format!("/{owner}/{new_path}/-/settings"))
854+ }
855+ .await;
856+ match result {
857+ Ok(dest) => Redirect::to(&dest).into_response(),
858+ Err(err) => err.into_response(),
859+ }
860+}
861+
862+/// The repo delete form (CSRF only).
863+#[derive(Debug, Deserialize)]
864+pub struct RepoDeleteForm {
865+ /// CSRF token.
866+ #[serde(rename = "_csrf")]
867+ csrf: String,
868+}
869+
870+/// `POST /repo-settings/{id}/delete` — delete the repo row and move its on-disk
871+/// directory to trash.
872+pub async fn settings_delete(
873+ State(state): State<AppState>,
874+ MaybeUser(viewer): MaybeUser,
875+ jar: CookieJar,
876+ headers: HeaderMap,
877+ Path(id): Path<String>,
878+ Form(form): Form<RepoDeleteForm>,
879+) -> Response {
880+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
881+ return AppError::Forbidden.into_response();
882+ }
883+ let result = async {
884+ let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?;
885+ let owner = state
886+ .store
887+ .user_by_id(&repo.owner_id)
888+ .await?
889+ .map_or_else(|| repo.owner_id.clone(), |u| u.username);
890+ state.store.delete_repo(&repo.id).await?;
891+ // Best-effort: move the on-disk directory to trash. A failure here does
892+ // not fail the request — the repo is already unreachable through fabrica.
893+ if let Ok(dir) = git::repo_path(&state.config.storage.repo_dir, &repo.id)
894+ && dir.exists()
895+ {
896+ let trash = state.config.storage.data_dir.join("trash").join(format!(
897+ "{}-{}.git",
898+ repo.id,
899+ crate::now_ms()
900+ ));
901+ if let Some(parent) = trash.parent() {
902+ let _ = std::fs::create_dir_all(parent);
903+ }
904+ if let Err(err) = std::fs::rename(&dir, &trash) {
905+ tracing::warn!(repo = %repo.id, %err, "could not move deleted repo to trash");
906+ }
907+ }
908+ Ok::<String, AppError>(format!("/{owner}"))
909+ }
910+ .await;
911+ match result {
912+ Ok(dest) => Redirect::to(&dest).into_response(),
913+ Err(err) => err.into_response(),
914+ }
915+}
916+
710917 // ---- Commit list ----
711918
712919 async fn commits_view(
@@ -1348,6 +1555,9 @@ fn repo_header(
13481555 (tab(Tab::Commits, "Commits", Icon::History, format!("{base}/-/commits/{rev}")))
13491556 (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches")))
13501557 (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags")))
1558+ @if ctx.access == auth::Access::Admin {
1559+ (tab(Tab::Settings, "Settings", Icon::Settings, format!("{base}/-/settings")))
1560+ }
13511561 }
13521562 div class="repo-actions" {
13531563 (branch_menu(&base, rev, branches))
crates/web/src/tests.rs +43 −0
@@ -387,6 +387,49 @@ async fn dashboard_shows_activity_and_repos_when_signed_in() {
387387 }
388388
389389 #[tokio::test]
390+async fn repo_settings_are_owner_only() {
391+ let (state, app) = app().await;
392+ let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
393+ let repo = state
394+ .store
395+ .create_repo(NewRepo {
396+ owner_id: ada.id,
397+ group_id: None,
398+ name: "proj".to_string(),
399+ path: "proj".to_string(),
400+ description: None,
401+ visibility: model::Visibility::Private,
402+ default_branch: "main".to_string(),
403+ })
404+ .await
405+ .unwrap();
406+
407+ // Anonymous visitor: a private repo's settings are a 404, not a 403.
408+ let res = app
409+ .clone()
410+ .oneshot(get("/ada/proj/-/settings"))
411+ .await
412+ .unwrap();
413+ assert_eq!(res.status(), StatusCode::NOT_FOUND);
414+
415+ // The owner sees the settings page with the delete form.
416+ let cookie = login(&app).await;
417+ let req = Request::builder()
418+ .uri("/ada/proj/-/settings")
419+ .header(header::COOKIE, cookie)
420+ .body(Body::empty())
421+ .unwrap();
422+ let res = app.oneshot(req).await.unwrap();
423+ assert_eq!(res.status(), StatusCode::OK);
424+ let html = body_string(res).await;
425+ assert!(html.contains("Danger zone"), "danger zone shown");
426+ assert!(
427+ html.contains(&format!("/repo-settings/{}/delete", repo.id)),
428+ "delete action targets the repo id"
429+ );
430+}
431+
432+#[tokio::test]
390433 async fn settings_requires_authentication() {
391434 let (_state, app) = app().await;
392435 let res = app.oneshot(get("/settings")).await.unwrap();