fabrica

hanna/fabrica

feat(web): optional self-registration with a disable toggle

be214ee · hanna committed on 2026-07-25

Add instance.allow_registration (off by default). When enabled, /register
offers a sign-up form that creates the account directly and signs in;
Sign-up links appear on the login page and in the drawer. When disabled, the
routes 404. (Captcha is deferred: it needs an outbound HTTP verifier and
loads external JS on the page, which conflicts with the no-network-requests
principle — a documented opt-in for later.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
7 files changed · +244 −3UnifiedSplit
assets/base.css +5 −0
@@ -462,6 +462,11 @@ label.checkbox input {
462462 justify-content: center;
463463 margin-top: 1.25rem;
464464 }
465+.auth-alt {
466+ margin: 1rem 0 0;
467+ text-align: center;
468+ font-size: 0.9em;
469+}
465470
466471 /* ---- Flash / notices ---- */
467472 .notice {
crates/config/src/types.rs +5 −0
@@ -106,6 +106,10 @@ pub struct Instance {
106106 /// Default visibility for newly created repositories.
107107 #[serde(default)]
108108 pub default_visibility: DefaultVisibility,
109+ /// Allow web self-registration (sign-up). Off by default; admin invites work
110+ /// regardless.
111+ #[serde(default)]
112+ pub allow_registration: bool,
109113 }
110114
111115 impl Default for Instance {
@@ -117,6 +121,7 @@ impl Default for Instance {
117121 default_branch: "main".to_string(),
118122 allow_anonymous: true,
119123 default_visibility: DefaultVisibility::Private,
124+ allow_registration: false,
120125 }
121126 }
122127 }
crates/web/src/layout.rs +5 −0
@@ -29,6 +29,8 @@ pub struct Chrome {
2929 pub themes: Vec<ThemeInfo>,
3030 /// Whether the theme picker is shown.
3131 pub allow_theme_choice: bool,
32+ /// Whether web self-registration is enabled (drives the Sign-up links).
33+ pub allow_registration: bool,
3234 /// The current request path, for the theme form's return and nav highlighting.
3335 pub path: String,
3436 /// The CSRF token for this session, injected into htmx and forms.
@@ -113,6 +115,9 @@ fn drawer(chrome: &Chrome) -> Markup {
113115 }
114116 } @else {
115117 a href="/login" { (icon(Icon::User)) span { "Sign in" } }
118+ @if chrome.allow_registration {
119+ a href="/register" { (icon(Icon::Plus)) span { "Sign up" } }
120+ }
116121 }
117122 }
118123 }
crates/web/src/lib.rs +4 −0
@@ -136,6 +136,10 @@ pub fn build_router(state: AppState) -> Router {
136136 .route("/explore", get(pages::explore))
137137 .route("/explore/users", get(pages::explore_users))
138138 .route("/login", get(pages::login_form).post(pages::login_submit))
139+ .route(
140+ "/register",
141+ get(pages::register_form).post(pages::register_submit),
142+ )
139143 .route("/logout", post(pages::logout))
140144 .route(
141145 "/invite/{token}",
crates/web/src/pages.rs +163 −3
@@ -52,6 +52,7 @@ pub(crate) fn build_chrome(
5252 active_scheme: scheme,
5353 themes,
5454 allow_theme_choice: state.config.ui.allow_theme_choice,
55+ allow_registration: state.config.instance.allow_registration,
5556 path,
5657 csrf,
5758 };
@@ -435,12 +436,12 @@ pub async fn login_form(
435436 return Redirect::to("/").into_response();
436437 }
437438 let (jar, chrome) = build_chrome(&state, jar, None, uri.path().to_string());
438- let body = login_body(&chrome.csrf, None);
439+ let body = login_body(&chrome.csrf, None, chrome.allow_registration);
439440 (jar, page(&chrome, "Sign in", body)).into_response()
440441 }
441442
442443 /// Render the login form with an optional error notice.
443-fn login_body(csrf: &str, error: Option<&str>) -> Markup {
444+fn login_body(csrf: &str, error: Option<&str>, allow_registration: bool) -> Markup {
444445 html! {
445446 div class="auth-wrap" {
446447 div class="auth-card" {
@@ -457,6 +458,9 @@ fn login_body(csrf: &str, error: Option<&str>) -> Markup {
457458 autocomplete="current-password" required;
458459 button class="btn btn-primary" type="submit" { "Sign in" }
459460 }
461+ @if allow_registration {
462+ p class="muted auth-alt" { "New here? " a href="/register" { "Create an account" } }
463+ }
460464 }
461465 }
462466 }
@@ -489,7 +493,11 @@ pub async fn login_submit(
489493 let Some(user) = user.filter(|_| ok) else {
490494 // Re-render the form with a generic error (no username enumeration).
491495 let (jar, chrome) = build_chrome(&state, jar, None, "/login".to_string());
492- let body = login_body(&chrome.csrf, Some("Incorrect username or password."));
496+ let body = login_body(
497+ &chrome.csrf,
498+ Some("Incorrect username or password."),
499+ chrome.allow_registration,
500+ );
493501 return (
494502 axum::http::StatusCode::UNAUTHORIZED,
495503 jar,
@@ -536,6 +544,158 @@ async fn open_session(
536544 Ok(jar.clone().add(cookie))
537545 }
538546
547+/// Sign-up form fields.
548+#[derive(Debug, Deserialize)]
549+pub struct RegisterForm {
550+ /// Desired username.
551+ #[serde(default)]
552+ username: String,
553+ /// Email address.
554+ #[serde(default)]
555+ email: String,
556+ /// Password.
557+ #[serde(default)]
558+ password: String,
559+ /// Confirmation.
560+ #[serde(default)]
561+ confirm: String,
562+ /// CSRF token.
563+ #[serde(rename = "_csrf")]
564+ csrf: String,
565+}
566+
567+/// `GET /register` — the sign-up form, or `404` when registration is disabled.
568+pub async fn register_form(
569+ State(state): State<AppState>,
570+ MaybeUser(user): MaybeUser,
571+ jar: CookieJar,
572+ uri: Uri,
573+) -> Response {
574+ if !state.config.instance.allow_registration {
575+ return AppError::NotFound.into_response();
576+ }
577+ if user.is_some() {
578+ return Redirect::to("/").into_response();
579+ }
580+ let (jar, chrome) = build_chrome(&state, jar, None, uri.path().to_string());
581+ let body = register_body(&chrome.csrf, None, "", "");
582+ (jar, page(&chrome, "Sign up", body)).into_response()
583+}
584+
585+/// Render the sign-up form with an optional error and prefilled fields.
586+fn register_body(csrf: &str, error: Option<&str>, username: &str, email: &str) -> Markup {
587+ html! {
588+ div class="auth-wrap" {
589+ div class="auth-card" {
590+ h1 { "Sign up" }
591+ @if let Some(err) = error {
592+ div class="notice notice-error" { (err) }
593+ }
594+ form method="post" action="/register" {
595+ input type="hidden" name="_csrf" value=(csrf);
596+ label for="username" { "Username" }
597+ input type="text" id="username" name="username" value=(username)
598+ autocomplete="username" required;
599+ label for="email" { "Email" }
600+ input type="email" id="email" name="email" value=(email)
601+ autocomplete="email" required;
602+ label for="password" { "Password" }
603+ input type="password" id="password" name="password"
604+ autocomplete="new-password" required minlength="8";
605+ label for="confirm" { "Confirm password" }
606+ input type="password" id="confirm" name="confirm"
607+ autocomplete="new-password" required;
608+ button class="btn btn-primary" type="submit" { "Create account" }
609+ }
610+ p class="muted auth-alt" { "Already have an account? " a href="/login" { "Sign in" } }
611+ }
612+ }
613+ }
614+}
615+
616+/// `POST /register` — create an account and sign in.
617+pub async fn register_submit(
618+ State(state): State<AppState>,
619+ jar: CookieJar,
620+ headers: HeaderMap,
621+ Form(form): Form<RegisterForm>,
622+) -> Response {
623+ if !state.config.instance.allow_registration {
624+ return AppError::NotFound.into_response();
625+ }
626+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
627+ return AppError::Forbidden.into_response();
628+ }
629+ let username = form.username.trim().to_string();
630+ let email = form.email.trim().to_string();
631+
632+ let reject = |msg: &str, username: &str, email: &str| {
633+ let (jar, chrome) = build_chrome(&state, jar.clone(), None, "/register".to_string());
634+ let body = register_body(&chrome.csrf, Some(msg), username, email);
635+ (
636+ axum::http::StatusCode::BAD_REQUEST,
637+ jar,
638+ page(&chrome, "Sign up", body),
639+ )
640+ .into_response()
641+ };
642+
643+ if !email.contains('@') {
644+ return reject("Enter a valid email address.", &username, &email);
645+ }
646+ if form.password != form.confirm {
647+ return reject("Passwords do not match.", &username, &email);
648+ }
649+ if form.password.len() < 8 {
650+ return reject("Password must be at least 8 characters.", &username, &email);
651+ }
652+
653+ let params = auth::HashParams {
654+ m_cost: state.config.auth.argon2_m_cost,
655+ t_cost: state.config.auth.argon2_t_cost,
656+ p_cost: state.config.auth.argon2_p_cost,
657+ };
658+ let Ok(hash) = auth::hash_password(&form.password, params) else {
659+ return AppError::internal("password hashing failed").into_response();
660+ };
661+
662+ let created = state
663+ .store
664+ .create_user(store::NewUser {
665+ username: username.clone(),
666+ email: email.clone(),
667+ display_name: None,
668+ password_hash: Some(hash),
669+ is_admin: false,
670+ must_change_password: false,
671+ })
672+ .await;
673+ let user = match created {
674+ Ok(u) => u,
675+ Err(store::StoreError::Conflict { field, .. }) => {
676+ let msg = if field == "email" {
677+ "That email is already in use."
678+ } else {
679+ "That username is already taken."
680+ };
681+ return reject(msg, &username, &email);
682+ }
683+ Err(store::StoreError::Name(_)) => {
684+ return reject(
685+ "Invalid username: use letters, digits, '-', and '_'.",
686+ &username,
687+ &email,
688+ );
689+ }
690+ Err(err) => return AppError::from(err).into_response(),
691+ };
692+
693+ match open_session(&state, &jar, &user, &headers).await {
694+ Ok(jar) => (jar, Redirect::to("/")).into_response(),
695+ Err(err) => err.into_response(),
696+ }
697+}
698+
539699 /// Logout form field.
540700 #[derive(Debug, Deserialize)]
541701 pub struct CsrfForm {
crates/web/src/tests.rs +61 −0
@@ -468,6 +468,67 @@ async fn settings_page_shows_account_and_tokens() {
468468 assert!(html.contains("API tokens"), "tokens section");
469469 }
470470
471+/// An app with self-registration enabled (and no seeded user).
472+async fn app_with_registration() -> Router {
473+ let store = Store::connect("sqlite::memory:", 1).await.unwrap();
474+ store.migrate().await.unwrap();
475+ let mut config = Config::default();
476+ config.instance.name = "Forge".to_string();
477+ config.instance.allow_registration = true;
478+ config.auth.cookie_secure = false;
479+ let state = AppState::build(Arc::new(config), store, "test-secret".to_string()).unwrap();
480+ crate::build_router(state)
481+}
482+
483+#[tokio::test]
484+async fn registration_is_404_when_disabled() {
485+ let (_state, app) = app().await;
486+ let res = app.oneshot(get("/register")).await.unwrap();
487+ assert_eq!(res.status(), StatusCode::NOT_FOUND);
488+}
489+
490+#[tokio::test]
491+async fn registration_creates_an_account_and_signs_in() {
492+ let app = app_with_registration().await;
493+
494+ let form = app.clone().oneshot(get("/register")).await.unwrap();
495+ assert_eq!(form.status(), StatusCode::OK);
496+ let cookie = form
497+ .headers()
498+ .get(header::SET_COOKIE)
499+ .unwrap()
500+ .to_str()
501+ .unwrap()
502+ .to_string();
503+ let token = cookie
504+ .split("fabrica_csrf=")
505+ .nth(1)
506+ .and_then(|s| s.split(';').next())
507+ .unwrap()
508+ .to_string();
509+ assert!(body_string(form).await.contains("Create account"));
510+
511+ let req = Request::builder()
512+ .method("POST")
513+ .uri("/register")
514+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
515+ .header(header::COOKIE, format!("fabrica_csrf={token}"))
516+ .body(Body::from(format!(
517+ "username=newbie&email=new@example.com&password=secret12&confirm=secret12&_csrf={token}"
518+ )))
519+ .unwrap();
520+ let res = app.oneshot(req).await.unwrap();
521+ assert_eq!(res.status(), StatusCode::SEE_OTHER, "redirect on success");
522+ let set = res
523+ .headers()
524+ .get_all(header::SET_COOKIE)
525+ .iter()
526+ .filter_map(|v| v.to_str().ok())
527+ .collect::<Vec<_>>()
528+ .join("\n");
529+ assert!(set.contains("fabrica_session="), "signed in: {set:?}");
530+}
531+
471532 #[tokio::test]
472533 async fn settings_requires_authentication() {
473534 let (_state, app) = app().await;
fabrica.example.toml +1 −0
@@ -21,6 +21,7 @@ description = "A small, private forge." # one line, shown on the landing pag
2121 default_branch = "main" # default branch for new repositories
2222 allow_anonymous = true # anonymous browse + clone of public repos
2323 default_visibility = "private" # new repos: public | internal | private
24+allow_registration = false # allow web self-registration (sign-up)
2425
2526 [server]
2627 address = "0.0.0.0"