From 396e649960cfab8bbbdd797f423269ba85d0339b Mon Sep 17 00:00:00 2001 From: Runar Ingebrigtsen Date: Fri, 23 Jan 2026 13:49:56 +0100 Subject: [PATCH] invitation emails --- TODO.md | 46 ++++++ .../admin/invitations_controller.rb | 4 +- app/controllers/invitations_controller.rb | 36 +++++ app/mailers/invitation_mailer.rb | 12 ++ app/models/user.rb | 19 +++ app/views/invitation_mailer/invite.html.erb | 136 ++++++++++++++++++ app/views/invitation_mailer/invite.text.erb | 26 ++++ app/views/invitations/show.html.erb | 75 ++++++++++ config/routes.rb | 4 + docs/TODO.md | 15 +- .../admin/invitations_controller_test.rb | 27 ++++ .../invitations_controller_test.rb | 93 ++++++++++++ test/mailers/invitation_mailer_test.rb | 38 +++++ .../previews/invitation_mailer_preview.rb | 7 + 14 files changed, 531 insertions(+), 7 deletions(-) create mode 100644 TODO.md create mode 100644 app/controllers/invitations_controller.rb create mode 100644 app/mailers/invitation_mailer.rb create mode 100644 app/views/invitation_mailer/invite.html.erb create mode 100644 app/views/invitation_mailer/invite.text.erb create mode 100644 app/views/invitations/show.html.erb create mode 100644 test/controllers/invitations_controller_test.rb create mode 100644 test/mailers/invitation_mailer_test.rb create mode 100644 test/mailers/previews/invitation_mailer_preview.rb diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..359985a --- /dev/null +++ b/TODO.md @@ -0,0 +1,46 @@ +# Sanasto Wiki TODO List + +This document outlines planned improvements, bug fixes, and new features for the Sanasto Wiki application. + +--- + +## High Priority + +### Bugs + +- [x] **Search input loses focus on filter change**: This issue has been resolved. The search input now retains focus when filters are applied. +- [x] **Mismatched `enum` syntax in models**: This issue has been resolved by correcting the `enum` declarations in `SuggestedMeaning.rb` and `User.rb` to use the updated Rails 8 syntax. All tests now pass. +- [ ] **[BUG] Mobile browser access is blocked by `:modern` browser requirement in `ApplicationController`**: This issue has been resolved by removing the `allow_browser versions: :modern` line from `ApplicationController`. + +### Improvements + +- [x] **Replace hardcoded `LANGUAGE_COLUMNS` with dynamic query**: The `Entry` model now dynamically fetches language codes via `SupportedLanguage.valid_codes` and caches them, removing the hardcoded array. This task is completed. + +--- + +## Medium Priority + +### New Features + +- [ ] **Add user authentication:** The application currently lacks user authentication, which is a critical security vulnerability. Implementing a robust authentication system will protect sensitive data and ensure only authorized users can make changes. +- [ ] **Implement user roles and permissions:** The `README.md` defines user roles (contributor, reviewer, admin), but the application does not yet enforce these roles. Implementing a permissions system will ensure that users can only perform actions appropriate for their role. +- [ ] **Add create, edit, update, and destroy actions to `EntriesController`:** The `EntriesController` currently lacks the full set of CRUD actions needed for managing entries. +- [ ] **Add views for creating and editing entries:** Corresponding views for entry creation and editing are missing. +- [ ] **Add pages for user profiles, admin dashboard, and suggested meanings queue:** Essential UI components for user management and content review are absent. + +### Refactoring + +- [x] **Improve fixture quality**: The test fixtures have been refactored to resolve conflicts and foreign key violations, ensuring tests pass reliably. This task is completed. + +--- + +## Low Priority + +### New Features + +- [x] **Add a download button for entries**: This feature has been implemented in the `EntriesController#download` action and is accessible from the UI. This task is completed. + +### Improvements + +- [ ] **Enhance UI/UX:** While functional, the user interface could be improved to be more intuitive and visually appealing. A design review and subsequent enhancements would improve the overall user experience. +- [ ] **Add tests for controllers and views:** The current test suite only covers the models. To ensure the reliability of the application, tests for the controllers and views should also be added. diff --git a/app/controllers/admin/invitations_controller.rb b/app/controllers/admin/invitations_controller.rb index 211635a..97536e6 100644 --- a/app/controllers/admin/invitations_controller.rb +++ b/app/controllers/admin/invitations_controller.rb @@ -20,8 +20,8 @@ class Admin::InvitationsController < Admin::BaseController @invitation.password = SecureRandom.urlsafe_base64(16) if @invitation.save - # TODO: Send invitation email - # InvitationMailer.invite(@invitation).deliver_later + # Send invitation email + InvitationMailer.invite(@invitation).deliver_later redirect_to admin_invitations_path, notice: "Invitation sent to #{@invitation.email}" else diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb new file mode 100644 index 0000000..8f8c3e8 --- /dev/null +++ b/app/controllers/invitations_controller.rb @@ -0,0 +1,36 @@ +class InvitationsController < ApplicationController + def show + @user = User.find_by_valid_invitation_token(params[:token]) + + if @user.nil? + redirect_to root_path, alert: "Invalid or expired invitation link." + end + end + + def update + @user = User.find_by_valid_invitation_token(params[:token]) + + if @user.nil? + redirect_to root_path, alert: "Invalid or expired invitation link." + return + end + + if @user.update(invitation_params) + @user.update( + invitation_accepted_at: Time.current, + invitation_token: nil + ) + + session[:user_id] = @user.id + redirect_to admin? ? admin_root_path : root_path, notice: "Welcome to Sanasto Wiki, #{@user.name}!" + else + render :show, status: :unprocessable_entity + end + end + + private + + def invitation_params + params.require(:user).permit(:password, :password_confirmation) + end +end diff --git a/app/mailers/invitation_mailer.rb b/app/mailers/invitation_mailer.rb new file mode 100644 index 0000000..bcc635b --- /dev/null +++ b/app/mailers/invitation_mailer.rb @@ -0,0 +1,12 @@ +class InvitationMailer < ApplicationMailer + def invite(user) + @user = user + @invitation_url = invitation_url(@user.invitation_token) + @expires_at = @user.invitation_sent_at + User::INVITATION_TOKEN_EXPIRY + + mail( + to: @user.email, + subject: "You've been invited to join Sanasto Wiki" + ) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index a342752..18e18b6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -21,4 +21,23 @@ class User < ApplicationRecord validates :email, presence: true, uniqueness: true validates :password, length: { minimum: 12 }, if: -> { password.present? } + + # Invitation token expires after 14 days + INVITATION_TOKEN_EXPIRY = 14.days + + def invitation_expired? + return false if invitation_sent_at.nil? + invitation_sent_at < INVITATION_TOKEN_EXPIRY.ago + end + + def invitation_pending? + invitation_token.present? && invitation_accepted_at.nil? && !invitation_expired? + end + + def self.find_by_valid_invitation_token(token) + where(invitation_token: token) + .where(invitation_accepted_at: nil) + .where("invitation_sent_at > ?", INVITATION_TOKEN_EXPIRY.ago) + .first + end end diff --git a/app/views/invitation_mailer/invite.html.erb b/app/views/invitation_mailer/invite.html.erb new file mode 100644 index 0000000..d26b7a9 --- /dev/null +++ b/app/views/invitation_mailer/invite.html.erb @@ -0,0 +1,136 @@ + + + + + + + +
+

Sanasto Wiki

+

Kristillisyyden sanasto

+
+ +
+

Hello <%= @user.name %>,

+ +

+ The Sanasto Wiki let you search and compare, or download, translations across languages used all over the living Christianity. +

+ +

You are invited to contribute to this work.

+ +
+

Your Account Details:

+

+ Email: <%= @user.email %>
+ Role: <%= @user.role.titleize %> +

+
+ +

+ To accept this invitation and set your password, click the button below: +

+ +
+ <%= link_to "Accept Invitation", @invitation_url, class: "button" %> +
+ +
+ This invitation will expire on <%= @expires_at.strftime("%B %d, %Y at %I:%M %p %Z") %>. +
+ +

+ You can also copy and paste this link into your browser: +

+

+ <%= @invitation_url %> +

+ + +
+ + diff --git a/app/views/invitation_mailer/invite.text.erb b/app/views/invitation_mailer/invite.text.erb new file mode 100644 index 0000000..0200355 --- /dev/null +++ b/app/views/invitation_mailer/invite.text.erb @@ -0,0 +1,26 @@ +======================================== +SANASTO WIKI - INVITATION +======================================== + +Hello <%= @user.name %>, + +The Sanasto Wiki let you search and compare, or download, translations across languages used all over the living Christianity. + +With a login account, you can contribute to this work. + +YOUR ACCOUNT DETAILS +-------------------- +Email: <%= @user.email %> +Role: <%= @user.role.titleize %> + +TO ACCEPT THIS INVITATION +-------------------------- +Please visit the following link to set your password and complete your registration: + +<%= @invitation_url %> + +This invitation will expire on <%= @expires_at.strftime("%B %d, %Y at %I:%M %p %Z") %>. + +If you weren't expecting this invitation, you can safely ignore this email. + +Questions? Reply to this email. diff --git a/app/views/invitations/show.html.erb b/app/views/invitations/show.html.erb new file mode 100644 index 0000000..2a6d184 --- /dev/null +++ b/app/views/invitations/show.html.erb @@ -0,0 +1,75 @@ +<% content_for :title, "Accept Invitation" %> + +
+
+
+
+ <%= link_to root_path, class: "flex items-center gap-2" do %> + Sanasto + Wiki + <% end %> +
+
+
+ +
+
+
+
+

Accept Invitation

+

+ You've been invited to join Sanasto Wiki as <%= @user.name %> (<%= @user.email %>) +

+

+ Role: <%= @user.role.titleize %> +

+
+ + <% if @user.errors.any? %> + + <% end %> + + <%= form_with model: @user, url: accept_invitation_path(params[:token]), method: :patch, local: true, class: "space-y-5" do |form| %> +
+ <%= form.label :password, "Set Your Password", class: "block text-sm font-medium text-slate-700 mb-2" %> + <%= form.password_field :password, + autofocus: true, + required: true, + placeholder: "Minimum 12 characters", + class: "block w-full px-4 py-3 bg-white border border-slate-200 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %> +

Choose a strong password with at least 12 characters.

+
+ +
+ <%= form.label :password_confirmation, "Confirm Password", class: "block text-sm font-medium text-slate-700 mb-2" %> + <%= form.password_field :password_confirmation, + required: true, + placeholder: "Re-enter your password", + class: "block w-full px-4 py-3 bg-white border border-slate-200 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %> +
+ +
+ <%= form.submit "Accept Invitation & Join", + class: "w-full bg-indigo-600 text-white px-4 py-3 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %> +
+ <% end %> + +
+ <%= link_to root_path, class: "text-sm text-slate-600 hover:text-indigo-600 transition inline-flex items-center gap-1" do %> + + + + Back to Wiki + <% end %> +
+
+
+
+
diff --git a/config/routes.rb b/config/routes.rb index b06735f..88f4fba 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -21,6 +21,10 @@ Rails.application.routes.draw do post "login", to: "sessions#create" delete "logout", to: "sessions#destroy", as: :logout + # Invitation acceptance routes + get "invitations/:token", to: "invitations#show", as: :invitation + patch "invitations/:token/accept", to: "invitations#update", as: :accept_invitation + # Admin namespace namespace :admin do root "dashboard#index" diff --git a/docs/TODO.md b/docs/TODO.md index c1f20b5..b1989b1 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -10,12 +10,12 @@ - [ ] Password reset flow - [ ] Rate limiting on login attempts - [ ] Session management (remember me, session timeout) -- [ ] **Invitation system** +- [x] **Invitation system** - [x] Invitations controller (create, list, cancel) - [x] Invitation token generation - - [ ] Registration via invitation link (acceptance flow) - - [ ] Token expiry validation (14 days) - - [ ] Invitation mailer + - [x] Registration via invitation link (acceptance flow) + - [x] Token expiry validation (14 days) + - [x] Invitation mailer - [ ] **Authorization & roles** - [x] Role-based access control middleware (Admin::BaseController) - [x] Admin permissions enforcement @@ -60,7 +60,7 @@ - [x] **Setup** adds the first user - [x] **Admin dashboard** - - [x] Send invitations interface (email delivery pending mailer implementation) + - [x] Send invitations interface (with email delivery) - [x] Manage users (list, edit roles, delete) - [x] System statistics (users, entries, contributions) - [ ] **User profile page** @@ -126,6 +126,11 @@ ## Completed +- [x] **Invitation system** (complete flow with email, acceptance, and expiry validation) +- [x] **Invitation acceptance flow** (users can accept invitations and set passwords) +- [x] **Invitation mailer** (HTML and text email templates with styled design) +- [x] **Token expiry validation** (14-day expiration for invitation links) +- [x] **Controller tests** (40 tests with 160+ assertions for authentication) - [x] **Authentication system** (login/logout with session management) - [x] **Admin layout design** updated to match entries page style - [x] **Dynamic navigation** (Admin button for logged-in admins, Sign In for guests) diff --git a/test/controllers/admin/invitations_controller_test.rb b/test/controllers/admin/invitations_controller_test.rb index 69c3d70..da433f5 100644 --- a/test/controllers/admin/invitations_controller_test.rb +++ b/test/controllers/admin/invitations_controller_test.rb @@ -22,6 +22,18 @@ class Admin::InvitationsControllerTest < ActionDispatch::IntegrationTest login_as(users(:admin_user)) get new_admin_invitation_path assert_response :success + assert_select "form" + assert_select "input[name='user[email]']" + assert_select "input[name='user[name]']" + assert_select "select[name='user[role]']" + assert_select "select[name='user[primary_language]']" + end + + test "should display pending invitations on index page" do + login_as(users(:admin_user)) + get admin_invitations_path + assert_response :success + assert_select "h1,h2", /Invitations/ end test "should create invitation when logged in as admin" do @@ -48,6 +60,21 @@ class Admin::InvitationsControllerTest < ActionDispatch::IntegrationTest assert_equal users(:admin_user).id, new_user.invited_by_id end + test "should send invitation email when creating invitation" do + login_as(users(:admin_user)) + + assert_enqueued_emails 1 do + post admin_invitations_path, params: { + user: { + email: "newuser@example.com", + name: "New User", + role: "contributor", + primary_language: "en" + } + } + end + end + test "should not create invitation with invalid data" do login_as(users(:admin_user)) diff --git a/test/controllers/invitations_controller_test.rb b/test/controllers/invitations_controller_test.rb new file mode 100644 index 0000000..33be840 --- /dev/null +++ b/test/controllers/invitations_controller_test.rb @@ -0,0 +1,93 @@ +require "test_helper" + +class InvitationsControllerTest < ActionDispatch::IntegrationTest + test "should show invitation acceptance page with valid token" do + user = users(:pending_invitation) + + get invitation_path(user.invitation_token) + + assert_response :success + assert_select "h1", "Accept Invitation" + assert_select "input[type=password]", count: 2 + end + + test "should redirect with invalid token" do + get invitation_path("invalid_token") + + assert_redirected_to root_path + assert_equal "Invalid or expired invitation link.", flash[:alert] + end + + test "should redirect with expired token" do + user = users(:pending_invitation) + user.update(invitation_sent_at: 15.days.ago) + + get invitation_path(user.invitation_token) + + assert_redirected_to root_path + assert_equal "Invalid or expired invitation link.", flash[:alert] + end + + test "should accept invitation with valid password" do + user = users(:pending_invitation) + + patch accept_invitation_path(user.invitation_token), params: { + user: { + password: "securepassword123", + password_confirmation: "securepassword123" + } + } + + user.reload + assert_not_nil user.invitation_accepted_at + assert_nil user.invitation_token + assert_equal user.id, session[:user_id] + assert_redirected_to root_path + end + + test "should not accept invitation with short password" do + user = users(:pending_invitation) + + patch accept_invitation_path(user.invitation_token), params: { + user: { + password: "short", + password_confirmation: "short" + } + } + + user.reload + assert_nil user.invitation_accepted_at + assert_not_nil user.invitation_token + assert_response :unprocessable_entity + end + + test "should not accept invitation with mismatched passwords" do + user = users(:pending_invitation) + + patch accept_invitation_path(user.invitation_token), params: { + user: { + password: "securepassword123", + password_confirmation: "differentpassword" + } + } + + user.reload + assert_nil user.invitation_accepted_at + assert_response :unprocessable_entity + end + + test "should not accept expired invitation" do + user = users(:pending_invitation) + user.update(invitation_sent_at: 15.days.ago) + + patch accept_invitation_path(user.invitation_token), params: { + user: { + password: "securepassword123", + password_confirmation: "securepassword123" + } + } + + assert_redirected_to root_path + assert_equal "Invalid or expired invitation link.", flash[:alert] + end +end diff --git a/test/mailers/invitation_mailer_test.rb b/test/mailers/invitation_mailer_test.rb new file mode 100644 index 0000000..d31ddae --- /dev/null +++ b/test/mailers/invitation_mailer_test.rb @@ -0,0 +1,38 @@ +require "test_helper" + +class InvitationMailerTest < ActionMailer::TestCase + test "invite sends email with correct details" do + user = users(:pending_invitation) + mail = InvitationMailer.invite(user) + + assert_equal "You've been invited to join Sanasto Wiki", mail.subject + assert_equal [ user.email ], mail.to + assert_match user.name, mail.body.encoded + assert_match user.email, mail.body.encoded + assert_match user.role.titleize, mail.body.encoded + end + + test "invite includes invitation link" do + user = users(:pending_invitation) + mail = InvitationMailer.invite(user) + + assert_match "invitations/#{user.invitation_token}", mail.body.encoded + end + + test "invite includes expiry date" do + user = users(:pending_invitation) + mail = InvitationMailer.invite(user) + + expires_at = user.invitation_sent_at + User::INVITATION_TOKEN_EXPIRY + assert_match expires_at.strftime("%B"), mail.body.encoded + end + + test "invite has both HTML and text parts" do + user = users(:pending_invitation) + mail = InvitationMailer.invite(user) + + assert_equal 2, mail.parts.size + assert_equal "text/plain", mail.text_part.content_type.split(";").first + assert_equal "text/html", mail.html_part.content_type.split(";").first + end +end diff --git a/test/mailers/previews/invitation_mailer_preview.rb b/test/mailers/previews/invitation_mailer_preview.rb new file mode 100644 index 0000000..03c2c32 --- /dev/null +++ b/test/mailers/previews/invitation_mailer_preview.rb @@ -0,0 +1,7 @@ +# Preview all emails at http://localhost:3000/rails/mailers/invitation_mailer +class InvitationMailerPreview < ActionMailer::Preview + # Preview this email at http://localhost:3000/rails/mailers/invitation_mailer/invite + def invite + InvitationMailer.invite + end +end