remember me, password reset
This commit is contained in:
@@ -14,9 +14,7 @@ class Admin::InvitationsController < Admin::BaseController
|
||||
|
||||
def create
|
||||
@invitation = User.new(invitation_params)
|
||||
@invitation.invitation_token = SecureRandom.urlsafe_base64(32)
|
||||
@invitation.invitation_sent_at = Time.current
|
||||
@invitation.invited_by = current_user
|
||||
@invitation.invite_by(current_user)
|
||||
@invitation.password = SecureRandom.urlsafe_base64(16)
|
||||
|
||||
if @invitation.save
|
||||
@@ -37,10 +35,7 @@ class Admin::InvitationsController < Admin::BaseController
|
||||
return
|
||||
end
|
||||
|
||||
@invitation.update!(
|
||||
invitation_token: SecureRandom.urlsafe_base64(32),
|
||||
invitation_sent_at: Time.current
|
||||
)
|
||||
@invitation.invite_by!(current_user)
|
||||
|
||||
InvitationMailer.invite(@invitation).deliver_later
|
||||
|
||||
|
||||
@@ -32,11 +32,7 @@ class Admin::RequestsController < Admin::BaseController
|
||||
@entry = Entry.find(params[:id])
|
||||
@user = @entry.requested_by
|
||||
|
||||
@user.update!(
|
||||
invitation_token: SecureRandom.urlsafe_base64(32),
|
||||
invitation_sent_at: Time.current,
|
||||
invited_by: current_user
|
||||
)
|
||||
@user.invite_by!(current_user)
|
||||
|
||||
@entry.update!(status: :approved)
|
||||
InvitationMailer.invite(@user, approved_entry: @entry).deliver_later
|
||||
|
||||
@@ -4,6 +4,10 @@ class ApplicationController < ActionController::Base
|
||||
# Changes to the importmap will invalidate the etag for HTML responses
|
||||
stale_when_importmap_changes
|
||||
|
||||
SESSION_TIMEOUT = 3.days
|
||||
|
||||
before_action :check_session_timeout
|
||||
|
||||
helper_method :supported_languages, :current_user, :logged_in?, :admin?, :reviewer_or_admin?,
|
||||
:contributor_or_above?, :setup_completed?
|
||||
|
||||
@@ -14,7 +18,40 @@ class ApplicationController < ActionController::Base
|
||||
end
|
||||
|
||||
def current_user
|
||||
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
|
||||
return @current_user if defined?(@current_user)
|
||||
|
||||
# First check session
|
||||
if session[:user_id]
|
||||
@current_user = User.find_by(id: session[:user_id])
|
||||
# Then check remember me cookie
|
||||
elsif cookies.signed[:remember_token]
|
||||
user = User.find_by_valid_remember_token(cookies.signed[:remember_token])
|
||||
if user
|
||||
session[:user_id] = user.id
|
||||
@current_user = user
|
||||
else
|
||||
# Invalid or expired remember token, clear it
|
||||
cookies.delete(:remember_token)
|
||||
end
|
||||
end
|
||||
|
||||
@current_user
|
||||
end
|
||||
|
||||
def check_session_timeout
|
||||
return unless logged_in?
|
||||
return if cookies.signed[:remember_token].present?
|
||||
|
||||
if session[:last_activity_at].present?
|
||||
last_activity = Time.parse(session[:last_activity_at])
|
||||
if last_activity < SESSION_TIMEOUT.ago
|
||||
reset_session
|
||||
redirect_to login_path, alert: "Your session has expired. Please sign in again."
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
session[:last_activity_at] = Time.current.to_s
|
||||
end
|
||||
|
||||
def logged_in?
|
||||
|
||||
@@ -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
|
||||
@@ -26,7 +26,9 @@ class SessionsController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
session[:user_id] = nil
|
||||
current_user&.forget_me if cookies.signed[:remember_token]
|
||||
reset_session
|
||||
cookies.delete(:remember_token)
|
||||
redirect_to root_path, notice: "You have been logged out."
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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_EXPIRY = 14.days
|
||||
# Remember me token expires after 2 weeks
|
||||
REMEMBER_TOKEN_EXPIRY = 2.weeks
|
||||
|
||||
def invitation_expired?
|
||||
return false if invitation_sent_at.nil?
|
||||
@@ -37,10 +39,43 @@ class User < ApplicationRecord
|
||||
invitation_token.present? && invitation_accepted_at.nil? && !invitation_expired?
|
||||
end
|
||||
|
||||
def invite_by(invitee)
|
||||
self.invited_by = invitee if invitee && invited_by.nil?
|
||||
self.invitation_token = SecureRandom.urlsafe_base64(32)
|
||||
self.invitation_sent_at = Time.current
|
||||
end
|
||||
|
||||
def invite_by!(invitee = nil)
|
||||
invite_by(invitee)
|
||||
save!
|
||||
end
|
||||
|
||||
def self.find_by_valid_invitation_token(token)
|
||||
where(invitation_token: token)
|
||||
.where(invitation_accepted_at: nil)
|
||||
.where("invitation_sent_at > ?", INVITATION_TOKEN_EXPIRY.ago)
|
||||
.first
|
||||
end
|
||||
|
||||
def remember_me
|
||||
self.remember_token = SecureRandom.urlsafe_base64(32)
|
||||
self.remember_created_at = Time.current
|
||||
save(validate: false)
|
||||
remember_token
|
||||
end
|
||||
|
||||
def forget_me
|
||||
update_columns(remember_token: nil, remember_created_at: nil)
|
||||
end
|
||||
|
||||
def remember_token_expired?
|
||||
return true if remember_created_at.nil?
|
||||
remember_created_at < REMEMBER_TOKEN_EXPIRY.ago
|
||||
end
|
||||
|
||||
def self.find_by_valid_remember_token(token)
|
||||
user = find_by(remember_token: token)
|
||||
return nil if user.nil? || user.remember_token_expired?
|
||||
user
|
||||
end
|
||||
end
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
<% 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>
|
||||
<%= form.label :password, "Set Your Password", class: "block text-sm font-medium text-slate-700 mb-2" %>
|
||||
<%= form.password_field :password,
|
||||
|
||||
@@ -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>
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
<% 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>
|
||||
<%= form.label :email, "Email", class: "block text-sm font-medium text-slate-700 mb-2" %>
|
||||
<%= form.email_field :email,
|
||||
@@ -38,7 +38,10 @@
|
||||
</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,
|
||||
autocomplete: "current-password",
|
||||
required: true,
|
||||
@@ -46,6 +49,11 @@
|
||||
class: "block w-full px-4 py-3 bg-white border border-slate-200 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %>
|
||||
</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">
|
||||
<%= 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" %>
|
||||
|
||||
Reference in New Issue
Block a user