invitation emails

This commit is contained in:
2026-01-23 13:49:56 +01:00
parent 35c29749fb
commit 396e649960
14 changed files with 531 additions and 7 deletions
@@ -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
+36
View File
@@ -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
+12
View File
@@ -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
+19
View File
@@ -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
+136
View File
@@ -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.
+75
View File
@@ -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>