Compare commits
4 Commits
4e5c25adbf
...
8ce7f1b913
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ce7f1b913 | ||
|
|
c407ee3530 | ||
|
|
32a4ffa70e | ||
|
|
20ce18ca74 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,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,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? %>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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,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">
|
||||||
|
|||||||
@@ -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" %>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user