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>