diff --git a/Gemfile b/Gemfile index beb83a012..4d979de51 100644 --- a/Gemfile +++ b/Gemfile @@ -50,4 +50,5 @@ end group :test do gem "selenium-webdriver" gem "webdrivers" + gem "with_model" end diff --git a/Gemfile.lock b/Gemfile.lock index bd556b8d9..238401ee2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -333,6 +333,8 @@ GEM websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) will_paginate (4.0.0) + with_model (2.1.6) + activerecord (>= 5.2) xpath (3.2.0) nokogiri (~> 1.8) zeitwerk (2.6.8) @@ -376,9 +378,10 @@ DEPENDENCIES webdrivers webmock will_paginate + with_model RUBY VERSION ruby 3.2.2 BUNDLED WITH - 2.3.25 + 2.4.13 diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index b5f91f980..e37f2a5b9 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -486,8 +486,12 @@ li.feed .remove-feed a:hover { padding-top: 10px; } +.setup__label { + font-weight: bold; +} + .setup { - width: 350px; + width: 500px; margin: 0 auto; padding-top: 100px; } diff --git a/app/commands/cast_boolean.rb b/app/commands/cast_boolean.rb new file mode 100644 index 000000000..2fa9a62af --- /dev/null +++ b/app/commands/cast_boolean.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module CastBoolean + TRUE_VALUES = Set.new(["true", true, "1"]).freeze + FALSE_VALUES = Set.new(["false", false, "0"]).freeze + + def self.call(boolean) + unless (TRUE_VALUES + FALSE_VALUES).include?(boolean) + raise(ArgumentError, "cannot cast to boolean: #{boolean.inspect}") + end + + TRUE_VALUES.include?(boolean) + end +end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index a985aa06e..c480f93dc 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -3,6 +3,7 @@ class PasswordsController < ApplicationController skip_before_action :complete_setup, only: [:new, :create] skip_before_action :authenticate_user, only: [:new, :create] + before_action :check_signups_enabled, only: [:new, :create] def new authorization.skip @@ -35,6 +36,10 @@ def update private + def check_signups_enabled + redirect_to(login_path) unless Setting::UserSignup.enabled? + end + def password_params params.require(:user) .permit(:password_challenge, :password, :password_confirmation) diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb new file mode 100644 index 000000000..55afee007 --- /dev/null +++ b/app/controllers/settings_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class SettingsController < ApplicationController + def index + authorization.skip + end + + def update + authorization.skip + + setting = Setting.find(params[:id]) + setting.update!(setting_params) + + redirect_to(settings_path) + end + + private + + def setting_params + params.require(:setting).permit(:enabled) + end +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index b8b39a408..866db6833 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -3,6 +3,20 @@ class ApplicationRecord < ActiveRecord::Base primary_abstract_class + def self.boolean_accessor(attribute, key, default: false) + store_accessor(attribute, key) + + define_method(key) do + value = super() + value.nil? ? default : CastBoolean.call(value) + end + alias_method(:"#{key}?", :"#{key}") + + define_method(:"#{key}=") do |value| + super(value.nil? ? default : CastBoolean.call(value)) + end + end + def error_messages errors.full_messages.join(", ") end diff --git a/app/models/setting.rb b/app/models/setting.rb new file mode 100644 index 000000000..cb38322ec --- /dev/null +++ b/app/models/setting.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Setting < ApplicationRecord + validates :type, presence: true, uniqueness: true +end diff --git a/app/models/setting/user_signup.rb b/app/models/setting/user_signup.rb new file mode 100644 index 000000000..d1ec03f81 --- /dev/null +++ b/app/models/setting/user_signup.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Setting::UserSignup < Setting + boolean_accessor :data, :enabled, default: false + + validates :enabled, inclusion: { in: [true, false] } + + def self.first + first_or_create! + end + + def self.enabled? + first_or_create!.enabled? || User.none? + end +end diff --git a/app/views/layouts/_footer.html.erb b/app/views/layouts/_footer.html.erb index 315759703..38c696638 100644 --- a/app/views/layouts/_footer.html.erb +++ b/app/views/layouts/_footer.html.erb @@ -1,6 +1,6 @@
-
+
-
+

<%= t('layout.hey') %> <%= t('layout.back_to_work') %>

diff --git a/app/views/sessions/new.erb b/app/views/sessions/new.erb index 66f485fd6..cb3d05e2f 100644 --- a/app/views/sessions/new.erb +++ b/app/views/sessions/new.erb @@ -19,7 +19,9 @@ <% end %> -
- <%= t('.sign_up_html', href: setup_password_path) %> -
+ <% if Setting::UserSignup.enabled? %> +
+ <%= t('.sign_up_html', href: setup_password_path) %> +
+ <% end %>
diff --git a/app/views/settings/index.html.erb b/app/views/settings/index.html.erb new file mode 100644 index 000000000..9924c21eb --- /dev/null +++ b/app/views/settings/index.html.erb @@ -0,0 +1,24 @@ +
+ <%= render "feeds/action_bar" %> +
+ +
+

<%= t('.heading') %>

+
+

<%= t('.description') %>

+
+
+ <% setting = Setting::UserSignup.first %> + <%= form_with(model: setting, scope: :setting, url: setting_path(setting)) do |form| %> + <% if Setting::UserSignup.enabled? %> + <%= form.hidden_field :enabled, value: false %> + <%= t('.signup.enabled') %> + <%= form.submit(t('.signup.disable'), class: 'btn btn-primary pull-right') %> + <% else %> + <%= form.hidden_field :enabled, value: true %> + <%= t('.signup.disabled') %> + <%= form.submit(t('.signup.enable'), class: 'btn btn-primary pull-right') %> + <% end %> + <% end %> +
+
diff --git a/config/locales/en.yml b/config/locales/en.yml index 5db40b9b4..a6e658125 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -66,6 +66,7 @@ en: subtitle: Let's setup your feeds. title: Welcome aboard. layout: + admin_settings: Admin Settings back_to_work: Get back to work, slacker! export: Export hey: Hey! @@ -150,6 +151,15 @@ en: sign_up_html: Or sign up. subtitle: Welcome back, friend. title: Stringer speaks + settings: + index: + heading: Application Settings + description: Edit application wide settings + signup: + enabled: User signups are enabled + disabled: User signups are disabled + enable: Enable + disable: Disable starred: next: Next of: of diff --git a/config/routes.rb b/config/routes.rb index 99abc8616..3016a3e66 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -6,6 +6,7 @@ scope :admin, constraints: AdminConstraint.new do mount GoodJob::Engine => "good_job" + resources :settings, only: [:index, :update] get "/debug", to: "debug#index" end diff --git a/db/migrate/20230721160939_create_settings.rb b/db/migrate/20230721160939_create_settings.rb new file mode 100644 index 000000000..cd2ec56f3 --- /dev/null +++ b/db/migrate/20230721160939_create_settings.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateSettings < ActiveRecord::Migration[7.0] + def change + create_table :settings do |t| + t.string :type, null: false, index: { unique: true } + t.jsonb :data, null: false, default: {} + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 7159db8d0..bac9e41d5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_03_30_215830) do +ActiveRecord::Schema[7.0].define(version: 2023_07_21_160939) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -96,6 +96,14 @@ t.index ["user_id"], name: "index_groups_on_user_id" end + create_table "settings", force: :cascade do |t| + t.string "type", null: false + t.jsonb "data", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["type"], name: "index_settings_on_type", unique: true + end + create_table "stories", force: :cascade do |t| t.text "title" t.text "permalink" diff --git a/spec/commands/cast_boolean_spec.rb b/spec/commands/cast_boolean_spec.rb new file mode 100644 index 000000000..6eec442e5 --- /dev/null +++ b/spec/commands/cast_boolean_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +RSpec.describe CastBoolean do + ["true", true, "1"].each do |true_value| + it "returns true when passed #{true_value.inspect}" do + expect(described_class.call(true_value)).to be(true) + end + end + + ["false", false, "0"].each do |false_value| + it "returns false when passed #{false_value.inspect}" do + expect(described_class.call(false_value)).to be(false) + end + end + + it "raises an error when passed non-boolean value" do + ["butt", 0, nil, "", []].each do |bad_value| + expected_message = "cannot cast to boolean: #{bad_value.inspect}" + expect { described_class.call(bad_value) } + .to raise_error(ArgumentError, expected_message) + end + end +end diff --git a/spec/models/application_record_spec.rb b/spec/models/application_record_spec.rb new file mode 100644 index 000000000..c02d0c3e9 --- /dev/null +++ b/spec/models/application_record_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +RSpec.describe ApplicationRecord do + describe ".boolean_accessor" do + with_model :Cheese, superclass: described_class do + table { |t| t.jsonb(:options, default: {}) } + end + + describe "#" do + it "returns the value when present" do + Cheese.boolean_accessor(:options, :stinky) + cheese = Cheese.new(options: { stinky: true }) + + expect(cheese.stinky).to be(true) + end + + it "returns false by default" do + Cheese.boolean_accessor(:options, :stinky) + cheese = Cheese.new + + expect(cheese.stinky).to be(false) + end + + it "returns the default when value is nil" do + Cheese.boolean_accessor(:options, :stinky, default: true) + cheese = Cheese.new + + expect(cheese.stinky).to be(true) + end + + it "casts the value to a boolean" do + Cheese.boolean_accessor(:options, :stinky) + cheese = Cheese.new(options: { stinky: "true" }) + + expect(cheese.stinky).to be(true) + end + end + + describe "#=" do + it "sets the value" do + Cheese.boolean_accessor(:options, :stinky) + cheese = Cheese.new + + cheese.stinky = true + + expect(cheese.options).to eq({ "stinky" => true }) + end + + it "casts the value to a boolean" do + Cheese.boolean_accessor(:options, :stinky) + cheese = Cheese.new + + cheese.stinky = "true" + + expect(cheese.options).to eq({ "stinky" => true }) + end + + it "uses the default when value is nil" do + Cheese.boolean_accessor(:options, :stinky, default: true) + cheese = Cheese.new + + cheese.stinky = nil + + expect(cheese.options).to eq({ "stinky" => true }) + end + end + end +end diff --git a/spec/models/setting/user_signup_spec.rb b/spec/models/setting/user_signup_spec.rb new file mode 100644 index 000000000..847443043 --- /dev/null +++ b/spec/models/setting/user_signup_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe Setting::UserSignup do + describe ".first" do + it "returns the first record" do + setting = described_class.create! + + expect(described_class.first).to eq(setting) + end + + it "creates a record if one does not already exist" do + expect { described_class.first }.to change(described_class, :count).by(1) + end + end + + describe ".enabled?" do + it "returns true when enabled" do + create(:user) + described_class.create!(enabled: true) + + expect(described_class.enabled?).to be(true) + end + + it "returns false when disabled" do + create(:user) + described_class.create!(enabled: false) + + expect(described_class.enabled?).to be(false) + end + + it "returns true when no users exist" do + described_class.create!(enabled: false) + + expect(described_class.enabled?).to be(true) + end + end +end diff --git a/spec/requests/debug_controller_spec.rb b/spec/requests/debug_controller_spec.rb index cc49648fe..a8b3cdc96 100644 --- a/spec/requests/debug_controller_spec.rb +++ b/spec/requests/debug_controller_spec.rb @@ -10,6 +10,14 @@ def setup .and_return(["Migration B - 2", "Migration C - 3"]) end + it "displays an admin settings link" do + setup + + get("/admin/debug") + + expect(rendered).to have_link("Admin Settings", href: settings_path) + end + it "displays the current Ruby version" do setup diff --git a/spec/requests/passwords_controller_spec.rb b/spec/requests/passwords_controller_spec.rb index 076cc8f04..e0b75a1d2 100644 --- a/spec/requests/passwords_controller_spec.rb +++ b/spec/requests/passwords_controller_spec.rb @@ -7,6 +7,15 @@ expect(rendered).to have_selector("form#password_setup") end + + it "redirects to the login page when signups are not enabled" do + create(:user) + Setting::UserSignup.update!(enabled: false) + + get "/setup/password" + + expect(response).to redirect_to(login_path) + end end describe "#create" do @@ -32,6 +41,15 @@ expect(response).to redirect_to("/feeds/import") end + + it "redirects to the login page when signups are not enabled" do + create(:user) + Setting::UserSignup.update!(enabled: false) + + post "/setup/password" + + expect(response).to redirect_to(login_path) + end end describe "#update" do diff --git a/spec/requests/settings_controller_spec.rb b/spec/requests/settings_controller_spec.rb new file mode 100644 index 000000000..a6952f1ee --- /dev/null +++ b/spec/requests/settings_controller_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +RSpec.describe SettingsController do + describe "#index" do + it "displays the settings page" do + login_as(create(:user, admin: true)) + + get(settings_path) + + expect(rendered).to have_selector("h1", text: "Settings") + .and have_text("User signups are disabled") + end + end + + describe "#update" do + it "allows enabling account creation" do + login_as(create(:user, admin: true)) + + params = { setting: { enabled: "true" } } + put(setting_path(Setting::UserSignup.first), params:) + + expect(Setting::UserSignup.enabled?).to be(true) + end + end +end diff --git a/spec/support/with_model.rb b/spec/support/with_model.rb new file mode 100644 index 000000000..f7a5f9ba7 --- /dev/null +++ b/spec/support/with_model.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class WithModel::Model + # Workaround for https://github.com/Casecommons/with_model/issues/35 + def cleanup_descendants_tracking + cache_classes = Rails.application.config.cache_classes + if defined?(ActiveSupport::DescendantsTracker) && !cache_classes + ActiveSupport::DescendantsTracker.clear([@model]) + elsif @model.superclass.respond_to?(:direct_descendants) + @model.superclass.subclasses.delete(@model) + end + end +end + +RSpec.configure { |config| config.extend(WithModel) } diff --git a/spec/system/account_setup_spec.rb b/spec/system/account_setup_spec.rb index 01426b7bc..16ae4eafe 100644 --- a/spec/system/account_setup_spec.rb +++ b/spec/system/account_setup_spec.rb @@ -17,13 +17,19 @@ def fill_in_fields(username:) end it "allows a second user to sign up" do + Setting::UserSignup.create!(enabled: true) create(:user) + visit "/" - click_link("sign up") + expect(page).to have_link("sign up") + end - fill_in_fields(username: "my-username-2") + it "does not allow a second user to signup when not enabled" do + create(:user) + + visit "/" - expect(page).to have_text("Logged in as my-username-2") + expect(page).not_to have_link("sign up") end end diff --git a/spec/system/application_settings_spec.rb b/spec/system/application_settings_spec.rb new file mode 100644 index 000000000..03019028c --- /dev/null +++ b/spec/system/application_settings_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +RSpec.describe "application settings" do + it "allows enabling account creation" do + login_as(create(:user, admin: true)) + visit(settings_path) + + within("form", text: "User signups are disabled") { click_on("Enable") } + + expect(page).to have_content("User signups are enabled") + end +end