fabrica

hanna/fabrica

feat(web): per-group settings, manual creation, and permission cascade

dea5315 · hanna committed on 2026-07-26

Add a manual group-creation form (`/new/group`) and a per-group settings page
(`/group-settings/{id}`) for renaming, visibility, collaborator management, and
deleting empty manual groups. The group listing enforces group visibility —
a private group 404s for non-members — and shows admin controls (new subgroup,
settings) inline.

Route repo access decisions through `effective_permission` so group
collaborators gain access to repos within the group, across the web UI, the
git-over-HTTP transport, and SSH.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
9 files changed · +623 −19UnifiedSplit
assets/base.css +14 −0
@@ -2069,6 +2069,20 @@ pre.code {
20692069 .repo-head {
20702070 margin-bottom: 1.25rem;
20712071 }
2072+/* Group listing header: slug on the left, admin actions on the right. */
2073+.group-head {
2074+ display: flex;
2075+ align-items: center;
2076+ justify-content: space-between;
2077+ gap: 1rem;
2078+ flex-wrap: wrap;
2079+ margin-bottom: 1.25rem;
2080+}
2081+.group-head-actions {
2082+ display: flex;
2083+ align-items: center;
2084+ gap: 0.5rem;
2085+}
20722086 /* "forked from owner/repo" line under the repo slug. */
20732087 .fork-note {
20742088 display: flex;
crates/ssh/src/server.rs +2 −2
@@ -295,7 +295,7 @@ impl GitHandler {
295295 let collaborator = self
296296 .shared
297297 .store
298- .collaborator_permission(&repo.id, &identity.user_id)
298+ .effective_permission(&repo.id, &identity.user_id)
299299 .await
300300 .ok()
301301 .flatten()
@@ -405,7 +405,7 @@ impl GitHandler {
405405 let collaborator = self
406406 .shared
407407 .store
408- .collaborator_permission(&repo.id, &identity.user_id)
408+ .effective_permission(&repo.id, &identity.user_id)
409409 .await
410410 .ok()
411411 .flatten()
crates/web/src/git_http.rs +1 −1
@@ -237,7 +237,7 @@ pub(crate) async fn authorize(
237237 let collaborator = match &viewer {
238238 Some(u) => state
239239 .store
240- .collaborator_permission(&repo.id, &u.id)
240+ .effective_permission(&repo.id, &u.id)
241241 .await
242242 .ok()
243243 .flatten()
crates/web/src/groups.rs +551 −0
@@ -0,0 +1,551 @@
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+//! Groups: manual creation and the per-group settings page (rename, visibility,
6+//! collaborators, delete). Auto-created groups are garbage-collected by the store
7+//! when their last repository leaves; only manual groups reach the delete control.
8+
9+use axum::Form;
10+use axum::extract::{Path, Query, State};
11+use axum::http::{HeaderMap, Uri};
12+use axum::response::{IntoResponse, Redirect, Response};
13+use axum_extra::extract::cookie::CookieJar;
14+use maud::{Markup, html};
15+use model::{Group, User, Visibility};
16+use serde::Deserialize;
17+
18+use crate::AppState;
19+use crate::error::{AppError, AppResult};
20+use crate::icons::{Icon, icon};
21+use crate::layout::page;
22+use crate::pages::build_chrome;
23+use crate::repo::path_crumbs;
24+use crate::session::{MaybeUser, RequireUser, verify_csrf};
25+
26+/// Resolve the access `viewer` has to `group`, mirroring the repo rules: admin and
27+/// owner get [`auth::Access::Admin`]; a group collaborator (on the group or an
28+/// ancestor) gets their stored permission; otherwise the group's visibility
29+/// decides read access, and everything else is [`auth::Access::None`].
30+///
31+/// # Errors
32+///
33+/// Returns [`AppError`] on a store failure.
34+pub(crate) async fn group_access(
35+ state: &AppState,
36+ viewer: Option<&User>,
37+ group: &Group,
38+) -> AppResult<auth::Access> {
39+ if let Some(v) = viewer
40+ && (v.is_admin || v.id == group.owner_id)
41+ {
42+ return Ok(auth::Access::Admin);
43+ }
44+ if let Some(v) = viewer
45+ && let Some(p) = state
46+ .store
47+ .effective_group_permission(&group.id, &v.id)
48+ .await?
49+ && let Some(perm) = auth::Permission::parse(&p)
50+ {
51+ return Ok(perm.into());
52+ }
53+ let read = match group.visibility {
54+ Visibility::Public => viewer.is_some() || state.allow_anonymous(),
55+ Visibility::Internal => viewer.is_some(),
56+ Visibility::Private => false,
57+ };
58+ Ok(if read {
59+ auth::Access::Read
60+ } else {
61+ auth::Access::None
62+ })
63+}
64+
65+/// Load a group by id and require the viewer be an admin of it, else 404 (never
66+/// leak the group's existence).
67+async fn require_admin_group(
68+ state: &AppState,
69+ viewer: Option<&User>,
70+ id: &str,
71+) -> AppResult<Group> {
72+ let group = state
73+ .store
74+ .group_by_id(id)
75+ .await?
76+ .ok_or(AppError::NotFound)?;
77+ if group_access(state, viewer, &group).await? == auth::Access::Admin {
78+ Ok(group)
79+ } else {
80+ Err(AppError::NotFound)
81+ }
82+}
83+
84+// ---- Manual creation ----
85+
86+/// `GET /new/group` — the manual group-creation form. An optional `?parent=path`
87+/// prefills the path so "New subgroup" lands inside an existing group.
88+pub async fn new_form(
89+ State(state): State<AppState>,
90+ RequireUser(user): RequireUser,
91+ jar: CookieJar,
92+ uri: Uri,
93+ Query(q): Query<NewGroupQuery>,
94+) -> Response {
95+ let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string());
96+ let prefill = q
97+ .parent
98+ .as_deref()
99+ .map(|p| format!("{}/", p.trim_matches('/')))
100+ .unwrap_or_default();
101+ let body = new_group_body(&chrome.csrf, None, &prefill);
102+ (jar, page(&chrome, "New group", body)).into_response()
103+}
104+
105+/// Query for the new-group form.
106+#[derive(Debug, Deserialize)]
107+pub struct NewGroupQuery {
108+ /// Optional parent group path to prefill.
109+ parent: Option<String>,
110+}
111+
112+/// New-group form fields.
113+#[derive(Debug, Deserialize)]
114+pub struct NewGroupForm {
115+ /// The group path (may be nested with `/`).
116+ #[serde(default)]
117+ path: String,
118+ /// Visibility token (`public` | `internal` | `private`).
119+ #[serde(default)]
120+ visibility: String,
121+ /// CSRF token.
122+ #[serde(rename = "_csrf")]
123+ csrf: String,
124+}
125+
126+/// `POST /new/group` — create a manual group and redirect to it.
127+pub async fn new_submit(
128+ State(state): State<AppState>,
129+ RequireUser(user): RequireUser,
130+ jar: CookieJar,
131+ headers: HeaderMap,
132+ Form(form): Form<NewGroupForm>,
133+) -> Response {
134+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
135+ return AppError::Forbidden.into_response();
136+ }
137+ match create_group_from_form(&state, &user, &form).await {
138+ Ok(path) => Redirect::to(&format!("/{}/{path}", user.username)).into_response(),
139+ Err(msg) => {
140+ let (jar, chrome) = build_chrome(&state, jar, Some(user), "/new/group".to_string());
141+ let body = new_group_body(&chrome.csrf, Some(&msg), form.path.trim());
142+ (
143+ axum::http::StatusCode::BAD_REQUEST,
144+ jar,
145+ page(&chrome, "New group", body),
146+ )
147+ .into_response()
148+ }
149+ }
150+}
151+
152+/// Create the group described by `form`, returning its path or a user-facing error.
153+async fn create_group_from_form(
154+ state: &AppState,
155+ user: &User,
156+ form: &NewGroupForm,
157+) -> Result<String, String> {
158+ let path = form.path.trim().trim_matches('/');
159+ if path.is_empty() {
160+ return Err("Group path is required.".to_string());
161+ }
162+ // A group may not collide with an existing repository at the same path.
163+ if state
164+ .store
165+ .repo_by_owner_path(&user.id, path)
166+ .await
167+ .map_err(|e| e.to_string())?
168+ .is_some()
169+ {
170+ return Err("A repository already exists at that path.".to_string());
171+ }
172+ let group = state
173+ .store
174+ .create_group(&user.id, path)
175+ .await
176+ .map_err(|e| match e {
177+ store::StoreError::Conflict { .. } => "That group already exists.".to_string(),
178+ store::StoreError::Name(_) => {
179+ "Invalid name: use letters, digits, '-', '_', and '/' for nesting.".to_string()
180+ }
181+ other => other.to_string(),
182+ })?;
183+ if let Some(vis) = Visibility::from_token(&form.visibility) {
184+ let _ = state.store.set_group_visibility(&group.id, vis).await;
185+ }
186+ Ok(group.path)
187+}
188+
189+/// The new-group form body.
190+fn new_group_body(csrf: &str, error: Option<&str>, prefill: &str) -> Markup {
191+ let vis_option = |value: Visibility, label: &str| {
192+ html! { option value=(value.as_str()) selected[value == Visibility::Private] { (label) } }
193+ };
194+ html! {
195+ h1 { "New group" }
196+ @if let Some(msg) = error { p class="form-error" { (msg) } }
197+ div class="card" {
198+ form method="post" action="/new/group" {
199+ input type="hidden" name="_csrf" value=(csrf);
200+ label for="path" { "Group path" }
201+ input type="text" id="path" name="path" value=(prefill)
202+ placeholder="team (or team/subteam)" autofocus required;
203+ p class="muted field-hint" {
204+ "Nest with '/'. Intermediate groups are created automatically."
205+ }
206+ label for="visibility" { "Visibility" }
207+ select id="visibility" name="visibility" {
208+ (vis_option(Visibility::Public, "Public — visible to everyone"))
209+ (vis_option(Visibility::Internal, "Internal — any signed-in user"))
210+ (vis_option(Visibility::Private, "Private — only you and collaborators"))
211+ }
212+ div class="new-issue-actions" {
213+ button class="btn btn-primary inline-btn" type="submit" { "Create group" }
214+ }
215+ }
216+ }
217+ }
218+}
219+
220+// ---- Settings page ----
221+
222+/// `GET /group-settings/{id}` — the per-group settings page (admin only).
223+pub async fn settings_view(
224+ State(state): State<AppState>,
225+ MaybeUser(viewer): MaybeUser,
226+ jar: CookieJar,
227+ uri: Uri,
228+ Path(id): Path<String>,
229+) -> Response {
230+ let result = async {
231+ let group = require_admin_group(&state, viewer.as_ref(), &id).await?;
232+ let owner = state
233+ .store
234+ .user_by_id(&group.owner_id)
235+ .await?
236+ .map_or_else(|| group.owner_id.clone(), |u| u.username);
237+ let collaborators = state.store.list_group_collaborators(&group.id).await?;
238+ // A manual group may be deleted only once its subtree holds no repos and no
239+ // child groups, so the delete never orphans anything.
240+ let subtree_repos = state.store.group_subtree_repo_count(&group).await?;
241+ let all_groups = state.store.groups_by_owner(&group.owner_id).await?;
242+ let child_prefix = format!("{}/", group.path);
243+ let has_children = all_groups.iter().any(|g| g.path.starts_with(&child_prefix));
244+ let deletable = group.manual && subtree_repos == 0 && !has_children;
245+
246+ let (jar, chrome) = build_chrome(&state, jar, viewer, uri.path().to_string());
247+ let gid = &group.id;
248+ let vis = group.visibility;
249+ let vis_option = |value: Visibility, label: &str| {
250+ html! { option value=(value.as_str()) selected[vis == value] { (label) } }
251+ };
252+ let crumbs = path_crumbs(&owner, &group.path);
253+ let body = html! {
254+ h1 class="repo-slug group-slug" {
255+ (icon(Icon::Folder))
256+ a href={ "/" (owner) } { (owner) }
257+ @for (name, href) in &crumbs {
258+ span class="slug-sep" { "/" }
259+ a href=(href) { (name) }
260+ }
261+ }
262+ p class="muted" { "Group settings" }
263+ section class="listing" {
264+ h2 { "General" }
265+ div class="card" {
266+ form id="group-general" method="post" action=(format!("/group-settings/{gid}/general")) {
267+ input type="hidden" name="_csrf" value=(chrome.csrf);
268+ label for="name" { "Group name" }
269+ input type="text" id="name" name="name" value=(group.name) required;
270+ label for="visibility" { "Visibility" }
271+ select id="visibility" name="visibility" {
272+ (vis_option(Visibility::Public, "Public — visible to everyone"))
273+ (vis_option(Visibility::Internal, "Internal — any signed-in user"))
274+ (vis_option(Visibility::Private, "Private — only you and collaborators"))
275+ }
276+ }
277+ div class="settings-actions" {
278+ button class="btn btn-primary" type="submit" form="group-general" { "Save changes" }
279+ }
280+ }
281+ }
282+ (group_collaborators_section(gid, &chrome.csrf, &collaborators))
283+ section class="listing" {
284+ h2 { "Danger zone" }
285+ div class="card danger-zone" {
286+ @if deletable {
287+ p { strong { "Delete this group." } " This removes the group; it holds no repositories or subgroups." }
288+ form method="post" action=(format!("/group-settings/{gid}/delete")) {
289+ input type="hidden" name="_csrf" value=(chrome.csrf);
290+ button class="btn btn-danger" type="submit" { "Delete group" }
291+ }
292+ } @else if !group.manual {
293+ p class="muted" { "Automatic groups are removed on their own once empty." }
294+ } @else {
295+ p class="muted" { "Empty the group of all repositories and subgroups before deleting it." }
296+ }
297+ }
298+ }
299+ };
300+ let title = format!("{owner}/{}: settings", group.path);
301+ Ok::<Response, AppError>((jar, page(&chrome, &title, body)).into_response())
302+ }
303+ .await;
304+ result.unwrap_or_else(IntoResponse::into_response)
305+}
306+
307+/// The group collaborators section: current members with remove buttons, add form.
308+fn group_collaborators_section(
309+ id: &str,
310+ csrf: &str,
311+ collaborators: &[store::Collaborator],
312+) -> Markup {
313+ let perm_option = |value: &str, label: &str| html! { option value=(value) { (label) } };
314+ html! {
315+ section class="listing" {
316+ h2 { "Collaborators" }
317+ p class="muted field-hint" { "Group collaborators gain the same access to every repository within this group." }
318+ div class="card" {
319+ @if collaborators.is_empty() {
320+ p class="muted" { "No collaborators yet." }
321+ } @else {
322+ ul class="entry-list collab-list" {
323+ @for c in collaborators {
324+ li class="entry-row" {
325+ div class="entry-row-body" {
326+ a class="entry-row-title" href={ "/" (c.username) } { (c.username) }
327+ div class="entry-row-meta muted" { (c.permission) }
328+ }
329+ div class="entry-row-actions" {
330+ form method="post" action=(format!("/group-settings/{id}/collaborators/remove")) {
331+ input type="hidden" name="_csrf" value=(csrf);
332+ input type="hidden" name="user_id" value=(c.user_id);
333+ button class="btn btn-danger" type="submit" { "Remove" }
334+ }
335+ }
336+ }
337+ }
338+ }
339+ }
340+ form class="collab-add" method="post" action=(format!("/group-settings/{id}/collaborators")) {
341+ input type="hidden" name="_csrf" value=(csrf);
342+ input type="text" name="username" placeholder="Username" aria-label="Username" required;
343+ select name="permission" aria-label="Permission" {
344+ (perm_option("read", "Read"))
345+ (perm_option("write", "Write"))
346+ (perm_option("admin", "Admin"))
347+ }
348+ button class="btn btn-primary inline-btn" type="submit" { "Add" }
349+ }
350+ }
351+ }
352+ }
353+}
354+
355+/// The group general-settings form (rename + visibility).
356+#[derive(Debug, Deserialize)]
357+pub struct GroupGeneralForm {
358+ /// The new leaf name (the parent prefix is preserved).
359+ #[serde(default)]
360+ name: String,
361+ /// The chosen visibility token.
362+ #[serde(default)]
363+ visibility: String,
364+ /// CSRF token.
365+ #[serde(rename = "_csrf")]
366+ csrf: String,
367+}
368+
369+/// `POST /group-settings/{id}/general` — rename and set visibility.
370+pub async fn settings_general(
371+ State(state): State<AppState>,
372+ MaybeUser(viewer): MaybeUser,
373+ jar: CookieJar,
374+ headers: HeaderMap,
375+ Path(id): Path<String>,
376+ Form(form): Form<GroupGeneralForm>,
377+) -> Response {
378+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
379+ return AppError::Forbidden.into_response();
380+ }
381+ let result = async {
382+ let group = require_admin_group(&state, viewer.as_ref(), &id).await?;
383+ let owner = state
384+ .store
385+ .user_by_id(&group.owner_id)
386+ .await?
387+ .map_or_else(|| group.owner_id.clone(), |u| u.username);
388+ let new_name = form.name.trim();
389+ let renamed = state
390+ .store
391+ .rename_group(&group.id, new_name)
392+ .await
393+ .map_err(|e| match e {
394+ store::StoreError::Conflict { .. } => {
395+ AppError::BadRequest("That name is already taken.".to_string())
396+ }
397+ store::StoreError::Name(_) => {
398+ AppError::BadRequest("Invalid group name.".to_string())
399+ }
400+ other => AppError::from(other),
401+ })?;
402+ if let Some(vis) = Visibility::from_token(&form.visibility) {
403+ state.store.set_group_visibility(&group.id, vis).await?;
404+ }
405+ let _ = owner;
406+ Ok::<String, AppError>(format!("/group-settings/{}", renamed.id))
407+ }
408+ .await;
409+ match result {
410+ Ok(dest) => Redirect::to(&dest).into_response(),
411+ Err(err) => err.into_response(),
412+ }
413+}
414+
415+/// The add-collaborator form.
416+#[derive(Debug, Deserialize)]
417+pub struct CollaboratorAddForm {
418+ /// The collaborator's username.
419+ #[serde(default)]
420+ username: String,
421+ /// The permission to grant (`read` | `write` | `admin`).
422+ #[serde(default)]
423+ permission: String,
424+ /// CSRF token.
425+ #[serde(rename = "_csrf")]
426+ csrf: String,
427+}
428+
429+/// `POST /group-settings/{id}/collaborators` — add or update a group collaborator.
430+pub async fn settings_collaborator_add(
431+ State(state): State<AppState>,
432+ MaybeUser(viewer): MaybeUser,
433+ jar: CookieJar,
434+ headers: HeaderMap,
435+ Path(id): Path<String>,
436+ Form(form): Form<CollaboratorAddForm>,
437+) -> Response {
438+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
439+ return AppError::Forbidden.into_response();
440+ }
441+ let result = async {
442+ let group = require_admin_group(&state, viewer.as_ref(), &id).await?;
443+ let permission = auth::Permission::parse(&form.permission)
444+ .ok_or_else(|| AppError::BadRequest("Invalid permission.".to_string()))?;
445+ let target = state
446+ .store
447+ .user_by_username(form.username.trim())
448+ .await?
449+ .ok_or_else(|| AppError::BadRequest("No such user.".to_string()))?;
450+ // The owner is already an admin; adding them is a no-op.
451+ if target.id != group.owner_id {
452+ state
453+ .store
454+ .add_group_collaborator(&group.id, &target.id, permission.as_str())
455+ .await?;
456+ }
457+ Ok::<String, AppError>(format!("/group-settings/{}", group.id))
458+ }
459+ .await;
460+ match result {
461+ Ok(dest) => Redirect::to(&dest).into_response(),
462+ Err(err) => err.into_response(),
463+ }
464+}
465+
466+/// The remove-collaborator form.
467+#[derive(Debug, Deserialize)]
468+pub struct CollaboratorRemoveForm {
469+ /// The collaborator's user id.
470+ #[serde(default)]
471+ user_id: String,
472+ /// CSRF token.
473+ #[serde(rename = "_csrf")]
474+ csrf: String,
475+}
476+
477+/// `POST /group-settings/{id}/collaborators/remove` — remove a group collaborator.
478+pub async fn settings_collaborator_remove(
479+ State(state): State<AppState>,
480+ MaybeUser(viewer): MaybeUser,
481+ jar: CookieJar,
482+ headers: HeaderMap,
483+ Path(id): Path<String>,
484+ Form(form): Form<CollaboratorRemoveForm>,
485+) -> Response {
486+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
487+ return AppError::Forbidden.into_response();
488+ }
489+ let result = async {
490+ let group = require_admin_group(&state, viewer.as_ref(), &id).await?;
491+ state
492+ .store
493+ .remove_group_collaborator(&group.id, &form.user_id)
494+ .await?;
495+ Ok::<String, AppError>(format!("/group-settings/{}", group.id))
496+ }
497+ .await;
498+ match result {
499+ Ok(dest) => Redirect::to(&dest).into_response(),
500+ Err(err) => err.into_response(),
501+ }
502+}
503+
504+/// The group delete form (CSRF only).
505+#[derive(Debug, Deserialize)]
506+pub struct GroupDeleteForm {
507+ /// CSRF token.
508+ #[serde(rename = "_csrf")]
509+ csrf: String,
510+}
511+
512+/// `POST /group-settings/{id}/delete` — delete an empty manual group.
513+pub async fn settings_delete(
514+ State(state): State<AppState>,
515+ MaybeUser(viewer): MaybeUser,
516+ jar: CookieJar,
517+ headers: HeaderMap,
518+ Path(id): Path<String>,
519+ Form(form): Form<GroupDeleteForm>,
520+) -> Response {
521+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
522+ return AppError::Forbidden.into_response();
523+ }
524+ let result = async {
525+ let group = require_admin_group(&state, viewer.as_ref(), &id).await?;
526+ let owner = state
527+ .store
528+ .user_by_id(&group.owner_id)
529+ .await?
530+ .map_or_else(|| group.owner_id.clone(), |u| u.username);
531+ // Guard against orphaning: refuse unless manual and the subtree is empty.
532+ let subtree_repos = state.store.group_subtree_repo_count(&group).await?;
533+ let all_groups = state.store.groups_by_owner(&group.owner_id).await?;
534+ let child_prefix = format!("{}/", group.path);
535+ let has_children = all_groups.iter().any(|g| g.path.starts_with(&child_prefix));
536+ if !group.manual || subtree_repos != 0 || has_children {
537+ return Err(AppError::BadRequest(
538+ "This group cannot be deleted while it is not empty.".to_string(),
539+ ));
540+ }
541+ state.store.delete_group(&group.id).await?;
542+ // Deleting this manual group may leave its (auto) parent empty.
543+ state.store.gc_empty_groups(group.parent_id.clone()).await?;
544+ Ok::<String, AppError>(format!("/{owner}"))
545+ }
546+ .await;
547+ match result {
548+ Ok(dest) => Redirect::to(&dest).into_response(),
549+ Err(err) => err.into_response(),
550+ }
551+}
crates/web/src/issues.rs +2 −2
@@ -658,7 +658,7 @@ pub(crate) async fn access_for(
658658 let collab = match viewer {
659659 Some(u) => state
660660 .store
661- .collaborator_permission(&repo.id, &u.id)
661+ .effective_permission(&repo.id, &u.id)
662662 .await?
663663 .and_then(|p| auth::Permission::parse(&p)),
664664 None => None,
@@ -770,7 +770,7 @@ pub(crate) async fn notify_mentions(
770770 };
771771 let collab = state
772772 .store
773- .collaborator_permission(&repo.id, &user.id)
773+ .effective_permission(&repo.id, &user.id)
774774 .await
775775 .ok()
776776 .flatten()
crates/web/src/layout.rs +3 −0
@@ -105,6 +105,9 @@ fn create_menu(_chrome: &Chrome) -> Markup {
105105 a class="menu-item" role="menuitem" href="/new/migrate" {
106106 (icon(Icon::Download)) span { "New migration" }
107107 }
108+ a class="menu-item" role="menuitem" href="/new/group" {
109+ (icon(Icon::Folder)) span { "New group" }
110+ }
108111 a class="menu-item" role="menuitem" href="/new/issue" {
109112 (icon(Icon::Issue)) span { "New issue" }
110113 }
crates/web/src/lib.rs +16 −0
@@ -17,6 +17,7 @@ mod avatar;
1717 mod diff;
1818 mod error;
1919 mod git_http;
20+mod groups;
2021 mod icons;
2122 mod issues;
2223 mod languages;
@@ -221,6 +222,7 @@ pub fn build_router(state: AppState) -> Router {
221222 )
222223 .route("/new/issue", get(pages::new_issue_chooser))
223224 .route("/new/pull", get(pages::new_pull_chooser))
225+ .route("/new/group", get(groups::new_form).post(groups::new_submit))
224226 .route("/explore", get(pages::explore))
225227 .route("/explore/users", get(pages::explore_users))
226228 .route("/login", get(pages::login_form).post(pages::login_submit))
@@ -321,6 +323,20 @@ pub fn build_router(state: AppState) -> Router {
321323 "/repo-fork/{id}",
322324 get(repo::fork_confirm).post(repo::fork_create),
323325 )
326+ .route("/group-settings/{id}", get(groups::settings_view))
327+ .route(
328+ "/group-settings/{id}/general",
329+ post(groups::settings_general),
330+ )
331+ .route(
332+ "/group-settings/{id}/collaborators",
333+ post(groups::settings_collaborator_add),
334+ )
335+ .route(
336+ "/group-settings/{id}/collaborators/remove",
337+ post(groups::settings_collaborator_remove),
338+ )
339+ .route("/group-settings/{id}/delete", post(groups::settings_delete))
324340 .route("/repo-bookmark/{id}", get(repo::bookmark_toggle))
325341 .route("/user-follow/{username}", get(repo::follow_toggle))
326342 .route("/notifications", get(notifications::list))
crates/web/src/repo.rs +33 −13
@@ -137,7 +137,7 @@ pub(crate) async fn resolve(
137137 let collaborator = match viewer {
138138 Some(u) => state
139139 .store
140- .collaborator_permission(&repo.id, &u.id)
140+ .effective_permission(&repo.id, &u.id)
141141 .await?
142142 .and_then(|p| auth::Permission::parse(&p)),
143143 None => None,
@@ -551,7 +551,7 @@ async fn filter_readable(
551551 let collab = match viewer {
552552 Some(v) => state
553553 .store
554- .collaborator_permission(&repo.id, &v.id)
554+ .effective_permission(&repo.id, &v.id)
555555 .await?
556556 .and_then(|p| auth::Permission::parse(&p)),
557557 None => None,
@@ -1852,7 +1852,7 @@ pub(crate) async fn require_readable_repo(
18521852 let collaborator = match viewer {
18531853 Some(u) => state
18541854 .store
1855- .collaborator_permission(&repo.id, &u.id)
1855+ .effective_permission(&repo.id, &u.id)
18561856 .await?
18571857 .and_then(|p| auth::Permission::parse(&p)),
18581858 None => None,
@@ -2083,7 +2083,7 @@ async fn require_admin_repo(
20832083 let collab = match viewer {
20842084 Some(u) => state
20852085 .store
2086- .collaborator_permission(&repo.id, &u.id)
2086+ .effective_permission(&repo.id, &u.id)
20872087 .await?
20882088 .and_then(|p| auth::Permission::parse(&p)),
20892089 None => None,
@@ -2793,6 +2793,13 @@ async fn group_listing(
27932793 return Err(AppError::NotFound);
27942794 };
27952795
2796+ // A private group is invisible to non-members (404, never 403).
2797+ let access = crate::groups::group_access(state, viewer.as_ref(), &group).await?;
2798+ if access == auth::Access::None {
2799+ return Err(AppError::NotFound);
2800+ }
2801+ let is_admin = access == auth::Access::Admin;
2802+
27962803 let all_groups = state.store.groups_by_owner(&owner.id).await?;
27972804 let prefix = format!("{group_path}/");
27982805 let subgroups: Vec<_> = all_groups
@@ -2807,12 +2814,25 @@ async fn group_listing(
28072814
28082815 let crumbs = path_crumbs(&owner.username, &group.path);
28092816 let body = html! {
2810- h1 class="repo-slug group-slug" {
2811- (icon(Icon::Folder))
2812- a href={ "/" (owner.username) } { (owner.username) }
2813- @for (name, href) in &crumbs {
2814- span class="slug-sep" { "/" }
2815- a href=(href) { (name) }
2817+ div class="group-head" {
2818+ h1 class="repo-slug group-slug" {
2819+ (icon(Icon::Folder))
2820+ a href={ "/" (owner.username) } { (owner.username) }
2821+ @for (name, href) in &crumbs {
2822+ span class="slug-sep" { "/" }
2823+ a href=(href) { (name) }
2824+ }
2825+ }
2826+ @if is_admin {
2827+ div class="group-head-actions" {
2828+ a class="btn inline-btn" href=(format!("/new/group?parent={}", group.path)) {
2829+ (icon(Icon::Plus)) span { "New subgroup" }
2830+ }
2831+ a class="btn inline-btn" href=(format!("/group-settings/{}", group.id))
2832+ title="Group settings" aria-label="Group settings" {
2833+ (icon(Icon::Settings))
2834+ }
2835+ }
28162836 }
28172837 }
28182838 @if !subgroups.is_empty() {
@@ -2860,7 +2880,7 @@ async fn visible_repos(
28602880 let collab = match viewer {
28612881 Some(v) => state
28622882 .store
2863- .collaborator_permission(&repo.id, &v.id)
2883+ .effective_permission(&repo.id, &v.id)
28642884 .await?
28652885 .and_then(|p| auth::Permission::parse(&p)),
28662886 None => None,
@@ -2986,7 +3006,7 @@ pub(crate) fn repo_card(title: &str, empty: &str, entries: &[RepoEntry]) -> Mark
29863006 }
29873007
29883008 /// Render an owner's repositories as a listing card.
2989-fn owned_repo_card(owner: &str, repos: &[Repo]) -> Markup {
3009+pub(crate) fn owned_repo_card(owner: &str, repos: &[Repo]) -> Markup {
29903010 let entries: Vec<RepoEntry> = repos.iter().map(|r| RepoEntry::owned(owner, r)).collect();
29913011 repo_card("Repositories", "No repositories.", &entries)
29923012 }
@@ -3116,7 +3136,7 @@ fn link_icon(url: &str) -> Icon {
31163136 /// Build breadcrumb `(segment, href)` pairs for each segment of a repo `path`
31173137 /// under `owner`. Group segments link to their group listing; the last is the
31183138 /// repo itself.
3119-fn path_crumbs(owner: &str, path: &str) -> Vec<(String, String)> {
3139+pub(crate) fn path_crumbs(owner: &str, path: &str) -> Vec<(String, String)> {
31203140 let mut out = Vec::new();
31213141 let mut cumulative = String::new();
31223142 for seg in path.split('/') {
crates/web/src/search.rs +1 −1
@@ -502,7 +502,7 @@ async fn can_read(state: &AppState, repo: &Repo, viewer: Option<&User>) -> AppRe
502502 let collaborator = match viewer {
503503 Some(u) => state
504504 .store
505- .collaborator_permission(&repo.id, &u.id)
505+ .effective_permission(&repo.id, &u.id)
506506 .await?
507507 .and_then(|p| auth::Permission::parse(&p)),
508508 None => None,