fabrica

hanna/fabrica

feat(web): hide admin invites when self-registration is on; clear flash

f3bb3d6 · hanna committed on 2026-07-26

- The Invites tab is hidden and /admin/invites (GET and the create POST)
  redirects to the overview when self-registration is enabled — invites
  only matter when open registration is closed.
- The one-time invite-link flash cookie is now removed with its original
  "/admin/invites" path, so the link no longer reappears on reload (a
  path-mismatched deletion left it in the browser).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
1 files changed · +26 −7UnifiedSplit
crates/web/src/admin.rs +26 −7
@@ -24,8 +24,10 @@ use crate::session::{RequireAdmin, verify_csrf};
24use crate::{AppState, setting_keys};24use crate::{AppState, setting_keys};
2525
26/// The admin dashboard shell: a left tab rail and the active tab's content.26/// The admin dashboard shell: a left tab rail and the active tab's content.
27/// `show_invites` hides the Invites tab when self-registration is on (invites
28/// are only useful when registration is otherwise closed).
27#[allow(clippy::needless_pass_by_value)] // `content` is embedded once.29#[allow(clippy::needless_pass_by_value)] // `content` is embedded once.
28fn admin_shell(active: &str, content: Markup) -> Markup {30fn admin_shell(active: &str, content: Markup, show_invites: bool) -> Markup {
29 let tab = |href: &str, key: &str, label: &str| {31 let tab = |href: &str, key: &str, label: &str| {
30 html! { a href=(href) aria-current=[(active == key).then_some("page")] { (label) } }32 html! { a href=(href) aria-current=[(active == key).then_some("page")] { (label) } }
31 };33 };
@@ -38,7 +40,7 @@ fn admin_shell(active: &str, content: Markup) -> Markup {
38 (tab("/admin/users", "users", "Users"))40 (tab("/admin/users", "users", "Users"))
39 (tab("/admin/repos", "repos", "Repositories"))41 (tab("/admin/repos", "repos", "Repositories"))
40 (tab("/admin/groups", "groups", "Groups"))42 (tab("/admin/groups", "groups", "Groups"))
41 (tab("/admin/invites", "invites", "Invites"))43 @if show_invites { (tab("/admin/invites", "invites", "Invites")) }
42 (tab("/admin/settings", "settings", "Settings"))44 (tab("/admin/settings", "settings", "Settings"))
43 }45 }
44 }46 }
@@ -56,8 +58,13 @@ fn render(
56 active: &str,58 active: &str,
57 content: Markup,59 content: Markup,
58) -> Response {60) -> Response {
61 let show_invites = !state.allow_registration();
59 let (jar, chrome) = build_chrome(state, jar, Some(user), uri.path().to_string());62 let (jar, chrome) = build_chrome(state, jar, Some(user), uri.path().to_string());
60 (jar, page(&chrome, "Admin", admin_shell(active, content))).into_response()63 (
64 jar,
65 page(&chrome, "Admin", admin_shell(active, content, show_invites)),
66 )
67 .into_response()
61}68}
6269
63/// Rows shown per page in the admin list views.70/// Rows shown per page in the admin list views.
@@ -557,6 +564,11 @@ pub async fn invites(
557 jar: CookieJar,564 jar: CookieJar,
558 uri: Uri,565 uri: Uri,
559) -> AppResult<Response> {566) -> AppResult<Response> {
567 // Invites only matter when self-registration is closed; otherwise the page
568 // is hidden and its route redirects to the overview.
569 if state.allow_registration() {
570 return Ok(Redirect::to("/admin").into_response());
571 }
560 let invites = state.store.list_signup_invites().await?;572 let invites = state.store.list_signup_invites().await?;
561 let base = state.config.instance.url.trim_end_matches('/').to_string();573 let base = state.config.instance.url.trim_end_matches('/').to_string();
562 let (_, chrome) = build_chrome(574 let (_, chrome) = build_chrome(
@@ -606,10 +618,14 @@ pub async fn invites(
606 }618 }
607 }619 }
608 };620 };
609 // Clear the flash cookie after showing it.621 // Clear the flash cookie after showing it once. The removal must carry the
610 let jar = jar.remove(axum_extra::extract::cookie::Cookie::from(622 // same path the cookie was set with ("/admin/invites"), or the browser keeps
611 "fabrica_flash_invite",623 // it and the link reappears on reload.
612 ));624 let jar = jar.remove(
625 axum_extra::extract::cookie::Cookie::build("fabrica_flash_invite")
626 .path("/admin/invites")
627 .build(),
628 );
613 Ok(render(&state, jar, user, &uri, "invites", content))629 Ok(render(&state, jar, user, &uri, "invites", content))
614}630}
615631
@@ -633,6 +649,9 @@ pub async fn invite_create(
633 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {649 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
634 return AppError::Forbidden.into_response();650 return AppError::Forbidden.into_response();
635 }651 }
652 if state.allow_registration() {
653 return Redirect::to("/admin").into_response();
654 }
636 let token = auth::new_session_token();655 let token = auth::new_session_token();
637 let note = form.note.trim();656 let note = form.note.trim();
638 let created = state657 let created = state