Add ability to email new user after creation (#2957)

Why:

* When a new user and/or identity is created using the Email provider,
there is currently no way to notify the new user/identity automatically.
With this commit an email will now be sent to the newly added
user/identity upon successful creation. This will only be done for
identities created with the 'Email' provider.


<img width="621" alt="new_user_email"
src="https://github.com/firezone/firezone/assets/2646332/2e50baf0-34cf-4615-b7f9-30500aa58920">

---------

Signed-off-by: Brian Manifold <bmanifold@users.noreply.github.com>
Co-authored-by: Andrew Dryga <andrew@dryga.com>
This commit is contained in:
Brian Manifold
2023-12-21 13:36:08 -05:00
committed by GitHub
parent 34ab093dbc
commit 479e2c9036
10 changed files with 403 additions and 4 deletions

View File

@@ -345,7 +345,8 @@ defmodule Web.CoreComponents do
class={[
"p-4 text-sm flash-#{@kind}",
@kind == :success && "text-green-800 bg-green-50",
@kind == :info && "text-yellow-800 bg-yellow-50",
@kind == :info && "text-blue-800 bg-blue-50",
@kind == :warning && "text-yellow-800 bg-yellow-50",
@kind == :error && "text-red-800 bg-red-50",
@style != "wide" && "mb-4 rounded"
]}
@@ -937,7 +938,15 @@ defmodule Web.CoreComponents do
end
def get_identity_email(identity) do
get_in(identity.provider_state, ["userinfo", "email"]) || identity.provider_identifier
provider_email(identity) || identity.provider_identifier
end
def identity_has_email?(identity) do
not is_nil(provider_email(identity)) || identity.provider.adapter == :email
end
defp provider_email(identity) do
get_in(identity.provider_state, ["userinfo", "email"])
end
attr :account, :any, required: true

View File

@@ -32,7 +32,7 @@ defmodule Web.PageComponents do
</p>
<section :for={content <- @content} class="section-body">
<div :if={Map.get(content, :flash)}>
<div :if={Map.get(content, :flash)} class="mb-4">
<.flash kind={:info} flash={Map.get(content, :flash)} style="wide" />
<.flash kind={:error} flash={Map.get(content, :flash)} style="wide" />
</div>

View File

@@ -123,6 +123,18 @@ defmodule Web.Actors.Show do
<:col :let={identity} label="LAST SIGNED IN" sortable="false">
<.relative_datetime datetime={identity.last_seen_at} />
</:col>
<:action :let={identity}>
<button
:if={identity_has_email?(identity)}
phx-click="send_welcome_email"
phx-value-id={identity.id}
class={[
"block w-full py-2 px-4 hover:bg-neutral-100"
]}
>
Send Welcome Email
</button>
</:action>
<:action :let={identity}>
<button
:if={identity.created_by != :provider}
@@ -353,6 +365,24 @@ defmodule Web.Actors.Show do
{:noreply, socket}
end
def handle_event("send_welcome_email", %{"id" => id}, socket) do
{:ok, identity} = Auth.fetch_identity_by_id(id, socket.assigns.subject)
{:ok, _} =
Web.Mailer.AuthEmail.new_user_email(
socket.assigns.account,
identity,
socket.assigns.subject
)
|> Web.Mailer.deliver()
socket =
socket
|> put_flash(:info, "Welcome email sent to #{identity.provider_identifier}")
{:noreply, socket}
end
defp last_seen_at(identities) do
identities
|> Enum.reject(&is_nil(&1.last_seen_at))

View File

@@ -95,13 +95,22 @@ defmodule Web.Actors.Users.NewIdentity do
end
def handle_event("submit", %{"identity" => attrs}, socket) do
with {:ok, _identity} <-
with {:ok, identity} <-
Auth.create_identity(
socket.assigns.actor,
socket.assigns.provider,
attrs,
socket.assigns.subject
) do
if socket.assigns.provider.adapter == :email do
Web.Mailer.AuthEmail.new_user_email(
socket.assigns.account,
identity,
socket.assigns.subject
)
|> Web.Mailer.deliver()
end
socket =
push_navigate(socket, to: ~p"/#{socket.assigns.account}/actors/#{socket.assigns.actor}")

View File

@@ -59,4 +59,19 @@ defmodule Web.Mailer.AuthEmail do
remote_ip: "#{:inet.ntoa(remote_ip)}"
)
end
def new_user_email(
%Domain.Accounts.Account{} = account,
%Domain.Auth.Identity{} = identity,
%Domain.Auth.Subject{} = subject
) do
default_email()
|> subject("Welcome to Firezone")
|> to(get_identity_email(identity))
|> render_body(__MODULE__, :new_user,
account: account,
identity: identity,
subject: subject
)
end
end

View File

@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
<head>
<meta charset="utf-8" />
<meta name="x-apple-disable-message-reformatting" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no" />
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: "Segoe UI", sans-serif; mso-line-height-rule: exactly;}
</style>
<![endif]-->
<title>Welcome to Firezone</title>
<style>
.hover-important-text-decoration-underline:hover {
text-decoration: underline !important
}
@media (max-width: 600px) {
.sm-my-8 {
margin-top: 32px !important;
margin-bottom: 32px !important
}
.sm-px-4 {
padding-left: 16px !important;
padding-right: 16px !important
}
.sm-px-6 {
padding-left: 24px !important;
padding-right: 24px !important
}
.sm-leading-8 {
line-height: 32px !important
}
}
</style>
</head>
<body style="margin: 0; width: 100%; background-color: #f6f6f6; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word">
<div role="article" aria-roledescription="email" aria-label="Welcome to Firezone" lang="en">
<div
class="sm-px-4"
style="background-color: #f6f6f6; font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif"
>
<table align="center" cellpadding="0" cellspacing="0" role="none">
<tr>
<td style="width: 552px; max-width: 100%">
<div
class="sm-my-8"
style="margin-top: 48px; margin-bottom: 48px; text-align: center"
>
<a href="https://firezone.dev">
<div>
<img
src="https://www.firezone.dev/images/logo-lockup.png"
width="250"
alt="Firezone logo"
style="max-width: 100%; vertical-align: middle; line-height: 1"
/>
</div>
</a>
</div>
<table style="width: 100%;" cellpadding="0" cellspacing="0" role="none">
<tr>
<td
class="sm-px-6"
style="border-radius: 4px; background-color: #ffffff; padding: 48px; font-size: 16px; color: #4f4f4f; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05)"
>
<h1
class="sm-leading-8"
style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #000000"
>
Welcome to Firezone!
</h1>
<p style="margin: 0; line-height: 24px">
<%= @subject.actor.name %> invited you to the following Firezone Account:<br />
</p>
<p>
<b>"<%= @account.name %>"</b>
</p>
<div role="separator" style="line-height: 16px">&zwj;</div>
To start accessing your resources, simply install one of the Firezone clients:
<ul>
<li>
<a href="https://www.firezone.dev/kb/user-guides/apple-client">
Apple Client (macOS/iOS)
</a>
</li>
<li>
<a href="https://www.firezone.dev/kb/user-guides/windows-client">
Windows Client
</a>
</li>
<li>
<a href="https://www.firezone.dev/kb/user-guides/android-client">
Android / ChromeOS Client
</a>
</li>
<li>
<a href="https://www.firezone.dev/kb/user-guides/linux-client">
Linux Client
</a>
</li>
</ul>
After installing the client, click "Sign In" and when prompted for an account name use the following:
<p style="border-radius: 4px; border: 1px solid #e7e7e7; padding: 8px; text-align: center; font-size: 20px">
<code><%= @account.slug %></code>
</p>
<p></p>
<div
role="separator"
style="background-color: #d1d1d1; height: 1px; line-height: 1px; margin: 32px 0"
>
&zwj;
</div>
<p style="margin: 0;">
If you feel this message has been sent to you by mistake, you can safely ignore this email.
<br />
<br /> Thanks, <br />The Firezone Team
</p>
</td>
</tr>
<tr role="separator">
<td style="line-height: 48px">&zwj;</td>
</tr>
<tr>
<td style="padding-left: 24px; padding-right: 24px; text-align: center; font-size: 12px; color: #575757">
<p style="margin: 0; font-style: italic">
Blazing-fast alternative to legacy VPNs
</p>
<p style="cursor: default">
<a
href="https://www.firezone.dev/kb"
class="hover-important-text-decoration-underline"
style="color: #37007f; text-decoration: none"
>
Docs
</a>
&bull;
<a
href="https://github.com/firezone"
class="hover-important-text-decoration-underline"
style="color: #37007f; text-decoration: none;"
>
Github
</a>
&bull;
<a
href="https://x.com/firezonehq"
class="hover-important-text-decoration-underline"
style="color: #37007f; text-decoration: none;"
>
X
</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,22 @@
Welcome to Firezone!
<%= "\n" %><%= @subject.actor.name %> invited you to the following Firezone Account:
<%= @account.name %><%= "\n" %>
To get started accessing your resources, you will need to install one of the Firezone clients:
https://www.firezone.dev/kb/user-guides/apple-client
https://www.firezone.dev/kb/user-guides/windows-client
https://www.firezone.dev/kb/user-guides/android-client
https://www.firezone.dev/kb/user-guides/linux-client
After installing the client, click "Sign In" and when prompted for an account name use the following:
<%= @account.slug %><%= "\n" %>
If you feel this message has been sent to you by mistake, you can safely ignore this email.
Thanks,
The Firezone Team

View File

@@ -338,6 +338,90 @@ defmodule Web.Live.Actors.ShowTest do
)
end
test "allows sending welcome email", %{
account: account,
actor: actor,
identity: admin_identity,
conn: conn
} do
Domain.Config.put_env_override(:outbound_email_adapter_configured?, true)
email_provider = Fixtures.Auth.create_email_provider(account: account)
email_identity =
Fixtures.Auth.create_identity(account: account, actor: actor, provider: email_provider)
|> Ecto.Changeset.change(
created_by: :identity,
created_by_identity_id: admin_identity.id
)
|> Repo.update!()
{:ok, lv, _html} =
conn
|> authorize_conn(admin_identity)
|> live(~p"/#{account}/actors/#{actor}")
assert lv
|> element("#identity-#{email_identity.id} button", "Send Welcome Email")
|> render_click()
|> Floki.find(".flash-info")
|> element_to_text() =~ "Welcome email sent to #{email_identity.provider_identifier}"
assert_email_sent(fn email ->
assert email.subject == "Welcome to Firezone"
assert email.text_body =~ account.slug
end)
end
test "shows email button for identities with email", %{
account: account,
actor: actor,
identity: admin_identity,
conn: conn
} do
Domain.Config.put_env_override(:outbound_email_adapter_configured?, true)
email_provider = Fixtures.Auth.create_email_provider(account: account)
{google_provider, _bypass} =
Fixtures.Auth.start_and_create_google_workspace_provider(account: account)
google_identity =
Fixtures.Auth.create_identity(
account: account,
actor: actor,
provider: google_provider,
provider_state: %{
"userinfo" => %{"email" => Fixtures.Auth.email()}
}
)
oidc_identity = Fixtures.Auth.create_identity(account: account, actor: actor)
email_identity =
Fixtures.Auth.create_identity(account: account, actor: actor, provider: email_provider)
|> Ecto.Changeset.change(
created_by: :identity,
created_by_identity_id: admin_identity.id
)
|> Repo.update!()
{:ok, lv, _html} =
conn
|> authorize_conn(admin_identity)
|> live(~p"/#{account}/actors/#{actor}")
assert lv
|> element("#identity-#{email_identity.id} button", "Send Welcome Email")
|> has_element?()
assert lv
|> element("#identity-#{google_identity.id} button", "Send Welcome Email")
|> has_element?()
refute lv
|> element("#identity-#{oidc_identity.id} button", "Send Welcome Email")
|> has_element?()
end
test "allows deleting identities", %{
account: account,
actor: actor,

View File

@@ -186,5 +186,9 @@ defmodule Web.Live.Actors.User.NewIdentityTest do
Repo.get_by(Domain.Auth.Identity, provider_identifier: attrs.provider_identifier)
assert_redirect(lv, ~p"/#{account}/actors/#{identity.actor_id}")
assert_email_sent(fn email ->
assert email.text_body =~ account.slug
end)
end
end

View File

@@ -0,0 +1,54 @@
defmodule Web.Mailer.AuthEmailTest do
use Web.ConnCase, async: true
import Web.Mailer.AuthEmail
setup do
Domain.Config.put_env_override(:outbound_email_adapter_configured?, true)
account = Fixtures.Accounts.create_account()
provider = Fixtures.Auth.create_email_provider(account: account)
admin_actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account)
admin_identity =
Fixtures.Auth.create_identity(account: account, provider: provider, actor: admin_actor)
client_actor = Fixtures.Actors.create_actor(type: :account_user, account: account)
client_identity =
Fixtures.Auth.create_identity(account: account, provider: provider, actor: client_actor)
%{
account: account,
provider: provider,
admin_actor: admin_actor,
admin_identity: admin_identity,
client_actor: client_actor,
client_identity: client_identity
}
end
describe "new_user_email/3" do
test "should contain relevant account and user info", %{
account: account,
provider: provider,
admin_actor: admin_actor,
admin_identity: admin_identity,
client_identity: client_identity
} do
admin_subject =
Fixtures.Auth.create_subject(
account: account,
provider: provider,
identity: admin_identity,
actor: admin_actor
)
email_body = new_user_email(account, client_identity, admin_subject)
assert email_body.text_body =~ "Welcome to Firezone!"
assert email_body.text_body =~ "#{admin_actor.name} invited you"
assert email_body.text_body =~ account.name
assert email_body.text_body =~ account.slug
end
end
end