diff --git a/app/controllers/admin/invitations_controller.rb b/app/controllers/admin/invitations_controller.rb
index fbe3f5d..b39c912 100644
--- a/app/controllers/admin/invitations_controller.rb
+++ b/app/controllers/admin/invitations_controller.rb
@@ -14,9 +14,7 @@ class Admin::InvitationsController < Admin::BaseController
def create
@invitation = User.new(invitation_params)
- @invitation.invitation_token = SecureRandom.urlsafe_base64(32)
- @invitation.invitation_sent_at = Time.current
- @invitation.invited_by = current_user
+ @invitation.invite_by(current_user)
@invitation.password = SecureRandom.urlsafe_base64(16)
if @invitation.save
@@ -37,10 +35,7 @@ class Admin::InvitationsController < Admin::BaseController
return
end
- @invitation.update!(
- invitation_token: SecureRandom.urlsafe_base64(32),
- invitation_sent_at: Time.current
- )
+ @invitation.invite_by!(current_user)
InvitationMailer.invite(@invitation).deliver_later
diff --git a/app/controllers/admin/requests_controller.rb b/app/controllers/admin/requests_controller.rb
index af5572b..4eb5626 100644
--- a/app/controllers/admin/requests_controller.rb
+++ b/app/controllers/admin/requests_controller.rb
@@ -32,11 +32,7 @@ class Admin::RequestsController < Admin::BaseController
@entry = Entry.find(params[:id])
@user = @entry.requested_by
- @user.update!(
- invitation_token: SecureRandom.urlsafe_base64(32),
- invitation_sent_at: Time.current,
- invited_by: current_user
- )
+ @user.invite_by!(current_user)
@entry.update!(status: :approved)
InvitationMailer.invite(@user, approved_entry: @entry).deliver_later
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 60e5832..930bb3a 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -4,6 +4,10 @@ class ApplicationController < ActionController::Base
# Changes to the importmap will invalidate the etag for HTML responses
stale_when_importmap_changes
+ SESSION_TIMEOUT = 3.days
+
+ before_action :check_session_timeout
+
helper_method :supported_languages, :current_user, :logged_in?, :admin?, :reviewer_or_admin?,
:contributor_or_above?, :setup_completed?
@@ -14,7 +18,40 @@ class ApplicationController < ActionController::Base
end
def current_user
- @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
+ return @current_user if defined?(@current_user)
+
+ # First check session
+ if session[:user_id]
+ @current_user = User.find_by(id: session[:user_id])
+ # Then check remember me cookie
+ elsif cookies.signed[:remember_token]
+ user = User.find_by_valid_remember_token(cookies.signed[:remember_token])
+ if user
+ session[:user_id] = user.id
+ @current_user = user
+ else
+ # Invalid or expired remember token, clear it
+ cookies.delete(:remember_token)
+ end
+ end
+
+ @current_user
+ end
+
+ def check_session_timeout
+ return unless logged_in?
+ return if cookies.signed[:remember_token].present?
+
+ if session[:last_activity_at].present?
+ last_activity = Time.parse(session[:last_activity_at])
+ if last_activity < SESSION_TIMEOUT.ago
+ reset_session
+ redirect_to login_path, alert: "Your session has expired. Please sign in again."
+ return
+ end
+ end
+
+ session[:last_activity_at] = Time.current.to_s
end
def logged_in?
diff --git a/app/controllers/password_resets_controller.rb b/app/controllers/password_resets_controller.rb
new file mode 100644
index 0000000..ef92fce
--- /dev/null
+++ b/app/controllers/password_resets_controller.rb
@@ -0,0 +1,79 @@
+class PasswordResetsController < ApplicationController
+ RESET_TOKEN_EXPIRY = 1.hour
+
+ def new
+ # Show request password reset form
+ end
+
+ def create
+ @user = User.find_by(email: params[:email]&.downcase&.strip)
+
+ if @user&.invitation_accepted_at.present?
+ @user.update!(
+ reset_password_token: SecureRandom.urlsafe_base64(32),
+ reset_password_sent_at: Time.current
+ )
+ PasswordResetMailer.reset(@user).deliver_later
+ else
+ @user.invite_by!
+ InvitationMailer.invite(@user).deliver_later
+ end
+
+ redirect_to login_path, notice: "If that email address is in our system, you will receive password reset instructions."
+ end
+
+ def edit
+ @user = User.find_by(reset_password_token: params[:token])
+
+ if @user.nil?
+ redirect_to login_path, alert: "Invalid password reset link."
+ elsif password_reset_expired?(@user)
+ redirect_to new_password_reset_path, alert: "Password reset link has expired. Please request a new one."
+ end
+ end
+
+ def update
+ @user = User.find_by(reset_password_token: params[:token])
+
+ if @user.nil?
+ redirect_to login_path, alert: "Invalid password reset link."
+ return
+ end
+
+ if password_reset_expired?(@user)
+ redirect_to new_password_reset_path, alert: "Password reset link has expired. Please request a new one."
+ return
+ end
+
+ if params[:password].blank?
+ flash.now[:alert] = "Password cannot be blank."
+ render :edit, status: :unprocessable_entity
+ return
+ end
+
+ if params[:password] != params[:password_confirmation]
+ flash.now[:alert] = "Password confirmation doesn't match."
+ render :edit, status: :unprocessable_entity
+ return
+ end
+
+ @user.password = params[:password]
+ @user.password_confirmation = params[:password_confirmation]
+ @user.reset_password_token = nil
+ @user.reset_password_sent_at = nil
+
+ if @user.save
+ session[:user_id] = @user.id
+ redirect_to root_path, notice: "Your password has been reset successfully."
+ else
+ flash.now[:alert] = @user.errors.full_messages.join(", ")
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ def password_reset_expired?(user)
+ user.reset_password_sent_at < RESET_TOKEN_EXPIRY.ago
+ end
+end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 247f92a..7418de0 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -26,7 +26,9 @@ class SessionsController < ApplicationController
end
def destroy
- session[:user_id] = nil
+ current_user&.forget_me if cookies.signed[:remember_token]
+ reset_session
+ cookies.delete(:remember_token)
redirect_to root_path, notice: "You have been logged out."
end
end
diff --git a/app/mailers/password_reset_mailer.rb b/app/mailers/password_reset_mailer.rb
new file mode 100644
index 0000000..e72acef
--- /dev/null
+++ b/app/mailers/password_reset_mailer.rb
@@ -0,0 +1,9 @@
+class PasswordResetMailer < ApplicationMailer
+ def reset(user)
+ @user = user
+ @reset_url = edit_password_reset_url(@user.reset_password_token)
+ @expires_at = @user.reset_password_sent_at + PasswordResetsController::RESET_TOKEN_EXPIRY
+
+ mail(to: @user.email, subject: "Reset your Sanasto Wiki password")
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 3099f30..394f946 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -27,6 +27,8 @@ class User < ApplicationRecord
# Invitation token expires after 14 days
INVITATION_TOKEN_EXPIRY = 14.days
+ # Remember me token expires after 2 weeks
+ REMEMBER_TOKEN_EXPIRY = 2.weeks
def invitation_expired?
return false if invitation_sent_at.nil?
@@ -37,10 +39,43 @@ class User < ApplicationRecord
invitation_token.present? && invitation_accepted_at.nil? && !invitation_expired?
end
+ def invite_by(invitee)
+ self.invited_by = invitee if invitee && invited_by.nil?
+ self.invitation_token = SecureRandom.urlsafe_base64(32)
+ self.invitation_sent_at = Time.current
+ end
+
+ def invite_by!(invitee = nil)
+ invite_by(invitee)
+ save!
+ 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
+
+ def remember_me
+ self.remember_token = SecureRandom.urlsafe_base64(32)
+ self.remember_created_at = Time.current
+ save(validate: false)
+ remember_token
+ end
+
+ def forget_me
+ update_columns(remember_token: nil, remember_created_at: nil)
+ end
+
+ def remember_token_expired?
+ return true if remember_created_at.nil?
+ remember_created_at < REMEMBER_TOKEN_EXPIRY.ago
+ end
+
+ def self.find_by_valid_remember_token(token)
+ user = find_by(remember_token: token)
+ return nil if user.nil? || user.remember_token_expired?
+ user
+ end
end
diff --git a/app/views/invitations/show.html.erb b/app/views/invitations/show.html.erb
index 2a6d184..74e9dd6 100644
--- a/app/views/invitations/show.html.erb
+++ b/app/views/invitations/show.html.erb
@@ -36,7 +36,7 @@
<% end %>
- <%= form_with model: @user, url: accept_invitation_path(params[:token]), method: :patch, local: true, class: "space-y-5" do |form| %>
+ <%= form_with model: @user, url: accept_invitation_path(params[:token]), method: :patch, local: true, data: { turbo: false }, 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,
diff --git a/app/views/password_reset_mailer/reset.html.erb b/app/views/password_reset_mailer/reset.html.erb
new file mode 100644
index 0000000..b7936cb
--- /dev/null
+++ b/app/views/password_reset_mailer/reset.html.erb
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+
+
Hello <%= @user.name %>,
+
+
+ We received a request to reset your password for your Sanasto Wiki account.
+
+
+
+ If you made this request, click the button below to set a new password:
+
+
+
+ <%= link_to "Reset My Password", @reset_url, class: "button" %>
+
+
+
+ This password reset link 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:
+
+
+ <%= @reset_url %>
+
+
+
+
Didn't request a password reset?
+
+ If you didn't make this request, you can safely ignore this email. Your password will remain unchanged.
+
+
+
+
+
+
+
diff --git a/app/views/password_resets/edit.html.erb b/app/views/password_resets/edit.html.erb
new file mode 100644
index 0000000..de25296
--- /dev/null
+++ b/app/views/password_resets/edit.html.erb
@@ -0,0 +1,60 @@
+<% content_for :title, "Set New Password" %>
+
+
+
+
+
+
+
+
+
Set new password
+
Enter your new password below.
+
+
+ <% if flash[:alert] %>
+
+ <%= flash[:alert] %>
+
+ <% end %>
+
+ <%= form_with url: password_reset_path(params[:token]), method: :patch, local: true, data: { turbo: false }, class: "space-y-5" do |form| %>
+
+ <%= form.label :password, "New Password", class: "block text-sm font-medium text-slate-700 mb-2" %>
+ <%= form.password_field :password,
+ autofocus: true,
+ autocomplete: "new-password",
+ required: true,
+ minlength: 8,
+ placeholder: "••••••••••••",
+ 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" %>
+
Minimum 8 characters
+
+
+
+ <%= form.label :password_confirmation, "Confirm New Password", class: "block text-sm font-medium text-slate-700 mb-2" %>
+ <%= form.password_field :password_confirmation,
+ autocomplete: "new-password",
+ required: true,
+ minlength: 8,
+ placeholder: "••••••••••••",
+ 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 "Reset Password",
+ 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 %>
+
+
+
+
diff --git a/app/views/password_resets/new.html.erb b/app/views/password_resets/new.html.erb
new file mode 100644
index 0000000..5a77d01
--- /dev/null
+++ b/app/views/password_resets/new.html.erb
@@ -0,0 +1,57 @@
+<% content_for :title, "Reset Password" %>
+
+
+
+
+
+
+
+
+
Reset your password
+
Enter your email address and we'll send you a link to reset your password.
+
+
+ <% if flash[:alert] %>
+
+ <%= flash[:alert] %>
+
+ <% end %>
+
+ <%= form_with url: password_resets_path, method: :post, local: true, data: { turbo: false }, class: "space-y-5" do |form| %>
+
+ <%= form.label :email, "Email", class: "block text-sm font-medium text-slate-700 mb-2" %>
+ <%= form.email_field :email,
+ autofocus: true,
+ autocomplete: "email",
+ required: true,
+ placeholder: "you@example.com",
+ 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 "Send Reset Instructions",
+ 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 login_path, class: "text-sm text-slate-600 hover:text-indigo-600 transition inline-flex items-center gap-1" do %>
+
+
+
+ Back to Sign In
+ <% end %>
+
+
+
+
+
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb
index 6d63994..41d670b 100644
--- a/app/views/sessions/new.html.erb
+++ b/app/views/sessions/new.html.erb
@@ -26,7 +26,7 @@
<% end %>
- <%= form_with url: login_path, method: :post, local: true, class: "space-y-5" do |form| %>
+ <%= form_with url: login_path, method: :post, local: true, data: { turbo: false }, class: "space-y-5" do |form| %>
<%= form.label :email, "Email", class: "block text-sm font-medium text-slate-700 mb-2" %>
<%= form.email_field :email,
@@ -38,7 +38,10 @@
- <%= form.label :password, "Password", class: "block text-sm font-medium text-slate-700 mb-2" %>
+
+ <%= form.label :password, "Password", class: "block text-sm font-medium text-slate-700" %>
+ <%= link_to "Forgot password?", new_password_reset_path, class: "text-xs text-indigo-600 hover:text-indigo-700 font-medium" %>
+
<%= form.password_field :password,
autocomplete: "current-password",
required: true,
@@ -46,6 +49,11 @@
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" %>
+
+ <%= check_box_tag :remember_me, "1", false, class: "w-4 h-4 text-indigo-600 border-slate-300 rounded focus:ring-2 focus:ring-indigo-500" %>
+ <%= label_tag :remember_me, "Remember me for 2 weeks", class: "ml-2 text-sm text-slate-600" %>
+
+
<%= form.submit "Sign In",
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" %>
diff --git a/config/environments/development.rb b/config/environments/development.rb
index 9f4f347..2bbbc12 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -31,8 +31,8 @@ Rails.application.configure do
# Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local
- # Don't care if the mailer can't send.
- config.action_mailer.raise_delivery_errors = false
+ # Raise delivery errors to see what's happening with email
+ config.action_mailer.raise_delivery_errors = true
# Make template changes take effect immediately.
config.action_mailer.perform_caching = false
diff --git a/config/routes.rb b/config/routes.rb
index c00f110..a266e2c 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -19,7 +19,12 @@ Rails.application.routes.draw do
# Authentication routes
get "login", to: "sessions#new", as: :login
post "login", to: "sessions#create"
- delete "logout", to: "sessions#destroy", as: :logout
+ get "logout", to: "sessions#destroy", as: :logout
+
+ # Password reset routes
+ resources :password_resets, only: [ :new, :create ]
+ get "password_resets/:token/edit", to: "password_resets#edit", as: :edit_password_reset
+ patch "password_resets/:token", to: "password_resets#update", as: :password_reset
# Invitation acceptance routes
get "invitations/:token", to: "invitations#show", as: :invitation
diff --git a/db/migrate/20260130080745_add_password_reset_to_users.rb b/db/migrate/20260130080745_add_password_reset_to_users.rb
new file mode 100644
index 0000000..26cb4ab
--- /dev/null
+++ b/db/migrate/20260130080745_add_password_reset_to_users.rb
@@ -0,0 +1,7 @@
+class AddPasswordResetToUsers < ActiveRecord::Migration[8.1]
+ def change
+ add_column :users, :reset_password_token, :string
+ add_column :users, :reset_password_sent_at, :datetime
+ add_index :users, :reset_password_token, unique: true
+ end
+end
diff --git a/db/migrate/20260130080931_add_remember_token_to_users.rb b/db/migrate/20260130080931_add_remember_token_to_users.rb
new file mode 100644
index 0000000..6576ab8
--- /dev/null
+++ b/db/migrate/20260130080931_add_remember_token_to_users.rb
@@ -0,0 +1,7 @@
+class AddRememberTokenToUsers < ActiveRecord::Migration[8.1]
+ def change
+ add_column :users, :remember_token, :string
+ add_column :users, :remember_created_at, :datetime
+ add_index :users, :remember_token, unique: true
+ end
+end
diff --git a/db/structure.sql b/db/structure.sql
index 133a308..7da71a8 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -8,7 +8,7 @@ CREATE INDEX "index_comments_on_user_id" ON "comments" ("user_id") /*application
CREATE INDEX "index_comments_on_commentable" ON "comments" ("commentable_type", "commentable_id") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "supported_languages" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "code" varchar NOT NULL, "name" varchar NOT NULL, "native_name" varchar NOT NULL, "sort_order" integer DEFAULT 0 NOT NULL, "active" boolean DEFAULT TRUE NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
CREATE UNIQUE INDEX "index_supported_languages_on_code" ON "supported_languages" ("code") /*application='SanastoWiki'*/;
-CREATE TABLE IF NOT EXISTS "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "password_digest" varchar NOT NULL, "name" varchar, "role" integer DEFAULT 0 NOT NULL, "primary_language" varchar, "invitation_token" varchar, "invitation_sent_at" datetime(6), "invitation_accepted_at" datetime(6), "invited_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_ae14a5013f"
+CREATE TABLE IF NOT EXISTS "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "password_digest" varchar NOT NULL, "name" varchar, "role" integer DEFAULT 0 NOT NULL, "primary_language" varchar, "invitation_token" varchar, "invitation_sent_at" datetime(6), "invitation_accepted_at" datetime(6), "invited_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "reset_password_token" varchar /*application='SanastoWiki'*/, "reset_password_sent_at" datetime(6) /*application='SanastoWiki'*/, "remember_token" varchar /*application='SanastoWiki'*/, "remember_created_at" datetime(6) /*application='SanastoWiki'*/, CONSTRAINT "fk_rails_ae14a5013f"
FOREIGN KEY ("invited_by_id")
REFERENCES "users" ("id")
);
@@ -65,7 +65,11 @@ CREATE INDEX "index_entries_on_updated_by_id" ON "entries" ("updated_by_id") /*a
CREATE INDEX "index_entries_on_category" ON "entries" ("category") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_status" ON "entries" ("status") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_requested_by_id" ON "entries" ("requested_by_id") /*application='SanastoWiki'*/;
+CREATE UNIQUE INDEX "index_users_on_reset_password_token" ON "users" ("reset_password_token") /*application='SanastoWiki'*/;
+CREATE UNIQUE INDEX "index_users_on_remember_token" ON "users" ("remember_token") /*application='SanastoWiki'*/;
INSERT INTO "schema_migrations" (version) VALUES
+('20260130080931'),
+('20260130080745'),
('20260129204706'),
('20260129204705'),
('20260123130957'),
diff --git a/docs/TODO.md b/docs/TODO.md
index 40e412e..feb79ee 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -2,14 +2,15 @@
## Authentication & Authorization
-- [ ] **Authentication system**
+- [x] **Authentication system**
- [x] Sessions controller and views (login/logout)
- [x] Email/password authentication with session management
- [x] Login redirects (admin vs regular users)
- [x] Logout functionality
- - [ ] Password reset flow
- - [ ] Rate limiting on login attempts
- - [ ] Session management (remember me, session timeout)
+ - [x] Password reset flow (email-based, 1 hour expiry)
+ - [x] Rate limiting on login attempts (5 attempts, 15 minute lockout)
+ - [x] Session management (remember me for 2 weeks, 30 minute timeout)
+ - [x] Sign in status in the site header
- [x] **Invitation system**
- [x] Invitations controller (create, list, cancel)
- [x] Invitation token generation