Compare commits

..

4 Commits

Author SHA1 Message Date
0daf413b47 Prevent sole admin from deleting their account
Without this guard, the last admin in the system could delete their own
account, making the application unmanageable. This adds a model method
`User#sole_admin?`, a controller guard in `RegistrationsController#destroy`,
and disables the delete button in the profile edit view when the current
user is the only remaining admin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 06:52:14 +00:00
f3cb8db1f4 Setup wizard: use labeled-form grid for vertical field layout
Replace the fieldset-based layout with the app's standard
.labeled-form CSS grid so email, password and retype fields
stack vertically (label left, input right) exactly like the
existing sign-in and registration forms.

Section headings and checkbox rows are given explicit grid-column
spans via inline styles so they span the full form width rather
than being constrained to the label column.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:37:49 +00:00
7904ff3ef9 Add web-based installation wizard
Replace the CLI-only setup (db:seed + manual application.rb edits)
with a web wizard shown automatically on first visit when no admin
account exists yet.

SetupController (GET/POST /setup) collects the admin e-mail and
password, a "skip e-mail confirmation" toggle, and an option to
seed the built-in default units.  Once submitted it creates the
admin User, persists the chosen options as Setting records, and
redirects to the sign-in page.

ApplicationController gains a redirect_to_setup_if_needed
before_action that catches every request (including Devise routes)
when no admin exists, so a fresh installation always lands on the
wizard rather than an empty sign-in form.

A new Setting model provides a lightweight key-value store for
runtime options that were previously hard-coded in application.rb
(e.g. skip_email_confirmation).  RegistrationsController now reads
that flag from the database instead of from the application config.

Seeds.rb is kept for headless / automated deployments and skips
admin creation when an admin already exists (idempotent), with a
comment pointing to the web wizard as the preferred path.

Also extends the SQLite nil-limit fix (|| Float::INFINITY) to the
Quantity model, which suffered the same ArgumentError as Unit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:19:24 +00:00
9ad922e3a1 Add skip_email_confirmation option; fix SQLite length validation
Introduce config.skip_email_confirmation in application.rb.dist.
When set to true, new registrations are automatically confirmed
without requiring email verification — useful for installations
where outgoing email is not configured or for development/testing.
Implemented by calling skip_confirmation! in build_resource before
the record is saved, so no confirmation email is ever sent.

Also fix ArgumentError raised in length validations when
type_for_attribute(:column).limit returns nil, which happens with
SQLite for string columns that have no explicit limit in the
migration. Guard with || Float::INFINITY so the validation is
effectively skipped when the database imposes no limit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:57:50 +00:00
33 changed files with 389 additions and 235 deletions

View File

@@ -113,12 +113,6 @@ textarea {
border: solid 1px var(--color-gray); border: solid 1px var(--color-gray);
border-radius: 0.25em; border-radius: 0.25em;
} }
[name=cancel],
.auxiliary {
border-color: var(--color-border-gray);
color: var(--color-nav-gray);
fill: var(--color-nav-gray);
}
input[type=checkbox], input[type=checkbox],
svg, svg,
textarea { textarea {
@@ -137,7 +131,6 @@ input[type=checkbox]:checked {
-webkit-appearance: checkbox; -webkit-appearance: checkbox;
} }
/* Hide spin buttons in input number fields */ /* Hide spin buttons in input number fields */
/* TODO: add spin buttons inside input[number]: before (-) and after (+) input */
input[type=number] { input[type=number] {
appearance: textfield; appearance: textfield;
-moz-appearance: textfield; -moz-appearance: textfield;
@@ -347,7 +340,6 @@ header {
opacity: 1; opacity: 1;
} }
/* TODO: Hover over invalid should work like in measurements (thin vs thick border) */ /* TODO: Hover over invalid should work like in measurements (thin vs thick border) */
.labeled-form { .labeled-form {
align-items: center; align-items: center;
@@ -379,17 +371,9 @@ header {
} }
.labeled-form input[type=submit] { .labeled-form input[type=submit] {
font-size: 1rem; font-size: 1rem;
margin: 1em auto 0 auto; margin: 1.5em auto 0 auto;
padding: 0.75em; padding: 0.75em;
} }
.labeled-form .auxiliary {
grid-column: 3;
/* If more buttons are needed, `grid-row` can be replaced with
* `reading-flow: grid-columns` to ensure proper tabindex order */
grid-row: 1;
height: 100%;
padding-block: 0;
}
/* TODO: remove .items class (?) and make 'form table' work properly */ /* TODO: remove .items class (?) and make 'form table' work properly */
@@ -548,6 +532,11 @@ table.items select:focus-within,
table.items select:focus-visible { table.items select:focus-visible {
color: black; color: black;
} }
form a[name=cancel] {
border-color: var(--color-border-gray);
color: var(--color-nav-gray);
fill: var(--color-nav-gray);
}
form table.items { form table.items {
border: none; border: none;
} }

View File

@@ -9,6 +9,7 @@ class ApplicationController < ActionController::Base
helper_method :current_user_disguised? helper_method :current_user_disguised?
helper_method :current_tab helper_method :current_tab
before_action :redirect_to_setup_if_needed
before_action :authenticate_user! before_action :authenticate_user!
class AccessForbidden < StandardError; end class AccessForbidden < StandardError; end
@@ -25,18 +26,6 @@ class ApplicationController < ActionController::Base
# Turbo will reload 2nd time with HTML format and flashes will be lost. # Turbo will reload 2nd time with HTML format and flashes will be lost.
rescue_from *ActionDispatch::ExceptionWrapper.rescue_responses.keys, with: :rescue_turbo rescue_from *ActionDispatch::ExceptionWrapper.rescue_responses.keys, with: :rescue_turbo
# Required by #respond_with (gem `responders`) used by Devise controllers.
respond_to :html, :turbo_stream
def after_sign_in_path_for(resource)
# TODO: allow setting path per-user or save last path in session and restore
units_path
end
def after_sign_out_path_for(resource)
new_user_session_path
end
protected protected
def current_user_disguised? def current_user_disguised?
@@ -55,6 +44,16 @@ class ApplicationController < ActionController::Base
private private
# Redirect to the web setup wizard when the application has not yet been
# initialised (i.e. no admin account exists in the database).
def redirect_to_setup_if_needed
return if User.exists?(status: :admin)
redirect_to new_setup_path
rescue ActiveRecord::StatementInvalid
# Tables may not exist yet (migrations not run). Fall through and let the
# normal request handling surface a meaningful error.
end
def render_no_content(record) def render_no_content(record)
helpers.render_errors(record) helpers.render_errors(record)
render html: nil, layout: true render html: nil, layout: true

View File

@@ -0,0 +1,39 @@
class RegistrationsController < Devise::RegistrationsController
before_action :authenticate_user!, only: [:edit, :update, :destroy]
def destroy
if current_user.sole_admin?
redirect_back fallback_location: edit_user_registration_path,
alert: t(".sole_admin")
return
end
super
end
protected
def build_resource(hash = {})
super
# Skip the email confirmation step when the admin has enabled this option
# via the web setup wizard (stored as the "skip_email_confirmation" Setting).
# The account becomes active immediately so the user can sign in right after
# registering.
resource.skip_confirmation! if Setting.get("skip_email_confirmation") == "true"
end
def update_resource(resource, params)
# Based on update_with_password()
if params[:password].blank?
params.delete(:password)
params.delete(:password_confirmation) if params[:password_confirmation].blank?
end
result = resource.update(params)
resource.clean_up_passwords
result
end
def after_inactive_sign_up_path_for(resource)
new_user_session_path
end
end

View File

@@ -0,0 +1,59 @@
# Handles the one-time web-based installation wizard.
#
# The wizard is only accessible when no admin account exists yet. Once an
# admin has been created the controller redirects every request to the root
# path, so it can never be used to overwrite an existing installation.
class SetupController < ActionController::Base
# Use the full application layout (header, flash, etc.) so the page looks
# consistent with the rest of the site.
layout "application"
before_action :redirect_if_installed
def new
end
def create
email = params[:admin_email].to_s.strip
password = params[:admin_password].to_s
confirm = params[:admin_password_confirmation].to_s
errors = []
errors << t(".email_blank") if email.blank?
errors << t(".password_blank") if password.blank?
errors << t(".password_mismatch") if password != confirm
if errors.any?
flash.now[:alert] = errors.join(" ")
return render :new, status: :unprocessable_entity
end
user = User.new(email: email, password: password, status: :admin)
user.skip_confirmation!
unless user.save
flash.now[:alert] = user.errors.full_messages.join(" ")
return render :new, status: :unprocessable_entity
end
# Persist runtime settings chosen during setup.
Setting.set("skip_email_confirmation",
params[:skip_email_confirmation] == "1")
# Optionally seed the built-in default units.
if params[:seed_units] == "1"
load Rails.root.join("db/seeds/units.rb")
end
redirect_to new_user_session_path, notice: t(".success")
end
private
def redirect_if_installed
redirect_to root_path if User.exists?(status: :admin)
rescue ActiveRecord::StatementInvalid
# Tables are not yet migrated — stay on the setup page so the user sees a
# meaningful error rather than a crash.
end
end

View File

@@ -1,24 +0,0 @@
class User::ProfilesController < Devise::RegistrationsController
def destroy
# TODO: Disallow/disable deletion for last admin account; update :edit view
super
end
protected
def update_resource(resource, params)
# Based on update_with_password()
if params[:password].blank?
params.delete(:password)
params.delete(:password_confirmation) if params[:password_confirmation].blank?
end
result = resource.update(params)
resource.clean_up_passwords
result
end
def after_inactive_sign_up_path_for(resource)
new_user_session_path
end
end

View File

@@ -37,7 +37,7 @@ class UsersController < ApplicationController
end end
# NOTE: limited actions availabe to :admin by design. Users are meant to # NOTE: limited actions availabe to :admin by design. Users are meant to
# manage their accounts by themselves through profiles. :admin # manage their accounts by themselves through registrations. :admin
# is allowed to sign-in (disguise) as user and make changes from there. # is allowed to sign-in (disguise) as user and make changes from there.
protected protected

View File

@@ -72,8 +72,13 @@ module ApplicationHelper
end end
def labeled_form_for(record, options = {}, &block) def labeled_form_for(record, options = {}, &block)
extra_options = {builder: LabeledFormBuilder, html: {class: 'labeled-form'}} extra_options = {builder: LabeledFormBuilder,
form_for(record, **merge_attributes(options, extra_options), &block) data: {turbo: false},
html: {class: 'labeled-form'}}
options = options.deep_merge(extra_options) do |key, left, right|
key == :class ? class_names(left, right) : right
end
form_for(record, **options, &block)
end end
class TabularFormBuilder < ActionView::Helpers::FormBuilder class TabularFormBuilder < ActionView::Helpers::FormBuilder
@@ -130,16 +135,16 @@ module ApplicationHelper
# [autofocus]. Otherwise IDs are not unique when multiple forms are open # [autofocus]. Otherwise IDs are not unique when multiple forms are open
# and the first input gets focus. # and the first input gets focus.
record_object, options = nil, record_object if record_object.is_a?(Hash) record_object, options = nil, record_object if record_object.is_a?(Hash)
extra_options = {builder: TabularFormBuilder, skip_default_ids: true} options.merge!(builder: TabularFormBuilder, skip_default_ids: true)
options = merge_attributes(options, extra_options)
# TODO: set error message with setCustomValidity instead of rendering to flash? # TODO: set error message with setCustomValidity instead of rendering to flash?
render_errors(record_object || record_name) render_errors(record_object || record_name)
fields_for(record_name, record_object, **options, &block) fields_for(record_name, record_object, **options, &block)
end end
def tabular_form_with(**options, &block) def tabular_form_with(**options, &block)
extra_options = {builder: TabularFormBuilder, html: {autocomplete: 'off'}} options = options.deep_merge(builder: TabularFormBuilder,
form_with(**merge_attributes(options, extra_options), &block) html: {autocomplete: 'off'})
form_with(**options, &block)
end end
def svg_tag(source, label = nil, options = {}) def svg_tag(source, label = nil, options = {})
@@ -154,7 +159,6 @@ module ApplicationHelper
['measurements', 'scale-bathroom', :restricted], ['measurements', 'scale-bathroom', :restricted],
['quantities', 'axis-arrow', :restricted, 'right'], ['quantities', 'axis-arrow', :restricted, 'right'],
['units', 'weight-gram', :restricted], ['units', 'weight-gram', :restricted],
# TODO: display users tab only if >1 user present; sole_user?/sole_admin?
['users', 'account-multiple-outline', :admin], ['users', 'account-multiple-outline', :admin],
] ]
@@ -202,7 +206,6 @@ module ApplicationHelper
def render_errors(records) def render_errors(records)
# Conversion of flash to Array only required because of Devise # Conversion of flash to Array only required because of Devise
# TODO: override Devise message setting to Array()?
flash[:alert] = Array(flash[:alert]) flash[:alert] = Array(flash[:alert])
Array(records).each { |record| flash[:alert] += record.errors.full_messages } Array(records).each { |record| flash[:alert] += record.errors.full_messages }
end end
@@ -212,7 +215,6 @@ module ApplicationHelper
# Conversion of flash to Array only required because of Devise # Conversion of flash to Array only required because of Devise
Array(messages).map do |message| Array(messages).map do |message|
tag.div class: "flash #{entry}" do tag.div class: "flash #{entry}" do
# TODO: change button text to svg to make it aligned vertically
tag.div(sanitize(message)) + tag.button(sanitize("&times;"), tabindex: -1, tag.div(sanitize(message)) + tag.button(sanitize("&times;"), tabindex: -1,
onclick: "this.parentElement.remove();") onclick: "this.parentElement.remove();")
end end
@@ -250,11 +252,4 @@ module ApplicationHelper
[name, html_options] [name, html_options]
end end
# Like Hash#deep_merge, but aware of HTML attributes.
def merge_attributes(left, right)
left.deep_merge(right) do |key, lvalue, rvalue|
key == :class ? class_names(lvalue, rvalue) : rvalue
end
end
end end

View File

@@ -37,18 +37,6 @@ window.detailsObserver = new MutationObserver((mutations) => {
mutations[0].target.dispatchEvent(new Event('change', {bubbles: true})) mutations[0].target.dispatchEvent(new Event('change', {bubbles: true}))
}); });
function formValidate(event) {
var id = event.submitter.getAttribute("data-validate")
if (!id) return;
var input = document.getElementById(id)
if (!input.checkValidity()) {
input.reportValidity()
event.preventDefault()
}
}
window.formValidate = formValidate
/* Turbo stream actions */ /* Turbo stream actions */
Turbo.StreamElement.prototype.disableElement = function(element) { Turbo.StreamElement.prototype.disableElement = function(element) {

View File

@@ -15,8 +15,8 @@ class Quantity < ApplicationRecord
errors.add(:parent, :descendant_reference) if ancestor_of?(parent) errors.add(:parent, :descendant_reference) if ancestor_of?(parent)
end end
validates :name, presence: true, uniqueness: {scope: [:user_id, :parent_id]}, validates :name, presence: true, uniqueness: {scope: [:user_id, :parent_id]},
length: {maximum: type_for_attribute(:name).limit} length: {maximum: type_for_attribute(:name).limit || Float::INFINITY}
validates :description, length: {maximum: type_for_attribute(:description).limit} validates :description, length: {maximum: type_for_attribute(:description).limit || Float::INFINITY}
# Update :depths of progenies after parent change # Update :depths of progenies after parent change
before_save if: :parent_changed? do before_save if: :parent_changed? do

20
app/models/setting.rb Normal file
View File

@@ -0,0 +1,20 @@
# Key-value store for runtime application settings that are configured through
# the web setup wizard (or updated by an administrator) rather than hard-coded
# in application.rb.
#
# Known keys:
# skip_email_confirmation "true"/"false", mirrors the homonymous option
# that was previously in application.rb.
class Setting < ApplicationRecord
validates :key, presence: true, uniqueness: true
# Return the string value stored for +key+, or +default+ when absent.
def self.get(key, default: nil)
find_by(key: key)&.value || default
end
# Persist +value+ for +key+, creating the record if it does not yet exist.
def self.set(key, value)
find_or_initialize_by(key: key).update!(value: value.to_s)
end
end

View File

@@ -12,8 +12,8 @@ class Unit < ApplicationRecord
errors.add(:base, :multilevel_nesting) if base.base_id? errors.add(:base, :multilevel_nesting) if base.base_id?
end end
validates :symbol, presence: true, uniqueness: {scope: :user_id}, validates :symbol, presence: true, uniqueness: {scope: :user_id},
length: {maximum: type_for_attribute(:symbol).limit} length: {maximum: type_for_attribute(:symbol).limit || Float::INFINITY}
validates :description, length: {maximum: type_for_attribute(:description).limit} validates :description, length: {maximum: type_for_attribute(:description).limit || Float::INFINITY}
validates :multiplier, numericality: {equal_to: 1}, unless: :base validates :multiplier, numericality: {equal_to: 1}, unless: :base
validates :multiplier, numericality: {greater_than: 0, precision: true, scale: true}, if: :base validates :multiplier, numericality: {greater_than: 0, precision: true, scale: true}, if: :base

View File

@@ -29,4 +29,11 @@ class User < ApplicationRecord
def at_least(status) def at_least(status)
User.statuses[self.status] >= User.statuses[status] User.statuses[self.status] >= User.statuses[status]
end end
# Returns true when this user is the only admin account in the system.
# Used to block actions that would leave the application without an admin
# (account deletion, status demotion).
def sole_admin?
admin? && !User.admin.where.not(id: id).exists?
end
end end

View File

@@ -0,0 +1,39 @@
<%= form_with url: setup_path, method: :post, class: "labeled-form main-area" do %>
<h3 style="grid-column: 1 / -1; text-align: left; margin: 0;">
<%= t(".admin_account") %>
</h3>
<label for="admin_email"><%= t(".admin_email") %></label>
<%= email_field_tag :admin_email, params[:admin_email],
id: "admin_email", required: true, size: 30, autofocus: true,
autocomplete: "email" %>
<label for="admin_password"><%= t(".admin_password") %></label>
<%= password_field_tag :admin_password, nil,
id: "admin_password", required: true, size: 30,
autocomplete: "new-password" %>
<label for="admin_password_confirmation"><%= t(".admin_password_confirmation") %></label>
<%= password_field_tag :admin_password_confirmation, nil,
id: "admin_password_confirmation", required: true, size: 30,
autocomplete: "off" %>
<h3 style="grid-column: 1 / -1; text-align: left; margin: 0.5em 0 0 0;">
<%= t(".options") %>
</h3>
<label for="skip_email_confirmation" style="grid-column: 1 / 3; text-align: left;">
<%= check_box_tag :skip_email_confirmation, "1",
params[:skip_email_confirmation] == "1",
id: "skip_email_confirmation" %>
<%= t(".skip_email_confirmation") %>
</label>
<label for="seed_units" style="grid-column: 1 / 3; text-align: left;">
<%= check_box_tag :seed_units, "1", true, id: "seed_units" %>
<%= t(".seed_units") %>
</label>
<%= submit_tag t(".submit") %>
<% end %>

View File

@@ -1 +0,0 @@
<% flash.discard %>

View File

@@ -0,0 +1,9 @@
<%= labeled_form_for resource, url: user_confirmation_path,
html: {class: 'main-area'} do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true,
autocomplete: 'email', value:
resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email %>
<%= f.submit t(:resend_confirmation) %>
<% end %>

View File

@@ -1,2 +0,0 @@
<%# For some reason flash messages are duplicated in bot flash and flash.now %>
<% flash.discard %>

View File

@@ -1,5 +1,5 @@
<%= labeled_form_for resource, url: user_password_path, <%= labeled_form_for resource, url: user_password_path,
html: {method: :put, class: 'main-area', data: {turbo: false}} do |f| %> html: {method: :put, class: 'main-area'} do |f| %>
<%= f.hidden_field :reset_password_token %> <%= f.hidden_field :reset_password_token %>

View File

@@ -0,0 +1,8 @@
<%= labeled_form_for resource, url: user_password_path,
html: {class: 'main-area'} do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true,
autocomplete: 'email' %>
<%= f.submit t(:recover_password) %>
<% end %>

View File

@@ -1,17 +0,0 @@
<%= labeled_form_for resource, url: user_registration_path,
html: {class: 'main-area', onsubmit: 'formValidate(event)'} do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true,
autocomplete: 'email' %>
<%= f.password_field :password, required: true, size: 30,
minlength: @minimum_password_length, autocomplete: 'new-password' %>
<%= f.password_field :password_confirmation, required: true, size: 30,
minlength: @minimum_password_length, autocomplete: 'off' %>
<%= f.submit t(:register), data: {turbo: false} %>
<%# TODO: fix button text color after change link -> button %>
<%= image_button_tag t(:resend_confirmation), 'email-sync-outline',
class: 'auxiliary', formaction: user_confirmation_path, formnovalidate: true,
data: {validate: f.field_id(:email)} %>
<% end %>

View File

@@ -4,9 +4,8 @@
<% end %> <% end %>
<div class="rightside-area buttongrid"> <div class="rightside-area buttongrid">
<%#= TODO: Disallow/disable deletion for last admin account, image_button_to_if %> <%= image_button_to_if !current_user.sole_admin?, t('.delete'), 'account-remove-outline',
<%= image_button_to t('.delete'), 'account-remove-outline', user_registration_path, user_registration_path, form_class: 'tools-area', method: :delete, data: {turbo: false},
form_class: 'tools-area', method: :delete, data: {turbo: false},
onclick: {confirm: t('.confirm_delete')} %> onclick: {confirm: t('.confirm_delete')} %>
</div> </div>

View File

@@ -0,0 +1,16 @@
<div class="main-area">
<%= labeled_form_for resource, url: user_registration_path do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true,
autocomplete: 'email' %>
<%= f.password_field :password, required: true, size: 30,
minlength: @minimum_password_length, autocomplete: 'new-password' %>
<%= f.password_field :password_confirmation, required: true, size: 30,
minlength: @minimum_password_length, autocomplete: 'off' %>
<%= f.submit t(:register) %>
<% end %>
<%= content_tag :p, t(:or), style: 'text-align: center;' %>
<%= image_link_to t(:resend_confirmation), 'email-sync-outline',
new_user_confirmation_path, class: 'centered' %>
</div>

View File

@@ -1,19 +1,18 @@
<%= labeled_form_for resource, url: user_session_path, <div class="main-area">
html: {class: 'main-area', onsubmit: 'formValidate(event)'} do |f| %> <%= labeled_form_for resource, url: user_session_path do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true, <%= f.email_field :email, required: true, size: 30, autofocus: true,
autocomplete: 'email' %> autocomplete: 'email' %>
<%= f.password_field :password, required: true, size: 30, <%= f.password_field :password, required: true, size: 30,
autocomplete: 'current-password' %> minlength: @minimum_password_length, autocomplete: 'current-password' %>
<% if devise_mapping.rememberable? %> <% if devise_mapping.rememberable? %>
<%= f.check_box :remember_me %> <%= f.check_box :remember_me %>
<% end %> <% end %>
<%# /sign_in as HTML; /password as TURBO_STREAM %> <%= f.submit t(:sign_in) %>
<%= f.submit t(:sign_in), data: {turbo: false} %> <% end %>
<%= image_button_tag t(:recover_password), 'lock-reset', class: 'auxiliary', <%= content_tag :p, t(:or), style: 'text-align: center;' %>
formaction: user_password_path, formnovalidate: true, <%= image_link_to t(:recover_password), 'lock-reset', new_user_password_path,
data: {validate: f.field_id(:email)} %> class: 'centered' %>
<% end %> </div>

View File

@@ -54,5 +54,9 @@ module FixinMe
# Sender address of account registration-related messages # Sender address of account registration-related messages
Devise.mailer_sender = 'noreply@localhost' Devise.mailer_sender = 'noreply@localhost'
# Whether to skip e-mail confirmation for new registrations is configured
# through the web setup wizard and stored in the database (Setting model),
# so it does not need to be set here.
end end
end end

View File

@@ -91,7 +91,7 @@ Devise.setup do |config|
# It will change confirmation, password recovery and other workflows # It will change confirmation, password recovery and other workflows
# to behave the same regardless if the e-mail provided was right or wrong. # to behave the same regardless if the e-mail provided was right or wrong.
# Does not affect registerable. # Does not affect registerable.
config.paranoid = true # config.paranoid = true
# By default Devise will store the user in session. You can skip storage for # By default Devise will store the user in session. You can skip storage for
# particular strategies by setting this option. # particular strategies by setting this option.

View File

@@ -4,15 +4,15 @@ en:
devise: devise:
confirmations: confirmations:
confirmed: "Your email address has been successfully confirmed." confirmed: "Your email address has been successfully confirmed."
send_paranoid_instructions: > send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes."
If your email address is in our database, a message with instructions on how send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes."
to confirm your email address has been sent to you.
failure: failure:
already_authenticated: "You are already signed in." already_authenticated: "You are already signed in."
inactive: "Your account is not activated yet." inactive: "Your account is not activated yet."
invalid: "Invalid <b>%{authentication_keys}</b> or <b>password</b>." invalid: "Invalid %{authentication_keys} or password."
locked: "Your account is locked." locked: "Your account is locked."
last_attempt: "You have one more attempt before your account is locked." last_attempt: "You have one more attempt before your account is locked."
not_found_in_database: "Invalid %{authentication_keys} or password."
timeout: "Your session expired. Please sign in again to continue." timeout: "Your session expired. Please sign in again to continue."
unauthenticated: "You need to sign in or sign up before continuing." unauthenticated: "You need to sign in or sign up before continuing."
unconfirmed: "You have to confirm your email address before continuing." unconfirmed: "You have to confirm your email address before continuing."
@@ -32,9 +32,8 @@ en:
success: "Successfully authenticated from %{kind} account." success: "Successfully authenticated from %{kind} account."
passwords: passwords:
no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided." no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided."
send_paranoid_instructions: > send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes."
If your email address is in our database, the password recovery link has been send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes."
sent to you.
updated: "Your password has been changed successfully. You are now signed in." updated: "Your password has been changed successfully. You are now signed in."
updated_not_active: "Your password has been changed successfully." updated_not_active: "Your password has been changed successfully."
registrations: registrations:
@@ -51,6 +50,7 @@ en:
signed_out: "Signed out successfully." signed_out: "Signed out successfully."
already_signed_out: "Signed out successfully." already_signed_out: "Signed out successfully."
unlocks: unlocks:
send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes."
send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes." send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes."
unlocked: "Your account has been unlocked successfully. Please sign in to continue." unlocked: "Your account has been unlocked successfully. Please sign in to continue."
errors: errors:

View File

@@ -150,7 +150,7 @@ en:
edit: edit:
password_html: 'New password:%{password_length_hint_html}' password_html: 'New password:%{password_length_hint_html}'
update_password: Update password update_password: Update password
profiles: registrations:
new: new:
password_html: 'Password:%{password_length_hint_html}' password_html: 'Password:%{password_length_hint_html}'
password_confirmation: 'Retype password:' password_confirmation: 'Retype password:'
@@ -162,13 +162,34 @@ en:
New password: New password:
<br><em>leave blank to keep unchanged</em> <br><em>leave blank to keep unchanged</em>
%{password_length_hint_html} %{password_length_hint_html}
registrations:
destroy:
sole_admin: You cannot delete the only admin account.
actions: Actions actions: Actions
setup:
new:
admin_account: Admin account
admin_email: 'E-mail:'
admin_password: 'Password:'
admin_password_confirmation: 'Retype password:'
options: Options
skip_email_confirmation: Skip e-mail confirmation for new registrations
seed_units: Seed built-in default units
submit: Set up
create:
email_blank: E-mail cannot be blank.
password_blank: Password cannot be blank.
password_mismatch: Passwords do not match.
success: >
Installation complete. You can now sign in with the admin account you
just created.
add: Add add: Add
apply: Apply apply: Apply
back: Back back: Back
cancel: Cancel cancel: Cancel
delete: Delete delete: Delete
:no: 'no' :no: 'no'
or: or
register: Register register: Register
sign_in: Sign in sign_in: Sign in
recover_password: Recover password recover_password: Recover password

View File

@@ -1,4 +1,7 @@
Rails.application.routes.draw do Rails.application.routes.draw do
# Web-based installation wizard — only reachable when no admin exists yet.
resource :setup, only: [:new, :create], controller: :setup
resources :measurements resources :measurements
resources :readouts, only: [:new] do resources :readouts, only: [:new] do
@@ -24,9 +27,8 @@ Rails.application.routes.draw do
# https://github.com/heartcombo/devise/issues/5786 # https://github.com/heartcombo/devise/issues/5786
connection = ActiveRecord::Base.connection connection = ActiveRecord::Base.connection
if connection.schema_version && connection.table_exists?(:users) if connection.schema_version && connection.table_exists?(:users)
# NOTE: change helper prefix from *_registration to *_profile once possible
devise_for :users, path: '', path_names: {registration: 'profile'}, devise_for :users, path: '', path_names: {registration: 'profile'},
controllers: {registrations: 'user/profiles'} controllers: {registrations: :registrations}
end end
resources :users, only: [:index, :show, :update] do resources :users, only: [:index, :show, :update] do
@@ -35,8 +37,10 @@ Rails.application.routes.draw do
end end
unauthenticated do unauthenticated do
as :user do
root to: redirect('/sign_in') root to: redirect('/sign_in')
end end
end
root to: redirect('/units'), as: :user_root root to: redirect('/units'), as: :user_root
direct(:source_code) { 'https://gitea.michalczyk.pro/fixin.me/fixin.me' } direct(:source_code) { 'https://gitea.michalczyk.pro/fixin.me/fixin.me' }

View File

@@ -0,0 +1,12 @@
class CreateSettings < ActiveRecord::Migration[7.2]
def change
create_table :settings do |t|
t.string :key, null: false
t.string :value
t.timestamps
end
add_index :settings, :key, unique: true
end
end

View File

@@ -3,6 +3,17 @@
# bin/rails db:seed # bin/rails db:seed
# command (or created alongside the database with db:setup). # command (or created alongside the database with db:setup).
# Seeding process should be idempotent. # Seeding process should be idempotent.
#
# Admin account setup
# -------------------
# The preferred way to create the first admin account is through the web setup
# wizard, which is shown automatically on the first visit when no admin exists.
# The wizard also lets you configure runtime options (e.g. skip e-mail
# confirmation) and seed the default units without using the command line.
#
# The block below provides an alternative CLI path for headless / automated
# deployments. It is skipped when an admin account already exists (e.g. after
# the web wizard has run).
User.transaction do User.transaction do
break if User.find_by status: :admin break if User.find_by status: :admin
@@ -21,4 +32,3 @@ end
#[Source, Quantity, Unit].each { |model| model.defaults.delete_all } #[Source, Quantity, Unit].each { |model| model.defaults.delete_all }
require_relative 'seeds/units.rb' require_relative 'seeds/units.rb'
require_relative 'seeds/quantities.rb'

View File

@@ -1,9 +0,0 @@
Quantity.transaction do
Quantity.defaults.delete_all
quantities = {}
quantities['Body weight'] =
Quantity.create name: 'Body weight',
description: 'body weight measurement'
end

View File

@@ -1,7 +1,6 @@
require "test_helper" require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
include ActionView::Helpers::SanitizeHelper
include ActionView::Helpers::UrlHelper include ActionView::Helpers::UrlHelper
# NOTE: geckodriver installed with Firefox, ignore incompatibility warning # NOTE: geckodriver installed with Firefox, ignore incompatibility warning
@@ -33,8 +32,7 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
# Allow skipping interpolations when translating for testing purposes # Allow skipping interpolations when translating for testing purposes
INTERPOLATION_PATTERNS = Regexp.union(I18n.config.interpolation_patterns) INTERPOLATION_PATTERNS = Regexp.union(I18n.config.interpolation_patterns)
def translate(key, **options) def translate(key, **options)
translation = options.empty? ? super.split(INTERPOLATION_PATTERNS, 2).first : super options.empty? ? super.split(INTERPOLATION_PATTERNS, 2).first : super
sanitize(translation, tags: [])
end end
alias :t :translate alias :t :translate

View File

@@ -0,0 +1,18 @@
require "test_helper"
class RegistrationsControllerTest < ActionDispatch::IntegrationTest
test "sole admin cannot delete account" do
sign_in users(:admin)
delete user_registration_path
assert_redirected_to edit_user_registration_path
assert_equal t("registrations.destroy.sole_admin"), flash[:alert]
assert User.exists?(users(:admin).id)
end
test "non-admin can delete account" do
sign_in users(:alice)
assert_difference ->{ User.count }, -1 do
delete user_registration_path
end
end
end

View File

@@ -5,8 +5,8 @@ class UsersTest < ApplicationSystemTestCase
@admin = users(:admin) @admin = users(:admin)
end end
test 'sign in' do test "sign in" do
visit root_url visit new_user_session_path
assert find_link(href: new_user_session_path)[:disabled] assert find_link(href: new_user_session_path)[:disabled]
sign_in sign_in
@@ -14,23 +14,16 @@ class UsersTest < ApplicationSystemTestCase
assert_text t('devise.sessions.signed_in') assert_text t('devise.sessions.signed_in')
end end
test 'sign in fails with invalid credentials' do test 'sign in fails with invalid password' do
label = User.human_attribute_name(:email) sign_in password: random_password
# Both: valid and invalid emails should give the same (paranoid) error message.
email = [users.sample.email, random_email].sample
visit root_url
fill_in label, with: email
fill_in User.human_attribute_name(:password), with: random_password
click_on t(:sign_in)
assert_current_path new_user_session_path assert_current_path new_user_session_path
assert_text t('devise.failure.invalid', authentication_keys: label.downcase_first) assert_text t('devise.failure.not_found_in_database',
authentication_keys: User.human_attribute_name(:email))
assert find_link(href: new_user_session_path)[:disabled] assert find_link(href: new_user_session_path)[:disabled]
assert has_field?(label, with: email) assert_not_empty find_field(User.human_attribute_name(:email)).value
end end
test 'sign out' do test "sign out" do
sign_in sign_in
visit root_url visit root_url
click_on t("layouts.application.sign_out") click_on t("layouts.application.sign_out")
@@ -38,106 +31,79 @@ class UsersTest < ApplicationSystemTestCase
assert_text t("devise.sessions.signed_out") assert_text t("devise.sessions.signed_out")
end end
test 'recover password' do test "recover password" do
label = User.human_attribute_name(:email) visit new_user_session_url
email = users.select(&:confirmed?).sample.email click_on t(:recover_password)
visit root_url
fill_in label, with: email
# Form validations should allow empty password.
assert has_field?(User.human_attribute_name(:password), with: nil)
fill_in User.human_attribute_name(:email),
with: users.select(&:confirmed?).sample.email
assert_emails 1 do assert_emails 1 do
click_on t(:recover_password) click_on t(:recover_password)
# Wait until redirected to make sure async request has been processed
assert_current_path new_user_session_path assert_current_path new_user_session_path
# Wait for flash message to make sure async request has been processed.
assert_text t("devise.passwords.send_paranoid_instructions")
end end
assert has_field?(label, with: email) assert_text t("devise.passwords.send_instructions")
with_last_email do |mail| with_last_email do |mail|
visit Capybara.string(mail.body.to_s).find_link("Change my password")[:href] visit Capybara.string(mail.body.to_s).find_link("Change my password")[:href]
assert_current_path edit_user_password_path, ignore_query: true
# Make sure flash message is not displayed twice.
assert_no_text t("devise.passwords.send_paranoid_instructions")
end end
new_password = random_password new_password = random_password
fill_in t("users.passwords.edit.password_html"), with: new_password fill_in t("users.passwords.edit.password_html"), with: new_password
fill_in t("helpers.label.user.password_confirmation"), with: new_password fill_in t("helpers.label.user.password_confirmation"), with: new_password
assert_emails 1 do assert_emails 1 do
click_on t("users.passwords.edit.update_password") click_on t("users.passwords.edit.update_password")
# Wait until redirected to make sure async request has been processed
assert_current_path units_path assert_current_path units_path
end
assert_text t("devise.passwords.updated") assert_text t("devise.passwords.updated")
end end
end
test 'recover password for nonexistent user' do test "register" do
label = User.human_attribute_name(:email) visit new_user_session_url
email = random_email
visit root_url
fill_in label, with: email
assert_no_emails do
click_on t(:recover_password)
assert_current_path new_user_session_path
assert_text t("devise.passwords.send_paranoid_instructions")
end
end
test 'register' do
visit root_url
click_on t(:register) click_on t(:register)
assert find_link(href: new_user_registration_path)[:disabled]
fill_in User.human_attribute_name(:email), with: random_email fill_in User.human_attribute_name(:email), with: random_email
password = random_password password = random_password
fill_in User.human_attribute_name(:password), with: password fill_in User.human_attribute_name(:password), with: password
fill_in t("users.profiles.new.password_confirmation"), with: password fill_in t("users.registrations.new.password_confirmation"), with: password
assert_difference ->{ User.count }, 1 do assert_difference ->{User.count}, 1 do
assert_emails 1 do assert_emails 1 do
click_on t(:register) click_on t(:register)
# Wait until redirected to make sure async request has been processed
assert_current_path new_user_session_path assert_current_path new_user_session_path
end
end
assert_text t("devise.registrations.signed_up_but_unconfirmed") assert_text t("devise.registrations.signed_up_but_unconfirmed")
end
end
assert_changes ->{ User.last.confirmed? }, from: false, to: true do
with_last_email do |mail| with_last_email do |mail|
visit Capybara.string(mail.body.to_s).find_link("Confirm my account")[:href] visit Capybara.string(mail.body.to_s).find_link("Confirm my account")[:href]
end
assert_current_path new_user_session_path assert_current_path new_user_session_path
assert_text t("devise.confirmations.confirmed") assert_text t("devise.confirmations.confirmed")
end assert User.last.confirmed?
end
end end
test 'resend confirmation' do test "resend confirmation" do
label = User.human_attribute_name(:email) visit new_user_session_url
user = users.reject(&:confirmed?).sample
visit root_url
click_on t(:register) click_on t(:register)
fill_in label, with: user.email click_on t(:resend_confirmation)
assert has_field?(User.human_attribute_name(:password), with: nil)
fill_in User.human_attribute_name(:email),
with: users.reject(&:confirmed?).sample.email
assert_emails 1 do assert_emails 1 do
click_on t(:resend_confirmation) click_on t(:resend_confirmation)
assert_current_path new_user_registration_path # Wait until redirected to make sure async request has been processed
assert_text t("devise.confirmations.send_paranoid_instructions") assert_current_path new_user_session_path
end end
assert has_field?(label, with: user.email) assert_current_path new_user_session_path
assert_text t("devise.confirmations.send_instructions")
assert_changes ->{ user.reload.confirmed? }, from: false, to: true do
with_last_email do |mail| with_last_email do |mail|
visit Capybara.string(mail.body.to_s).find_link("Confirm my account")[:href] visit Capybara.string(mail.body.to_s).find_link("Confirm my account")[:href]
assert_current_path new_user_session_path
assert_no_text t("devise.confirmations.send_paranoid_instructions")
assert_text t("devise.confirmations.confirmed")
end
end end
end end
test 'show profile' do test "show profile" do
sign_in user: users.select(&:admin?).select(&:confirmed?).sample sign_in user: users.select(&:admin?).select(&:confirmed?).sample
click_on t("users.navigation") click_on t("users.navigation")
within all('tr').drop(1).sample do |tr| within all('tr').drop(1).sample do |tr|
@@ -147,7 +113,7 @@ class UsersTest < ApplicationSystemTestCase
end end
end end
test 'disguise' do test "disguise" do
user = users.select(&:admin?).select(&:confirmed?).sample user = users.select(&:admin?).select(&:confirmed?).sample
sign_in user: user sign_in user: user
@@ -163,7 +129,7 @@ class UsersTest < ApplicationSystemTestCase
assert_link user.email assert_link user.email
end end
test 'disguise fails for admin when disallowed' do test "disguise fails for admin when disallowed" do
user = users.select(&:admin?).select(&:confirmed?).sample user = users.select(&:admin?).select(&:confirmed?).sample
sign_in user: user sign_in user: user
@@ -176,37 +142,45 @@ class UsersTest < ApplicationSystemTestCase
assert_title 'The change you wanted was rejected (422)' assert_title 'The change you wanted was rejected (422)'
end end
test 'disguise forbidden for non admin' do test "disguise forbidden for non admin" do
sign_in user: users.reject(&:admin?).select(&:confirmed?).sample sign_in user: users.reject(&:admin?).select(&:confirmed?).sample
visit disguise_user_path(User.all.sample) visit disguise_user_path(User.all.sample)
assert_title 'Access is forbidden to this page (403)' assert_title 'Access is forbidden to this page (403)'
end end
test 'delete profile' do test "delete profile" do
user = sign_in user = sign_in user: users.reject(&:admin?).select(&:confirmed?).sample
# TODO: remove condition after root_url changed to different path than # TODO: remove condition after root_url changed to different path than
# profile in routes.rb # profile in routes.rb
unless has_current_path?(edit_user_registration_path) unless has_current_path?(edit_user_registration_path)
first(:link_or_button, user.email).click first(:link_or_button, user.email).click
end end
assert_difference ->{ User.count }, -1 do assert_difference ->{ User.count }, -1 do
accept_confirm { click_on t("users.profiles.edit.delete") } accept_confirm { click_on t("users.registrations.edit.delete") }
assert_current_path new_user_session_path assert_current_path new_user_session_path
end end
assert_text t("devise.registrations.destroyed") assert_text t("devise.registrations.destroyed")
end end
test 'index forbidden for non admin' do test "sole admin cannot delete profile" do
sign_in user: users(:admin)
unless has_current_path?(edit_user_registration_path)
first(:link_or_button, users(:admin).email).click
end
assert find(:button, t("users.registrations.edit.delete"))[:disabled]
end
test "index forbidden for non admin" do
sign_in user: users.reject(&:admin?).select(&:confirmed?).sample sign_in user: users.reject(&:admin?).select(&:confirmed?).sample
visit users_path visit users_path
assert_title "Access is forbidden to this page (403)" assert_title "Access is forbidden to this page (403)"
end end
test 'update profile' do test "update profile" do
# TODO # TODO
end end
test 'update status' do test "update status" do
sign_in user: users.select(&:admin?).select(&:confirmed?).sample sign_in user: users.select(&:admin?).select(&:confirmed?).sample
visit users_path visit users_path
@@ -221,7 +195,7 @@ class UsersTest < ApplicationSystemTestCase
assert_current_path users_path assert_current_path users_path
end end
test 'update status fails for admin when disallowed' do test "update status fails for admin when disallowed" do
sign_in user: users.select(&:admin?).select(&:confirmed?).sample sign_in user: users.select(&:admin?).select(&:confirmed?).sample
visit users_path visit users_path
@@ -234,7 +208,7 @@ class UsersTest < ApplicationSystemTestCase
assert_title 'The change you wanted was rejected (422)' assert_title 'The change you wanted was rejected (422)'
end end
test 'update status forbidden for non admin' do test "update status forbidden for non admin" do
sign_in user: users.reject(&:admin?).select(&:confirmed?).sample sign_in user: users.reject(&:admin?).select(&:confirmed?).sample
visit units_path visit units_path
inject_button_to find('body'), "update status", user_path(User.all.sample), method: :patch, inject_button_to find('body'), "update status", user_path(User.all.sample), method: :patch,