diff --git a/Gemfile b/Gemfile
index 0b02568e..0a522dcc 100644
--- a/Gemfile
+++ b/Gemfile
@@ -18,6 +18,8 @@ gem 'turbo-rails'
gem 'stimulus-rails'
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem 'jbuilder'
+# Allows returning free formed structs
+gem 'ostruct'
# Used to differentaite text
gem 'diff-lcs'
diff --git a/Gemfile.lock b/Gemfile.lock
index 9b93d98c..d7f4a4cd 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -208,6 +208,7 @@ GEM
racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-musl)
racc (~> 1.4)
+ ostruct (0.6.1)
parallel (1.27.0)
parser (3.3.8.0)
ast (~> 2.4.1)
@@ -405,6 +406,7 @@ DEPENDENCIES
htmldiff
importmap-rails
jbuilder
+ ostruct
pg
propshaft
puma (>= 5.0)
diff --git a/app/controllers/courses_controller.rb b/app/controllers/courses_controller.rb
index e831d08c..84fe926d 100644
--- a/app/controllers/courses_controller.rb
+++ b/app/controllers/courses_controller.rb
@@ -178,25 +178,19 @@ def handle_add_lecturers
begin
ActiveRecord::Base.transaction do
- create_lecturer_enrolments(lecturer_emails, @course, unregistered_lecturers, registered_lecturers)
+ create_lecturer_enrolments(lecturer_emails, @course)
end
rescue StandardError => e
redirect_back_or_to '/', alert: e.message
return
end
- send_invite_emails(unregistered_lecturers)
- send_notification_emails(registered_lecturers, @course)
-
redirect_to course_path(@course)
end
def handle_add_students
authorize @course, :manage_students?
- unregistered_students = Set[]
- registered_students = []
-
if params[:csv_file].blank? || params[:csv_file].content_type != 'text/csv'
redirect_back_or_to '/', alert: 'Please provide a CSV file from ebwise'
return
@@ -233,10 +227,10 @@ def handle_add_students
if @course.grouped
student_hashmap = @course.parse_csv_grouped(csv_obj)
- create_db_entries_grouped(student_hashmap, @course, unregistered_students, registered_students)
+ create_db_entries_grouped(student_hashmap, @course)
else
student_set = @course.parse_csv_solo(csv_obj)
- create_db_entries_solo(student_set, @course, unregistered_students, registered_students)
+ create_db_entries_solo(student_set, @course)
end
end
rescue StandardError => e
@@ -244,8 +238,6 @@ def handle_add_students
return
end
- send_invite_emails(unregistered_students)
- send_notification_emails(registered_students, @course)
redirect_to course_path(@course)
end
@@ -531,37 +523,37 @@ def load_capacity_result
@capacity_result = SupervisorCapacityCalculator.new(@course).calculate
end
- def create_db_entries_grouped(hash_map, parent_course, unregistered_students, registered_students)
+ def create_db_entries_grouped(hash_map, parent_course)
hash_map.keys.each do |group|
new_group = ProjectGroup.find_or_create_by!(group_name: group, course: parent_course)
hash_map[group].each do |group_member|
new_user = User.find_by(email_address: group_member[:email_address])
- if new_user
- new_user.update!(instid: group_member[:instid])
-
- registered_students.push(group_member[:email_address]) if new_user.enrolments.where(course: parent_course).empty?
- else
- new_user = User.create!(
- email_address: group_member[:email_address],
+ if !new_user
+ result = UserCreator.call(
name: group_member[:name],
+ email: group_member[:email_address],
password: SecureRandom.base64(24),
- has_registered: false,
+ verify_only: false,
instid: group_member[:instid]
)
- new_otp_instance = Otp.create!(
- user: new_user,
- token: SecureRandom.uuid
- )
+ GeneralMailer.with(
+ email_address: group_member[:email],
+ otp_token: result.otp_instance.token,
+ ).ProPro_Invite.deliver_later
- unregistered_students.add(
- {
- email_address: group_member[:email_address],
- otp_token: new_otp_instance.token
- }
- )
+ new_user = result.user
+ else
+ new_user.update!(instid: group_member[:instid])
+
+ if new_user.enrolments.where(course: parent_course).empty?
+ GeneralMailer.with(
+ course: parent_course,
+ email_address: group_member[:email]
+ ).Course_Invite_Notification.deliver_later
+ end
end
Enrolment.find_or_create_by!(
@@ -585,54 +577,34 @@ def create_db_entries_grouped(hash_map, parent_course, unregistered_students, re
end
end
- def send_invite_emails(unregistered_users)
- unregistered_users.each do |user|
- GeneralMailer.with(
- email_address: user[:email_address],
- otp_token: user[:otp_token],
- from_course: true
- ).ProPro_Invite.deliver_later
- end
- end
-
- def send_notification_emails(registered_emails, course)
- registered_emails.each do |email|
- GeneralMailer.with(
- course: course,
- email_address: email
- ).Course_Invite_Notification.deliver_later
- end
- end
-
- def create_db_entries_solo(student_set, parent_course, unregistered_students, registered_students)
+ def create_db_entries_solo(student_set, parent_course)
student_set.each do |student|
new_user = User.find_by(email_address: student[:email_address])
- if new_user
- new_user.update!(instid: student[:instid])
-
- registered_students.push(student[:email_address]) if new_user.enrolments.where(course: parent_course).empty?
- else
- new_user = User.create!(
- email_address: student[:email_address],
+ if !new_user
+ result = UserCreator.call(
name: student[:name],
+ email: student[:email_address],
password: SecureRandom.base64(24),
- has_registered: false,
+ verify_only: false,
instid: student[:instid]
)
- new_otp_instance = Otp.create!(
- user: new_user,
- token: SecureRandom.uuid
- )
+ GeneralMailer.with(
+ email_address: student[:email_address],
+ otp_token: result.otp_instance.token,
+ ).ProPro_Invite.deliver_later
- unregistered_students.add(
- {
- email_address: student[:email_address],
- otp_token: new_otp_instance.token,
- otp: new_otp_instance.otp
- }
- )
+ new_user = result.user
+ else
+ new_user.update!(instid: student[:instid])
+
+ if new_user.enrolments.where(course: parent_course).empty?
+ GeneralMailer.with(
+ course: parent_course,
+ email_address: student[:email]
+ ).Course_Invite_Notification.deliver_later
+ end
end
Enrolment.find_or_create_by!(
@@ -643,33 +615,32 @@ def create_db_entries_solo(student_set, parent_course, unregistered_students, re
end
end
- def create_lecturer_enrolments(lecturer_emails, parent_course, unregistered_lecturers, registered_lecturers)
+ def create_lecturer_enrolments(lecturer_emails, parent_course)
lecturer_emails.each do |email|
next if email.blank?
new_lecturer = User.find_by(email_address: email)
if !new_lecturer
- new_lecturer = User.create!(
- email_address: email,
+ result = UserCreator.call(
+ name: "Lecturer-#{SecureRandom.hex(2)}",
+ email: email,
password: SecureRandom.base64(24),
- has_registered: false,
- name: "Lecturer-#{SecureRandom.hex(2)}"
+ verify_only: false
)
- new_otp_instance = Otp.create!(
- user: new_lecturer,
- token: SecureRandom.uuid
- )
+ GeneralMailer.with(
+ email_address: email,
+ otp_token: result.otp_instance.token,
+ ).ProPro_Invite.deliver_later
+
+ new_lecturer = result.user
- unregistered_lecturers.add(
- {
- email_address: email,
- otp_token: new_otp_instance.token
- }
- )
elsif new_lecturer.enrolments.where(course: parent_course).empty?
- registered_lecturers.push(email)
+ GeneralMailer.with(
+ course: parent_course,
+ email_address: email
+ ).Course_Invite_Notification.deliver_later
end
Enrolment.find_or_create_by!(
diff --git a/app/controllers/user_controller.rb b/app/controllers/user_controller.rb
index 220710ba..b0e0cc3b 100644
--- a/app/controllers/user_controller.rb
+++ b/app/controllers/user_controller.rb
@@ -1,5 +1,5 @@
class UserController < ApplicationController
- allow_unauthenticated_access only: %i[new create claim handle_claim]
+ allow_unauthenticated_access only: %i[new create claim handle_claim verify]
def resend_invite
user = User.find(params[:id])
@@ -14,7 +14,6 @@ def resend_invite
GeneralMailer.with(
email_address: user.email_address,
otp_token: otp_instance.token,
- from_course: false
).ProPro_Invite.deliver_later
redirect_back_or_to '/', notice: "Invitation resent to #{user.email_address}"
@@ -22,6 +21,32 @@ def resend_invite
def new; end
+ def create
+ name = params[:name].strip
+ email = params[:email].strip
+
+ result = UserDetailsValidator.call(name: name, password: params[:password], password_confirmation: params[:password_confirmation])
+
+ unless result.success?
+ redirect_to user_new_path, alert: result.message
+ return
+ end
+
+ result = UserCreator.call(name: name, email: email, password: params[:password], verify_only: true)
+
+ unless result.success?
+ redirect_back_or_to user_new_path, alert: result.message
+ return
+ end
+
+ GeneralMailer.with(
+ email_address: email,
+ otp_token: result.otp_instance.token,
+ ).Signup_Verification.deliver_later
+
+ redirect_to login_path, notice: 'Account created successfully. Check your inbox!'
+ end
+
def edit
@user = Current.user
@@ -57,41 +82,15 @@ def edit
end
def claim
- @email = Otp.find_by(token: params[:token]).user.email_address
+ @email = Otp.find_by(token: params[:token], verify_only: false).user.email_address
rescue StandardError
redirect_to login_path, alert: "Invalid token, perhaps you've already claimed your account? Try logging in."
end
def handle_claim
- response = params.permit(:password, :password_confirmation, :name, :instid, :token)
- return if response[:token].blank?
-
- if response[:password].blank?
- redirect_back_or_to '/', alert: 'Password cannot be empty'
- return
- end
-
- if response[:password_confirmation].blank?
- redirect_back_or_to '/', alert: 'Password confirmation cannot be empty'
- return
- end
-
- if response[:instid].blank?
- redirect_back_or_to '/', alert: 'Institution ID cannot be empty'
- return
- end
+ return if params[:token].blank?
- if response[:password] != response[:password_confirmation]
- redirect_back_or_to '/', alert: 'Passwords are not the same'
- return
- end
-
- if response[:password].length > 72
- redirect_back_or_to '/', alert: 'Password must be less than or equal to 72 characters'
- return
- end
-
- otp_instance = Otp.find_by(token: response[:token])
+ otp_instance = Otp.find_by(token: params[:token], verify_only: false)
unless otp_instance
redirect_back_or_to '/', alert: 'Something went wrong'
@@ -99,57 +98,52 @@ def handle_claim
end
user = otp_instance.user
+ name = params[:name].strip
- if response[:name].blank?
- redirect_back_or_to '/', alert: 'Name cannot be empty'
+ result = UserDetailsValidator.call(name: name, password: params[:password], password_confirmation: params[:password_confirmation])
+
+ if !result.success?
+ redirect_back_or_to '/', alert: result.message
return
end
- if user.update!(has_registered: true, password: response[:password], name: response[:name].strip, instid: response[:instid].strip)
- redirect_to '/session/new', notice: 'Account successfully claimed'
- else
+ begin
+ user.update!(has_registered: true, name: name, password: params[:password])
+ otp_instance.destroy
+ rescue ActiveRecord::RecordInvalid => e
+ redirect_back_or_to '/', alert: e.message
+ return
+ rescue StandardError => e
redirect_back_or_to '/', alert: 'Something went wrong'
+ return
end
- user.otp.destroy
+ redirect_to '/session/new', notice: 'Account successfully claimed'
end
- def create
- email = params.require(:email_address).strip
+ def verify
+ return if params[:token].blank?
+
+ otp_instance = Otp.find_by(token: params[:token], verify_only: true)
- if User.find_by(email_address: email)
- redirect_to user_profile_path, notice: 'Your email is already in the system. Check your inbox or login!'
+ unless otp_instance
+ redirect_back_or_to new_session_path, alert: 'Something went wrong'
return
end
+ user = otp_instance.user
+
begin
ActiveRecord::Base.transaction do
- new_user = User.create!(
- email_address: email,
- name: 'Placeholder Username',
- password: SecureRandom.base64(24),
- has_registered: false
- )
-
- Otp.create!(
- user: new_user,
- token: SecureRandom.uuid
- )
+ user.update!(has_registered: true)
+ otp_instance.destroy
end
rescue StandardError => e
- redirect_back_or_to '/', alert: e.message
+ redirect_to new_session_path, alert: 'Something went wrong'
return
end
- new_user = User.find_by(email_address: email)
-
- GeneralMailer.with(
- email_address: new_user.email_address,
- otp_token: new_user.otp.token,
- from_course: false
- ).ProPro_Invite.deliver_later
-
- redirect_to login_path, notice: 'Account created successfully. Check your inbox!'
+ redirect_to new_session_path, notice: 'Account successfully verified'
end
def profile
diff --git a/app/mailers/general_mailer.rb b/app/mailers/general_mailer.rb
index 43d4f4f4..7e414355 100644
--- a/app/mailers/general_mailer.rb
+++ b/app/mailers/general_mailer.rb
@@ -2,10 +2,15 @@ class GeneralMailer < ApplicationMailer
def ProPro_Invite
@otp_token = params[:otp_token]
@email_address = params[:email_address]
- @from_course = params[:from_course]
mail(to: @email_address, Subject: 'Invitation for ProPro')
end
+ def Signup_Verification
+ @otp_token = params[:otp_token]
+ @email_address = params[:email_address]
+ mail(to: @email_address, Subject: "Verify Your Account on ProPro")
+ end
+
def Project_Status_Updated
@course = params[:course]
@project = params[:project]
diff --git a/app/models/user.rb b/app/models/user.rb
index 0dac08a3..901f1025 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -17,6 +17,7 @@ class User < ApplicationRecord
has_many :solo_projects, as: :owner, class_name: 'Project'
has_many :group_projects, through: :project_groups, source: :project
- validates :email_address, presence: { message: 'cannot be empty' }, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i, on: :create }
+ validates :email_address, presence: { message: 'cannot be empty' }, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i, on: :create }, uniqueness: { case_sensitive: false }
validates :password, length: { maximum: 72, message: 'must be less than 72 characters' }
+ validates :name, presence: { message: 'cannot be blank' }
end
diff --git a/app/services/user_creator.rb b/app/services/user_creator.rb
new file mode 100644
index 00000000..3c9f4e79
--- /dev/null
+++ b/app/services/user_creator.rb
@@ -0,0 +1,39 @@
+require 'ostruct'
+
+class UserCreator
+ def self.call(name:, email:, password:, verify_only:, instid: nil)
+ name = name.strip
+ email = email.strip
+
+ if instid
+ instid = instid.strip
+ end
+
+ new_user = nil
+ new_otp = nil
+
+ begin
+ ActiveRecord::Base.transaction do
+ new_user = User.create!(
+ name: name,
+ email_address: email,
+ password: password,
+ instid: instid,
+ has_registered: false
+ )
+
+ new_otp = Otp.create!(
+ user: new_user,
+ token: SecureRandom.uuid,
+ verify_only: verify_only
+ )
+ end
+ rescue ActiveRecord::RecordNotUnique
+ return OpenStruct.new(success?: false, message: 'Your email is already in the system. Check your inbox or login!')
+ rescue StandardError => e
+ return OpenStruct.new(success?: false, message: e.message)
+ end
+
+ return OpenStruct.new(success?: true, user: new_user, otp_instance: new_otp)
+ end
+end
diff --git a/app/services/user_details_validator.rb b/app/services/user_details_validator.rb
new file mode 100644
index 00000000..c75658ff
--- /dev/null
+++ b/app/services/user_details_validator.rb
@@ -0,0 +1,23 @@
+require 'ostruct'
+
+class UserDetailsValidator
+ def self.call(name:, password:, password_confirmation:)
+ if name.blank?
+ return OpenStruct.new(success?: false, message: 'Name cannot be empty')
+ end
+
+ if password.blank?
+ return OpenStruct.new(success?: false, message: 'Password cannot be empty')
+ end
+
+ if password_confirmation.blank?
+ return OpenStruct.new(success?: false, message: 'Password confirmation cannot be empty')
+ end
+
+ if password != password_confirmation
+ return OpenStruct.new(success?: false, message: 'Passwords are not the same')
+ end
+
+ return OpenStruct.new(success?: true)
+ end
+end
diff --git a/app/views/general_mailer/ProPro_Invite.html.erb b/app/views/general_mailer/ProPro_Invite.html.erb
index d34fc3c8..982c3c6f 100644
--- a/app/views/general_mailer/ProPro_Invite.html.erb
+++ b/app/views/general_mailer/ProPro_Invite.html.erb
@@ -26,31 +26,21 @@
- <% if @from_course %>
-
- You have been invited and enrolled into a ProPro course.
-
- <% else %>
-
- Your email has been used to register for an account on ProPro
-
- <% end %>
+
+ You have been invited and enrolled into a ProPro course.
+
ProPro is a web app for Project Proposals and Progress monitoring. It is ideal for educators managing student projects where students propose their ideas for approval!
- <% if @from_course %>
-
- You have been added to a course via your email address. An account have automatically been created for you. To claim your account, visit:
-
- <% end %>
+
+ You have been added to a course via your email address. An account have automatically been created for you. To claim your account, visit:
+
diff --git a/app/views/general_mailer/ProPro_Invite.text.erb b/app/views/general_mailer/ProPro_Invite.text.erb
index baeab90f..1bdf9989 100644
--- a/app/views/general_mailer/ProPro_Invite.text.erb
+++ b/app/views/general_mailer/ProPro_Invite.text.erb
@@ -1,10 +1,6 @@
Activate Your Account on ProPro!
=======================================================================
-<% if @from_course %>
You have been invited and enrolled into a ProPro course.
-<% else %>
-Your email has been used to register for an account on ProPro
-<% end %>
ProPro is a web app for Project Proposals and Progress monitoring. It is ideal for educators managing student projects where students propose their ideas for approval!
You have been added to a course via your email address. An account have automatically been created for you. To claim your account, visit:
diff --git a/app/views/general_mailer/Signup_Verification.html.erb b/app/views/general_mailer/Signup_Verification.html.erb
new file mode 100644
index 00000000..7f6ef060
--- /dev/null
+++ b/app/views/general_mailer/Signup_Verification.html.erb
@@ -0,0 +1,64 @@
+
+
+
+
+
+ Verify Your Account on ProPro!
+
+
+
+
+
+
+
+
+
+
+
+
+ Verify Your Account on ProPro!
+
+
+
+
+
+
+
+
+
+
+ Your email has been used to register for an account on ProPro
+
+
+ ProPro is a web app for Project Proposals and Progress monitoring. It is ideal for educators managing student projects where students propose their ideas for approval!
+
+ This is for YOUR account activation only. Do not share this information with a third-party.
+
+
+ Welcome to ProPro and we hope you have a great project experience!
+ - ProPro Team
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/general_mailer/Signup_Verification.text.erb b/app/views/general_mailer/Signup_Verification.text.erb
new file mode 100644
index 00000000..fae8bde1
--- /dev/null
+++ b/app/views/general_mailer/Signup_Verification.text.erb
@@ -0,0 +1,7 @@
+Verify Your Account on ProPro!
+=======================================================================
+Your email has been used to register for an account on ProPro
+ProPro is a web app for Project Proposals and Progress monitoring. It is ideal for educators managing student projects where students propose their ideas for approval!
+
+To verify your account, visit:
+<%= user_verify_url(token: @otp_token) %>
diff --git a/app/views/user/claim.html.erb b/app/views/user/claim.html.erb
index 2bb7d6b2..c3e2aa39 100644
--- a/app/views/user/claim.html.erb
+++ b/app/views/user/claim.html.erb
@@ -1,26 +1,73 @@
-<% content_for :stylesheets do %>
- <%= stylesheet_link_tag "courses" %>
- <%= stylesheet_link_tag "tailwind", "new_tailwind" %>
-<% end %>
-<% content_for :title, "New Lecturer | ProPro" %>
-
-
-
New User
-
-
-
-
<%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
-
<%= tag.div(flash[:notice], style: "color:green") if flash[:notice] %>
<%= form.submit "Sign Up",
class:
diff --git a/config/routes.rb b/config/routes.rb
index f7a01753..3cbceb9b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -11,6 +11,7 @@
post 'user/handle_claim'
post 'user/edit'
post 'user/:id/resend_invite', to: 'user#resend_invite', as: :resend_invite
+ get 'user/verify'
resources :enrolments, only: [:destroy]
post 'invite', to: 'courses#enroll_via_coursecode', as: 'invite'
diff --git a/db/migrate/20261001123305_add_verify_only_to_otp.rb b/db/migrate/20261001123305_add_verify_only_to_otp.rb
new file mode 100644
index 00000000..3e0c6e9c
--- /dev/null
+++ b/db/migrate/20261001123305_add_verify_only_to_otp.rb
@@ -0,0 +1,5 @@
+class AddVerifyOnlyToOtp < ActiveRecord::Migration[8.0]
+ def change
+ add_column :otps, :verify_only, :boolean, default: false, null: false
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 45c5a861..750b2382 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[8.0].define(version: 2026_08_29_082339) do
+ActiveRecord::Schema[8.0].define(version: 2026_10_01_123305) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -74,6 +74,7 @@
t.datetime "updated_at", null: false
t.string "token", null: false
t.integer "user_id", null: false
+ t.boolean "verify_only", default: false, null: false
t.index ["user_id"], name: "index_otps_on_user_id"
end