mirror of
https://github.com/astuto/astuto.git
synced 2025-12-15 03:07:52 +01:00
Add invitation system (#398)
This commit is contained in:
committed by
GitHub
parent
b6c92cc1b0
commit
519ec80b90
@@ -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';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Credits: https://codepen.io/chriscoyier/pen/YzXBYvL
|
||||
.scroll-shadows {
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
|
||||
background:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@
|
||||
position: sticky;
|
||||
bottom: 16px;
|
||||
z-index: 100;
|
||||
width: 80%;
|
||||
margin: 0 auto;
|
||||
|
||||
span.warning {
|
||||
|
||||
@@ -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)
|
||||
|
||||
62
app/controllers/invitations_controller.rb
Normal file
62
app/controllers/invitations_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,10 @@ class SiteSettingsController < ApplicationController
|
||||
def roadmap
|
||||
end
|
||||
|
||||
def invitations
|
||||
@invitations = Invitation.all.order(updated_at: :desc)
|
||||
end
|
||||
|
||||
def appearance
|
||||
end
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
246
app/javascript/components/SiteSettings/Invitations/index.tsx
Normal file
246
app/javascript/components/SiteSettings/Invitations/index.tsx
Normal 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')}
|
||||
|
||||
({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')}
|
||||
|
||||
({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')}
|
||||
|
||||
({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;
|
||||
5
app/javascript/helpers/regex.ts
Normal file
5
app/javascript/helpers/regex.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { EMAIL_REGEX } from "../constants/regex";
|
||||
|
||||
export const isValidEmail = (email: string): boolean => {
|
||||
return EMAIL_REGEX.test(email);
|
||||
};
|
||||
7
app/javascript/interfaces/IInvitation.ts
Normal file
7
app/javascript/interfaces/IInvitation.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
interface IInvitation {
|
||||
email: string;
|
||||
updated_at: string;
|
||||
accepted_at?: string;
|
||||
}
|
||||
|
||||
export default IInvitation;
|
||||
10
app/mailers/invitation_mailer.rb
Normal file
10
app/mailers/invitation_mailer.rb
Normal 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
3
app/models/invitation.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class Invitation < ApplicationRecord
|
||||
include TenantOwnable
|
||||
end
|
||||
@@ -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>
|
||||
|
||||
1
app/views/invitation_mailer/invite.html.erb
Normal file
1
app/views/invitation_mailer/invite.html.erb
Normal file
@@ -0,0 +1 @@
|
||||
<%= @body %>
|
||||
@@ -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>
|
||||
|
||||
16
app/views/site_settings/invitations.html.erb
Normal file
16
app/views/site_settings/invitations.html.erb
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
14
db/migrate/20240902151945_create_invitations.rb
Normal file
14
db/migrate/20240902151945_create_invitations.rb
Normal 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
|
||||
14
db/schema.rb
14
db/schema.rb
@@ -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"
|
||||
|
||||
8
spec/factories/invitations.rb
Normal file
8
spec/factories/invitations.rb
Normal 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
|
||||
13
spec/mailers/previews/invitation_mailer_preview.rb
Normal file
13
spec/mailers/previews/invitation_mailer_preview.rb
Normal 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
|
||||
9
spec/models/invitation_spec.rb
Normal file
9
spec/models/invitation_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user