From f45284b5feb913046ce4d2c5f45e686f58d6385a Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Wed, 29 Apr 2026 20:02:41 -0400 Subject: [PATCH 1/2] feat: magic links --- app/controllers/admin_controller.rb | 5 ++ app/controllers/application_controller.rb | 1 - .../classroom_rosters_controller.rb | 2 +- app/controllers/classrooms_controller.rb | 2 +- .../concerns/student_authentication.rb | 52 +++++++++++++++++++ app/controllers/passwords_controller.rb | 2 +- app/controllers/schools_controller.rb | 2 +- app/controllers/sessions_controller.rb | 2 +- app/controllers/student_homes_controller.rb | 7 +++ .../student_sessions_controller.rb | 30 +++++++++++ app/controllers/students_controller.rb | 2 +- app/models/current.rb | 4 +- app/models/student.rb | 1 + app/models/student_session.rb | 3 ++ app/views/classroom_rosters/show.html.erb | 14 ++--- app/views/student_homes/index.html.erb | 11 ++++ app/views/student_sessions/new.html.erb | 8 +++ config/routes.rb | 2 + .../20260429230254_create_student_sessions.rb | 9 ++++ db/schema.rb | 10 +++- .../student_home_controller_test.rb | 15 ++++++ .../student_sessions_controller_test.rb | 42 +++++++++++++++ test/models/student_session_test.rb | 7 +++ test/test_helper.rb | 2 + .../student_session_test_helper.rb | 19 +++++++ 25 files changed, 239 insertions(+), 15 deletions(-) create mode 100644 app/controllers/admin_controller.rb create mode 100644 app/controllers/concerns/student_authentication.rb create mode 100644 app/controllers/student_homes_controller.rb create mode 100644 app/controllers/student_sessions_controller.rb create mode 100644 app/models/student_session.rb create mode 100644 app/views/student_homes/index.html.erb create mode 100644 app/views/student_sessions/new.html.erb create mode 100644 db/migrate/20260429230254_create_student_sessions.rb create mode 100644 test/controllers/student_home_controller_test.rb create mode 100644 test/controllers/student_sessions_controller_test.rb create mode 100644 test/models/student_session_test.rb create mode 100644 test/test_helpers/student_session_test_helper.rb diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb new file mode 100644 index 0000000..32d4bd4 --- /dev/null +++ b/app/controllers/admin_controller.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class AdminController < ApplicationController + include Authentication +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5f38f02..c353756 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,5 +1,4 @@ class ApplicationController < ActionController::Base - include Authentication # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern diff --git a/app/controllers/classroom_rosters_controller.rb b/app/controllers/classroom_rosters_controller.rb index 9c0f608..f118800 100644 --- a/app/controllers/classroom_rosters_controller.rb +++ b/app/controllers/classroom_rosters_controller.rb @@ -1,6 +1,6 @@ class ClassroomRostersController < ApplicationController + include StudentAuthentication allow_unauthenticated_access - def show @classroom = Classroom.includes(:students).find_by!(uuid: params.expect(:uuid)) end diff --git a/app/controllers/classrooms_controller.rb b/app/controllers/classrooms_controller.rb index bf715f6..2077184 100644 --- a/app/controllers/classrooms_controller.rb +++ b/app/controllers/classrooms_controller.rb @@ -1,4 +1,4 @@ -class ClassroomsController < ApplicationController +class ClassroomsController < AdminController before_action :set_classroom, only: %i[ edit update ] # GET /classrooms/1/edit diff --git a/app/controllers/concerns/student_authentication.rb b/app/controllers/concerns/student_authentication.rb new file mode 100644 index 0000000..4d92195 --- /dev/null +++ b/app/controllers/concerns/student_authentication.rb @@ -0,0 +1,52 @@ +module StudentAuthentication + extend ActiveSupport::Concern + + included do + before_action :require_student_authentication + helper_method :student_authenticated? + end + + class_methods do + def allow_unauthenticated_access(**options) + skip_before_action :require_student_authentication, **options + end + end + + private + def student_authenticated? + resume_student_session + end + + def require_student_authentication + resume_student_session || request_student_authentication + end + + def resume_student_session + Current.student_session ||= find_student_session_by_cookie + end + + def find_student_session_by_cookie + StudentSession.find_by(id: cookies.signed[:student_session_id]) if cookies.signed[:student_session_id] + end + + def request_student_authentication + session[:return_to_after_authenticating] = request.url + redirect_to new_student_session_path + end + + def after_student_authentication_url + session.delete(:return_to_after_authenticating) || student_homes_url + end + + def start_new_student_session_for(student) + student.sessions.create!.tap do |session| + Current.student_session = session + cookies.signed.permanent[:student_session_id] = { value: session.id, httponly: true, same_site: :lax } + end + end + + def terminate_student_session + Current.student_session.destroy + cookies.delete(:student_session_id) + end +end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index f95ec78..eebcc38 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -1,4 +1,4 @@ -class PasswordsController < ApplicationController +class PasswordsController < AdminController allow_unauthenticated_access before_action :set_user_by_token, only: %i[ edit update ] rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." } diff --git a/app/controllers/schools_controller.rb b/app/controllers/schools_controller.rb index e542b75..bc0f91b 100644 --- a/app/controllers/schools_controller.rb +++ b/app/controllers/schools_controller.rb @@ -1,4 +1,4 @@ -class SchoolsController < ApplicationController +class SchoolsController < AdminController before_action :set_school, only: %i[ show edit update destroy ] # GET /schools or /schools.json diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index cf7fccd..c663cc0 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,4 +1,4 @@ -class SessionsController < ApplicationController +class SessionsController < AdminController allow_unauthenticated_access only: %i[ new create ] rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } diff --git a/app/controllers/student_homes_controller.rb b/app/controllers/student_homes_controller.rb new file mode 100644 index 0000000..36a2740 --- /dev/null +++ b/app/controllers/student_homes_controller.rb @@ -0,0 +1,7 @@ +class StudentHomesController < ApplicationController + include StudentAuthentication + + def index + @student = Current.student + end +end diff --git a/app/controllers/student_sessions_controller.rb b/app/controllers/student_sessions_controller.rb new file mode 100644 index 0000000..d91284f --- /dev/null +++ b/app/controllers/student_sessions_controller.rb @@ -0,0 +1,30 @@ +class StudentSessionsController < ApplicationController + include StudentAuthentication + allow_unauthenticated_access only: %i[ create new ] + before_action :set_student, only: %i[ create ] + + def new + end + + def create + if @student + start_new_student_session_for(@student) + redirect_to after_student_authentication_url + else + redirect_to new_student_session_path, alert: "Invalid classroom or student ID." + end + end + + def destroy + terminate_student_session + redirect_to new_student_session_path, status: :see_other + end + + private + + def set_student + classroom = Classroom.find_by(uuid: params.expect(:classroom_uuid)) + return unless classroom + @student = classroom.students.find_by(id: params.expect(:student_id)) + end +end diff --git a/app/controllers/students_controller.rb b/app/controllers/students_controller.rb index 0e8e46e..0823a82 100644 --- a/app/controllers/students_controller.rb +++ b/app/controllers/students_controller.rb @@ -1,4 +1,4 @@ -class StudentsController < ApplicationController +class StudentsController < AdminController before_action :set_school, only: %i[ index new create ] before_action :set_student, only: %i[ show edit update destroy ] diff --git a/app/models/current.rb b/app/models/current.rb index 2bef56d..e591ba6 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -1,4 +1,6 @@ class Current < ActiveSupport::CurrentAttributes - attribute :session + attribute :session, :student_session delegate :user, to: :session, allow_nil: true + attribute :student_session + delegate :student, to: :student_session, allow_nil: true end diff --git a/app/models/student.rb b/app/models/student.rb index 6fd9a19..923373d 100644 --- a/app/models/student.rb +++ b/app/models/student.rb @@ -1,6 +1,7 @@ class Student < ApplicationRecord belongs_to :school belongs_to :classroom + has_many :sessions, class_name: "StudentSession", dependent: :destroy enum :gender, %i[male female other].index_by(&:itself) diff --git a/app/models/student_session.rb b/app/models/student_session.rb new file mode 100644 index 0000000..3ac91a8 --- /dev/null +++ b/app/models/student_session.rb @@ -0,0 +1,3 @@ +class StudentSession < ApplicationRecord + belongs_to :student +end diff --git a/app/views/classroom_rosters/show.html.erb b/app/views/classroom_rosters/show.html.erb index f500b42..ecb23a4 100644 --- a/app/views/classroom_rosters/show.html.erb +++ b/app/views/classroom_rosters/show.html.erb @@ -3,12 +3,14 @@ diff --git a/app/views/student_homes/index.html.erb b/app/views/student_homes/index.html.erb new file mode 100644 index 0000000..ed74f03 --- /dev/null +++ b/app/views/student_homes/index.html.erb @@ -0,0 +1,11 @@ +
+
+
+

Welcome to EndsideOut!

+

You are logged in as <%= @student.full_name %>

+
+ <%= button_to "Logout", student_session_path, method: :delete, class: "btn btn-primary" %> +
+
+
+
\ No newline at end of file diff --git a/app/views/student_sessions/new.html.erb b/app/views/student_sessions/new.html.erb new file mode 100644 index 0000000..036fac8 --- /dev/null +++ b/app/views/student_sessions/new.html.erb @@ -0,0 +1,8 @@ +
+
+
+

Welcome to EndsideOut

+

If you are a student looking to log in, you need to get a special link from your teacher or facilitator.

+
+
+
\ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index f43a6d5..3efd131 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,6 +2,8 @@ get "classroom_rosters/show" resource :session resources :passwords, param: :token + resource :student_session, only: %i[new create destroy] + resources :student_homes, only: %i[index] resources :classroom_rosters, only: %i[show], param: :uuid scope :admin do resources :schools do diff --git a/db/migrate/20260429230254_create_student_sessions.rb b/db/migrate/20260429230254_create_student_sessions.rb new file mode 100644 index 0000000..6c80454 --- /dev/null +++ b/db/migrate/20260429230254_create_student_sessions.rb @@ -0,0 +1,9 @@ +class CreateStudentSessions < ActiveRecord::Migration[8.1] + def change + create_table :student_sessions do |t| + t.belongs_to :student, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 68b3779..4000b96 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.1].define(version: 2026_04_28_233313) do +ActiveRecord::Schema[8.1].define(version: 2026_04_29_230254) do create_table "classrooms", force: :cascade do |t| t.datetime "created_at", null: false t.string "name" @@ -37,6 +37,13 @@ t.index ["user_id"], name: "index_sessions_on_user_id" end + create_table "student_sessions", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "student_id", null: false + t.datetime "updated_at", null: false + t.index ["student_id"], name: "index_student_sessions_on_student_id" + end + create_table "students", force: :cascade do |t| t.integer "classroom_id", null: false t.datetime "created_at", null: false @@ -62,6 +69,7 @@ add_foreign_key "classrooms", "schools" add_foreign_key "sessions", "users" + add_foreign_key "student_sessions", "students" add_foreign_key "students", "classrooms" add_foreign_key "students", "schools" end diff --git a/test/controllers/student_home_controller_test.rb b/test/controllers/student_home_controller_test.rb new file mode 100644 index 0000000..1cdaa5c --- /dev/null +++ b/test/controllers/student_home_controller_test.rb @@ -0,0 +1,15 @@ +require "test_helper" + +class StudentHomeControllerTest < ActionDispatch::IntegrationTest + test "should get index" do + student_sign_in_as(students(:ada)) + + get student_homes_path + assert_response :success + end + + test "should redirect to new session if not signed in" do + get student_homes_path + assert_redirected_to new_student_session_path + end +end diff --git a/test/controllers/student_sessions_controller_test.rb b/test/controllers/student_sessions_controller_test.rb new file mode 100644 index 0000000..1c63c2c --- /dev/null +++ b/test/controllers/student_sessions_controller_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class StudentSessionsControllerTest < ActionDispatch::IntegrationTest + setup { @student = students(:ada) } + + test "new" do + get new_student_session_path + assert_response :success + end + + test "create with valid classroom" do + post student_session_path, params: { student_id: @student.id, classroom_uuid: @student.classroom.uuid } + + assert_redirected_to student_homes_path + assert cookies[:student_session_id] + end + + test "create with invalid uuid" do + post student_session_path, params: { student_id: @student.id, classroom_uuid: "wrong" } + + assert_redirected_to new_student_session_path + assert_nil cookies[:student_session_id] + end + + test "create with wrong classroom uuid" do + other_classroom = classrooms(:two) + assert_not @student.classroom_id == other_classroom.id + post student_session_path, params: { student_id: @student.id, classroom_uuid: other_classroom.uuid } + + assert_redirected_to new_student_session_path + assert_nil cookies[:student_session_id] + end + + test "destroy" do + student_sign_in_as(@student) + + delete student_session_path + + assert_redirected_to new_student_session_path + assert_empty cookies[:student_session_id] + end +end diff --git a/test/models/student_session_test.rb b/test/models/student_session_test.rb new file mode 100644 index 0000000..559da5b --- /dev/null +++ b/test/models/student_session_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class StudentSessionTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 85c54c6..e1d270e 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -2,6 +2,8 @@ require_relative "../config/environment" require "rails/test_help" require_relative "test_helpers/session_test_helper" +require_relative "test_helpers/student_session_test_helper" + module ActiveSupport class TestCase diff --git a/test/test_helpers/student_session_test_helper.rb b/test/test_helpers/student_session_test_helper.rb new file mode 100644 index 0000000..d033fb9 --- /dev/null +++ b/test/test_helpers/student_session_test_helper.rb @@ -0,0 +1,19 @@ +module StudentSessionTestHelper + def student_sign_in_as(student) + Current.student_session = student.sessions.create! + + ActionDispatch::TestRequest.create.cookie_jar.tap do |cookie_jar| + cookie_jar.signed[:student_session_id] = Current.student_session.id + cookies["student_session_id"] = cookie_jar[:student_session_id] + end + end + + def student_sign_out + Current.student_session&.destroy! + cookies.delete("student_session_id") + end +end + +ActiveSupport.on_load(:action_dispatch_integration_test) do + include StudentSessionTestHelper +end From 3ef98cff0c43cffedd61ab0cd9a24c6f73142e7d Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Thu, 30 Apr 2026 18:40:16 -0400 Subject: [PATCH 2/2] fix: rosters --- app/views/classroom_rosters/show.html.erb | 12 ++++++------ config/routes.rb | 1 - 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/views/classroom_rosters/show.html.erb b/app/views/classroom_rosters/show.html.erb index ecb23a4..d353db4 100644 --- a/app/views/classroom_rosters/show.html.erb +++ b/app/views/classroom_rosters/show.html.erb @@ -3,14 +3,14 @@ diff --git a/config/routes.rb b/config/routes.rb index 3efd131..0b0b9f9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,4 @@ Rails.application.routes.draw do - get "classroom_rosters/show" resource :session resources :passwords, param: :token resource :student_session, only: %i[new create destroy]