discourse/discourse·PR #43845·keegangeorge

Email-code signup asks for a username before the account exists

A correct code no longer creates a new member's account by itself. The server answers with a details step, and the account is created by the request that brings the chosen username back.

Revision
fix/email-code-signup-completion → main
320f173 → 64358ac
Shape
mixed
Commits
6
Files
36
By kind
20 prod · 16 test
What changed

The account is created by the request that carries the username

For a new address on an open site, a correct code now returns username_required with a suggested name. The form shows a details step for username and avatar, and a second verify request carrying the chosen name creates the account. CAPTCHA, username validation and consuming the code all move to that second request. An avatar picked on the step is saved by the browser once the account exists. A plugin can also switch the step to a server-generated name that the form submits by itself. The description says this restores a reverted change with a distinct details state.

Two smaller changes ride along:

  • A caller checking a username for another user (for_user_id) must now be signed in and allowed to edit that user's username.
  • enable_random_usernames now defaults to off.
Before
Entering the code created the account with a generated name, which the new member could then rename.
After
The new member chooses the name, and optionally an avatar, before the account is created.
Context

What the judgments below rely on

Before
  1. email entered
  2. request codeapp/controllers/session_controller.rb:516code sent
  3. submit codeapp/controllers/session_controller.rb:975account created
  4. renamefrontend/discourse/app/components/code-login-form.gjs:751username changed
After
  1. email entered
  2. request codeapp/controllers/session_controller.rb:519code sent
  3. submit codeapp/controllers/session_controller.rb:1021-1027username asked
  4. submit code and usernameapp/controllers/session_controller.rb:978-996account created
Which request creates the account, for a new member on an open site. Used by Checkpoint A, Checkpoint B, Checkpoint D
The CAPTCHA plugin's controller patch
The bundled discourse-captcha plugin prepends a module onto SessionController that wraps process_verified_login_code and decides, per request, whether a CAPTCHA token is required. plugins/discourse-captcha/plugin.rb:28 Used by Checkpoint A
Approval signup
With must_approve_users on and an address that is not auto-approved, a verified code returns a signup token held in the server session rather than an account. app/controllers/session_controller.rb:1049-1075 Used by Checkpoint A
Plugin modifiers
DiscoursePluginRegistry.apply_modifier returns the default value it is handed unless some plugin has registered a modifier under that name. lib/discourse_plugin_registry.rb:306-310 Used by Checkpoint C
Attention

What needs your attention

The first four turn on the new request sequence, the last two on the bundled changes. The order is the order to think about them.

Checkpoint A

Does every request that creates an email-code account still pass CAPTCHA?

The patch used to demand a token on the request that submitted the code. It now demands one only when the request carries a username, is not an approval signup, and has no user fields or full name missing. Those negations mirror the controller's early returns, so the requests it lets through stop at required_username_provided before the code is consumed. Approval signups are checked on the completion request instead. Decide whether that mirroring holds for every way a request reaches EmailLoginCode::Redeem.

  • The new predicate the username condition, and the existing-user and invite exits above it
    ▸ plugins/discourse-captcha/lib/discourse_captcha/session_controller_patch.rb:39-46 Changed
           return false if matched_user.present? || params[:invite_key].present?
           return false if !registration_via_login_code_open?
     
    -      pending_approval_signup?(matched_user) ||
    -        (!signup_user_fields_missing? && !signup_full_name_missing?)
    +      !pending_approval_signup?(matched_user) && params[:username].present? &&
    +        !signup_user_fields_missing? && !signup_full_name_missing?
         end
     
         include CaptchaVerification
    
  • The exits it mirrors approval signups, then missing fields or name, both returning before Redeem
    ▸ app/controllers/session_controller.rb:966-976 At head
        return process_verified_invite_login_code(matched_user) if params[:invite_key].present?
    
        return begin_verified_login_code_signup if pending_approval_signup?(matched_user)
    
        if matched_user.nil? && registration_via_login_code_open?
          user_fields_required = signup_user_fields_missing?
          name_required = signup_full_name_missing?
          if user_fields_required || name_required
            return render json: { user_fields_required:, name_required: }
          end
        end
    
  • Approval completion check runs CAPTCHA when the stored proof was not verified, which is now every approval signup
    ▸ plugins/discourse-captcha/lib/discourse_captcha/session_controller_patch.rb:17-25 At head
        def complete_verified_login_code_signup
          proof = verified_login_code_signup_proof_for(params[:signup_token].to_s)
          if proof.is_a?(Hash) && !proof[:captcha_verified] && (error = captcha_verification_error)
            render json: { error: I18n.t(error) }
            return
          end
    
          super
        end
    
  • What the request specs pin no CAPTCHA on the code-only request, no account without one, and the generated-name case plugins/discourse-captcha/spec/requests/discourse_captcha/session_controller_spec.rb:16-75
Checkpoint B

When a chosen username is rejected, is the code still good for another try?

Every new account now arrives with a member-chosen name, so rejection is the ordinary path. On the login-code path the reserved-name policy runs before the transaction, and a format or uniqueness failure fails the model step inside it, which leaves the code unconsumed inferred from tests. On the invite path InviteRedeemer now takes a verified-email username as given instead of falling back to a suggestion, so the User validator at app/models/user.rb:2156-2181 is its only format and uniqueness check, and the resulting RecordInvalid comes back as username_errors from unchanged code. Judge whether both paths leave the code and any staged account reusable.

  • Policies, then the transaction username_allowed runs before consume_code and account creation
    ▸ app/services/email_login_code/redeem.rb:46-56 At head
      policy :username_allowed
      policy :required_fields_provided
      policy :required_full_name_provided
      policy :required_username_provided
    
      lock(:email) do
        transaction do
          step :consume_code
          model :user, :ensure_user
        end
      end
    
  • Invite username taken as given the format and availability branch is skipped once the email is verified
    ▸ app/models/invite_redeemer.rb:99-108 Changed
       )
         raise Discourse::SiteArchived if SiteSetting.site_archived
     
    -    if username && UsernameValidator.new(username).valid_format? &&
    -         User.username_available?(username, email)
    +    if email_verified && username.present?
    +      available_username = username
    +    elsif username && UsernameValidator.new(username).valid_format? &&
    +          User.username_available?(username, email)
           available_username = username
         elsif email_verified
           available_username =
    
  • The validator that now decides format and uniqueness always; reserved names only with enforce_username_restrictions, which the invite path never sets, so the invite service's own policy covers them from unchanged code
    ▸ app/models/user.rb:2156-2174 Unchanged
      def username_validator
        username_format_validator ||
          begin
            if will_save_change_to_username?
              if enforce_username_restrictions &&
                   (
                     User.reserved_username?(username) ||
                       UsernameValidator.clashing_with_existing_route?(username)
                   )
                errors.add(:username, I18n.t("login.reserved_username"))
              end
    
              existing =
                DB.query(USERNAME_EXISTS_SQL, username: self.class.normalize_username(username))
    
              user_id = existing.select { |u| u.is_user }.first&.id
              same_user = user_id && user_id == id
    
              errors.add(:username, I18n.t(:"user.username.unique")) if existing.present? && !same_user
    
  • Rejection specs on the invite service missing, reserved, route-clashing, invalid and taken names each assert the code unconsumed; the login-code service carries the same contexts at spec/services/email_login_code/redeem_spec.rb:220-290 spec/services/invite/redeem_with_email_code_spec.rb:171-250
Checkpoint C

What should the server-side generated-username flag change?

The controller sets generated_username only when a proof in the server session matches the code, the email, the name and the signup context, and Redeem forwards it to CreateFromVerifiedEmail, whose call never reads it. Its whole server-side effect is in the controller: a rejected generated name is replaced with a fresh one instead of returning an error. The mode is only on when a plugin registers email_login_code_username_mode; otherwise lib/discourse_plugin_registry.rb:306-310 returns the default, and no plugin in this repository registers it from unchanged code. The same is true one layer up: the contract still declares username_required at app/services/email_login_code/redeem.rb:11 and the approval completion still sends it at app/controllers/session_controller.rb:1095, but no policy reads it any more. Decide whether each unused input is leftover plumbing or a missing behaviour.

  • The option nothing reads declared beside username; restrictions are still decided by username.present? app/services/user/action/create_from_verified_email.rb:3-39
  • Where Redeem forwards it true only when the proof's code id matches the code being redeemed
    ▸ app/services/email_login_code/redeem.rb:146-158 At head
      def ensure_user(existing_user:, login_code:, params:, ip_address:, options:)
        generated_username =
          options.generated_username && options.generated_username_login_code_id == login_code.id
        existing_user ||
          User::Action::CreateFromVerifiedEmail.call(
            email: params.email,
            ip_address: ip_address,
            user_fields: params.user_fields,
            name: params.name,
            password: params.password,
            username: params.username,
            generated_username: generated_username,
          )
    
  • The mode switch defaults to :required unless a plugin modifier answers otherwise
    ▸ app/controllers/session_controller.rb:1347-1356 At head
      def email_login_code_username_mode(email, signup_context)
        DiscoursePluginRegistry.apply_modifier(
          :email_login_code_username_mode,
          :required,
          email,
          server_session,
          signup_context,
          cookies[:destination_url],
        )
      end
    
  • A retry instead of an error a rejected generated name re-renders the details step with a new one app/controllers/session_controller.rb:1004-1014

Open question Is generated_username on CreateFromVerifiedEmail meant to relax a check, and should the unread username_required go?

Checkpoint D

Is it acceptable that a chosen avatar can be lost without a word?

The avatar is picked before the account exists and saved by the browser after account_created: an upload then pickAvatar, or selectAvatar for a preset, with every failure swallowed before the redirect. The picker offers upload when can_upload_avatar is true, which only checks groups, but the upload endpoint also refuses avatars when auth_overrides_avatar or discourse_connect_overrides_avatar is set (app/controllers/uploads_controller.rb:51-56) from unchanged code. On such a site a member can choose a file, see it previewed, and arrive with the default avatar. Approval signups never reach this save, because app/controllers/session_controller.rb:1180-1181 answers pending_approval first.

  • The deferred save upload or select as the new user, then an empty catch
    ▸ frontend/discourse/app/components/code-login-form.gjs:1018-1040 At head
          await updateCsrfToken();
          if (selection.file && result.can_upload_avatar) {
            const data = new FormData();
            data.append("type", "avatar");
            data.append("user_id", user.id);
            data.append("files[]", selection.file);
            const upload = await ajax("/uploads.json", {
              type: "POST",
              data,
              processData: false,
              contentType: false,
              timeout: 30000,
              ignoreUnsent: false,
            });
            if (upload?.id) {
              await user.pickAvatar(upload.id, "custom");
            }
          } else if (selection.url) {
            await user.selectAvatar(selection.url);
          }
        } catch {
          // Avatar customization must not block an account that was successfully created.
        }
    
  • The upload endpoint's refusal the two override settings and the group check, returning 422 for an avatar upload from unchanged code
    ▸ app/controllers/uploads_controller.rb:51-56 Unchanged
        if type == "avatar" &&
             (
               SiteSetting.discourse_connect_overrides_avatar || SiteSetting.auth_overrides_avatar ||
                 !me.in_any_groups?(SiteSetting.uploaded_avatars_allowed_groups_map)
             )
          return render json: failed_json, status: :unprocessable_entity
    
  • What the picker is told can_upload_avatar from the default trust-level groups and any invite groups app/controllers/session_controller.rb:1318-1333

Open question Should the override settings hide the upload option, or should a failed save tell the member?

Checkpoint E

Should sites that never set enable_random_usernames lose random names?

The setting arrived on 2026-08-27 defaulting to on, and this flips the default. Every site still on the default stops getting adjective-and-noun suggestions on the username and approval details steps, loses the regenerate button (frontend/discourse/app/components/code-login-form.gjs:1282), and gets a 404 from the random-username endpoint. The plugin-driven generated mode ignores the setting through allow_when_disabled: true. Whether a release shipped with the old default is uncertain; the release tags would settle it.

  • The default true to false, still dependent on code logins
    ▸ config/site_settings.yml:696-702 Changed
             - everyone
       enable_random_usernames:
         client: true
    -    default: true
    +    default: false
         area: "login"
         depends_on:
           - enable_local_logins_via_code
    
  • The endpoint guard ensure_random_username_access raises NotFound when the setting is off
    ▸ app/controllers/users_controller.rb:2193-2197 At head
    
      def ensure_random_username_access
        raise Discourse::NotFound if !SiteSetting.enable_random_usernames
        return redirect_to_login_if_required if current_user
        return if !SiteSetting.login_required?
    
  • The bypass generated names call the generator whatever the setting says app/controllers/session_controller.rb:1358-1370
Checkpoint F

Does the new for_user_id guard turn away a caller that still needs it?

check_username used to load any for_user_id for any caller, anonymous included, and check availability against that user's email. It now raises for an anonymous caller and requires ensure_can_edit_username!; the signup form stopped sending the id. The username preference, which does send it, only shows its edit control when the serialized can_edit_username is true, computed by the same guardian method at app/serializers/user_serializer.rb:216-218 from unchanged code. Decide whether any caller outside this repository relied on the old behaviour.

  • The guard anonymous callers refused, then the edit permission enforced
    ▸ app/controllers/users_controller.rb:714-721 At head
      def user_from_params_or_current_user
        return current_user if !params[:for_user_id]
        raise Discourse::InvalidAccess if !current_user
    
        user = User.find(params[:for_user_id])
        guardian.ensure_can_edit_username!(user)
        user
      end
    
  • The guardian rule overrides, staff, the change period, then self within it from unchanged code
    ▸ lib/guardian/user_guardian.rb:24-30 Unchanged
      def can_edit_username?(user)
        return false if SiteSetting.auth_overrides_username?
        return true if is_staff?
        return false if SiteSetting.username_change_period <= 0
        return false if is_anonymous?
        is_me?(user) && user.created_at > SiteSetting.username_change_period.days.ago
      end
    
  • Where the preference gets its flag the same guardian call, so its edit control and the guard agree from unchanged code
    ▸ app/serializers/user_serializer.rb:216-218 Unchanged
      def can_edit_username
        scope.can_edit_username?(object)
      end
    
Where to begin

Read the code in this order

  1. 01 app/services/email_login_code/redeem.rb:46-56 The policy order that stops a request without a username before the code is consumed; everything else reacts to it. Checkpoint B →
  2. 02 app/controllers/session_controller.rb:966-1044 The verify action: early exits, then Redeem and the handlers that turn a missing or rejected name into the details step. Checkpoint A →
  3. 03 plugins/discourse-captcha/lib/discourse_captcha/session_controller_patch.rb:38-44 The CAPTCHA predicate, read against the exits in the previous stop. Checkpoint A →
  4. 04 app/controllers/session_controller.rb:1299-1406 The username-required response, the generated-name proof, and the clean-up after login; also where the avatar permission is computed for Checkpoint D. Checkpoint C →
  5. 05 frontend/discourse/app/components/code-login-form.gjs:387-547 The client side of the sequence, ending in the deferred avatar save. Checkpoint D →
  6. 06 config/site_settings.yml:697-702 The default flip, separate from the sequence. Checkpoint E →
  7. 07 app/controllers/users_controller.rb:714-721 The availability check's new permission guard. Checkpoint F →
Beyond the diff

Impact outside the diff

Impact paths
A · The CAPTCHA widget moves to the details step

On sites running the CAPTCHA plugin the challenge now appears beside the username field rather than under the code entry from unchanged code. Checkpoint A

In this PR's filesOutside the diff
  1. code-login-after-codeoutlet moved to details stepsfrontend/discourse/app/components/code-login-form.gjs:1498-1501
  2. rendered byCaptchaFieldsConnectorhCaptcha or reCAPTCHAplugins/discourse-captcha/assets/javascripts/discourse/connectors/code-login-after-code/captcha-fields-connector.gjs:11
  3. produceschallenge beside the username fieldobservable behaviour
B · An invitee's name is checked only by the User model

A malformed or taken name chosen by an email-code invitee is no longer swapped for a suggestion; it is rejected and shown as a username error from unchanged code. Checkpoint B

In this PR's filesOutside the diff
  1. InviteRedeemer.create_user_from_invitekeeps the given nameapp/models/invite_redeemer.rb:102-106
  2. passesUser#username_validatorformat, uniquenessapp/models/user.rb:2156-2181
  3. producesusername error on the details stepobservable behaviour
In this PR's files Affected, not changed Behaviour

A name-based pass cannot see plugins outside this repository that register email_login_code_username_mode, listen for email_login_code_authenticated, or render into code-login-after-code.

Affected, not changed

▸ Evidence & diff coverage · all 36 changed paths
▸ Searches run, and what they could not reach
  • git grep -n "code-login-after-code\|registerBehaviorTransformer\|registerValueTransformer" plugins/discourse-captcha one outlet connector, one behaviour and one value transformer
  • git grep -n "email_login_code_username_mode\|email_login_code_authenticated" -- ':!spec' only session_controller.rb; the CAPTCHA request spec registers the modifier
  • git grep -n "generated_username" app/services/user declared at create_from_verified_email.rb:6, never read
  • git grep -n "email_verified" -- app lib plugins only Invite::RedeemWithEmailCode passes true
  • grep -rn "enforce_username_restrictions" app lib plugins set only in create_from_verified_email.rb:32
  • git grep -n "checkUsername(\|for_user_id" -- frontend plugins app username-preference.gjs is the only other caller sending an id
  • git grep -n "RandomUsernameGenerator\|enable_random_usernames" the generator, the endpoint guard, three client reads, three controller calls, the invite fallback
  • grep -n "changeAvatar\|pendingAvatar" frontend/discourse/app/components/code-login-form.gjs avatar only on the signup-details step

Out of reach. Plugins and themes outside this repository that register the new modifier, listen for the new event, render into the moved outlet, or call check_username with for_user_id.