Skip to content

Commit 49d66a8

Browse files
authored
Add setting to control user signups (#1068)
**What** This allows admins to enable and disable user signups in the app. **Why** This will help prevent bots from creating accounts if they manage to find a stringer instance online, as well as allowing admins to control who can create accounts. Fixes [#1063][is]. [is]: #1063
1 parent 3360e77 commit 49d66a8

25 files changed

+367
-11
lines changed

Diff for: Gemfile

+1
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,5 @@ end
5050
group :test do
5151
gem "selenium-webdriver"
5252
gem "webdrivers"
53+
gem "with_model"
5354
end

Diff for: Gemfile.lock

+4-1
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,8 @@ GEM
333333
websocket-extensions (>= 0.1.0)
334334
websocket-extensions (0.1.5)
335335
will_paginate (4.0.0)
336+
with_model (2.1.6)
337+
activerecord (>= 5.2)
336338
xpath (3.2.0)
337339
nokogiri (~> 1.8)
338340
zeitwerk (2.6.8)
@@ -376,9 +378,10 @@ DEPENDENCIES
376378
webdrivers
377379
webmock
378380
will_paginate
381+
with_model
379382

380383
RUBY VERSION
381384
ruby 3.2.2
382385

383386
BUNDLED WITH
384-
2.3.25
387+
2.4.13

Diff for: app/assets/stylesheets/application.css

+5-1
Original file line numberDiff line numberDiff line change
@@ -486,8 +486,12 @@ li.feed .remove-feed a:hover {
486486
padding-top: 10px;
487487
}
488488

489+
.setup__label {
490+
font-weight: bold;
491+
}
492+
489493
.setup {
490-
width: 350px;
494+
width: 500px;
491495
margin: 0 auto;
492496
padding-top: 100px;
493497
}

Diff for: app/commands/cast_boolean.rb

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# frozen_string_literal: true
2+
3+
module CastBoolean
4+
TRUE_VALUES = Set.new(["true", true, "1"]).freeze
5+
FALSE_VALUES = Set.new(["false", false, "0"]).freeze
6+
7+
def self.call(boolean)
8+
unless (TRUE_VALUES + FALSE_VALUES).include?(boolean)
9+
raise(ArgumentError, "cannot cast to boolean: #{boolean.inspect}")
10+
end
11+
12+
TRUE_VALUES.include?(boolean)
13+
end
14+
end

Diff for: app/controllers/passwords_controller.rb

+5
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
class PasswordsController < ApplicationController
44
skip_before_action :complete_setup, only: [:new, :create]
55
skip_before_action :authenticate_user, only: [:new, :create]
6+
before_action :check_signups_enabled, only: [:new, :create]
67

78
def new
89
authorization.skip
@@ -35,6 +36,10 @@ def update
3536

3637
private
3738

39+
def check_signups_enabled
40+
redirect_to(login_path) unless Setting::UserSignup.enabled?
41+
end
42+
3843
def password_params
3944
params.require(:user)
4045
.permit(:password_challenge, :password, :password_confirmation)

Diff for: app/controllers/settings_controller.rb

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# frozen_string_literal: true
2+
3+
class SettingsController < ApplicationController
4+
def index
5+
authorization.skip
6+
end
7+
8+
def update
9+
authorization.skip
10+
11+
setting = Setting.find(params[:id])
12+
setting.update!(setting_params)
13+
14+
redirect_to(settings_path)
15+
end
16+
17+
private
18+
19+
def setting_params
20+
params.require(:setting).permit(:enabled)
21+
end
22+
end

Diff for: app/models/application_record.rb

+14
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,20 @@
33
class ApplicationRecord < ActiveRecord::Base
44
primary_abstract_class
55

6+
def self.boolean_accessor(attribute, key, default: false)
7+
store_accessor(attribute, key)
8+
9+
define_method(key) do
10+
value = super()
11+
value.nil? ? default : CastBoolean.call(value)
12+
end
13+
alias_method(:"#{key}?", :"#{key}")
14+
15+
define_method(:"#{key}=") do |value|
16+
super(value.nil? ? default : CastBoolean.call(value))
17+
end
18+
end
19+
620
def error_messages
721
errors.full_messages.join(", ")
822
end

Diff for: app/models/setting.rb

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# frozen_string_literal: true
2+
3+
class Setting < ApplicationRecord
4+
validates :type, presence: true, uniqueness: true
5+
end

Diff for: app/models/setting/user_signup.rb

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
class Setting::UserSignup < Setting
4+
boolean_accessor :data, :enabled, default: false
5+
6+
validates :enabled, inclusion: { in: [true, false] }
7+
8+
def self.first
9+
first_or_create!
10+
end
11+
12+
def self.enabled?
13+
first_or_create!.enabled? || User.none?
14+
end
15+
end

Diff for: app/views/layouts/_footer.html.erb

+6-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<div class="container">
22
<div class="row-fluid">
3-
<div class="span6">
3+
<div class="span8">
44
<ul class="footer-links">
55
<% if current_user %>
66
<li><a href="/logout"><%= t('layout.logout') %></a></li>
@@ -13,12 +13,16 @@
1313
<li class="muted">·</li>
1414
<li><%= link_to(t('layout.profile'), edit_profile_path) %></li>
1515
<li class="muted">·</li>
16+
<% if current_user.admin? %>
17+
<li><%= link_to(t('layout.admin_settings'), settings_path) %></li>
18+
<li class="muted">·</li>
19+
<% end %>
1620
<% end %>
1721

1822
<li><a href="https://github.com/stringer-rss/stringer"><%= t('layout.support') %></a></li>
1923
</ul>
2024
</div>
21-
<div class="span6">
25+
<div class="span4">
2226
<p class="pull-right">
2327
<b><%= t('layout.hey') %></b> <%= t('layout.back_to_work') %>
2428
</p>

Diff for: app/views/sessions/new.erb

+5-3
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
<input type="submit" value="<%= t('sessions.new.fields.submit') %>" class="btn btn-primary pull-right" />
2020
<% end %>
2121

22-
<div>
23-
<%= t('.sign_up_html', href: setup_password_path) %>
24-
</div>
22+
<% if Setting::UserSignup.enabled? %>
23+
<div>
24+
<%= t('.sign_up_html', href: setup_password_path) %>
25+
</div>
26+
<% end %>
2527
</div>

Diff for: app/views/settings/index.html.erb

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<div id="action-bar">
2+
<%= render "feeds/action_bar" %>
3+
</div>
4+
5+
<div class="setup">
6+
<h1><%= t('.heading') %></h1>
7+
<hr />
8+
<p><%= t('.description') %></p>
9+
<hr />
10+
<div class="control-group">
11+
<% setting = Setting::UserSignup.first %>
12+
<%= form_with(model: setting, scope: :setting, url: setting_path(setting)) do |form| %>
13+
<% if Setting::UserSignup.enabled? %>
14+
<%= form.hidden_field :enabled, value: false %>
15+
<span class='setup__label'><%= t('.signup.enabled') %></span>
16+
<%= form.submit(t('.signup.disable'), class: 'btn btn-primary pull-right') %>
17+
<% else %>
18+
<%= form.hidden_field :enabled, value: true %>
19+
<span class='setup__label'><%= t('.signup.disabled') %></span>
20+
<%= form.submit(t('.signup.enable'), class: 'btn btn-primary pull-right') %>
21+
<% end %>
22+
<% end %>
23+
</div>
24+
</div>

Diff for: config/locales/en.yml

+10
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ en:
6666
subtitle: Let's setup your feeds.
6767
title: Welcome aboard.
6868
layout:
69+
admin_settings: Admin Settings
6970
back_to_work: Get back to work, slacker!
7071
export: Export
7172
hey: Hey!
@@ -150,6 +151,15 @@ en:
150151
sign_up_html: Or <a href=%{href}>sign up</a>.
151152
subtitle: Welcome back, friend.
152153
title: Stringer speaks
154+
settings:
155+
index:
156+
heading: Application Settings
157+
description: Edit application wide settings
158+
signup:
159+
enabled: User signups are enabled
160+
disabled: User signups are disabled
161+
enable: Enable
162+
disable: Disable
153163
starred:
154164
next: Next
155165
of: of

Diff for: config/routes.rb

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
scope :admin, constraints: AdminConstraint.new do
77
mount GoodJob::Engine => "good_job"
88

9+
resources :settings, only: [:index, :update]
910
get "/debug", to: "debug#index"
1011
end
1112

Diff for: db/migrate/20230721160939_create_settings.rb

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# frozen_string_literal: true
2+
3+
class CreateSettings < ActiveRecord::Migration[7.0]
4+
def change
5+
create_table :settings do |t|
6+
t.string :type, null: false, index: { unique: true }
7+
t.jsonb :data, null: false, default: {}
8+
9+
t.timestamps
10+
end
11+
end
12+
end

Diff for: db/schema.rb

+9-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: spec/commands/cast_boolean_spec.rb

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe CastBoolean do
4+
["true", true, "1"].each do |true_value|
5+
it "returns true when passed #{true_value.inspect}" do
6+
expect(described_class.call(true_value)).to be(true)
7+
end
8+
end
9+
10+
["false", false, "0"].each do |false_value|
11+
it "returns false when passed #{false_value.inspect}" do
12+
expect(described_class.call(false_value)).to be(false)
13+
end
14+
end
15+
16+
it "raises an error when passed non-boolean value" do
17+
["butt", 0, nil, "", []].each do |bad_value|
18+
expected_message = "cannot cast to boolean: #{bad_value.inspect}"
19+
expect { described_class.call(bad_value) }
20+
.to raise_error(ArgumentError, expected_message)
21+
end
22+
end
23+
end

Diff for: spec/models/application_record_spec.rb

+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe ApplicationRecord do
4+
describe ".boolean_accessor" do
5+
with_model :Cheese, superclass: described_class do
6+
table { |t| t.jsonb(:options, default: {}) }
7+
end
8+
9+
describe "#<key>" do
10+
it "returns the value when present" do
11+
Cheese.boolean_accessor(:options, :stinky)
12+
cheese = Cheese.new(options: { stinky: true })
13+
14+
expect(cheese.stinky).to be(true)
15+
end
16+
17+
it "returns false by default" do
18+
Cheese.boolean_accessor(:options, :stinky)
19+
cheese = Cheese.new
20+
21+
expect(cheese.stinky).to be(false)
22+
end
23+
24+
it "returns the default when value is nil" do
25+
Cheese.boolean_accessor(:options, :stinky, default: true)
26+
cheese = Cheese.new
27+
28+
expect(cheese.stinky).to be(true)
29+
end
30+
31+
it "casts the value to a boolean" do
32+
Cheese.boolean_accessor(:options, :stinky)
33+
cheese = Cheese.new(options: { stinky: "true" })
34+
35+
expect(cheese.stinky).to be(true)
36+
end
37+
end
38+
39+
describe "#<key>=" do
40+
it "sets the value" do
41+
Cheese.boolean_accessor(:options, :stinky)
42+
cheese = Cheese.new
43+
44+
cheese.stinky = true
45+
46+
expect(cheese.options).to eq({ "stinky" => true })
47+
end
48+
49+
it "casts the value to a boolean" do
50+
Cheese.boolean_accessor(:options, :stinky)
51+
cheese = Cheese.new
52+
53+
cheese.stinky = "true"
54+
55+
expect(cheese.options).to eq({ "stinky" => true })
56+
end
57+
58+
it "uses the default when value is nil" do
59+
Cheese.boolean_accessor(:options, :stinky, default: true)
60+
cheese = Cheese.new
61+
62+
cheese.stinky = nil
63+
64+
expect(cheese.options).to eq({ "stinky" => true })
65+
end
66+
end
67+
end
68+
end

0 commit comments

Comments
 (0)