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:
parent
b897330a7e
commit
f326eb5dd4
16 changed files with 730 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
33
lib/towerops_web/controllers/invitation_controller.ex
Normal file
33
lib/towerops_web/controllers/invitation_controller.ex
Normal 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
|
||||
42
lib/towerops_web/controllers/user_confirmation_controller.ex
Normal file
42
lib/towerops_web/controllers/user_confirmation_controller.ex
Normal 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
|
||||
5
lib/towerops_web/controllers/user_confirmation_html.ex
Normal file
5
lib/towerops_web/controllers/user_confirmation_html.ex
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
defmodule ToweropsWeb.UserConfirmationHTML do
|
||||
use ToweropsWeb, :html
|
||||
|
||||
embed_templates "user_confirmation_html/*"
|
||||
end
|
||||
|
|
@ -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>
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
139
test/towerops_web/live/org/settings_live_members_test.exs
Normal file
139
test/towerops_web/live/org/settings_live_members_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue