Allow closing accounts

Closes #37
This commit is contained in:
ricola 2026-04-20 14:17:52 -06:00
parent 1acc369774
commit 29bd105072
10 changed files with 115 additions and 15 deletions

View file

@ -0,0 +1,5 @@
class AddClosedAtToUser < ActiveRecord::Migration[7.2]
def change
add_column :users, :closed_at, :datetime
end
end

View file

@ -0,0 +1,5 @@
class AddOpenedAtToVote < ActiveRecord::Migration[7.2]
def change
add_column :votes, :opened_at, :datetime
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_03_23_215246) do
ActiveRecord::Schema[7.2].define(version: 2026_04_20_210736) do
create_table "candidates", force: :cascade do |t|
t.integer "vote_id"
t.string "name"
@ -46,6 +46,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_23_215246) do
t.datetime "updated_at", null: false
t.string "reset"
t.boolean "admin"
t.datetime "closed_at"
end
create_table "votes", force: :cascade do |t|
@ -57,6 +58,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_23_215246) do
t.datetime "updated_at", null: false
t.string "state"
t.integer "reminders"
t.datetime "opened_at"
end
add_foreign_key "ratings", "votes"

View file

@ -120,7 +120,7 @@ end
post '/login' do
user = User.find_by(email: params[:email].downcase.strip)
if user && verify_password(params[:password], user.password)
if user && active(user) && verify_password(params[:password], user.password)
if not user.reset.nil?
user.reset = nil
user.save
@ -154,7 +154,7 @@ post '/reset' do
erb :reset
else
@user = User.find_by(email: params[:email])
if @user
if @user && active(@user)
@reset = SecureRandom.uuid
@user.reset = hash_password(@reset)
@user.save
@ -174,7 +174,7 @@ get '/reset/:uuid' do
User.where.not(reset: nil).each do |user|
@user = user if verify_password(params[:uuid], user.reset)
end
if @user
if @user && active(@user)
erb :reset_change
else
erb :reset_invalid
@ -186,7 +186,7 @@ post '/reset/:uuid' do
User.where.not(reset: nil).each do |user|
@user = user if verify_password(params[:uuid], user.reset)
end
if @user
if @user && active(@user)
@errors = []
if params[:password].empty?
@errors << OpenStruct.new(:attribute => :password, :type => :blank)
@ -223,9 +223,28 @@ get '/users/:id' do
erb :users_show
end
get '/users/:id/close' do
require_admin_or_self
require_active
erb :users_close
end
post '/users/:id/close' do
require_admin_or_self
require_active
@user.closed_at = Time.now.utc
@user.save
if is_admin
redirect "/users/#{@user.id}"
else
@closed = true
session.clear
erb :login
end
end
get '/admin' do
require_admin
@users = User.all
@votes = Vote.all
erb :admin
end
@ -556,7 +575,7 @@ post '/votes/:id/organizers' do
@errors << OpenStruct.new(:attribute => :email, :type => :invalid)
else
user = User.find_by(email: params[:email])
if not user
if not user or not active(user)
@errors << OpenStruct.new(:attribute => :email, :type => :unknown)
@params = params
elsif @vote.users.exists?(user.id)
@ -584,7 +603,7 @@ def close_expired_votes
puts "#{Time.now.utc} Closing vote \"#{vote.title}\" because it expired on #{vote.expire_on}..."
vote.state = 'closed'
vote.save
all_users_sorted.each do |user|
users_for_vote(vote).each do |user|
puts "#{Time.now.utc} Sending results by email to #{user.email}..."
mail = Mail.new
mail.from = settings.admin_email
@ -602,7 +621,7 @@ def send_reminders
settings.reminders.slice(vote.reminders..settings.reminders.length-1).each do |reminder|
minutes_to_expiry = ( vote.expire_on - Time.now.utc ) / 60
if minutes_to_expiry < reminder[:timeout] / 60
all_users_sorted.each do |user|
active_users.each do |user|
if not vote.ratings.collect { |rating| rating.user }.include?(user)
puts "#{Time.now.utc} Sending reminder #{reminder[:template]} for '#{vote.title}' to #{user.email}..."
mail = Mail.new
@ -628,7 +647,13 @@ end
helpers do
def current_user
if session[:user_id]
User.find(session[:user_id])
user = User.find(session[:user_id])
if active(user)
return user
else
session.clear
return nil
end
elsif settings.spoof_admin
session.clear
session[:timezone] = 'UTC'
@ -657,6 +682,15 @@ helpers do
redirect '/' unless is_admin or current_user == @user
end
def active(user)
user.closed_at.nil?
end
def require_active
find_user
redirect '/' unless active(@user)
end
def find_vote
@vote = Vote.find_by(secure_id: params[:id])
end
@ -669,10 +703,39 @@ helpers do
@user = User.find(params[:id])
end
def all_users_sorted
def all_users
User.all.each.sort_by { |user| user.email }
end
def active_users(timestamp = Time.now.utc)
users = []
User.all.each do |user|
if user.created_at < timestamp and ( active(user) or user.closed_at > timestamp )
users << user
end
end
users.sort_by { |user| user.email }
end
def users_created_between(min, max)
users = []
User.all.each do |user|
if user.created_at > min and user.created_at < max
users << user
end
end
users.sort_by { |user| user.email }
end
def users_for_vote(vote)
# Users who were active when the vote was open (or created if not open yet)
# Users who were created between the vote was open and the vote was closed (or now if the vote is not closed yet)
min = vote.opened_at ? vote.opened_at : vote.created_at
max = vote.expire_on ? vote.expire_on : Time.now.utc
users = active_users(min) + users_created_between(min, max)
users.sort_by { |user| user.email }
end
def require_candidate_in_vote
redirect '/votes/' + @vote.secure_id unless @candidate.vote == @vote
end

View file

@ -7,15 +7,17 @@
<tr>
<th><%= _("Email") %></th>
<th><%= _("Created") %></th>
<th><%= _("Closed") %></th>
<th><%= _("Admin") %></th>
<th><%= _("Votes") %></th>
<th><%= _("Ratings") %></th>
</tr>
</thead>
<% @users.sort_by { |user| user.email }.each do |user| %>
<% all_users.each do |user| %>
<tr>
<td><a href="/users/<%= user.id %>"><%= user.email %></a></td>
<td><%= format_date(user.created_at) %></td>
<td><%= active(user) ? nil : format_date(user.closed_at) %></td>
<td><%= user.admin %></td>
<td><%= user.votes.length %></td>
<td><%= user.ratings.length %></td>

View file

@ -1,5 +1,9 @@
<h1 class="mb-5"><%= _("Login") %></h1>
<% if @closed %>
<p class="alert alert-success mb-4"><%= _("Your account was closed successfully.") %></p>
<% end %>
<% if @error %>
<div class="alert alert-warning mb-4">
<p><%= _("Incorrect email or password.") %></p>

11
views/users_close.erb Normal file
View file

@ -0,0 +1,11 @@
<h1 class="mb-5"><%= _("Closing account %{email}") % { email: @user.email } %></h1>
<p><%= _("Do you really want to close the account for %{email}?") % { email: @user.email } %></p>
<p><%= _("Past votes and ratings created by this account will remain available, but the account will no longer be able to log in, create new votes, or participate in open votes.") %></p>
<p><%= _("This action cannot be undone.") %></p>
<form action="/users/<%= @user.id %>/close" method="post" class="mb-5">
<button type="submit" class="btn btn-danger"><%= _("Close account") %></button>
</form>

View file

@ -4,7 +4,15 @@
<p><%= _("Updated: %{date}") % { date: format_date(@user.updated_at) } %></p>
<p class="mb-5"><%= _("Admin: %{admin}") % { admin: @user.admin ? _("Yes") : _("No") } %></p>
<p><%= _("Admin: %{admin}") % { admin: @user.admin ? _("Yes") : _("No") } %></p>
<% if @user.closed_at %>
<p class="mb-5"><%= _("Closed: %{date}") % { date: format_date(@user.closed_at) } %></p>
<% else %>
<form action="/users/<%= @user.id %>/close" method="get" class="mb-5">
<button type="submit" class="btn btn-outline-danger"><%= _("Close account") %></button>
</form>
<% end %>
<h2 class="mb-4"><%= _("Organized votes") %></h2>

View file

@ -175,7 +175,7 @@
<% end %>
</tr>
</thead>
<% all_users_sorted.each do |user| %>
<% users_for_vote(@vote).each do |user| %>
<tr>
<td><%= user.email %></td>
<% @vote.candidates.each do |candidate| %>

View file

@ -66,7 +66,7 @@
<h2 class="mb-4"><%= _("Participants") + " (#{@vote.ratings.collect { |rating| rating.user }.uniq.count})" %></h2>
<ul class="mb-5">
<% all_users_sorted.each do |user| %>
<% users_for_vote(@vote).each do |user| %>
<li>
<% if @vote.ratings.collect { |rating| rating.user }.include?(user) %>
<span>