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 {
2069.repo-head {2069.repo-head {
2070 margin-bottom: 1.25rem;2070 margin-bottom: 1.25rem;
2071}2071}
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}
2072/* "forked from owner/repo" line under the repo slug. */2086/* "forked from owner/repo" line under the repo slug. */
2073.fork-note {2087.fork-note {
2074 display: flex;2088 display: flex;
crates/ssh/src/server.rs +2 −2
@@ -295,7 +295,7 @@ impl GitHandler {
295 let collaborator = self295 let collaborator = self
296 .shared296 .shared
297 .store297 .store
298 .collaborator_permission(&repo.id, &identity.user_id)298 .effective_permission(&repo.id, &identity.user_id)
299 .await299 .await
300 .ok()300 .ok()
301 .flatten()301 .flatten()
@@ -405,7 +405,7 @@ impl GitHandler {
405 let collaborator = self405 let collaborator = self
406 .shared406 .shared
407 .store407 .store
408 .collaborator_permission(&repo.id, &identity.user_id)408 .effective_permission(&repo.id, &identity.user_id)
409 .await409 .await
410 .ok()410 .ok()
411 .flatten()411 .flatten()
crates/web/src/git_http.rs +1 −1
@@ -237,7 +237,7 @@ pub(crate) async fn authorize(
237 let collaborator = match &viewer {237 let collaborator = match &viewer {
238 Some(u) => state238 Some(u) => state
239 .store239 .store
240 .collaborator_permission(&repo.id, &u.id)240 .effective_permission(&repo.id, &u.id)
241 .await241 .await
242 .ok()242 .ok()
243 .flatten()243 .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
9use axum::Form;
10use axum::extract::{Path, Query, State};
11use axum::http::{HeaderMap, Uri};
12use axum::response::{IntoResponse, Redirect, Response};
13use axum_extra::extract::cookie::CookieJar;
14use maud::{Markup, html};
15use model::{Group, User, Visibility};
16use serde::Deserialize;
17
18use crate::AppState;
19use crate::error::{AppError, AppResult};
20use crate::icons::{Icon, icon};
21use crate::layout::page;
22use crate::pages::build_chrome;
23use crate::repo::path_crumbs;
24use 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.
34pub(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).
67async 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.
88pub 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)]
107pub struct NewGroupQuery {
108 /// Optional parent group path to prefill.
109 parent: Option<String>,
110}
111
112/// New-group form fields.
113#[derive(Debug, Deserialize)]
114pub 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.
127pub 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.
153async 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.
190fn 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).
223pub 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.
308fn 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)]
357pub 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.
370pub 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)]
417pub 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.
430pub 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)]
468pub 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.
478pub 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)]
506pub 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.
513pub 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(
658 let collab = match viewer {658 let collab = match viewer {
659 Some(u) => state659 Some(u) => state
660 .store660 .store
661 .collaborator_permission(&repo.id, &u.id)661 .effective_permission(&repo.id, &u.id)
662 .await?662 .await?
663 .and_then(|p| auth::Permission::parse(&p)),663 .and_then(|p| auth::Permission::parse(&p)),
664 None => None,664 None => None,
@@ -770,7 +770,7 @@ pub(crate) async fn notify_mentions(
770 };770 };
771 let collab = state771 let collab = state
772 .store772 .store
773 .collaborator_permission(&repo.id, &user.id)773 .effective_permission(&repo.id, &user.id)
774 .await774 .await
775 .ok()775 .ok()
776 .flatten()776 .flatten()
crates/web/src/layout.rs +3 −0
@@ -105,6 +105,9 @@ fn create_menu(_chrome: &Chrome) -> Markup {
105 a class="menu-item" role="menuitem" href="/new/migrate" {105 a class="menu-item" role="menuitem" href="/new/migrate" {
106 (icon(Icon::Download)) span { "New migration" }106 (icon(Icon::Download)) span { "New migration" }
107 }107 }
108 a class="menu-item" role="menuitem" href="/new/group" {
109 (icon(Icon::Folder)) span { "New group" }
110 }
108 a class="menu-item" role="menuitem" href="/new/issue" {111 a class="menu-item" role="menuitem" href="/new/issue" {
109 (icon(Icon::Issue)) span { "New issue" }112 (icon(Icon::Issue)) span { "New issue" }
110 }113 }
crates/web/src/lib.rs +16 −0
@@ -17,6 +17,7 @@ mod avatar;
17mod diff;17mod diff;
18mod error;18mod error;
19mod git_http;19mod git_http;
20mod groups;
20mod icons;21mod icons;
21mod issues;22mod issues;
22mod languages;23mod languages;
@@ -221,6 +222,7 @@ pub fn build_router(state: AppState) -> Router {
221 )222 )
222 .route("/new/issue", get(pages::new_issue_chooser))223 .route("/new/issue", get(pages::new_issue_chooser))
223 .route("/new/pull", get(pages::new_pull_chooser))224 .route("/new/pull", get(pages::new_pull_chooser))
225 .route("/new/group", get(groups::new_form).post(groups::new_submit))
224 .route("/explore", get(pages::explore))226 .route("/explore", get(pages::explore))
225 .route("/explore/users", get(pages::explore_users))227 .route("/explore/users", get(pages::explore_users))
226 .route("/login", get(pages::login_form).post(pages::login_submit))228 .route("/login", get(pages::login_form).post(pages::login_submit))
@@ -321,6 +323,20 @@ pub fn build_router(state: AppState) -> Router {
321 "/repo-fork/{id}",323 "/repo-fork/{id}",
322 get(repo::fork_confirm).post(repo::fork_create),324 get(repo::fork_confirm).post(repo::fork_create),
323 )325 )
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))
324 .route("/repo-bookmark/{id}", get(repo::bookmark_toggle))340 .route("/repo-bookmark/{id}", get(repo::bookmark_toggle))
325 .route("/user-follow/{username}", get(repo::follow_toggle))341 .route("/user-follow/{username}", get(repo::follow_toggle))
326 .route("/notifications", get(notifications::list))342 .route("/notifications", get(notifications::list))
crates/web/src/repo.rs +33 −13
@@ -137,7 +137,7 @@ pub(crate) async fn resolve(
137 let collaborator = match viewer {137 let collaborator = match viewer {
138 Some(u) => state138 Some(u) => state
139 .store139 .store
140 .collaborator_permission(&repo.id, &u.id)140 .effective_permission(&repo.id, &u.id)
141 .await?141 .await?
142 .and_then(|p| auth::Permission::parse(&p)),142 .and_then(|p| auth::Permission::parse(&p)),
143 None => None,143 None => None,
@@ -551,7 +551,7 @@ async fn filter_readable(
551 let collab = match viewer {551 let collab = match viewer {
552 Some(v) => state552 Some(v) => state
553 .store553 .store
554 .collaborator_permission(&repo.id, &v.id)554 .effective_permission(&repo.id, &v.id)
555 .await?555 .await?
556 .and_then(|p| auth::Permission::parse(&p)),556 .and_then(|p| auth::Permission::parse(&p)),
557 None => None,557 None => None,
@@ -1852,7 +1852,7 @@ pub(crate) async fn require_readable_repo(
1852 let collaborator = match viewer {1852 let collaborator = match viewer {
1853 Some(u) => state1853 Some(u) => state
1854 .store1854 .store
1855 .collaborator_permission(&repo.id, &u.id)1855 .effective_permission(&repo.id, &u.id)
1856 .await?1856 .await?
1857 .and_then(|p| auth::Permission::parse(&p)),1857 .and_then(|p| auth::Permission::parse(&p)),
1858 None => None,1858 None => None,
@@ -2083,7 +2083,7 @@ async fn require_admin_repo(
2083 let collab = match viewer {2083 let collab = match viewer {
2084 Some(u) => state2084 Some(u) => state
2085 .store2085 .store
2086 .collaborator_permission(&repo.id, &u.id)2086 .effective_permission(&repo.id, &u.id)
2087 .await?2087 .await?
2088 .and_then(|p| auth::Permission::parse(&p)),2088 .and_then(|p| auth::Permission::parse(&p)),
2089 None => None,2089 None => None,
@@ -2793,6 +2793,13 @@ async fn group_listing(
2793 return Err(AppError::NotFound);2793 return Err(AppError::NotFound);
2794 };2794 };
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
2796 let all_groups = state.store.groups_by_owner(&owner.id).await?;2803 let all_groups = state.store.groups_by_owner(&owner.id).await?;
2797 let prefix = format!("{group_path}/");2804 let prefix = format!("{group_path}/");
2798 let subgroups: Vec<_> = all_groups2805 let subgroups: Vec<_> = all_groups
@@ -2807,12 +2814,25 @@ async fn group_listing(
28072814
2808 let crumbs = path_crumbs(&owner.username, &group.path);2815 let crumbs = path_crumbs(&owner.username, &group.path);
2809 let body = html! {2816 let body = html! {
2810 h1 class="repo-slug group-slug" {2817 div class="group-head" {
2811 (icon(Icon::Folder))2818 h1 class="repo-slug group-slug" {
2812 a href={ "/" (owner.username) } { (owner.username) }2819 (icon(Icon::Folder))
2813 @for (name, href) in &crumbs {2820 a href={ "/" (owner.username) } { (owner.username) }
2814 span class="slug-sep" { "/" }2821 @for (name, href) in &crumbs {
2815 a href=(href) { (name) }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 }
2816 }2836 }
2817 }2837 }
2818 @if !subgroups.is_empty() {2838 @if !subgroups.is_empty() {
@@ -2860,7 +2880,7 @@ async fn visible_repos(
2860 let collab = match viewer {2880 let collab = match viewer {
2861 Some(v) => state2881 Some(v) => state
2862 .store2882 .store
2863 .collaborator_permission(&repo.id, &v.id)2883 .effective_permission(&repo.id, &v.id)
2864 .await?2884 .await?
2865 .and_then(|p| auth::Permission::parse(&p)),2885 .and_then(|p| auth::Permission::parse(&p)),
2866 None => None,2886 None => None,
@@ -2986,7 +3006,7 @@ pub(crate) fn repo_card(title: &str, empty: &str, entries: &[RepoEntry]) -> Mark
2986}3006}
29873007
2988/// Render an owner's repositories as a listing card.3008/// Render an owner's repositories as a listing card.
2989fn owned_repo_card(owner: &str, repos: &[Repo]) -> Markup {3009pub(crate) fn owned_repo_card(owner: &str, repos: &[Repo]) -> Markup {
2990 let entries: Vec<RepoEntry> = repos.iter().map(|r| RepoEntry::owned(owner, r)).collect();3010 let entries: Vec<RepoEntry> = repos.iter().map(|r| RepoEntry::owned(owner, r)).collect();
2991 repo_card("Repositories", "No repositories.", &entries)3011 repo_card("Repositories", "No repositories.", &entries)
2992}3012}
@@ -3116,7 +3136,7 @@ fn link_icon(url: &str) -> Icon {
3116/// Build breadcrumb `(segment, href)` pairs for each segment of a repo `path`3136/// Build breadcrumb `(segment, href)` pairs for each segment of a repo `path`
3117/// under `owner`. Group segments link to their group listing; the last is the3137/// under `owner`. Group segments link to their group listing; the last is the
3118/// repo itself.3138/// repo itself.
3119fn path_crumbs(owner: &str, path: &str) -> Vec<(String, String)> {3139pub(crate) fn path_crumbs(owner: &str, path: &str) -> Vec<(String, String)> {
3120 let mut out = Vec::new();3140 let mut out = Vec::new();
3121 let mut cumulative = String::new();3141 let mut cumulative = String::new();
3122 for seg in path.split('/') {3142 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
502 let collaborator = match viewer {502 let collaborator = match viewer {
503 Some(u) => state503 Some(u) => state
504 .store504 .store
505 .collaborator_permission(&repo.id, &u.id)505 .effective_permission(&repo.id, &u.id)
506 .await?506 .await?
507 .and_then(|p| auth::Permission::parse(&p)),507 .and_then(|p| auth::Permission::parse(&p)),
508 None => None,508 None => None,