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};
2424 use crate::{AppState, setting_keys};
2525
2626 /// 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).
2729 #[allow(clippy::needless_pass_by_value)] // `content` is embedded once.
28-fn admin_shell(active: &str, content: Markup) -> Markup {
30+fn admin_shell(active: &str, content: Markup, show_invites: bool) -> Markup {
2931 let tab = |href: &str, key: &str, label: &str| {
3032 html! { a href=(href) aria-current=[(active == key).then_some("page")] { (label) } }
3133 };
@@ -38,7 +40,7 @@ fn admin_shell(active: &str, content: Markup) -> Markup {
3840 (tab("/admin/users", "users", "Users"))
3941 (tab("/admin/repos", "repos", "Repositories"))
4042 (tab("/admin/groups", "groups", "Groups"))
41- (tab("/admin/invites", "invites", "Invites"))
43+ @if show_invites { (tab("/admin/invites", "invites", "Invites")) }
4244 (tab("/admin/settings", "settings", "Settings"))
4345 }
4446 }
@@ -56,8 +58,13 @@ fn render(
5658 active: &str,
5759 content: Markup,
5860 ) -> Response {
61+ let show_invites = !state.allow_registration();
5962 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()
6168 }
6269
6370 /// Rows shown per page in the admin list views.
@@ -557,6 +564,11 @@ pub async fn invites(
557564 jar: CookieJar,
558565 uri: Uri,
559566 ) -> 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+ }
560572 let invites = state.store.list_signup_invites().await?;
561573 let base = state.config.instance.url.trim_end_matches('/').to_string();
562574 let (_, chrome) = build_chrome(
@@ -606,10 +618,14 @@ pub async fn invites(
606618 }
607619 }
608620 };
609- // Clear the flash cookie after showing it.
610- let jar = jar.remove(axum_extra::extract::cookie::Cookie::from(
611- "fabrica_flash_invite",
612- ));
621+ // Clear the flash cookie after showing it once. The removal must carry the
622+ // same path the cookie was set with ("/admin/invites"), or the browser keeps
623+ // it and the link reappears on reload.
624+ let jar = jar.remove(
625+ axum_extra::extract::cookie::Cookie::build("fabrica_flash_invite")
626+ .path("/admin/invites")
627+ .build(),
628+ );
613629 Ok(render(&state, jar, user, &uri, "invites", content))
614630 }
615631
@@ -633,6 +649,9 @@ pub async fn invite_create(
633649 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
634650 return AppError::Forbidden.into_response();
635651 }
652+ if state.allow_registration() {
653+ return Redirect::to("/admin").into_response();
654+ }
636655 let token = auth::new_session_token();
637656 let note = form.note.trim();
638657 let created = state