invitation emails
This commit is contained in:
@@ -20,8 +20,8 @@ class Admin::InvitationsController < Admin::BaseController
|
||||
@invitation.password = SecureRandom.urlsafe_base64(16)
|
||||
|
||||
if @invitation.save
|
||||
# TODO: Send invitation email
|
||||
# InvitationMailer.invite(@invitation).deliver_later
|
||||
# Send invitation email
|
||||
InvitationMailer.invite(@invitation).deliver_later
|
||||
|
||||
redirect_to admin_invitations_path, notice: "Invitation sent to #{@invitation.email}"
|
||||
else
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
class InvitationsController < ApplicationController
|
||||
def show
|
||||
@user = User.find_by_valid_invitation_token(params[:token])
|
||||
|
||||
if @user.nil?
|
||||
redirect_to root_path, alert: "Invalid or expired invitation link."
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@user = User.find_by_valid_invitation_token(params[:token])
|
||||
|
||||
if @user.nil?
|
||||
redirect_to root_path, alert: "Invalid or expired invitation link."
|
||||
return
|
||||
end
|
||||
|
||||
if @user.update(invitation_params)
|
||||
@user.update(
|
||||
invitation_accepted_at: Time.current,
|
||||
invitation_token: nil
|
||||
)
|
||||
|
||||
session[:user_id] = @user.id
|
||||
redirect_to admin? ? admin_root_path : root_path, notice: "Welcome to Sanasto Wiki, #{@user.name}!"
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def invitation_params
|
||||
params.require(:user).permit(:password, :password_confirmation)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
class InvitationMailer < ApplicationMailer
|
||||
def invite(user)
|
||||
@user = user
|
||||
@invitation_url = invitation_url(@user.invitation_token)
|
||||
@expires_at = @user.invitation_sent_at + User::INVITATION_TOKEN_EXPIRY
|
||||
|
||||
mail(
|
||||
to: @user.email,
|
||||
subject: "You've been invited to join Sanasto Wiki"
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -21,4 +21,23 @@ class User < ApplicationRecord
|
||||
|
||||
validates :email, presence: true, uniqueness: true
|
||||
validates :password, length: { minimum: 12 }, if: -> { password.present? }
|
||||
|
||||
# Invitation token expires after 14 days
|
||||
INVITATION_TOKEN_EXPIRY = 14.days
|
||||
|
||||
def invitation_expired?
|
||||
return false if invitation_sent_at.nil?
|
||||
invitation_sent_at < INVITATION_TOKEN_EXPIRY.ago
|
||||
end
|
||||
|
||||
def invitation_pending?
|
||||
invitation_token.present? && invitation_accepted_at.nil? && !invitation_expired?
|
||||
end
|
||||
|
||||
def self.find_by_valid_invitation_token(token)
|
||||
where(invitation_token: token)
|
||||
.where(invitation_accepted_at: nil)
|
||||
.where("invitation_sent_at > ?", INVITATION_TOKEN_EXPIRY.ago)
|
||||
.first
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<!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;
|
||||
}
|
||||
.info-box {
|
||||
background: #f8fafc;
|
||||
border-left: 4px solid #6366f1;
|
||||
padding: 16px;
|
||||
margin: 20px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.info-box strong {
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Sanasto Wiki</h1>
|
||||
<p>Kristillisyyden sanasto</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p class="greeting">Hello <%= @user.name %>,</p>
|
||||
|
||||
<p>
|
||||
The <strong>Sanasto Wiki</strong> let you search and compare, or download, translations across languages used all over the living Christianity.
|
||||
</p>
|
||||
|
||||
<p>You are invited to contribute to this work.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<p style="margin: 0;"><strong>Your Account Details:</strong></p>
|
||||
<p style="margin: 8px 0 0 0;">
|
||||
Email: <%= @user.email %><br>
|
||||
Role: <%= @user.role.titleize %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
To accept this invitation and set your password, click the button below:
|
||||
</p>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<%= link_to "Accept Invitation", @invitation_url, class: "button" %>
|
||||
</div>
|
||||
|
||||
<div class="expiry-notice">
|
||||
This invitation 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;">
|
||||
<%= @invitation_url %>
|
||||
</p>
|
||||
|
||||
<div class="footer">
|
||||
<p>
|
||||
If you weren't expecting this invitation, you can safely ignore this email.
|
||||
</p>
|
||||
<p style="margin-top: 12px;">
|
||||
Questions? Reply to this email.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
========================================
|
||||
SANASTO WIKI - INVITATION
|
||||
========================================
|
||||
|
||||
Hello <%= @user.name %>,
|
||||
|
||||
The Sanasto Wiki let you search and compare, or download, translations across languages used all over the living Christianity.
|
||||
|
||||
With a login account, you can contribute to this work.
|
||||
|
||||
YOUR ACCOUNT DETAILS
|
||||
--------------------
|
||||
Email: <%= @user.email %>
|
||||
Role: <%= @user.role.titleize %>
|
||||
|
||||
TO ACCEPT THIS INVITATION
|
||||
--------------------------
|
||||
Please visit the following link to set your password and complete your registration:
|
||||
|
||||
<%= @invitation_url %>
|
||||
|
||||
This invitation will expire on <%= @expires_at.strftime("%B %d, %Y at %I:%M %p %Z") %>.
|
||||
|
||||
If you weren't expecting this invitation, you can safely ignore this email.
|
||||
|
||||
Questions? Reply to this email.
|
||||
@@ -0,0 +1,75 @@
|
||||
<% content_for :title, "Accept Invitation" %>
|
||||
|
||||
<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">Accept Invitation</h1>
|
||||
<p class="text-sm text-slate-600">
|
||||
You've been invited to join Sanasto Wiki as <%= @user.name %> (<%= @user.email %>)
|
||||
</p>
|
||||
<p class="text-sm text-slate-600 mt-1">
|
||||
Role: <span class="font-medium text-indigo-600"><%= @user.role.titleize %></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<% if @user.errors.any? %>
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<h3 class="font-medium mb-2"><%= pluralize(@user.errors.count, "error") %> prevented acceptance:</h3>
|
||||
<ul class="list-disc pl-5 space-y-1 text-sm">
|
||||
<% @user.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= form_with model: @user, url: accept_invitation_path(params[:token]), method: :patch, local: true, 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,
|
||||
autofocus: true,
|
||||
required: true,
|
||||
placeholder: "Minimum 12 characters",
|
||||
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">Choose a strong password with at least 12 characters.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :password_confirmation, "Confirm Password", class: "block text-sm font-medium text-slate-700 mb-2" %>
|
||||
<%= form.password_field :password_confirmation,
|
||||
required: true,
|
||||
placeholder: "Re-enter your password",
|
||||
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 "Accept Invitation & Join",
|
||||
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">
|
||||
<%= link_to root_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 Wiki
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user