feat: require email verification before first login

- Remove auto-confirmation on registration (register_user and register_user_with_organization)
- Add deliver_user_confirmation_instructions/2 and confirm_user/1 to Accounts context
- Add verify_email_token_query/2 and by_user_and_contexts_query/2 to UserToken
- Make deliver_confirmation_email public in UserNotifier
- Send confirmation email after registration in both flows
- Block unconfirmed users at password login with helpful message
- Add UserConfirmationController with confirm/resend routes
- Add resend confirmation link to login page
- Update test fixtures to confirm users after registration
This commit is contained in:
Graham McIntire 2026-02-14 10:55:58 -06:00
parent b897330a7e
commit f326eb5dd4
16 changed files with 730 additions and 18 deletions

View file

@ -125,7 +125,6 @@ defmodule Towerops.Accounts do
user_changeset =
%User{}
|> User.registration_changeset(attrs)
|> Ecto.Changeset.put_change(:confirmed_at, DateTime.utc_now(:second))
multi = Ecto.Multi.new()
multi = Ecto.Multi.insert(multi, :user, user_changeset)
@ -158,7 +157,6 @@ defmodule Towerops.Accounts do
user_changeset =
%User{}
|> User.registration_changeset(attrs)
|> Ecto.Changeset.put_change(:confirmed_at, DateTime.utc_now(:second))
multi = Ecto.Multi.new()
multi = Ecto.Multi.insert(multi, :user, user_changeset)
@ -1599,4 +1597,34 @@ defmodule Towerops.Accounts do
count
end
@doc """
Delivers confirmation instructions to the given user.
"""
def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun) do
{encoded_token, user_token} = UserToken.build_email_token(user, "confirm")
Repo.insert!(user_token)
UserNotifier.deliver_confirmation_email(user, confirmation_url_fun.(encoded_token))
end
@doc """
Confirms a user by the given token.
"""
def confirm_user(token) do
with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"),
%User{} = user <- Repo.one(query),
{:ok, %{user: user}} <-
Repo.transaction(
Ecto.Multi.new()
|> Ecto.Multi.update(:user, User.confirm_changeset(user))
|> Ecto.Multi.delete_all(
:tokens,
UserToken.by_user_and_contexts_query(user, ["confirm"])
)
) do
{:ok, user}
else
_ -> :error
end
end
end

View file

@ -66,7 +66,7 @@ defmodule Towerops.Accounts.UserNotifier do
"""
def deliver_login_instructions(user, url) do
case user do
%User{confirmed_at: nil} -> deliver_confirmation_instructions(user, url)
%User{confirmed_at: nil} -> deliver_confirmation_email(user, url)
_ -> deliver_magic_link_instructions(user, url)
end
end
@ -92,7 +92,10 @@ defmodule Towerops.Accounts.UserNotifier do
deliver(user.email, subject, body)
end
defp deliver_confirmation_instructions(user, url) do
@doc """
Deliver email confirmation instructions.
"""
def deliver_confirmation_email(user, url) do
subject = t_email("Confirmation instructions")
body =
@ -113,6 +116,29 @@ defmodule Towerops.Accounts.UserNotifier do
deliver(user.email, subject, body)
end
@doc """
Deliver an invitation email to join an organization.
"""
def deliver_invitation_email(invitation, accept_url) do
subject = "You've been invited to join #{invitation.organization.name} on Towerops"
body = """
Hi,
You've been invited to join #{invitation.organization.name} on Towerops as a #{invitation.role}.
You can accept this invitation by visiting the URL below:
#{accept_url}
This invitation expires in 7 days.
If you didn't expect this invitation, please ignore this email.
"""
deliver(invitation.email, subject, body)
end
@doc """
Deliver instructions to reset a user password.
"""

View file

@ -189,6 +189,43 @@ defmodule Towerops.Accounts.UserToken do
end
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user found by the token, if any.
This is used to validate email confirmation tokens.
The given token is valid if it matches its hashed counterpart in the
database and if it has not expired (within 3 days).
"""
@confirm_validity_in_days 3
def verify_email_token_query(token, context) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, context),
join: user in assoc(token, :user),
where: token.inserted_at > ago(@confirm_validity_in_days, "day"),
where: token.sent_to == user.email,
select: user
{:ok, query}
:error ->
:error
end
end
@doc """
Returns the token struct for the given user and contexts.
"""
def by_user_and_contexts_query(user, contexts) do
from t in UserToken,
where: t.user_id == ^user.id and t.context in ^contexts
end
defp by_token_and_context_query(token, context) do
from UserToken, where: [token: ^token, context: ^context]
end

View file

@ -213,6 +213,46 @@ defmodule Towerops.Organizations do
)
end
@doc """
Lists all members for an organization with preloaded users.
Ordered by role priority (owner first) then email.
"""
def list_organization_members(organization_id) do
Repo.all(
from(m in Membership,
where: m.organization_id == ^organization_id,
join: u in assoc(m, :user),
preload: [user: u],
order_by: [
fragment("CASE ? WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 WHEN 'member' THEN 2 WHEN 'viewer' THEN 3 END", m.role),
asc: u.email
]
)
)
end
@doc """
Removes a member from an organization. Owners cannot be removed.
"""
def remove_member(organization_id, user_id) do
case get_membership(organization_id, user_id) do
%Membership{role: :owner} -> {:error, :cannot_remove_owner}
%Membership{} = membership -> delete_membership(membership)
nil -> {:error, :not_found}
end
end
@doc """
Updates a member's role. Owners cannot have their role changed.
"""
def update_member_role(organization_id, user_id, new_role) do
case get_membership(organization_id, user_id) do
%Membership{role: :owner} -> {:error, :cannot_change_owner_role}
%Membership{} = membership -> update_membership(membership, %{role: new_role})
nil -> {:error, :not_found}
end
end
@doc """
Lists users who should receive alert notifications for an organization.
Returns owners and admins.

View file

@ -0,0 +1,33 @@
defmodule ToweropsWeb.InvitationController do
use ToweropsWeb, :controller
alias Towerops.Organizations
def show(conn, %{"token" => token}) do
case Organizations.get_invitation_by_token(token) do
nil ->
conn
|> put_flash(:error, "This invitation is invalid or has expired.")
|> redirect(to: ~p"/")
invitation ->
case conn.assigns[:current_scope] do
%{user: user} when not is_nil(user) ->
case Organizations.accept_invitation(invitation, user.id) do
{:ok, _membership} ->
conn
|> put_flash(:info, "You've joined #{invitation.organization.name}!")
|> redirect(to: ~p"/orgs/#{invitation.organization.slug}/settings?tab=members")
{:error, _changeset} ->
conn
|> put_flash(:error, "Could not accept invitation. You may already be a member.")
|> redirect(to: ~p"/dashboard")
end
_ ->
redirect(conn, to: ~p"/users/register?invitation_token=#{token}")
end
end
end
end

View file

@ -0,0 +1,42 @@
defmodule ToweropsWeb.UserConfirmationController do
use ToweropsWeb, :controller
alias Towerops.Accounts
def new(conn, _params) do
render(conn, :new)
end
def create(conn, %{"user" => %{"email" => email}}) do
if user = Accounts.get_user_by_email(email) do
if is_nil(user.confirmed_at) do
Accounts.deliver_user_confirmation_instructions(
user,
&url(~p"/users/confirm/#{&1}")
)
end
end
# Always show success to prevent email enumeration
conn
|> put_flash(
:info,
"If your email is in our system and has not yet been confirmed, you will receive an email with instructions shortly."
)
|> redirect(to: ~p"/users/log-in")
end
def confirm(conn, %{"token" => token}) do
case Accounts.confirm_user(token) do
{:ok, _user} ->
conn
|> put_flash(:info, "Email confirmed successfully. You may now log in.")
|> redirect(to: ~p"/users/log-in")
:error ->
conn
|> put_flash(:error, "Email confirmation link is invalid or has expired.")
|> redirect(to: ~p"/users/log-in")
end
end
end

View file

@ -0,0 +1,5 @@
defmodule ToweropsWeb.UserConfirmationHTML do
use ToweropsWeb, :html
embed_templates "user_confirmation_html/*"
end

View file

@ -0,0 +1,15 @@
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm">
<.header>Resend confirmation instructions</.header>
<.form for={%{}} as={:user} action={~p"/users/confirm"} class="mt-8">
<.input name="user[email]" type="email" label="Email" required />
<.button class="w-full mt-4">Resend confirmation instructions</.button>
</.form>
<p class="text-center text-sm mt-4">
<.link href={~p"/users/log-in"}>Log in</.link> |
<.link href={~p"/users/register"}>Register</.link>
</p>
</div>
</Layouts.app>

View file

@ -37,18 +37,27 @@ defmodule ToweropsWeb.UserSessionController do
# email + password login
def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do
if user = Accounts.get_user_by_email_and_password(email, password) do
# Check if user has TOTP enabled - require verification before creating session
if Accounts.totp_enabled?(user) do
# Store pending auth state and redirect to TOTP verification
remember_me = Map.get(user_params, "remember_me") == "true"
cond do
is_nil(user.confirmed_at) ->
conn
|> put_flash(
:error,
"Please verify your email address before logging in. Check your inbox for a confirmation link."
)
|> render(:new, form: Phoenix.Component.to_form(user_params, as: "user"))
conn
|> put_session(:pending_totp_user_id, user.id)
|> put_session(:pending_totp_remember_me, remember_me)
|> redirect(to: ~p"/users/log-in/totp")
else
# No TOTP, proceed with normal login
UserAuth.log_in_user(conn, user, user_params)
# Check if user has TOTP enabled - require verification before creating session
Accounts.totp_enabled?(user) ->
remember_me = Map.get(user_params, "remember_me") == "true"
conn
|> put_session(:pending_totp_user_id, user.id)
|> put_session(:pending_totp_remember_me, remember_me)
|> redirect(to: ~p"/users/log-in/totp")
true ->
# No TOTP, proceed with normal login
UserAuth.log_in_user(conn, user, user_params)
end
else
form = Phoenix.Component.to_form(user_params, as: "user")

View file

@ -105,6 +105,15 @@
{t_auth("Send me a login link instead")}
</.button>
</.form>
<p class="text-center text-sm mt-4 text-gray-600 dark:text-gray-400">
<.link
href={~p"/users/confirm"}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
Didn't receive confirmation instructions?
</.link>
</p>
</div>
</div>
</Layouts.app>

View file

@ -8,6 +8,7 @@ defmodule ToweropsWeb.Org.SettingsLive do
alias Towerops.Integrations
alias Towerops.Integrations.Integration
alias Towerops.Organizations
alias Towerops.Accounts.UserNotifier
alias Towerops.Preseem.Client, as: PreseemClient
require Logger
@ -50,6 +51,10 @@ defmodule ToweropsWeb.Org.SettingsLive do
|> assign(:assignment_breakdown, assignment_breakdown)
|> assign(:form, to_form(changeset))
|> assign(:active_tab, "general")
# Members tab assigns - loaded lazily
|> assign(:members, [])
|> assign(:pending_invitations, [])
|> assign(:invite_form, to_form(%{"email" => "", "role" => "member"}))
# Integration assigns - loaded lazily when tab is selected
|> assign(:providers, @providers)
|> assign(:integrations, %{})
@ -71,6 +76,14 @@ defmodule ToweropsWeb.Org.SettingsLive do
{:noreply, socket}
end
defp maybe_load_integrations(socket, "members") do
org_id = socket.assigns.organization.id
socket
|> assign(:members, Organizations.list_organization_members(org_id))
|> assign(:pending_invitations, Organizations.list_pending_invitations(org_id))
end
defp maybe_load_integrations(socket, "integrations") do
integrations = load_integrations(socket.assigns.organization.id)
assign(socket, :integrations, integrations)
@ -121,6 +134,94 @@ defmodule ToweropsWeb.Org.SettingsLive do
{:noreply, put_flash(socket, :info, "Applied default agent to #{count} device records across all sites")}
end
# === Members tab events ===
@impl true
def handle_event("send_invitation", %{"email" => email, "role" => role}, socket) do
organization = socket.assigns.organization
user = socket.assigns.current_scope.user
attrs = %{
email: String.trim(email),
role: role,
organization_id: organization.id,
invited_by_id: user.id
}
case Organizations.create_invitation(attrs) do
{:ok, invitation} ->
invitation = %{invitation | organization: organization}
accept_url = url(~p"/invitations/#{invitation.token}")
UserNotifier.deliver_invitation_email(invitation, accept_url)
{:noreply,
socket
|> put_flash(:info, "Invitation sent to #{email}")
|> assign(:pending_invitations, Organizations.list_pending_invitations(organization.id))
|> assign(:invite_form, to_form(%{"email" => "", "role" => "member"}))}
{:error, changeset} ->
{:noreply,
socket
|> put_flash(:error, "Failed to send invitation: #{error_messages(changeset)}")}
end
end
@impl true
def handle_event("cancel_invitation", %{"id" => id}, socket) do
organization = socket.assigns.organization
invitation = Towerops.Repo.get!(Towerops.Organizations.Invitation, id)
if invitation.organization_id == organization.id do
Organizations.delete_invitation(invitation)
{:noreply,
socket
|> put_flash(:info, "Invitation cancelled")
|> assign(:pending_invitations, Organizations.list_pending_invitations(organization.id))}
else
{:noreply, put_flash(socket, :error, "Invitation not found")}
end
end
@impl true
def handle_event("remove_member", %{"user-id" => user_id}, socket) do
organization = socket.assigns.organization
case Organizations.remove_member(organization.id, user_id) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Member removed")
|> assign(:members, Organizations.list_organization_members(organization.id))}
{:error, :cannot_remove_owner} ->
{:noreply, put_flash(socket, :error, "Cannot remove the organization owner")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to remove member")}
end
end
@impl true
def handle_event("change_role", %{"user-id" => user_id, "role" => role}, socket) do
organization = socket.assigns.organization
case Organizations.update_member_role(organization.id, user_id, role) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Role updated")
|> assign(:members, Organizations.list_organization_members(organization.id))}
{:error, :cannot_change_owner_role} ->
{:noreply, put_flash(socket, :error, "Cannot change the owner's role")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to update role")}
end
end
@impl true
def handle_event("toggle_default_org", _params, socket) do
user = socket.assigns.current_scope.user
@ -367,6 +468,15 @@ defmodule ToweropsWeb.Org.SettingsLive do
end
end
defp error_messages(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
|> Enum.map_join(", ", fn {field, msgs} -> "#{field} #{Enum.join(msgs, ", ")}" end)
end
defp format_connection_result({:ok, _body}), do: {:ok, "Connection successful"}
defp format_connection_result({:error, :unauthorized}), do: {:error, "Invalid API key"}
defp format_connection_result({:error, :forbidden}), do: {:error, "Access forbidden"}

View file

@ -44,6 +44,11 @@
Agents
</.link>
</li>
<li>
<.link patch={~p"/orgs/#{@organization.slug}/settings?tab=members"} class={if @active_tab == "members", do: "text-indigo-600 dark:text-indigo-400", else: ""}>
Members
</.link>
</li>
<li>
<.link patch={~p"/orgs/#{@organization.slug}/settings?tab=integrations"} class={if @active_tab == "integrations", do: "text-indigo-600 dark:text-indigo-400", else: ""}>
Integrations
@ -53,7 +58,7 @@
</nav>
</header>
<%= if @active_tab != "integrations" do %>
<%= if @active_tab not in ["integrations", "members"] do %>
<.form
for={@form}
id="organization-form"
@ -525,6 +530,199 @@
</.form>
<% end %>
<%= if @active_tab == "members" do %>
<div class="divide-y divide-gray-200 dark:divide-white/10">
<!-- Invite form (owner/admin only) -->
<%= if @membership.role in [:owner, :admin] do %>
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Invite a Team Member
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Send an invitation email to add someone to this organization.
</p>
</div>
<div class="md:col-span-2">
<.form for={@invite_form} id="invite-form" phx-submit="send_invitation">
<div class="flex flex-col gap-4 sm:flex-row sm:items-end">
<div class="flex-1">
<.input
field={@invite_form[:email]}
type="email"
label="Email address"
placeholder="colleague@example.com"
required
/>
</div>
<div class="w-full sm:w-40">
<.input
field={@invite_form[:role]}
type="select"
label="Role"
options={[{"Admin", "admin"}, {"Member", "member"}, {"Viewer", "viewer"}]}
/>
</div>
<div>
<button
type="submit"
phx-disable-with="Sending..."
class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400"
>
Send Invite
</button>
</div>
</div>
</.form>
</div>
</div>
<% end %>
<!-- Pending invitations -->
<%= if @pending_invitations != [] do %>
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Pending Invitations
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Invitations that haven't been accepted yet.
</p>
</div>
<div class="md:col-span-2">
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-white/5">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Email</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Role</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Sent</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Expires</th>
<%= if @membership.role in [:owner, :admin] do %>
<th class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Actions</th>
<% end %>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-white/10 dark:bg-transparent">
<tr :for={invitation <- @pending_invitations}>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-white">{invitation.email}</td>
<td class="whitespace-nowrap px-4 py-3 text-sm">
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
{invitation.role}
</span>
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
<.timestamp datetime={invitation.inserted_at} timezone={@timezone} format="absolute" />
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
<.timestamp datetime={invitation.expires_at} timezone={@timezone} format="absolute" />
</td>
<%= if @membership.role in [:owner, :admin] do %>
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
<button
type="button"
phx-click="cancel_invitation"
phx-value-id={invitation.id}
data-confirm="Cancel this invitation?"
class="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
Cancel
</button>
</td>
<% end %>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<% end %>
<!-- Current members -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Members
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
People who have access to this organization.
</p>
</div>
<div class="md:col-span-2">
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-white/5">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">User</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Role</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Joined</th>
<%= if @membership.role in [:owner, :admin] do %>
<th class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Actions</th>
<% end %>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-white/10 dark:bg-transparent">
<tr :for={member <- @members}>
<td class="whitespace-nowrap px-4 py-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-white">{member.user.email}</div>
</div>
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm">
<span class={[
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
case member.role do
:owner -> "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400"
:admin -> "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
:member -> "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
:viewer -> "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400"
end
]}>
{member.role}
</span>
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
<.timestamp datetime={member.inserted_at} timezone={@timezone} format="absolute" />
</td>
<%= if @membership.role in [:owner, :admin] do %>
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
<%= if member.role != :owner do %>
<div class="flex items-center justify-end gap-3">
<form phx-change="change_role" phx-value-user-id={member.user_id}>
<select
name="role"
class="rounded-md border-gray-300 py-1 pl-2 pr-8 text-xs dark:border-white/10 dark:bg-white/5 dark:text-white"
>
<option value="admin" selected={member.role == :admin}>Admin</option>
<option value="member" selected={member.role == :member}>Member</option>
<option value="viewer" selected={member.role == :viewer}>Viewer</option>
</select>
</form>
<button
type="button"
phx-click="remove_member"
phx-value-user-id={member.user_id}
data-confirm="Remove this member from the organization?"
class="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
Remove
</button>
</div>
<% end %>
</td>
<% end %>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<% end %>
<%= if @active_tab == "integrations" do %>
<div class="mt-8 space-y-6">
<div

View file

@ -92,9 +92,14 @@ defmodule ToweropsWeb.UserRegistrationLive do
case Accounts.register_user_with_organization(user_params_with_timezone) do
{:ok, user} ->
Accounts.deliver_user_confirmation_instructions(
user,
&url(~p"/users/confirm/#{&1}")
)
{:noreply,
socket
|> put_flash(:info, "Account created successfully.")
|> put_flash(:info, "Account created! Please check your email to verify your account.")
|> redirect(to: ~p"/users/log-in?email=#{user.email}")
|> assign(:trigger_action, true)}
@ -110,9 +115,14 @@ defmodule ToweropsWeb.UserRegistrationLive do
with {:ok, invitation} <- get_valid_invitation(invitation_token),
{:ok, user} <- Accounts.register_user(user_params_with_timezone),
{:ok, _membership} <- Organizations.accept_invitation(invitation, user.id) do
Accounts.deliver_user_confirmation_instructions(
user,
&url(~p"/users/confirm/#{&1}")
)
{:noreply,
socket
|> put_flash(:info, "Account created successfully. Welcome to #{invitation.organization.name}!")
|> put_flash(:info, "Account created! Please check your email to verify your account.")
|> redirect(to: ~p"/users/log-in?email=#{user.email}")
|> assign(:trigger_action, true)}
else

View file

@ -76,6 +76,7 @@ defmodule ToweropsWeb.Router do
get "/privacy", PageController, :privacy
get "/terms", PageController, :terms
get "/docs/api", ApiDocsController, :index
get "/invitations/:token", InvitationController, :show
live "/help", HelpLive.Index, :index
end
@ -213,6 +214,10 @@ defmodule ToweropsWeb.Router do
post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete
get "/users/confirm/:token", UserConfirmationController, :confirm
get "/users/confirm", UserConfirmationController, :new
post "/users/confirm", UserConfirmationController, :create
get "/users/reset-password", UserResetPasswordController, :new
post "/users/reset-password", UserResetPasswordController, :create
live "/users/reset-password/:token", UserResetPasswordLive, :edit

View file

@ -43,6 +43,12 @@ defmodule Towerops.AccountsFixtures do
|> valid_user_attributes()
|> Accounts.register_user()
# Confirm user so tests pass by default (registration no longer auto-confirms)
user =
user
|> Towerops.Accounts.User.confirm_changeset()
|> Towerops.Repo.update!()
# Enable TOTP by default for all test users
# Tests can explicitly pass enable_totp: false to skip this
if enable_totp do

View file

@ -0,0 +1,139 @@
defmodule ToweropsWeb.Org.SettingsLiveMembersTest do
use ToweropsWeb.ConnCase
use ExUnitProperties
import Phoenix.LiveViewTest
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Organizations
setup do
owner = user_fixture()
organization = organization_fixture(owner.id)
%{owner: owner, organization: organization}
end
describe "Members tab" do
test "renders member list", %{conn: conn, owner: owner, organization: org} do
{:ok, _view, html} =
conn
|> log_in_user(owner)
|> live(~p"/orgs/#{org.slug}/settings?tab=members")
assert html =~ "Members"
assert html =~ owner.email
assert html =~ "owner"
end
test "owner can see invite form", %{conn: conn, owner: owner, organization: org} do
{:ok, _view, html} =
conn
|> log_in_user(owner)
|> live(~p"/orgs/#{org.slug}/settings?tab=members")
assert html =~ "Invite a Team Member"
assert html =~ "Send Invite"
end
test "non-admin member cannot see invite form", %{conn: conn, organization: org} do
member_user = user_fixture()
membership_fixture(org.id, member_user.id, :viewer)
{:ok, _view, html} =
conn
|> log_in_user(member_user)
|> live(~p"/orgs/#{org.slug}/settings?tab=members")
refute html =~ "Invite a Team Member"
refute html =~ "Send Invite"
end
test "owner can send invitation", %{conn: conn, owner: owner, organization: org} do
{:ok, view, _html} =
conn
|> log_in_user(owner)
|> live(~p"/orgs/#{org.slug}/settings?tab=members")
html =
view
|> form("#invite-form", %{"email" => "newuser@example.com", "role" => "member"})
|> render_submit()
assert html =~ "Invitation sent to newuser@example.com"
end
test "pending invitations appear after sending", %{conn: conn, owner: owner, organization: org} do
{:ok, view, _html} =
conn
|> log_in_user(owner)
|> live(~p"/orgs/#{org.slug}/settings?tab=members")
view
|> form("#invite-form", %{"email" => "pending@example.com", "role" => "admin"})
|> render_submit()
html = render(view)
assert html =~ "Pending Invitations"
assert html =~ "pending@example.com"
assert html =~ "admin"
end
test "owner can cancel invitation", %{conn: conn, owner: owner, organization: org} do
# Create an invitation first
{:ok, invitation} =
Organizations.create_invitation(%{
email: "cancel-me@example.com",
role: :member,
organization_id: org.id,
invited_by_id: owner.id
})
{:ok, view, html} =
conn
|> log_in_user(owner)
|> live(~p"/orgs/#{org.slug}/settings?tab=members")
assert html =~ "cancel-me@example.com"
view
|> element("button[phx-click=cancel_invitation][phx-value-id=#{invitation.id}]")
|> render_click()
html = render(view)
assert html =~ "Invitation cancelled"
refute html =~ "cancel-me@example.com"
end
test "invitation email is sent via Swoosh", %{conn: conn, owner: owner, organization: org} do
{:ok, view, _html} =
conn
|> log_in_user(owner)
|> live(~p"/orgs/#{org.slug}/settings?tab=members")
view
|> form("#invite-form", %{"email" => "swoosh-test@example.com", "role" => "member"})
|> render_submit()
# Verify the invitation was created
invitations = Organizations.list_pending_invitations(org.id)
assert length(invitations) == 1
assert hd(invitations).email == "swoosh-test@example.com"
end
property "random email strings through invite form don't crash", %{conn: conn, owner: owner, organization: org} do
{:ok, view, _html} =
conn
|> log_in_user(owner)
|> live(~p"/orgs/#{org.slug}/settings?tab=members")
check all email <- string(:alphanumeric, min_length: 1, max_length: 50) do
# Should not crash, may flash error for invalid emails
view
|> form("#invite-form", %{"email" => email, "role" => "member"})
|> render_submit()
end
end
end
end