DEV: Allow new instances to be set up directly with Discourse ID (#36731)

This relies on https://github.com/discourse/discourse_docker/pull/1010 

When email setup is skipped, we can register the site for Discourse ID.
That means the admin (and other users), can signup/login without the
site having working email at all. The goal of this feature is to
simplify bootstrapping a test site. A well-functioning community will
likely need working email at some point, this simplifies the first step
of getting started.
This commit is contained in:
Penar Musaraj
2025-12-23 12:13:36 -05:00
committed by GitHub
parent 3bb19668d2
commit e8c4b80906
13 changed files with 420 additions and 82 deletions
+10 -1
View File
@@ -379,10 +379,11 @@ body.wizard {
}
}
// Register admin pages
// New instance setup screens
// /finish-installation and /finish-installation/register
.wizard-container-contents.finish-installation {
font-family: var(--font-family);
max-width: min(800px, 90vh);
.wizard-container__combobox {
background: var(--secondary);
@@ -436,4 +437,12 @@ body.wizard {
background-color: var(--tertiary-low);
}
}
.alert-error {
padding: 0.5em 2.5em 0.5em 1em;
background-color: var(--danger-low);
color: var(--primary);
position: relative;
margin-bottom: 1em;
}
}
@@ -10,6 +10,9 @@ class FinishInstallationController < ApplicationController
before_action :ensure_no_admins, except: %w[confirm_email resend_email]
def index
@setting_up_discourse_id = ENV["DISCOURSE_SKIP_EMAIL_SETUP"] == "1"
setup_discourse_id if @setting_up_discourse_id
end
def register
@@ -49,6 +52,16 @@ class FinishInstallationController < ApplicationController
send_signup_email if @user.present?
end
def redirect_discourse_id
seed_admin_users
# Set a global notice in case the first admin login doesn't get completed
# This gets cleared when an admin successfully actives their account
SiteSetting.global_notice = I18n.t("finish_installation.discourse_id.global_notice")
redirect_to("/auth/discourse_id")
end
protected
def send_signup_email
@@ -70,6 +83,49 @@ class FinishInstallationController < ApplicationController
GlobalSetting.developer_emails.split(",").map(&:strip)
end
def setup_discourse_id
begin
if find_allowed_emails.empty?
raise StandardError.new(I18n.t("finish_installation.discourse_id.no_allowed_emails"))
end
SiteSetting.enable_discourse_id = true
# Since we're setting up Discourse ID, disable local logins
SiteSetting.enable_local_logins = false
# Let ID set people's usernames
SiteSetting.auth_overrides_username = true
@discourse_id_enabled = true
@discourse_id_error = nil
rescue StandardError => e
@discourse_id_enabled = false
@discourse_id_error = e.message
end
end
def seed_admin_users
allowed_emails = find_allowed_emails
if allowed_emails.empty?
raise StandardError.new(I18n.t("finish_installation.discourse_id.no_allowed_emails"))
end
allowed_emails.each do |email|
next if User.find_by_email(email)
username = UserNameSuggester.suggest(email)
user =
User.new(
email: email,
username: username,
# no password needed, users will login via Discourse ID
active: false, # will be activated upon first login
admin: true,
trust_level: TrustLevel[4],
)
user.save!(validate: false)
end
Group.refresh_automatic_groups!(:staff, :admins)
end
def ensure_no_admins
raise Discourse::InvalidAccess.new unless SiteSetting.has_login_hint?
end
+1 -1
View File
@@ -1977,7 +1977,7 @@ class User < ActiveRecord::Base
def clear_global_notice_if_needed
return if id < 0
if admin && SiteSetting.has_login_hint
if admin && active && SiteSetting.has_login_hint
SiteSetting.has_login_hint = false
SiteSetting.global_notice = ""
end
+4 -6
View File
@@ -55,13 +55,11 @@ class DiscourseId::Register
SiteSetting.discourse_id_client_secret = data["client_secret"]
end
def log_action(guardian:, params:, data:)
def log_action(params:, data:)
return if params.update
return if guardian.blank?
StaffActionLogger.new(guardian.user).log_custom(
"discourse_id_register",
client_id: data["client_id"],
)
user = context[:guardian]&.user || Discourse.system_user
StaffActionLogger.new(user).log_custom("discourse_id_register", client_id: data["client_id"])
end
end
+21 -7
View File
@@ -1,15 +1,29 @@
<div class="wizard-congratulations-wrap">
<div class='row'>
<h1><%= t 'finish_installation.congratulations' %></h1>
<p><%= t 'finish_installation.register.help' %></p>
<%= link_to(finish_installation_register_path, class: 'wizard-container__button btn-primary finish-installation__register') do %>
<svg xmlns="http://www.w3.org/2000/svg" width="14px" height="14px" viewBox="0 0 448 512">
<path fill="currentColor" d="M224 256c70.7 0 128-57.3 128-128S294.7 0 224 0 96 57.3 96 128s57.3 128 128 128zm89.6 32h-16.7c-22.2 10.2-46.9 16-72.9 16s-50.6-5.8-72.9-16h-16.7C60.2 288 0 348.2 0 422.4V464c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48v-41.6c0-74.2-60.2-134.4-134.4-134.4z"></path>
</svg>
<%= t 'finish_installation.register.button' %>
<% if @setting_up_discourse_id && @discourse_id_enabled %>
<p><%= raw(t 'finish_installation.discourse_id_help') %></p>
<%= link_to(finish_installation_redirect_discourse_id_path, class: 'wizard-container__button btn-primary finish-installation__discourse-id') do %>
<svg xmlns="http://www.w3.org/2000/svg" width="14px" height="14px" viewBox="0 0 448 512">
<path fill="currentColor" d="M224 256c70.7 0 128-57.3 128-128S294.7 0 224 0 96 57.3 96 128s57.3 128 128 128zm89.6 32h-16.7c-22.2 10.2-46.9 16-72.9 16s-50.6-5.8-72.9-16h-16.7C60.2 288 0 348.2 0 422.4V464c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48v-41.6c0-74.2-60.2-134.4-134.4-134.4z"></path>
</svg>
<%= t 'finish_installation.login_with_discourse_id' %>
<% end %>
<% elsif @setting_up_discourse_id && @discourse_id_error %>
<div class="alert alert-error">
<%= @discourse_id_error %>
</div>
<p><%= raw(t 'finish_installation.discourse_id_error_help') %></p>
<% else %>
<p><%= t 'finish_installation.register.help' %></p>
<%= link_to(finish_installation_register_path, class: 'wizard-container__button btn-primary finish-installation__register') do %>
<svg xmlns="http://www.w3.org/2000/svg" width="14px" height="14px" viewBox="0 0 448 512">
<path fill="currentColor" d="M224 256c70.7 0 128-57.3 128-128S294.7 0 224 0 96 57.3 96 128s57.3 128 128 128zm89.6 32h-16.7c-22.2 10.2-46.9 16-72.9 16s-50.6-5.8-72.9-16h-16.7C60.2 288 0 348.2 0 422.4V464c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48v-41.6c0-74.2-60.2-134.4-134.4-134.4z"></path>
</svg>
<%= t 'finish_installation.register.button' %>
<% end %>
<% end %>
</div>
<div class='row finish-installation-image'>
@@ -0,0 +1,9 @@
<div class="wizard-congratulations-wrap">
<div class='row'>
<h1><%= t 'finish_installation.congratulations' %></h1>
<p><%= t 'finish_installation.discourse_id.help' %></p>
<div class='row finish-installation-image'>
<img src="<%= Discourse.base_path + '/images/wizard/tada.svg' %>" alt="tada emoji" class="tada">
</div>
</div>
@@ -13,9 +13,6 @@
<div class='wizard-container-contents finish-installation'>
<%= yield %>
</div>
<div class='wizard-footer'>
<div class='discourse-logo'></div>
</div>
</div>
</div>
</body>
+7
View File
@@ -5482,6 +5482,9 @@ en:
finish_installation:
congratulations: "Congratulations, you installed Discourse!"
login_with_discourse_id: "Login with Discourse ID"
discourse_id_help: "Please log in with Discourse ID using the button below. <br/><br/>Make sure you use a Discourse ID account with a primary email that matches the `DISCOURSE_DEVELOPER_EMAILS` environment variable."
discourse_id_error_help: "You have opted to skip setting up email for now. Unfortunately due to the error message above, the setup cannot continue. Please report this issue on <a href='https://meta.discourse.org/'>meta.discourse.org</a>."
register:
button: "Register"
title: "Register Admin Account"
@@ -5493,6 +5496,10 @@ en:
resend_email:
title: "Resend Activation Email"
message: "<p>We've re-sent the activation email to <b>%{email}</b>"
discourse_id:
help: "We are now setting up Discourse ID for you."
no_allowed_emails: "No allowed emails configured in DISCOURSE_DEVELOPER_EMAILS. Cannot continue with site setup."
global_notice: "No admins have logged in yet. Please log in using a Discourse ID account with an email that matches the `DISCOURSE_DEVELOPER_EMAILS` environment variable to complete the installation."
safe_mode:
title: "Enter safe mode"
+1
View File
@@ -71,6 +71,7 @@ Discourse::Application.routes.draw do
get "finish-installation" => "finish_installation#index"
get "finish-installation/register" => "finish_installation#register"
post "finish-installation/register" => "finish_installation#register"
get "finish-installation/redirect-discourse-id" => "finish_installation#redirect_discourse_id"
get "finish-installation/confirm-email" => "finish_installation#confirm_email"
put "finish-installation/resend-email" => "finish_installation#resend_email"
+9 -1
View File
@@ -2360,6 +2360,7 @@ RSpec.describe User do
describe ".clear_global_notice_if_needed" do
fab!(:user)
fab!(:admin)
let!(:inactive_user) { Fabricate(:user, active: false) }
before do
SiteSetting.has_login_hint = true
@@ -2378,11 +2379,18 @@ RSpec.describe User do
expect(SiteSetting.global_notice).to eq("some notice")
end
it "clears the notice when the admin is saved" do
it "clears the notice when an active admin is saved" do
admin.save
expect(SiteSetting.has_login_hint).to eq(false)
expect(SiteSetting.global_notice).to eq("")
end
it "does not clear the notice when an inactive admin is saved" do
inactive_user.admin = true
inactive_user.save
expect(SiteSetting.has_login_hint).to eq(true)
expect(SiteSetting.global_notice).to eq("some notice")
end
end
describe ".human_users" do
@@ -1,6 +1,86 @@
# frozen_string_literal: true
RSpec.describe FinishInstallationController do
describe "#index" do
context "when has_login_hint is false" do
before { SiteSetting.has_login_hint = false }
it "doesn't allow access" do
get "/finish-installation"
expect(response.status).to eq(403)
end
end
context "when has_login_hint is true" do
before { SiteSetting.has_login_hint = true }
it "allows access" do
get "/finish-installation"
expect(response.status).to eq(200)
end
context "when setting up Discourse ID" do
before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with("DISCOURSE_SKIP_EMAIL_SETUP").and_return("1")
GlobalSetting.stubs(:developer_emails).returns("admin@example.com")
end
it "enables the enable_discourse_id site setting and shows login button on success" do
stub_request(:post, "https://id.discourse.com/challenge").to_return(
status: 200,
body: { domain: Discourse.current_hostname, token: "test_token" }.to_json,
)
stub_request(:post, "https://id.discourse.com/register").to_return(
status: 200,
body: { client_id: "test_client_id", client_secret: "test_client_secret" }.to_json,
)
get "/finish-installation"
expect(response.status).to eq(200)
expect(SiteSetting.enable_discourse_id).to eq(true)
expect(SiteSetting.enable_local_logins).to eq(false)
expect(response.body).to include("Login with Discourse ID")
expect(response.body).to include("/finish-installation/redirect-discourse-id")
end
it "shows error message and no login button on failure" do
stub_request(:post, "https://id.discourse.com/challenge").to_return(
status: 500,
body: "Internal Server Error",
)
get "/finish-installation"
expect(response.status).to eq(200)
expect(SiteSetting.enable_discourse_id).to eq(false)
expect(response.body).not_to include("Login with Discourse ID")
expect(response.body).to include("alert-error")
end
it "shows error when developer_emails is empty" do
GlobalSetting.stubs(:developer_emails).returns("")
stub_request(:post, "https://id.discourse.com/challenge").to_return(
status: 200,
body: { domain: Discourse.current_hostname, token: "test_token" }.to_json,
)
stub_request(:post, "https://id.discourse.com/register").to_return(
status: 200,
body: { client_id: "test_client_id", client_secret: "test_client_secret" }.to_json,
)
get "/finish-installation"
expect(response.status).to eq(200)
expect(response.body).not_to include("Login with Discourse ID")
expect(response.body).to include("alert-error")
expect(response.body).to include(
I18n.t("finish_installation.discourse_id.no_allowed_emails"),
)
end
end
end
end
describe "#register" do
before do
SiteSetting.has_login_hint = true
@@ -62,4 +142,32 @@ RSpec.describe FinishInstallationController do
expect(response.status).to eq(200)
end
end
describe "#redirect_discourse_id" do
context "when has_login_hint is true" do
before do
SiteSetting.has_login_hint = true
GlobalSetting.stubs(:developer_emails).returns("info@test.com")
end
it "creates admin users and redirects to Discourse ID auth" do
get "/finish-installation/redirect-discourse-id"
expect(response.status).to eq(302)
expect(response.location).to include("/auth/discourse_id")
admin_user = User.find_by_email("info@test.com")
expect(admin_user).to be_present
expect(admin_user.admin).to eq(true)
end
end
context "when has_login_hint is false" do
before { SiteSetting.has_login_hint = false }
it "returns 403" do
get "/finish-installation/redirect-discourse-id"
expect(response.status).to eq(403)
end
end
end
end
+159 -61
View File
@@ -7,92 +7,190 @@ RSpec.describe "Finish Installation", type: :system do
before { SiteSetting.has_login_hint = false }
it "denies access" do
finish_installation_page.visit_index
finish_installation_page.visit_page
expect(finish_installation_page).to have_access_denied
end
end
context "when has_login_hint is true" do
before do
SiteSetting.has_login_hint = true
GlobalSetting.stubs(:developer_emails).returns("admin@example.com,other@example.com")
before { SiteSetting.has_login_hint = true }
context "when DISCOURSE_SKIP_EMAIL_SETUP=1" do
before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with("DISCOURSE_SKIP_EMAIL_SETUP").and_return("1")
end
context "when ID registration is successful" do
before do
GlobalSetting.stubs(:developer_emails).returns("dev1@example.com,dev2@example.com")
stub_request(:post, "https://id.discourse.com/challenge").to_return(
status: 200,
body: { domain: Discourse.current_hostname, token: "test_token" }.to_json,
)
stub_request(:post, "https://id.discourse.com/register").to_return(
status: 200,
body: { client_id: "test_client_id", client_secret: "test_client_secret" }.to_json,
)
end
it "enables Discourse ID and shows login button" do
finish_installation_page.visit_page
expect(finish_installation_page).to have_discourse_id_button
expect(finish_installation_page).to have_no_register_button
expect(finish_installation_page).to have_no_error_message
end
it "creates multiple admin users when multiple emails are configured" do
finish_installation_page.visit_page
finish_installation_page.click_login_with_discourse_id
user1 = User.find_by_email("dev1@example.com")
user2 = User.find_by_email("dev2@example.com")
expect(user1).to be_present
expect(user1.admin).to eq(true)
expect(user2).to be_present
expect(user2.admin).to eq(true)
expect(page.current_url).to include("id.discourse.com")
end
it "skips creating users that already exist" do
Fabricate(:user, email: "dev1@example.com", admin: false)
Fabricate(:user, email: "dev2@example.com", admin: false)
initial_user_count = User.count
finish_installation_page.visit_page
finish_installation_page.click_login_with_discourse_id
expect(User.count).to eq(initial_user_count)
end
end
context "when developer_emails is empty" do
before { GlobalSetting.stubs(:developer_emails).returns("") }
it "shows error message about missing allowed emails" do
finish_installation_page.visit_page
expect(finish_installation_page).to have_no_discourse_id_button
expect(finish_installation_page).to have_no_register_button
expect(finish_installation_page).to have_error_message
expect(finish_installation_page.error_message_text).to include(
I18n.t("finish_installation.discourse_id.no_allowed_emails"),
)
end
end
context "when ID registration fails with an error" do
before do
GlobalSetting.stubs(:developer_emails).returns("dev1@example.com,dev2@example.com")
failed_context = Service::Base::Context.new
failed_context.fail(error: "Failed to connect to Discourse ID")
allow(DiscourseId::Register).to receive(:call).and_return(failed_context)
end
it "shows error message and no login button" do
finish_installation_page.visit_page
expect(finish_installation_page).to have_no_discourse_id_button
expect(finish_installation_page).to have_no_register_button
expect(finish_installation_page).to have_error_message
end
end
end
it "shows validation error when username is blank" do
finish_installation_page.visit_register.fill_password("supersecurepassword").submit
expect(finish_installation_page).to have_username_error
end
context "when using local registration" do
before do
GlobalSetting.stubs(:developer_emails).returns("admin@example.com,other@example.com")
end
it "shows validation error when password is blank" do
finish_installation_page.visit_register.fill_username("newadmin").submit
expect(finish_installation_page).to have_password_error
end
it "shows local register form button" do
finish_installation_page.visit_page
it "shows validation error when password is too short" do
finish_installation_page
.visit_register
.fill_username("newadmin")
.fill_password("short")
.submit
expect(finish_installation_page).to have_password_error("too short")
end
expect(finish_installation_page).to have_register_button
expect(finish_installation_page).to have_no_discourse_id_button
expect(finish_installation_page).to have_no_error_message
end
it "registers admin and redirects to confirm email page" do
finish_installation_page
.visit_register
.select_email("admin@example.com")
.fill_username("newadmin")
.fill_password("supersecurepassword")
.submit
it "shows validation error when username is blank" do
finish_installation_page.visit_register.fill_password("supersecurepassword").submit
expect(finish_installation_page).to have_username_error
end
expect(finish_installation_page).to be_redirected_to_confirm_email
expect(User.find_by(username: "newadmin")).to have_attributes(
email: "admin@example.com",
trust_level: 1,
)
end
it "shows validation error when password is blank" do
finish_installation_page.visit_register.fill_username("newadmin").submit
expect(finish_installation_page).to have_password_error
end
it "handles multiple developer emails" do
finish_installation_page
.visit_register
.select_email("other@example.com")
.fill_username("otheradmin")
.fill_password("supersecurepassword")
.submit
it "shows validation error when password is too short" do
finish_installation_page
.visit_register
.fill_username("newadmin")
.fill_password("short")
.submit
expect(finish_installation_page).to have_password_error("too short")
end
expect(finish_installation_page).to be_redirected_to_confirm_email
expect(User.find_by(username: "otheradmin")).to be_present
end
it "resends activation email when user already exists" do
Fabricate(:user, email: "admin@example.com")
expect {
it "registers admin and redirects to confirm email page" do
finish_installation_page
.visit_register
.select_email("admin@example.com")
.fill_username("differentuser")
.fill_username("newadmin")
.fill_password("supersecurepassword")
.submit
}.to change { Jobs::CriticalUserEmail.jobs.size }.by(1)
expect(finish_installation_page).to be_redirected_to_confirm_email
end
expect(finish_installation_page).to be_redirected_to_confirm_email
expect(User.find_by(username: "newadmin")).to have_attributes(
email: "admin@example.com",
trust_level: 1,
)
end
it "does not send email when user is already active and confirmed" do
user = Fabricate(:user, email: "admin@example.com", active: true)
user.activate
expect {
it "handles multiple developer emails" do
finish_installation_page
.visit_register
.select_email("admin@example.com")
.fill_username("differentuser")
.select_email("other@example.com")
.fill_username("otheradmin")
.fill_password("supersecurepassword")
.submit
}.not_to change { Jobs::CriticalUserEmail.jobs.size }
expect(finish_installation_page).to be_redirected_to_confirm_email
expect(finish_installation_page).to be_redirected_to_confirm_email
expect(User.find_by(username: "otheradmin")).to be_present
end
it "resends activation email when user already exists" do
Fabricate(:user, email: "admin@example.com")
expect {
finish_installation_page
.visit_register
.select_email("admin@example.com")
.fill_username("differentuser")
.fill_password("supersecurepassword")
.submit
}.to change { Jobs::CriticalUserEmail.jobs.size }.by(1)
expect(finish_installation_page).to be_redirected_to_confirm_email
end
it "does not send email when user is already active and confirmed" do
user = Fabricate(:user, email: "admin@example.com", active: true)
user.activate
expect {
finish_installation_page
.visit_register
.select_email("admin@example.com")
.fill_username("differentuser")
.fill_password("supersecurepassword")
.submit
}.not_to change { Jobs::CriticalUserEmail.jobs.size }
end
end
end
end
@@ -3,13 +3,46 @@
module PageObjects
module Pages
class FinishInstallation < PageObjects::Pages::Base
def visit_page
page.visit("/finish-installation")
self
end
def visit_register
visit("/finish-installation/register")
self
end
def visit_index
visit("/finish-installation")
def has_discourse_id_button?
page.has_css?(".finish-installation__discourse-id", text: "Login with Discourse ID")
end
def has_no_discourse_id_button?
page.has_no_css?(".finish-installation__discourse-id")
end
def has_register_button?
page.has_css?(".finish-installation__register", text: "Register")
end
def has_no_register_button?
page.has_no_css?(".finish-installation__register")
end
def has_error_message?
page.has_css?(".alert-error")
end
def has_no_error_message?
page.has_no_css?(".alert-error")
end
def error_message_text
find(".alert-error").text
end
def click_login_with_discourse_id
find(".finish-installation__discourse-id").click
self
end