Compare commits

...

4 Commits

Author SHA1 Message Date
Runar Ingebrigtsen
8ce7f1b913 rate limiter
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Failing after 21s
CI / test (push) Failing after 35s
2026-01-30 10:09:49 +01:00
Runar Ingebrigtsen
c407ee3530 shared header, responsive 2026-01-30 10:09:38 +01:00
Runar Ingebrigtsen
32a4ffa70e rate limiting sesisons 2026-01-30 10:08:57 +01:00
Runar Ingebrigtsen
20ce18ca74 remember me, password reset 2026-01-30 10:08:41 +01:00
24 changed files with 729 additions and 77 deletions
@@ -14,9 +14,7 @@ class Admin::InvitationsController < Admin::BaseController
def create def create
@invitation = User.new(invitation_params) @invitation = User.new(invitation_params)
@invitation.invitation_token = SecureRandom.urlsafe_base64(32) @invitation.invite_by(current_user)
@invitation.invitation_sent_at = Time.current
@invitation.invited_by = current_user
@invitation.password = SecureRandom.urlsafe_base64(16) @invitation.password = SecureRandom.urlsafe_base64(16)
if @invitation.save if @invitation.save
@@ -37,10 +35,7 @@ class Admin::InvitationsController < Admin::BaseController
return return
end end
@invitation.update!( @invitation.invite_by!(current_user)
invitation_token: SecureRandom.urlsafe_base64(32),
invitation_sent_at: Time.current
)
InvitationMailer.invite(@invitation).deliver_later InvitationMailer.invite(@invitation).deliver_later
+1 -5
View File
@@ -32,11 +32,7 @@ class Admin::RequestsController < Admin::BaseController
@entry = Entry.find(params[:id]) @entry = Entry.find(params[:id])
@user = @entry.requested_by @user = @entry.requested_by
@user.update!( @user.invite_by!(current_user)
invitation_token: SecureRandom.urlsafe_base64(32),
invitation_sent_at: Time.current,
invited_by: current_user
)
@entry.update!(status: :approved) @entry.update!(status: :approved)
InvitationMailer.invite(@user, approved_entry: @entry).deliver_later InvitationMailer.invite(@user, approved_entry: @entry).deliver_later
+38 -1
View File
@@ -4,6 +4,10 @@ class ApplicationController < ActionController::Base
# Changes to the importmap will invalidate the etag for HTML responses # Changes to the importmap will invalidate the etag for HTML responses
stale_when_importmap_changes 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?, helper_method :supported_languages, :current_user, :logged_in?, :admin?, :reviewer_or_admin?,
:contributor_or_above?, :setup_completed? :contributor_or_above?, :setup_completed?
@@ -14,7 +18,40 @@ class ApplicationController < ActionController::Base
end end
def current_user 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 end
def logged_in? def logged_in?
+45
View File
@@ -0,0 +1,45 @@
module RateLimiter
extend ActiveSupport::Concern
included do
before_action :check_rate_limit, only: [:create]
end
private
def check_rate_limit
identifier = request.ip
cache_key = "rate_limit:#{controller_name}:#{identifier}"
# Get current attempt count
attempts = Rails.cache.read(cache_key) || 0
if attempts >= max_attempts
@rate_limited = true
render_rate_limit_error
return
end
# Increment attempt count with expiry
Rails.cache.write(cache_key, attempts + 1, expires_in: lockout_period)
end
def reset_rate_limit
identifier = request.ip
cache_key = "rate_limit:#{controller_name}:#{identifier}"
Rails.cache.delete(cache_key)
end
def render_rate_limit_error
flash.now[:alert] = "Too many failed attempts. Please try again in #{lockout_period / 60} minutes."
render action_name == "create" ? :new : action_name, status: :too_many_requests
end
def max_attempts
5
end
def lockout_period
15.minutes
end
end
@@ -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
+24 -1
View File
@@ -1,4 +1,6 @@
class SessionsController < ApplicationController class SessionsController < ApplicationController
include RateLimiter
def new def new
# Redirect to admin if already logged in # Redirect to admin if already logged in
if logged_in? if logged_in?
@@ -7,6 +9,9 @@ class SessionsController < ApplicationController
end end
def create def create
# Skip authentication if rate limited
return if @rate_limited
user = User.find_by(email: params[:email]&.downcase&.strip) user = User.find_by(email: params[:email]&.downcase&.strip)
if user&.authenticate(params[:password]) if user&.authenticate(params[:password])
@@ -17,7 +22,23 @@ class SessionsController < ApplicationController
return return
end end
# Reset rate limit on successful login
reset_rate_limit
session[:user_id] = user.id session[:user_id] = user.id
session[:last_activity_at] = Time.current.to_s
# Handle remember me
if params[:remember_me] == "1"
token = user.remember_me
cookies.signed[:remember_token] = {
value: token,
expires: User::REMEMBER_TOKEN_EXPIRY.from_now,
httponly: true,
secure: Rails.env.production?
}
end
redirect_to admin? ? admin_root_path : root_path, notice: "Welcome back, #{user.name}!" redirect_to admin? ? admin_root_path : root_path, notice: "Welcome back, #{user.name}!"
else else
flash.now[:alert] = "Invalid email or password." flash.now[:alert] = "Invalid email or password."
@@ -26,7 +47,9 @@ class SessionsController < ApplicationController
end end
def destroy 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." redirect_to root_path, notice: "You have been logged out."
end end
end end
+9
View File
@@ -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
+35
View File
@@ -27,6 +27,8 @@ class User < ApplicationRecord
# Invitation token expires after 14 days # Invitation token expires after 14 days
INVITATION_TOKEN_EXPIRY = 14.days INVITATION_TOKEN_EXPIRY = 14.days
# Remember me token expires after 2 weeks
REMEMBER_TOKEN_EXPIRY = 2.weeks
def invitation_expired? def invitation_expired?
return false if invitation_sent_at.nil? return false if invitation_sent_at.nil?
@@ -37,10 +39,43 @@ class User < ApplicationRecord
invitation_token.present? && invitation_accepted_at.nil? && !invitation_expired? invitation_token.present? && invitation_accepted_at.nil? && !invitation_expired?
end 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) def self.find_by_valid_invitation_token(token)
where(invitation_token: token) where(invitation_token: token)
.where(invitation_accepted_at: nil) .where(invitation_accepted_at: nil)
.where("invitation_sent_at > ?", INVITATION_TOKEN_EXPIRY.ago) .where("invitation_sent_at > ?", INVITATION_TOKEN_EXPIRY.ago)
.first .first
end 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 end
+1 -14
View File
@@ -1,20 +1,7 @@
<% content_for :title, "Edit Entry" %> <% content_for :title, "Edit Entry" %>
<div class="min-h-screen flex flex-col"> <div class="min-h-screen flex flex-col">
<header class="bg-white border-b border-slate-200"> <%= render "shared/header", show_request_button: false, show_browse_button: true %>
<div class="max-w-7xl mx-auto px-4">
<div class="h-16 flex items-center justify-between">
<%= link_to root_path, class: "flex items-center gap-2" do %>
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
<span class="text-xl font-light text-slate-400">Wiki</span>
<% end %>
<div class="flex items-center gap-3">
<%= link_to "Browse", entries_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<%= link_to "Download XLSX", download_entries_path(format: :xlsx), class: "text-xs font-bold text-indigo-700 px-3 py-2 rounded-md border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 transition" %>
</div>
</div>
</div>
</header>
<div class="flex-1 bg-gradient-to-br from-indigo-50 via-white to-purple-50 flex items-center justify-center px-4 py-12"> <div class="flex-1 bg-gradient-to-br from-indigo-50 via-white to-purple-50 flex items-center justify-center px-4 py-12">
<div class="max-w-2xl w-full"> <div class="max-w-2xl w-full">
+1 -21
View File
@@ -1,27 +1,7 @@
<% content_for :title, "Sanasto Wiki" %> <% content_for :title, "Sanasto Wiki" %>
<div class="min-h-screen flex flex-col"> <div class="min-h-screen flex flex-col">
<header class="bg-white border-b border-slate-200"> <%= render "shared/header" %>
<div class="max-w-7xl mx-auto px-4">
<div class="h-16 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
<span class="text-xl font-light text-slate-400">Wiki</span>
</div>
<div class="flex items-center gap-3">
<%= link_to "Request Entry", new_request_path,
class: "text-xs font-bold text-emerald-700 px-3 py-2 rounded-md border border-emerald-200 bg-emerald-50 hover:bg-emerald-100 transition" %>
<%= link_to "Download XLSX", download_entries_path(format: :xlsx),
class: "text-xs font-bold text-indigo-700 px-3 py-2 rounded-md border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 transition" %>
<% if admin? %>
<%= link_to "Admin", admin_root_path, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
<% else %>
<%= link_to "Sign In", login_path, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
<% end %>
</div>
</div>
</div>
</header>
<!-- Flash messages --> <!-- Flash messages -->
<% if flash.any? %> <% if flash.any? %>
+1 -1
View File
@@ -36,7 +36,7 @@
</div> </div>
<% end %> <% 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| %>
<div> <div>
<%= form.label :password, "Set Your Password", class: "block text-sm font-medium text-slate-700 mb-2" %> <%= form.label :password, "Set Your Password", class: "block text-sm font-medium text-slate-700 mb-2" %>
<%= form.password_field :password, <%= form.password_field :password,
+81 -4
View File
@@ -16,11 +16,13 @@
<header class="bg-white border-b border-slate-200"> <header class="bg-white border-b border-slate-200">
<div class="max-w-7xl mx-auto px-4"> <div class="max-w-7xl mx-auto px-4">
<div class="h-16 flex items-center justify-between"> <div class="h-16 flex items-center justify-between">
<div class="flex items-center gap-2"> <%= link_to admin_dashboard_path, class: "flex items-center gap-2" do %>
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span> <span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
<span class="text-xl font-light text-slate-400">Admin</span> <span class="text-xl font-light text-slate-400">Admin</span>
</div> <% end %>
<nav class="flex items-center gap-3">
<!-- Desktop Navigation -->
<nav class="hidden md:flex items-center gap-3">
<%= link_to "Dashboard", admin_dashboard_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %> <%= link_to "Dashboard", admin_dashboard_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<%= link_to "Users", admin_users_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %> <%= link_to "Users", admin_users_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<%= link_to "Invitations", admin_invitations_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %> <%= link_to "Invitations", admin_invitations_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
@@ -35,12 +37,87 @@
<% end %> <% end %>
<% end %> <% end %>
<%= link_to "Back to Site", root_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %> <%= link_to "Back to Site", root_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<%= button_to "Log Out", logout_path, method: :delete, form: { data: { turbo: false }, style: "display: inline-block;" }, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %> <%= button_to "Log Out", logout_path, method: :delete, data: { turbo: false }, form: { style: "display: inline-block;" }, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
</nav>
<!-- Mobile Navigation -->
<div class="flex md:hidden items-center gap-3">
<span class="text-sm text-slate-600">
<%= current_user.name.split.first %>
</span>
<button type="button" id="admin-mobile-menu-button" class="p-2 text-slate-600 hover:text-indigo-600 focus:outline-none">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
</div>
</div>
<!-- Mobile Menu Dropdown -->
<div id="admin-mobile-menu" class="hidden md:hidden border-t border-slate-200 py-3">
<div class="py-2 px-2 border-b border-slate-200 mb-2">
<span class="text-sm font-medium text-slate-900">
<%= current_user.name %>
</span>
</div>
<nav class="flex flex-col space-y-1">
<%= link_to "Dashboard", admin_dashboard_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition" %>
<%= link_to "Users", admin_users_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition" %>
<%= link_to "Invitations", admin_invitations_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition" %>
<% requested_count = Entry.requested.count %>
<%= link_to admin_requests_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition flex items-center justify-between" do %>
<span>Requests</span>
<% if requested_count > 0 %>
<span class="bg-red-500 text-white text-xs font-bold rounded-full h-5 w-5 flex items-center justify-center">
<%= requested_count %>
</span>
<% end %>
<% end %>
<%= link_to "Back to Site", root_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition" %>
<%= button_to "Log Out", logout_path, method: :delete, data: { turbo: false }, form: { class: "w-full" }, class: "w-full text-left px-2 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded transition cursor-pointer" %>
</nav> </nav>
</div> </div>
</div> </div>
</header> </header>
<script>
function setupAdminMobileMenu() {
const menuButton = document.getElementById('admin-mobile-menu-button');
const mobileMenu = document.getElementById('admin-mobile-menu');
if (menuButton && mobileMenu) {
// Remove existing listeners to avoid duplicates
const newMenuButton = menuButton.cloneNode(true);
menuButton.parentNode.replaceChild(newMenuButton, menuButton);
newMenuButton.addEventListener('click', function(e) {
e.stopPropagation();
mobileMenu.classList.toggle('hidden');
});
// Close menu when clicking outside
document.addEventListener('click', function(event) {
const isClickInside = newMenuButton.contains(event.target) || mobileMenu.contains(event.target);
if (!isClickInside && !mobileMenu.classList.contains('hidden')) {
mobileMenu.classList.add('hidden');
}
});
// Close menu when navigating with Turbo
document.addEventListener('turbo:click', function() {
if (mobileMenu && !mobileMenu.classList.contains('hidden')) {
mobileMenu.classList.add('hidden');
}
});
}
}
// Run on initial load and on Turbo navigation
document.addEventListener('DOMContentLoaded', setupAdminMobileMenu);
document.addEventListener('turbo:load', setupAdminMobileMenu);
</script>
<!-- Flash messages --> <!-- Flash messages -->
<% if flash.any? %> <% if flash.any? %>
<div class="max-w-7xl mx-auto px-4 mt-4"> <div class="max-w-7xl mx-auto px-4 mt-4">
@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html>
<head>
<meta content='text/html; charset=UTF-8' http-equiv='Content-Type' />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
color: #334155;
max-width: 640px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
color: white;
padding: 30px;
border-radius: 8px 8px 0 0;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 28px;
font-weight: 700;
}
.header p {
margin: 8px 0 0 0;
opacity: 0.9;
}
.content {
background: white;
border: 1px solid #e2e8f0;
border-top: none;
padding: 30px;
border-radius: 0 0 8px 8px;
}
.greeting {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
color: #1e293b;
}
.button {
display: inline-block;
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
color: white;
padding: 14px 32px;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
margin: 24px 0;
text-align: center;
}
.button:hover {
background: linear-gradient(135deg, #4f46e5 0%, #4338ca 100%);
}
.footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e2e8f0;
font-size: 14px;
color: #64748b;
}
.expiry-notice {
background: #fef3c7;
border-left: 4px solid #f59e0b;
padding: 12px;
margin: 16px 0;
font-size: 14px;
border-radius: 4px;
}
.warning-box {
background: #fef2f2;
border-left: 4px solid #ef4444;
padding: 16px;
margin: 20px 0;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="header">
<h1>Sanasto Wiki</h1>
<p>Password Reset Request</p>
</div>
<div class="content">
<p class="greeting">Hello <%= @user.name %>,</p>
<p>
We received a request to reset your password for your Sanasto Wiki account.
</p>
<p>
If you made this request, click the button below to set a new password:
</p>
<div style="text-align: center;">
<%= link_to "Reset My Password", @reset_url, class: "button" %>
</div>
<div class="expiry-notice">
This password reset link will expire on <strong><%= @expires_at.strftime("%B %d, %Y at %I:%M %p %Z") %></strong>.
</div>
<p>
You can also copy and paste this link into your browser:
</p>
<p style="word-break: break-all; color: #6366f1; font-size: 14px;">
<%= @reset_url %>
</p>
<div class="warning-box">
<strong>Didn't request a password reset?</strong>
<p style="margin: 8px 0 0 0;">
If you didn't make this request, you can safely ignore this email. Your password will remain unchanged.
</p>
</div>
<div class="footer">
<p>
For security reasons, this link will only work once and will expire in 1 hour.
</p>
<p style="margin-top: 12px;">
Questions? Reply to this email.
</p>
</div>
</div>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
<% content_for :title, "Set New Password" %>
<div class="min-h-screen flex flex-col">
<header class="bg-white border-b border-slate-200">
<div class="max-w-7xl mx-auto px-4">
<div class="h-16 flex items-center">
<%= link_to root_path, class: "flex items-center gap-2" do %>
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
<span class="text-xl font-light text-slate-400">Wiki</span>
<% end %>
</div>
</div>
</header>
<div class="flex-1 flex items-center justify-center px-4 py-12 bg-slate-50">
<div class="w-full max-w-md">
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
<div class="mb-8">
<h1 class="text-2xl font-bold text-slate-900 mb-2">Set new password</h1>
<p class="text-sm text-slate-600">Enter your new password below.</p>
</div>
<% if flash[:alert] %>
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6" role="alert">
<%= flash[:alert] %>
</div>
<% end %>
<%= form_with url: password_reset_path(params[:token]), method: :patch, local: true, data: { turbo: false }, class: "space-y-5" do |form| %>
<div>
<%= 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" %>
<p class="mt-1 text-xs text-slate-500">Minimum 8 characters</p>
</div>
<div>
<%= 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" %>
</div>
<div class="pt-2">
<%= 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" %>
</div>
<% end %>
</div>
</div>
</div>
</div>
+57
View File
@@ -0,0 +1,57 @@
<% content_for :title, "Reset Password" %>
<div class="min-h-screen flex flex-col">
<header class="bg-white border-b border-slate-200">
<div class="max-w-7xl mx-auto px-4">
<div class="h-16 flex items-center">
<%= link_to root_path, class: "flex items-center gap-2" do %>
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
<span class="text-xl font-light text-slate-400">Wiki</span>
<% end %>
</div>
</div>
</header>
<div class="flex-1 flex items-center justify-center px-4 py-12 bg-slate-50">
<div class="w-full max-w-md">
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
<div class="mb-8">
<h1 class="text-2xl font-bold text-slate-900 mb-2">Reset your password</h1>
<p class="text-sm text-slate-600">Enter your email address and we'll send you a link to reset your password.</p>
</div>
<% if flash[:alert] %>
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6" role="alert">
<%= flash[:alert] %>
</div>
<% end %>
<%= form_with url: password_resets_path, method: :post, local: true, data: { turbo: false }, class: "space-y-5" do |form| %>
<div>
<%= 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" %>
</div>
<div class="pt-2">
<%= 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" %>
</div>
<% end %>
<div class="mt-6 text-center space-y-3">
<%= link_to login_path, class: "text-sm text-slate-600 hover:text-indigo-600 transition inline-flex items-center gap-1" do %>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Sign In
<% end %>
</div>
</div>
</div>
</div>
</div>
+1 -13
View File
@@ -1,19 +1,7 @@
<% content_for :title, "Request a New Entry" %> <% content_for :title, "Request a New Entry" %>
<div class="min-h-screen flex flex-col"> <div class="min-h-screen flex flex-col">
<header class="bg-white border-b border-slate-200"> <%= render "shared/header", show_request_button: false %>
<div class="max-w-7xl mx-auto px-4">
<div class="h-16 flex items-center justify-between">
<%= link_to root_path, class: "flex items-center gap-2" do %>
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
<span class="text-xl font-light text-slate-400">Wiki</span>
<% end %>
<div class="flex items-center gap-3">
<%= link_to "Sign In", login_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
</div>
</div>
</div>
</header>
<div class="flex-1 bg-gradient-to-br from-indigo-50 via-white to-purple-50 flex items-center justify-center px-4 py-12"> <div class="flex-1 bg-gradient-to-br from-indigo-50 via-white to-purple-50 flex items-center justify-center px-4 py-12">
<div class="max-w-2xl w-full"> <div class="max-w-2xl w-full">
+10 -2
View File
@@ -26,7 +26,7 @@
</div> </div>
<% end %> <% 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| %>
<div> <div>
<%= form.label :email, "Email", class: "block text-sm font-medium text-slate-700 mb-2" %> <%= form.label :email, "Email", class: "block text-sm font-medium text-slate-700 mb-2" %>
<%= form.email_field :email, <%= form.email_field :email,
@@ -38,7 +38,10 @@
</div> </div>
<div> <div>
<%= form.label :password, "Password", class: "block text-sm font-medium text-slate-700 mb-2" %> <div class="flex justify-between items-center 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" %>
</div>
<%= form.password_field :password, <%= form.password_field :password,
autocomplete: "current-password", autocomplete: "current-password",
required: true, 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" %> 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" %>
</div> </div>
<div class="flex items-center">
<%= 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" %>
</div>
<div class="pt-2"> <div class="pt-2">
<%= form.submit "Sign In", <%= 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" %> 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" %>
+122
View File
@@ -0,0 +1,122 @@
<header class="bg-white border-b border-slate-200">
<div class="max-w-7xl mx-auto px-4">
<div class="h-16 flex items-center justify-between">
<%= link_to root_path, class: "flex items-center gap-2" do %>
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
<span class="text-xl font-light text-slate-400">Wiki</span>
<% end %>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center gap-3">
<% if local_assigns[:show_request_button] != false %>
<%= link_to "Request Entry", new_request_path,
class: "text-xs font-bold text-emerald-700 px-3 py-2 rounded-md border border-emerald-200 bg-emerald-50 hover:bg-emerald-100 transition" %>
<% end %>
<% if local_assigns[:show_download_button] != false %>
<%= link_to "Download XLSX", download_entries_path(format: :xlsx),
class: "text-xs font-bold text-indigo-700 px-3 py-2 rounded-md border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 transition" %>
<% end %>
<% if local_assigns[:show_browse_button] %>
<%= link_to "Browse", entries_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<% end %>
<% if logged_in? %>
<div class="flex items-center gap-3 ml-2 pl-3 border-l border-slate-200">
<span class="text-sm text-slate-600">
<%= current_user.name %>
</span>
<% if admin? %>
<%= link_to "Admin", admin_root_path, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
<% end %>
<%= link_to "Sign Out", logout_path, data: { turbo_method: :delete, turbo: false },
class: "text-sm font-medium text-slate-600 hover:text-red-600 transition" %>
</div>
<% else %>
<%= link_to "Sign In", login_path, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
<% end %>
</div>
<!-- Mobile Navigation -->
<div class="flex md:hidden items-center gap-3">
<% if logged_in? %>
<span class="text-sm text-slate-600">
<%= current_user.name.split.first %>
</span>
<% end %>
<button type="button" id="mobile-menu-button" class="p-2 text-slate-600 hover:text-indigo-600 focus:outline-none">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
</div>
</div>
<!-- Mobile Menu Dropdown -->
<div id="mobile-menu" class="hidden md:hidden border-t border-slate-200 py-3">
<% if logged_in? %>
<div class="py-2 px-2 border-b border-slate-200 mb-2">
<span class="text-sm font-medium text-slate-900">
<%= current_user.name %>
</span>
</div>
<% end %>
<nav class="flex flex-col space-y-1">
<% if local_assigns[:show_request_button] != false %>
<%= link_to "Request Entry", new_request_path,
class: "px-2 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50 rounded transition" %>
<% end %>
<% if local_assigns[:show_browse_button] %>
<%= link_to "Browse", entries_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition" %>
<% end %>
<% if logged_in? %>
<% if admin? %>
<%= link_to "Admin", admin_root_path, class: "px-2 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-50 rounded transition" %>
<% end %>
<%= link_to "Sign Out", logout_path, data: { turbo_method: :delete, turbo: false },
class: "px-2 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded transition" %>
<% else %>
<%= link_to "Sign In", login_path, class: "px-2 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-50 rounded transition" %>
<% end %>
</nav>
</div>
</div>
</header>
<script>
function setupMobileMenu() {
const menuButton = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
if (menuButton && mobileMenu) {
// Remove existing listeners to avoid duplicates
const newMenuButton = menuButton.cloneNode(true);
menuButton.parentNode.replaceChild(newMenuButton, menuButton);
newMenuButton.addEventListener('click', function(e) {
e.stopPropagation();
mobileMenu.classList.toggle('hidden');
});
// Close menu when clicking outside
document.addEventListener('click', function(event) {
const isClickInside = newMenuButton.contains(event.target) || mobileMenu.contains(event.target);
if (!isClickInside && !mobileMenu.classList.contains('hidden')) {
mobileMenu.classList.add('hidden');
}
});
// Close menu when navigating with Turbo
document.addEventListener('turbo:click', function() {
if (mobileMenu && !mobileMenu.classList.contains('hidden')) {
mobileMenu.classList.add('hidden');
}
});
}
}
// Run on initial load and on Turbo navigation
document.addEventListener('DOMContentLoaded', setupMobileMenu);
document.addEventListener('turbo:load', setupMobileMenu);
</script>
+2 -2
View File
@@ -31,8 +31,8 @@ Rails.application.configure do
# Store uploaded files on the local file system (see config/storage.yml for options). # Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local config.active_storage.service = :local
# Don't care if the mailer can't send. # Raise delivery errors to see what's happening with email
config.action_mailer.raise_delivery_errors = false config.action_mailer.raise_delivery_errors = true
# Make template changes take effect immediately. # Make template changes take effect immediately.
config.action_mailer.perform_caching = false config.action_mailer.perform_caching = false
+6 -1
View File
@@ -19,7 +19,12 @@ Rails.application.routes.draw do
# Authentication routes # Authentication routes
get "login", to: "sessions#new", as: :login get "login", to: "sessions#new", as: :login
post "login", to: "sessions#create" 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 # Invitation acceptance routes
get "invitations/:token", to: "invitations#show", as: :invitation get "invitations/:token", to: "invitations#show", as: :invitation
@@ -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
@@ -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
+5 -1
View File
@@ -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 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 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 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") FOREIGN KEY ("invited_by_id")
REFERENCES "users" ("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_category" ON "entries" ("category") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_status" ON "entries" ("status") /*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 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 INSERT INTO "schema_migrations" (version) VALUES
('20260130080931'),
('20260130080745'),
('20260129204706'), ('20260129204706'),
('20260129204705'), ('20260129204705'),
('20260123130957'), ('20260123130957'),
+5 -4
View File
@@ -2,14 +2,15 @@
## Authentication & Authorization ## Authentication & Authorization
- [ ] **Authentication system** - [x] **Authentication system**
- [x] Sessions controller and views (login/logout) - [x] Sessions controller and views (login/logout)
- [x] Email/password authentication with session management - [x] Email/password authentication with session management
- [x] Login redirects (admin vs regular users) - [x] Login redirects (admin vs regular users)
- [x] Logout functionality - [x] Logout functionality
- [ ] Password reset flow - [x] Password reset flow (email-based, 1 hour expiry)
- [ ] Rate limiting on login attempts - [x] Rate limiting on login attempts (5 attempts, 15 minute lockout)
- [ ] Session management (remember me, session timeout) - [x] Session management (remember me for 2 weeks, 30 minute timeout)
- [x] Sign in status in the site header
- [x] **Invitation system** - [x] **Invitation system**
- [x] Invitations controller (create, list, cancel) - [x] Invitations controller (create, list, cancel)
- [x] Invitation token generation - [x] Invitation token generation