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 {
265 border-color: var(--fb-danger);265 border-color: var(--fb-danger);
266 color: var(--fb-danger);266 color: var(--fb-danger);
267}267}
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
269label {282label {
270 display: block;283 display: block;
crates/web/src/lib.rs +4 −0
@@ -145,6 +145,10 @@ pub fn build_router(state: AppState) -> Router {
145 .route("/settings/avatar", post(avatar::upload))145 .route("/settings/avatar", post(avatar::upload))
146 .route("/settings/theme", get(pages::set_theme))146 .route("/settings/theme", get(pages::set_theme))
147 .route("/avatar/{username}", get(avatar::show))147 .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))
148 .route("/healthz", get(serve_assets::healthz))152 .route("/healthz", get(serve_assets::healthz))
149 .route("/version", get(serve_assets::version))153 .route("/version", get(serve_assets::version))
150 .route("/theme.css", get(serve_assets::theme_default))154 .route("/theme.css", get(serve_assets::theme_default))
crates/web/src/repo.rs +217 −7
@@ -14,12 +14,13 @@
14use std::collections::HashMap;14use std::collections::HashMap;
15use std::path::Path as FsPath;15use std::path::Path as FsPath;
1616
17use axum::Form;
17use axum::extract::{Path, Query, State};18use axum::extract::{Path, Query, State};
18use axum::http::{HeaderMap, Uri, header};19use axum::http::{HeaderMap, Uri, header};
19use axum::response::{Html, IntoResponse, Response};20use axum::response::{Html, IntoResponse, Redirect, Response};
20use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};21use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
21use maud::{Markup, html};22use maud::{Markup, html};
22use model::{Repo, User};23use model::{Repo, User, Visibility};
23use serde::Deserialize;24use serde::Deserialize;
2425
25use crate::AppState;26use crate::AppState;
@@ -29,7 +30,7 @@ use crate::icons::{Icon, icon};
29use crate::layout::page;30use crate::layout::page;
30use crate::markdown;31use crate::markdown;
31use crate::pages::build_chrome;32use crate::pages::build_chrome;
32use crate::session::MaybeUser;33use crate::session::{MaybeUser, verify_csrf};
3334
34/// The diff-view preference cookie (`unified` | `split`).35/// The diff-view preference cookie (`unified` | `split`).
35const DIFF_COOKIE: &str = "fabrica_diffview";36const DIFF_COOKIE: &str = "fabrica_diffview";
@@ -63,15 +64,15 @@ enum Tab {
63 Branches,64 Branches,
64 Tags,65 Tags,
65 Search,66 Search,
67 Settings,
66}68}
6769
68/// A resolved repository plus the viewer's access to it.70/// A resolved repository plus the viewer's access to it.
69pub(crate) struct RepoCtx {71pub(crate) struct RepoCtx {
70 pub(crate) owner: User,72 pub(crate) owner: User,
71 pub(crate) repo: Repo,73 pub(crate) repo: Repo,
72 /// The viewer's access level. Reserved for gating mutation UI (settings, edit)74 /// The viewer's access level, used to gate the Settings tab and its actions.
73 /// in later phases; every browse view here requires only `Read`.75 /// Every browse view requires only `Read`.
74 #[allow(dead_code)]
75 pub(crate) access: auth::Access,76 pub(crate) access: auth::Access,
76}77}
7778
@@ -288,7 +289,8 @@ async fn dispatch_view(
288 let raw_query = dq.q.clone().unwrap_or_default();289 let raw_query = dq.q.clone().unwrap_or_default();
289 crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await290 crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await
290 }291 }
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.
292 _ => Err(AppError::NotFound),294 _ => Err(AppError::NotFound),
293 }295 }
294}296}
@@ -707,6 +709,211 @@ async fn tags_view(
707 Ok((jar, page(&chrome, &title, body)).into_response())709 Ok((jar, page(&chrome, &title, body)).into_response())
708}710}
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).
716async 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).
772async 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)]
809pub 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.
822pub 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)]
864pub 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.
872pub 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
710// ---- Commit list ----917// ---- Commit list ----
711918
712async fn commits_view(919async fn commits_view(
@@ -1348,6 +1555,9 @@ fn repo_header(
1348 (tab(Tab::Commits, "Commits", Icon::History, format!("{base}/-/commits/{rev}")))1555 (tab(Tab::Commits, "Commits", Icon::History, format!("{base}/-/commits/{rev}")))
1349 (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches")))1556 (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches")))
1350 (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags")))1557 (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 }
1351 }1561 }
1352 div class="repo-actions" {1562 div class="repo-actions" {
1353 (branch_menu(&base, rev, branches))1563 (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() {
387}387}
388388
389#[tokio::test]389#[tokio::test]
390async 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]
390async fn settings_requires_authentication() {433async fn settings_requires_authentication() {
391 let (_state, app) = app().await;434 let (_state, app) = app().await;
392 let res = app.oneshot(get("/settings")).await.unwrap();435 let res = app.oneshot(get("/settings")).await.unwrap();