Skip to content

Commit

Permalink
Adds ability to send user invitation emails from Account Overview (#703)
Browse files Browse the repository at this point in the history
* Adds ability to send user invitation emails from Account Overview

* Replaces link with a button

* Adds handling of deliver result
  • Loading branch information
rhonsby authored Apr 5, 2021
1 parent caef5ff commit f5f036a
Show file tree
Hide file tree
Showing 7 changed files with 309 additions and 0 deletions.
15 changes: 15 additions & 0 deletions assets/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,21 @@ export const generateUserInvitation = async (token = getAccessToken()) => {
.then((res) => res.body.data);
};

export const sendUserInvitationEmail = async (
to_address: string,
token = getAccessToken()
) => {
if (!token) {
throw new Error('Invalid token!');
}

return request
.post(`/api/user_invitation_emails`)
.send({to_address})
.set('Authorization', token)
.then((res) => res.body.data);
};

export const fetchSlackAuthorization = async (
type = 'reply',
token = getAccessToken()
Expand Down
86 changes: 86 additions & 0 deletions assets/src/components/account/AccountOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ type State = {
companyName: string;
currentUser: User | null;
inviteUrl: string;
inviteUserEmail: string;
isLoading: boolean;
isEditing: boolean;
isRefreshing: boolean;
showInviteMoreInput: boolean;
};

class AccountOverview extends React.Component<Props, State> {
Expand All @@ -39,9 +41,11 @@ class AccountOverview extends React.Component<Props, State> {
companyName: '',
currentUser: null,
inviteUrl: '',
inviteUserEmail: '',
isLoading: true,
isEditing: false,
isRefreshing: false,
showInviteMoreInput: false,
};

async componentDidMount() {
Expand Down Expand Up @@ -107,6 +111,49 @@ class AccountOverview extends React.Component<Props, State> {
}
};

handleSendInviteEmail = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();

try {
const {inviteUserEmail} = this.state;
await API.sendUserInvitationEmail(inviteUserEmail);
notification.success({
message: `Invitation was successfully sent to ${inviteUserEmail}!`,
duration: 10, // 10 seconds
});

this.setState({inviteUserEmail: ''});
} catch (err) {
// TODO: consolidate error logic with handleGenerateInviteUrl
const hasServerErrorMessage = !!err?.response?.body?.error?.message;
const shouldDisplayBillingLink =
hasServerErrorMessage && hasValidStripeKey();
const description =
err?.response?.body?.error?.message || err?.message || String(err);

notification.error({
message: hasServerErrorMessage
? 'Please upgrade to add more users!'
: 'Failed to generate user invitation!',
description,
duration: 10, // 10 seconds
btn: (
<a
href={
shouldDisplayBillingLink
? '/billing'
: 'https://papercups.io/pricing'
}
>
<Button type="primary" size="small">
Upgrade subscription
</Button>
</a>
),
});
}
};

focusAndHighlightInput = () => {
if (!this.input) {
return;
Expand All @@ -129,6 +176,10 @@ class AccountOverview extends React.Component<Props, State> {
this.setState({companyName: e.target.value});
};

handleChangeInviteUserEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({inviteUserEmail: e.target.value});
};

handleStartEditing = () => {
this.setState({isEditing: true});
};
Expand Down Expand Up @@ -216,15 +267,21 @@ class AccountOverview extends React.Component<Props, State> {
.then(() => this.setState({isRefreshing: false}));
};

handleClickOnInviteMoreLink = () => {
this.setState({showInviteMoreInput: true});
};

render() {
const {
account,
currentUser,
companyName,
inviteUrl,
inviteUserEmail,
isLoading,
isEditing,
isRefreshing,
showInviteMoreInput,
} = this.state;

if (isLoading) {
Expand Down Expand Up @@ -356,6 +413,35 @@ class AccountOverview extends React.Component<Props, State> {
isAdmin={isAdmin}
onDisableUser={this.handleDisableUser}
/>
{isAdmin && (
<Box mt={2}>
{showInviteMoreInput ? (
<form onSubmit={this.handleSendInviteEmail}>
<Flex sx={{maxWidth: 480}}>
<Box mr={1} sx={{flex: 1}}>
<Input
onChange={this.handleChangeInviteUserEmail}
placeholder="Email address"
required
type="email"
value={inviteUserEmail}
/>
</Box>
<Button type="primary" htmlType="submit">
Send invite
</Button>
</Flex>
</form>
) : (
<Button
type="primary"
onClick={this.handleClickOnInviteMoreLink}
>
Invite teammate
</Button>
)}
</Box>
)}
</Box>

{isAdmin && (
Expand Down
12 changes: 12 additions & 0 deletions lib/chat_api/emails.ex
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ defmodule ChatApi.Emails do
|> deliver()
end

@spec send_user_invitation_email(User.t(), Account.t(), binary(), binary()) :: deliver_result()
def send_user_invitation_email(user, account, to_address, invitation_token) do
Email.user_invitation(%{
company: account.company_name,
from_address: user.email,
from_name: format_sender_name(user, account),
invitation_token: invitation_token,
to_address: to_address
})
|> deliver()
end

@spec send_via_gmail(binary(), map()) :: deliver_result()
def send_via_gmail(
access_token,
Expand Down
83 changes: 83 additions & 0 deletions lib/chat_api/emails/email.ex
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,89 @@ defmodule ChatApi.Emails.Email do
"""
end

def user_invitation(
%{
company: company,
from_address: from_address,
from_name: from_name,
invitation_token: invitation_token,
to_address: to_address
} = _params
) do
subject =
if from_name == company,
do: "You've been invited to join #{company} on Papercups!",
else: "#{from_name} has invited you to join #{company} on Papercups!"

intro_line =
if from_name == company,
do: "#{from_address} has invited you to join #{company} on Papercups!",
else: "#{from_name} (#{from_address}) has invited you to join #{company} on Papercups!"

invitation_url = "#{get_app_domain()}/registration/#{invitation_token}"

new()
|> to(to_address)
|> from({"Alex", @from_address})
|> reply_to("[email protected]")
|> subject(subject)
|> html_body(
user_invitation_email_html(%{
intro_line: intro_line,
invitation_url: invitation_url
})
)
|> text_body(
user_invitation_email_text(%{
intro_line: intro_line,
invitation_url: invitation_url
})
)
end

defp user_invitation_email_text(
%{
invitation_url: invitation_url,
intro_line: intro_line
} = _params
) do
"""
Hi there!
#{intro_line}
Click the link below to sign up:
#{invitation_url}
Best,
Alex & Kam @ Papercups
"""
end

# TODO: figure out a better way to create templates for these
defp user_invitation_email_html(
%{
invitation_url: invitation_url,
intro_line: intro_line
} = _params
) do
"""
<p>Hi there!</p>
<p>#{intro_line}</p>
<p>Click the link below to sign up:</p>
<a href="#{invitation_url}">#{invitation_url}</a>
<p>
Best,<br />
Alex & Kam @ Papercups
</p>
"""
end

def password_reset(%ChatApi.Users.User{email: email, password_reset_token: token} = _user) do
new()
|> to(email)
Expand Down
54 changes: 54 additions & 0 deletions lib/chat_api_web/controllers/user_invitation_email_controller.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
defmodule ChatApiWeb.UserInvitationEmailController do
use ChatApiWeb, :controller

alias ChatApi.{Accounts, UserInvitations}
alias ChatApi.UserInvitations.UserInvitation

plug ChatApiWeb.EnsureRolePlug, :admin when action in [:create]

action_fallback ChatApiWeb.FallbackController

@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create(conn, %{"to_address" => to_address}) do
current_user = Pow.Plug.current_user(conn)

# TODO: consolidate logic related to checking user capacity in controllers.
if Accounts.has_reached_user_capacity?(current_user.account_id) do
conn
|> put_status(403)
|> json(%{
error: %{
status: 403,
message:
"You've hit the user limit for our free tier. " <>
"Try the premium plan free for 14 days to invite more users to your account!"
}
})
else
{:ok, %UserInvitation{} = user_invitation} =
UserInvitations.create_user_invitation(%{account_id: current_user.account_id})

enqueue_user_invitation_email(
current_user.id,
current_user.account_id,
to_address,
user_invitation.id
)

conn
|> put_status(:created)
|> json(%{})
end
end

def enqueue_user_invitation_email(user_id, account_id, to_address, invitation_token) do
%{
user_id: user_id,
account_id: account_id,
to_address: to_address,
invitation_token: invitation_token
}
|> ChatApi.Workers.SendUserInvitationEmail.new()
|> Oban.insert()
end
end
1 change: 1 addition & 0 deletions lib/chat_api_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ defmodule ChatApiWeb.Router do
get("/browser_sessions/count", BrowserSessionController, :count)

resources("/user_invitations", UserInvitationController, except: [:new, :edit])
resources("/user_invitation_emails", UserInvitationEmailController, only: [:create])
resources("/accounts", AccountController, only: [:update, :delete])
resources("/messages", MessageController, except: [:new, :edit])
resources("/conversations", ConversationController, except: [:new, :edit, :create])
Expand Down
58 changes: 58 additions & 0 deletions lib/workers/send_user_invitation_email.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
defmodule ChatApi.Workers.SendUserInvitationEmail do
@moduledoc false

use Oban.Worker, queue: :mailers

alias ChatApi.{Accounts, Users}
alias ChatApi.Repo

require Logger

@impl Oban.Worker
@spec perform(Oban.Job.t()) :: :ok
def perform(%Oban.Job{
args: %{
"user_id" => user_id,
"account_id" => account_id,
"to_address" => to_address,
"invitation_token" => invitation_token
}
}) do
if send_user_invitation_email_enabled?() do
user = Users.find_by_id!(user_id) |> Repo.preload([:profile])
account = Accounts.get_account!(account_id)
Logger.info("Sending user invitation email to #{to_address}")

deliver_result =
ChatApi.Emails.send_user_invitation_email(
user,
account,
to_address,
invitation_token
)

case deliver_result do
{:ok, result} ->
Logger.info("Successfully sent user invitation email: #{result}")

{:warning, reason} ->
Logger.warn("Warning when sending user invitation email: #{inspect(reason)}")

{:error, reason} ->
Logger.error("Error when sending user invitation email: #{inspect(reason)}")
end
else
Logger.info("Skipping user invitation email to #{to_address}")
end

:ok
end

@spec send_user_invitation_email_enabled? :: boolean()
def send_user_invitation_email_enabled?() do
case System.get_env("USER_INVITATION_EMAIL_ENABLED") do
x when x == "1" or x == "true" -> true
_ -> false
end
end
end

0 comments on commit f5f036a

Please sign in to comment.