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.
+
+ 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 %>)
+
+ <%= 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