Add invitation system (#398)

This commit is contained in:
Riccardo Graziosi
2024-09-06 20:27:15 +02:00
committed by GitHub
parent b6c92cc1b0
commit 519ec80b90
25 changed files with 591 additions and 10 deletions

View File

@@ -30,6 +30,7 @@
@import 'components/SiteSettings/Roadmap';
@import 'components/SiteSettings/Authentication';
@import 'components/SiteSettings/Appearance/';
@import 'components/SiteSettings/Invitations';
/* Moderation Components */
@import 'components/Moderation/Feedback';

View File

@@ -1,6 +1,5 @@
// Credits: https://codepen.io/chriscoyier/pen/YzXBYvL
.scroll-shadows {
max-height: 200px;
overflow: auto;
background:

View File

@@ -0,0 +1,93 @@
.newInvitationsBox {
textarea#body {
height: 280px;
}
.submitFormDiv {
button {
@extend .mr-4;
vertical-align: text-bottom;
}
.testInvitation {
@extend .mt-2;
display: inline-block;
a.actionLink { display: inline-block; }
}
}
}
.pastInvitationsBox {
.filterInvitationsNav {
@extend
.nav,
.nav-pills,
.align-self-center,
.px-2,
.py-1,
.mt-4;
background-color: var(--astuto-grey-light);
border-radius: 0.5rem;
.nav-item {
cursor: pointer;
}
.nav-link {
@extend
.px-3,
.py-1;
color: var(--astuto-black);
&.active {
color: var(--astuto-black);
background-color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
}
ul.invitationsList {
@extend
.scroll-shadows,
.mt-4,
.pl-0;
list-style: none;
height: 500px;
overflow-y: scroll;
li.invitationListItem {
@extend
.d-flex,
.justify-content-between,
.my-2,
.p-2;
div.invitationUserInfo {
@extend .d-flex;
span.invitationEmail {
@extend
.align-self-center,
.ml-4;
font-size: 18px;
}
}
div.invitationInfo {
@extend .d-flex;
span.invitationAcceptedAt, span.invitationSentAt {
@extend .align-self-center, .mutedText;
}
}
}
}
}

View File

@@ -14,7 +14,6 @@
position: sticky;
bottom: 16px;
z-index: 100;
width: 80%;
margin: 0 auto;
span.warning {

View File

@@ -6,7 +6,7 @@ class ApplicationController < ActionController::Base
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
before_action :configure_permitted_parameters, if: :devise_controller?
before_action :configure_devise_permitted_parameters, if: :devise_controller?
before_action :check_tenant_is_private, if: :should_check_tenant_is_private?
prepend_before_action :load_tenant_data
@@ -31,8 +31,8 @@ class ApplicationController < ActionController::Base
protected
def configure_permitted_parameters
additional_permitted_parameters = [:full_name, :notifications_enabled]
def configure_devise_permitted_parameters
additional_permitted_parameters = [:full_name, :notifications_enabled, :invitation_token]
devise_parameter_sanitizer.permit(:sign_up, keys: additional_permitted_parameters)
devise_parameter_sanitizer.permit(:account_update, keys: additional_permitted_parameters)

View File

@@ -0,0 +1,62 @@
class InvitationsController < ApplicationController
before_action :authenticate_admin
def create
to = invitation_params[:to].split(',').map(&:strip).select { |email| URI::MailTo::EMAIL_REGEXP.match?(email) }
subject = invitation_params[:subject]
body = invitation_params[:body]
num_invitations_sent = 0
to.each do |email|
invitation_token = SecureRandom.hex(16)
invitation_token_digest = Digest::SHA256.hexdigest(invitation_token)
# skip if user already registered
next if User.find_by(email: email).present?
invitation = Invitation.find_or_initialize_by(email: email)
# skip if invitation already exists and accepted
next if invitation.persisted? && invitation.accepted_at.present?
invitation.token_digest = invitation_token_digest
invitation.save!
# replace %link% placeholder in body with the invitation link
body_with_link = body.gsub('%link%', get_url_for(method(:new_user_registration_url), options: { invitation_token: invitation_token, email: email }))
InvitationMailer.invite(to: email, subject: subject, body: body_with_link).deliver_later
num_invitations_sent += 1
end
status = num_invitations_sent > 0 ? :ok : :unprocessable_entity
render json: { num_invitations_sent: num_invitations_sent }, status: status
end
def test
to = invitation_params[:to]
subject = invitation_params[:subject]
body = invitation_params[:body]
invitation_token = SecureRandom.hex(16)
subject = "[TEST] " + subject
body_with_link = body.gsub('%link%', get_url_for(method(:new_user_registration_url), options: { invitation_token: invitation_token, email: to }))
InvitationMailer.invite(to: to, subject: subject, body: body_with_link).deliver_later
render json: {}, status: :ok
end
private
def invitation_params
params.require(:invitations).tap do |invitation|
invitation.require(:to)
invitation.require(:subject)
invitation.require(:body)
end
end
end

View File

@@ -10,6 +10,38 @@ class RegistrationsController < Devise::RegistrationsController
ts = Current.tenant.tenant_setting
email = sign_up_params[:email]
# Handle invitations
is_invitation = sign_up_params[:invitation_token].present?
is_invitation_valid = true
invitation = nil
if is_invitation
invitation = Invitation.find_by(email: email)
if invitation.nil? || invitation.token_digest != Digest::SHA256.hexdigest(sign_up_params[:invitation_token]) || invitation.accepted_at.present?
flash[:alert] = t('errors.unauthorized')
redirect_to new_user_registration_path and return
end
ActiveRecord::Base.transaction do
invitation.accepted_at = Time.now
invitation.save!
# Sign up user without confirmation email and log them in
user = User.new(email: email, full_name: sign_up_params[:full_name], password: sign_up_params[:password], password_confirmation: sign_up_params[:password], status: "active")
user.skip_confirmation
user.save!
sign_in(user)
flash[:notice] = t('devise.registrations.signed_up')
redirect_to root_path
end
return
end
# ... if not an invitation, continue with normal registration ...
# Check if email registration is allowed
if ts.email_registration_policy == "none_allowed" || (ts.email_registration_policy == "custom_domains_allowed" && !allowed_domain?(email))
flash[:alert] = t('errors.email_domain_not_allowed')
redirect_to new_user_registration_path and return

View File

@@ -19,6 +19,10 @@ class SiteSettingsController < ApplicationController
def roadmap
end
def invitations
@invitations = Invitation.all.order(updated_at: :desc)
end
def appearance
end

View File

@@ -1,5 +1,6 @@
import * as React from 'react';
import { useEffect } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import I18n from 'i18n-js';
import Box from '../../common/Box';
@@ -7,7 +8,6 @@ import SiteSettingsInfoBox from '../../common/SiteSettingsInfoBox';
import Button from '../../common/Button';
import HttpStatus from '../../../constants/http_status';
import { getLabel } from '../../../helpers/formUtils';
import { SubmitHandler, useForm } from 'react-hook-form';
import ActionLink from '../../common/ActionLink';
import { LearnMoreIcon } from '../../common/Icons';

View File

@@ -0,0 +1,246 @@
import * as React from 'react';
import Gravatar from 'react-gravatar';
import { useForm } from 'react-hook-form';
import I18n from 'i18n-js';
import Box from '../../common/Box';
import Button from '../../common/Button';
import { DangerText, SmallMutedText, SuccessText } from '../../common/CustomTexts';
import buildRequestHeaders from '../../../helpers/buildRequestHeaders';
import HttpStatus from '../../../constants/http_status';
import { isValidEmail } from '../../../helpers/regex';
import IInvitation from '../../../interfaces/IInvitation';
import friendlyDate from '../../../helpers/datetime';
import ActionLink from '../../common/ActionLink';
import { TestIcon } from '../../common/Icons';
interface Props {
siteName: string;
invitations: Array<IInvitation>;
currentUserEmail: string;
authenticityToken: string;
}
interface IFormData {
to: string;
subject: string;
body: string;
}
const MAX_INVITATIONS = 20;
const LINK_PLACEHOLDER = '%link%';
const Invitations = ({ siteName, invitations, currentUserEmail, authenticityToken }: Props) => {
const {
register,
handleSubmit,
formState: {},
watch,
} = useForm<IFormData>({
defaultValues: {
to: '',
subject: I18n.t('site_settings.invitations.subject_default', { name: siteName }),
body: I18n.t('site_settings.invitations.body_default', { name: siteName }),
},
});
const to = watch('to');
const emailList = to.split(',');
const subject = watch('subject')
const body = watch('body')
const [successMessage, setSuccessMessage] = React.useState<string | null>(null);
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const [filter, setFilter] = React.useState<'all' | 'pending' | 'accepted'>('pending');
const pendingInvitations = invitations.filter((invitation) => !invitation.accepted_at);
const acceptedInvitations = invitations.filter((invitation) => invitation.accepted_at);
let invitationsToDisplay = invitations;
if (filter === 'pending') invitationsToDisplay = pendingInvitations;
if (filter === 'accepted') invitationsToDisplay = acceptedInvitations;
return (
<>
<Box customClass="newInvitationsBox">
<h2>{ I18n.t('site_settings.invitations.new_invitations_title') }</h2>
<form
onSubmit={handleSubmit(async (formData) => {
const emailToList = formData.to.split(',').map((email) => email.trim());
const invalidEmails = emailToList.filter((email) => !isValidEmail(email));
if (emailList.length > MAX_INVITATIONS) {
alert(I18n.t('site_settings.invitations.too_many_emails', { count: MAX_INVITATIONS }));
return;
}
if (invalidEmails.length > 0) {
alert(I18n.t('site_settings.invitations.invalid_emails', { emails: invalidEmails.join(', ').replace(/, $/, '') }));
return;
}
if (!formData.body.includes(LINK_PLACEHOLDER)) {
alert(I18n.t('site_settings.invitations.invitation_link_not_found'));
return;
}
const res = await fetch(`/invitations`, {
method: 'POST',
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({
invitations: {
to: formData.to,
subject: formData.subject,
body: formData.body,
}
}),
});
if (res.status === HttpStatus.OK) {
setSuccessMessage(I18n.t('site_settings.invitations.submit_success'));
setErrorMessage(null);
setTimeout(() => window.location.reload(), 2000);
} else {
setErrorMessage(I18n.t('site_settings.invitations.submit_failure'));
}
}
)}>
<div className="formGroup">
<label htmlFor="to">{ I18n.t('site_settings.invitations.to') }</label>
<input
{...register('to', { required: true })}
placeholder="alice@example.com,bob@test.org"
type="text"
className="formControl"
id="to"
/>
<SmallMutedText>
{ I18n.t('site_settings.invitations.to_help') }
</SmallMutedText>
</div>
<div className="formGroup">
<label htmlFor="subject">{ I18n.t('site_settings.invitations.subject') }</label>
<input
{...register('subject', { required: true })}
type="text"
className="formControl"
id="subject"
/>
</div>
<div className="formGroup">
<label htmlFor="body">{ I18n.t('site_settings.invitations.body') }</label>
<textarea
{...register('body', { required: true })}
className="formControl"
id="body"
/>
</div>
<div className="submitFormDiv">
<Button onClick={() => {}} disabled={to === ''}>
{ I18n.t('site_settings.invitations.send', { count: emailList.length }) }
</Button>
<div className="testInvitation">
<ActionLink
icon={<TestIcon />}
onClick={async () => {
if (!body.includes(LINK_PLACEHOLDER)) {
alert(I18n.t('site_settings.invitations.invitation_link_not_found'));
return;
}
const res = await fetch(`/invitations/test`, {
method: 'POST',
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({
invitations: {
to: currentUserEmail,
subject: subject,
body: body,
}
}),
});
if (res.status === HttpStatus.OK) {
alert(I18n.t('site_settings.invitations.test_invitation_success', { email: currentUserEmail }));
} else {
alert(I18n.t('site_settings.invitations.submit_failure'));
}
}}
>
{ I18n.t('site_settings.invitations.test_invitation_button') }
</ActionLink>
<SmallMutedText>{ I18n.t('site_settings.invitations.test_invitation_help', { email: currentUserEmail }) }</SmallMutedText>
</div>
</div>
</form>
<br />
{ successMessage ? <SuccessText>{ successMessage }</SuccessText> : null }
{ errorMessage ? <DangerText>{ errorMessage }</DangerText> : null }
</Box>
<Box customClass="pastInvitationsBox">
<h2>{ I18n.t('site_settings.invitations.past_invitations_title') }</h2>
<ul className="filterInvitationsNav">
<li className="nav-item">
<a onClick={() => setFilter('all')} className={`nav-link${filter === 'all' ? ' active' : ''}`}>
{I18n.t('site_settings.invitations.all')}
&nbsp;
({invitations && invitations.length})
</a>
</li>
<li className="nav-item">
<a onClick={() => setFilter('pending')} className={`nav-link${filter === 'pending' ? ' active' : ''}`}>
{I18n.t('site_settings.invitations.pending')}
&nbsp;
({pendingInvitations && pendingInvitations.length})
</a>
</li>
<li className="nav-item">
<a onClick={() => setFilter('accepted')} className={`nav-link${filter === 'accepted' ? ' active' : ''}`}>
{I18n.t('site_settings.invitations.accepted')}
&nbsp;
({acceptedInvitations && acceptedInvitations.length})
</a>
</li>
</ul>
<ul className="invitationsList">
{
invitationsToDisplay.map((invitation, i) => (
<li key={i} className="invitationListItem">
<div className="invitationUserInfo">
<Gravatar email={invitation.email} size={42} className="gravatar userGravatar" />
<span className="invitationEmail">{ invitation.email }</span>
</div>
<div className="invitationInfo">
{
invitation.accepted_at ?
<span className="invitationAcceptedAt" title={invitation.accepted_at}>
{ I18n.t('site_settings.invitations.accepted_at', { when: friendlyDate(invitation.accepted_at) }) }
</span>
:
<span className="invitationSentAt" title={invitation.updated_at}>
{ I18n.t('site_settings.invitations.sent_at', { when: friendlyDate(invitation.updated_at) }) }
</span>
}
</div>
</li>
))
}
</ul>
</Box>
</>
);
};
export default Invitations;

View File

@@ -0,0 +1,5 @@
import { EMAIL_REGEX } from "../constants/regex";
export const isValidEmail = (email: string): boolean => {
return EMAIL_REGEX.test(email);
};

View File

@@ -0,0 +1,7 @@
interface IInvitation {
email: string;
updated_at: string;
accepted_at?: string;
}
export default IInvitation;

View File

@@ -0,0 +1,10 @@
class InvitationMailer < ApplicationMailer
def invite(to:, subject:, body:)
@body = body
mail(
to: to,
subject: subject
)
end
end

3
app/models/invitation.rb Normal file
View File

@@ -0,0 +1,3 @@
class Invitation < ApplicationRecord
include TenantOwnable
end

View File

@@ -4,7 +4,7 @@
<%= render "devise/shared/error_messages", resource: resource %>
<% unless Current.tenant.tenant_setting.email_registration_policy == "none_allowed" %>
<% if Current.tenant.tenant_setting.email_registration_policy != "none_allowed" || (params[:email].present? && params[:invitation_token].present?) %>
<div class="form-group">
<%= f.label :full_name, class: "sr-only" %>
<%= f.text_field :full_name,
@@ -19,7 +19,10 @@
autocomplete: "email",
placeholder: t('common.forms.auth.email'),
required: true,
class: "form-control" %>
class: "form-control",
value: params[:email],
readonly: params[:email].present?,
tabindex: (params[:email].present? ? -1 : nil) %>
</div>
<div class="form-group">
@@ -38,6 +41,10 @@
class: "form-control" %>
</div>
<% if params[:invitation_token].present? %>
<%= f.hidden_field :invitation_token, value: params[:invitation_token] %>
<% end %>
<div class="actions">
<%= f.submit t('common.forms.auth.sign_up'), class: "btnPrimary btn-block" %>
</div>

View File

@@ -0,0 +1 @@
<%= @body %>

View File

@@ -8,6 +8,7 @@
<%= render 'shared/sidebar_menu_link', label: t('site_settings.menu.boards'), path: site_settings_boards_path %>
<%= render 'shared/sidebar_menu_link', label: t('site_settings.menu.post_statuses'), path: site_settings_post_statuses_path %>
<%= render 'shared/sidebar_menu_link', label: t('site_settings.menu.roadmap'), path: site_settings_roadmap_path %>
<%= render 'shared/sidebar_menu_link', label: t('site_settings.menu.invitations'), path: site_settings_invitations_path %>
<%= render 'shared/sidebar_menu_link', label: t('site_settings.menu.appearance'), path: site_settings_appearance_path %>
</div>
</div>

View File

@@ -0,0 +1,16 @@
<div class="twoColumnsContainer">
<%= render 'menu' %>
<div>
<%=
react_component(
'SiteSettings/Invitations',
{
siteName: Current.tenant.site_name,
invitations: @invitations,
currentUserEmail: current_user.email,
authenticityToken: form_authenticity_token
}
)
%>
</div>
</div>

View File

@@ -167,10 +167,11 @@ en:
menu:
title: 'Site settings'
general: 'General'
authentication: 'Authentication'
boards: 'Boards'
post_statuses: 'Statuses'
roadmap: 'Roadmap'
authentication: 'Authentication'
invitations: 'Invitations'
appearance: 'Appearance'
info_box:
up_to_date: 'All changes saved'
@@ -216,6 +217,40 @@ en:
title2: 'Not in roadmap'
empty: 'The roadmap is empty.'
help: 'You can add statuses to the roadmap by dragging them from the section below. If you instead want to create a new status or change their order, go to Site settings > Statuses.'
invitations:
new_invitations_title: 'New invitations'
past_invitations_title: 'Invitations'
to: 'Send invitations to'
to_help: 'Email addresses of people you want to invite, separated by commas.'
subject: 'Email subject'
subject_default: 'We value your feedback! Join us in shaping %{name}!'
body: 'Email body'
body_default: |
Hello!
We truly appreciate your experience with %{name} and would love to hear your feedback. Your insights help us improve and continue delivering the best possible service.
Click the link below to share your thoughts and make your voice heard:
%link%
Have a great day!
send:
one: 'Send invitation'
other: 'Send %{count} invitations'
submit_success: 'Invitations sent!'
submit_failure: 'An error occurred while sending invitations'
invalid_emails: 'Invalid email addresses: %{emails}'
too_many_emails: 'You can only send %{count} invitations at a time.'
invitation_link_not_found: 'You must use the %link% placeholder in the email body'
test_invitation_button: 'Test send invitation'
test_invitation_help: 'This will send a test invitation email to %{email}'
test_invitation_success: 'A test invitation email has been sent to %{email}'
all: 'All'
pending: 'Pending'
accepted: 'Accepted'
sent_at: 'Sent %{when}'
accepted_at: 'Accepted %{when}'
appearance:
title: 'Appearance'
learn_more: 'Learn how to customize appearance'

View File

@@ -66,6 +66,9 @@ Rails.application.routes.draw do
resources :post_statuses, only: [:index, :create, :update, :destroy] do
patch 'update_order', on: :collection
end
resources :invitations, only: [:create]
post '/invitations/test', to: 'invitations#test', as: :invitation_test
namespace :site_settings do
get 'general'
@@ -73,6 +76,7 @@ Rails.application.routes.draw do
get 'boards'
get 'post_statuses'
get 'roadmap'
get 'invitations'
get 'appearance'
end

View File

@@ -0,0 +1,14 @@
class CreateInvitations < ActiveRecord::Migration[6.1]
def change
create_table :invitations do |t|
t.string :email, null: false
t.string :token_digest, null: false
t.datetime :accepted_at
t.references :tenant, null: false, foreign_key: true
t.timestamps
t.index [:email, :tenant_id], unique: true
end
end
end

View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2024_08_21_133530) do
ActiveRecord::Schema.define(version: 2024_09_02_151945) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -55,6 +55,17 @@ ActiveRecord::Schema.define(version: 2024_08_21_133530) do
t.index ["user_id"], name: "index_follows_on_user_id"
end
create_table "invitations", force: :cascade do |t|
t.string "email", null: false
t.string "token_digest", null: false
t.datetime "accepted_at"
t.bigint "tenant_id", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["email", "tenant_id"], name: "index_invitations_on_email_and_tenant_id", unique: true
t.index ["tenant_id"], name: "index_invitations_on_tenant_id"
end
create_table "likes", force: :cascade do |t|
t.bigint "user_id", null: false
t.bigint "post_id", null: false
@@ -224,6 +235,7 @@ ActiveRecord::Schema.define(version: 2024_08_21_133530) do
add_foreign_key "follows", "posts"
add_foreign_key "follows", "tenants"
add_foreign_key "follows", "users"
add_foreign_key "invitations", "tenants"
add_foreign_key "likes", "posts"
add_foreign_key "likes", "tenants"
add_foreign_key "likes", "users"

View File

@@ -0,0 +1,8 @@
FactoryBot.define do
factory :invitation do
sequence(:email) { |n| "user#{n}@example.com" }
token_digest { "my_token_digest" }
accepted_at { "2024-09-02 15:19:45" }
tenant
end
end

View File

@@ -0,0 +1,13 @@
# Preview all emails at http://localhost:3000/rails/mailers/invitation_mailer
class InvitationMailerPreview < ActionMailer::Preview
def initialize(params={})
super(params)
Current.tenant = Tenant.first
end
def invite
InvitationMailer.invite(to: 'test@example.com', subject: 'Invitation', body: 'Invitation body')
end
end

View File

@@ -0,0 +1,9 @@
require 'rails_helper'
RSpec.describe Invitation, type: :model do
let(:invitation) { FactoryBot.build(:invitation) }
it 'has a valid factory' do
expect(invitation).to be_valid
end
end