@@ -19,6 +19,7 @@ class UsersController < ApplicationController
|
|||||||
def update
|
def update
|
||||||
authorize @user
|
authorize @user
|
||||||
|
|
||||||
|
@user.password_change_attempt = false
|
||||||
if @user.update(user_params)
|
if @user.update(user_params)
|
||||||
redirect_to contests_path, notice: t("users.edit.notice")
|
redirect_to contests_path, notice: t("users.edit.notice")
|
||||||
else
|
else
|
||||||
@@ -26,6 +27,18 @@ class UsersController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def change_password
|
||||||
|
@user = User.find(params[:user_id])
|
||||||
|
authorize @user
|
||||||
|
|
||||||
|
@user.password_change_attempt = true
|
||||||
|
if @user.update(user_password_params)
|
||||||
|
redirect_to contests_path, notice: t("users.edit.notice")
|
||||||
|
else
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
authorize @user
|
authorize @user
|
||||||
|
|
||||||
@@ -89,6 +102,10 @@ class UsersController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def user_params
|
def user_params
|
||||||
params.expect(user: [ :username, :email_address, :lang, :password ])
|
params.expect(user: [ :username, :email_address, :lang ])
|
||||||
|
end
|
||||||
|
|
||||||
|
def user_password_params
|
||||||
|
params.expect(user: [ :password ])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
#
|
#
|
||||||
# Table name: users
|
# Table name: users
|
||||||
#
|
#
|
||||||
# id :integer not null, primary key
|
# id :integer not null, primary key
|
||||||
# admin :boolean default(FALSE), not null
|
# admin :boolean default(FALSE), not null
|
||||||
# email_address :string not null
|
# email_address :string not null
|
||||||
# lang :string default("en")
|
# lang :string default("en")
|
||||||
# password_digest :string not null
|
# password_change_attempt :boolean
|
||||||
# username :string
|
# password_digest :string not null
|
||||||
# created_at :datetime not null
|
# username :string
|
||||||
# updated_at :datetime not null
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
@@ -25,4 +26,5 @@ class User < ApplicationRecord
|
|||||||
validates :username, presence: true, uniqueness: true
|
validates :username, presence: true, uniqueness: true
|
||||||
validates :email_address, presence: true, uniqueness: true
|
validates :email_address, presence: true, uniqueness: true
|
||||||
validates :lang, inclusion: { in: Languages::AVAILABLE_LANGUAGES.map { |lang| lang[:id] } }
|
validates :lang, inclusion: { in: Languages::AVAILABLE_LANGUAGES.map { |lang| lang[:id] } }
|
||||||
|
validates :password, presence: true, if: -> { password_change_attempt }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ class UserPolicy < ApplicationPolicy
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update?
|
def update?
|
||||||
user.admin? || user.id == record.id
|
edit?
|
||||||
|
end
|
||||||
|
|
||||||
|
def change_password?
|
||||||
|
edit?
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy?
|
def destroy?
|
||||||
|
|||||||
@@ -30,13 +30,13 @@
|
|||||||
= form.label :password, class: "required"
|
= form.label :password, class: "required"
|
||||||
= form.submit t("helpers.buttons.save"), class: "btn btn-primary"
|
= form.submit t("helpers.buttons.save"), class: "btn btn-primary"
|
||||||
|
|
||||||
- if method == :patch
|
- if method == :patch
|
||||||
h4.mt-5 = t("users.edit.password_section")
|
h4.mt-5 = t("users.edit.password_section")
|
||||||
|
|
||||||
= form_with model: user, method: method do |form|
|
= form_with model: user, url: user_password_path(user) do |form|
|
||||||
.row.mb-3
|
.row.mb-3
|
||||||
.col
|
.col
|
||||||
.form-floating
|
.form-floating
|
||||||
= form.password_field :password, autocomplete: "off", class: "form-control"
|
= form.password_field :password, autocomplete: "off", class: "form-control"
|
||||||
= form.label :password, class: "required"
|
= form.label :password, class: "required"
|
||||||
= form.submit t("helpers.buttons.save"), class: "btn btn-primary"
|
= form.submit t("helpers.buttons.save_password"), class: "btn btn-primary"
|
||||||
@@ -167,6 +167,9 @@ en:
|
|||||||
blank: Your email cannot be empty
|
blank: Your email cannot be empty
|
||||||
username:
|
username:
|
||||||
blank: Your username cannot be empty
|
blank: Your username cannot be empty
|
||||||
|
taken: This username is already taken
|
||||||
|
password:
|
||||||
|
blank: Your password cannot be empty
|
||||||
categories:
|
categories:
|
||||||
destroy:
|
destroy:
|
||||||
notice: Category deleted
|
notice: Category deleted
|
||||||
@@ -290,6 +293,7 @@ en:
|
|||||||
settings: Settings
|
settings: Settings
|
||||||
sign_in: Sign in
|
sign_in: Sign in
|
||||||
save: Save
|
save: Save
|
||||||
|
save_password: Save password
|
||||||
start: Click here to start your participation
|
start: Click here to start your participation
|
||||||
stopwatch_continue: Continue
|
stopwatch_continue: Continue
|
||||||
stopwatch_pause: Pause
|
stopwatch_pause: Pause
|
||||||
@@ -352,7 +356,7 @@ en:
|
|||||||
notice: Settings updated
|
notice: Settings updated
|
||||||
title: "My settings"
|
title: "My settings"
|
||||||
general_section: "General settings"
|
general_section: "General settings"
|
||||||
password_section: "Change password"
|
password_section: "Password"
|
||||||
index:
|
index:
|
||||||
title: "All users"
|
title: "All users"
|
||||||
new:
|
new:
|
||||||
|
|||||||
@@ -138,6 +138,9 @@ fr:
|
|||||||
blank: L'email est obligatoire
|
blank: L'email est obligatoire
|
||||||
username:
|
username:
|
||||||
blank: Le nom d'utilisateur.ice est obligatoire
|
blank: Le nom d'utilisateur.ice est obligatoire
|
||||||
|
taken: Ce nom d'utilisateur.ice est déjà utilisé
|
||||||
|
password:
|
||||||
|
blank: Le mot de passe ne peut pas être vide
|
||||||
categories:
|
categories:
|
||||||
destroy:
|
destroy:
|
||||||
notice: Catégorie supprimée
|
notice: Catégorie supprimée
|
||||||
@@ -261,6 +264,7 @@ fr:
|
|||||||
settings: Paramètres
|
settings: Paramètres
|
||||||
sign_in: Se connecter
|
sign_in: Se connecter
|
||||||
save: Modifier
|
save: Modifier
|
||||||
|
save_password: Modifier le mot de passe
|
||||||
start: Clique ici pour démarrer ta participation
|
start: Clique ici pour démarrer ta participation
|
||||||
stopwatch_continue: Reprendre
|
stopwatch_continue: Reprendre
|
||||||
stopwatch_pause: Pause
|
stopwatch_pause: Pause
|
||||||
@@ -323,7 +327,7 @@ fr:
|
|||||||
notice: Paramètres modifiés
|
notice: Paramètres modifiés
|
||||||
title: "Mes paramètres"
|
title: "Mes paramètres"
|
||||||
general_section: "Paramètres globaux"
|
general_section: "Paramètres globaux"
|
||||||
password_section: "Modifier mon mot de passe"
|
password_section: "Mot de passe"
|
||||||
index:
|
index:
|
||||||
title: "Tous.tes les utilisateur.ices"
|
title: "Tous.tes les utilisateur.ices"
|
||||||
new:
|
new:
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
resources :passwords, param: :token
|
resources :passwords, param: :token
|
||||||
resource :session
|
resource :session
|
||||||
resources :users
|
resources :users do
|
||||||
|
patch "password", to: "users#change_password"
|
||||||
|
end
|
||||||
|
|
||||||
options "connect", to: "messages#cors_preflight_check"
|
options "connect", to: "messages#cors_preflight_check"
|
||||||
options "message", to: "messages#cors_preflight_check"
|
options "message", to: "messages#cors_preflight_check"
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddPasswordChangeAttemptToUser < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
add_column :users, :password_change_attempt, :boolean
|
||||||
|
end
|
||||||
|
end
|
||||||
3
db/schema.rb
generated
3
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2025_12_09_081941) do
|
ActiveRecord::Schema[8.0].define(version: 2025_12_10_092658) do
|
||||||
create_table "active_storage_attachments", force: :cascade do |t|
|
create_table "active_storage_attachments", force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "record_type", null: false
|
t.string "record_type", null: false
|
||||||
@@ -185,6 +185,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_09_081941) do
|
|||||||
t.string "username"
|
t.string "username"
|
||||||
t.boolean "admin", default: false, null: false
|
t.boolean "admin", default: false, null: false
|
||||||
t.string "lang", default: "en"
|
t.string "lang", default: "en"
|
||||||
|
t.boolean "password_change_attempt"
|
||||||
t.index ["email_address"], name: "index_users_on_email_address", unique: true
|
t.index ["email_address"], name: "index_users_on_email_address", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
#
|
#
|
||||||
# Table name: users
|
# Table name: users
|
||||||
#
|
#
|
||||||
# id :integer not null, primary key
|
# id :integer not null, primary key
|
||||||
# admin :boolean default(FALSE), not null
|
# admin :boolean default(FALSE), not null
|
||||||
# email_address :string not null
|
# email_address :string not null
|
||||||
# lang :string default("en")
|
# lang :string default("en")
|
||||||
# password_digest :string not null
|
# password_change_attempt :boolean
|
||||||
# username :string
|
# password_digest :string not null
|
||||||
# created_at :datetime not null
|
# username :string
|
||||||
# updated_at :datetime not null
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -21,21 +21,64 @@ RSpec.feature "Users", type: :feature do
|
|||||||
expect(page).not_to have_content(I18n.t("users.index.title"))
|
expect(page).not_to have_content(I18n.t("users.index.title"))
|
||||||
end
|
end
|
||||||
|
|
||||||
it "should be able to create a new contest" do
|
it "should be able to open their account info" do
|
||||||
visit root_path
|
visit root_path
|
||||||
|
|
||||||
click_link "Create a new contest"
|
click_link I18n.t("nav.settings")
|
||||||
|
|
||||||
expect(page).to have_content(I18n.t("contests.new.title"))
|
expect(page).to have_current_path(edit_user_path(user))
|
||||||
end
|
end
|
||||||
|
|
||||||
it "should be able to open an existing contest" do
|
context "when updating their account info" do
|
||||||
visit root_path
|
let!(:existing_user) { create(:user, username: "taken_username") }
|
||||||
|
|
||||||
expect(page).to have_content(contest.name)
|
it "should allow changing to an untaken username" do
|
||||||
find("div.card", text: contest.name).find("a").click
|
visit edit_user_path(user)
|
||||||
|
|
||||||
expect(page).to have_content(I18n.t("contests.show.title", name: contest.name))
|
fill_in I18n.t("activerecord.attributes.user.username"), with: "untaken_username"
|
||||||
|
|
||||||
|
expect { click_button(I18n.t("helpers.buttons.save")); user.reload }.to change(user, :username).to("untaken_username")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should prevent changing to an already taken username" do
|
||||||
|
visit edit_user_path(user)
|
||||||
|
|
||||||
|
fill_in I18n.t("activerecord.attributes.user.username"), with: "taken_username"
|
||||||
|
|
||||||
|
expect { click_button(I18n.t("helpers.buttons.save")); user.reload }.not_to change(user, :username)
|
||||||
|
|
||||||
|
expect(page).to have_content(I18n.t("activerecord.errors.models.user.attributes.username.taken"))
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should prevent changing to a blank username" do
|
||||||
|
visit edit_user_path(user)
|
||||||
|
|
||||||
|
fill_in I18n.t("activerecord.attributes.user.username"), with: ""
|
||||||
|
|
||||||
|
expect { click_button(I18n.t("helpers.buttons.save")); user.reload }.not_to change(user, :username)
|
||||||
|
|
||||||
|
expect(page).to have_content(I18n.t("activerecord.errors.models.user.attributes.username.blank"))
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should allow changing to a non-blank password" do
|
||||||
|
visit edit_user_path(user)
|
||||||
|
|
||||||
|
fill_in I18n.t("activerecord.attributes.user.password"), with: "new_password"
|
||||||
|
|
||||||
|
expect { click_button(I18n.t("helpers.buttons.save_password")); user.reload }
|
||||||
|
.to change(user, :password_digest)
|
||||||
|
.and change { user.authenticate("new_password") }.from(false).to(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should prevent changing to a blank password" do
|
||||||
|
visit edit_user_path(user)
|
||||||
|
|
||||||
|
fill_in I18n.t("activerecord.attributes.user.password"), with: ""
|
||||||
|
|
||||||
|
expect { click_button(I18n.t("helpers.buttons.save_password")); user.reload }.not_to change(user, :password)
|
||||||
|
|
||||||
|
expect(page).to have_content(I18n.t("activerecord.errors.models.user.attributes.password.blank"))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
#
|
#
|
||||||
# Table name: users
|
# Table name: users
|
||||||
#
|
#
|
||||||
# id :integer not null, primary key
|
# id :integer not null, primary key
|
||||||
# admin :boolean default(FALSE), not null
|
# admin :boolean default(FALSE), not null
|
||||||
# email_address :string not null
|
# email_address :string not null
|
||||||
# lang :string default("en")
|
# lang :string default("en")
|
||||||
# password_digest :string not null
|
# password_change_attempt :boolean
|
||||||
# username :string
|
# password_digest :string not null
|
||||||
# created_at :datetime not null
|
# username :string
|
||||||
# updated_at :datetime not null
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
|
|||||||
Reference in New Issue
Block a user