From 519ec80b902438b30c9bab32c196a650de9b28af Mon Sep 17 00:00:00 2001 From: Riccardo Graziosi <31478034+riggraz@users.noreply.github.com> Date: Fri, 6 Sep 2024 20:27:15 +0200 Subject: [PATCH] Add invitation system (#398) --- app/assets/stylesheets/application.sass.scss | 1 + .../stylesheets/common/_scroll_shadows.scss | 1 - .../SiteSettings/Invitations/index.scss | 93 +++++++ .../components/SiteSettings/index.scss | 1 - app/controllers/application_controller.rb | 6 +- app/controllers/invitations_controller.rb | 62 +++++ app/controllers/registrations_controller.rb | 32 +++ app/controllers/site_settings_controller.rb | 4 + .../Appearance/AppearanceSiteSettingsP.tsx | 2 +- .../SiteSettings/Invitations/index.tsx | 246 ++++++++++++++++++ app/javascript/helpers/regex.ts | 5 + app/javascript/interfaces/IInvitation.ts | 7 + app/mailers/invitation_mailer.rb | 10 + app/models/invitation.rb | 3 + app/views/devise/registrations/new.html.erb | 11 +- app/views/invitation_mailer/invite.html.erb | 1 + app/views/site_settings/_menu.html.erb | 1 + app/views/site_settings/invitations.html.erb | 16 ++ config/locales/en.yml | 37 ++- config/routes.rb | 4 + .../20240902151945_create_invitations.rb | 14 + db/schema.rb | 14 +- spec/factories/invitations.rb | 8 + .../previews/invitation_mailer_preview.rb | 13 + spec/models/invitation_spec.rb | 9 + 25 files changed, 591 insertions(+), 10 deletions(-) create mode 100644 app/assets/stylesheets/components/SiteSettings/Invitations/index.scss create mode 100644 app/controllers/invitations_controller.rb create mode 100644 app/javascript/components/SiteSettings/Invitations/index.tsx create mode 100644 app/javascript/helpers/regex.ts create mode 100644 app/javascript/interfaces/IInvitation.ts create mode 100644 app/mailers/invitation_mailer.rb create mode 100644 app/models/invitation.rb create mode 100644 app/views/invitation_mailer/invite.html.erb create mode 100644 app/views/site_settings/invitations.html.erb create mode 100644 db/migrate/20240902151945_create_invitations.rb create mode 100644 spec/factories/invitations.rb create mode 100644 spec/mailers/previews/invitation_mailer_preview.rb create mode 100644 spec/models/invitation_spec.rb diff --git a/app/assets/stylesheets/application.sass.scss b/app/assets/stylesheets/application.sass.scss index a808384a..5a2f69a5 100644 --- a/app/assets/stylesheets/application.sass.scss +++ b/app/assets/stylesheets/application.sass.scss @@ -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'; diff --git a/app/assets/stylesheets/common/_scroll_shadows.scss b/app/assets/stylesheets/common/_scroll_shadows.scss index 93a5f58a..14683009 100644 --- a/app/assets/stylesheets/common/_scroll_shadows.scss +++ b/app/assets/stylesheets/common/_scroll_shadows.scss @@ -1,6 +1,5 @@ // Credits: https://codepen.io/chriscoyier/pen/YzXBYvL .scroll-shadows { - max-height: 200px; overflow: auto; background: diff --git a/app/assets/stylesheets/components/SiteSettings/Invitations/index.scss b/app/assets/stylesheets/components/SiteSettings/Invitations/index.scss new file mode 100644 index 00000000..9b42c2a3 --- /dev/null +++ b/app/assets/stylesheets/components/SiteSettings/Invitations/index.scss @@ -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; + } + } + } + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/components/SiteSettings/index.scss b/app/assets/stylesheets/components/SiteSettings/index.scss index d00901b2..919b14b7 100644 --- a/app/assets/stylesheets/components/SiteSettings/index.scss +++ b/app/assets/stylesheets/components/SiteSettings/index.scss @@ -14,7 +14,6 @@ position: sticky; bottom: 16px; z-index: 100; - width: 80%; margin: 0 auto; span.warning { diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 55dc034e..37a3caa2 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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) diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb new file mode 100644 index 00000000..51dda92a --- /dev/null +++ b/app/controllers/invitations_controller.rb @@ -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 \ No newline at end of file diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 6fdd12f3..897623f9 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -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 diff --git a/app/controllers/site_settings_controller.rb b/app/controllers/site_settings_controller.rb index 68f384b8..7c20e945 100644 --- a/app/controllers/site_settings_controller.rb +++ b/app/controllers/site_settings_controller.rb @@ -19,6 +19,10 @@ class SiteSettingsController < ApplicationController def roadmap end + def invitations + @invitations = Invitation.all.order(updated_at: :desc) + end + def appearance end diff --git a/app/javascript/components/SiteSettings/Appearance/AppearanceSiteSettingsP.tsx b/app/javascript/components/SiteSettings/Appearance/AppearanceSiteSettingsP.tsx index d0ef1318..98f05ec7 100644 --- a/app/javascript/components/SiteSettings/Appearance/AppearanceSiteSettingsP.tsx +++ b/app/javascript/components/SiteSettings/Appearance/AppearanceSiteSettingsP.tsx @@ -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'; diff --git a/app/javascript/components/SiteSettings/Invitations/index.tsx b/app/javascript/components/SiteSettings/Invitations/index.tsx new file mode 100644 index 00000000..efb67793 --- /dev/null +++ b/app/javascript/components/SiteSettings/Invitations/index.tsx @@ -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; + 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({ + 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(null); + const [errorMessage, setErrorMessage] = React.useState(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 ( + <> + +

{ I18n.t('site_settings.invitations.new_invitations_title') }

+ +
{ + 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')); + } + } + )}> +
+ + + + { I18n.t('site_settings.invitations.to_help') } + +
+ +
+ + +
+ +
+ +