diff --git a/lib/towerops/accounts.ex b/lib/towerops/accounts.ex
index 70c3e2aa..668205f4 100644
--- a/lib/towerops/accounts.ex
+++ b/lib/towerops/accounts.ex
@@ -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
diff --git a/lib/towerops/accounts/user_notifier.ex b/lib/towerops/accounts/user_notifier.ex
index af5098f1..3ae653a8 100644
--- a/lib/towerops/accounts/user_notifier.ex
+++ b/lib/towerops/accounts/user_notifier.ex
@@ -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.
"""
diff --git a/lib/towerops/accounts/user_token.ex b/lib/towerops/accounts/user_token.ex
index 3e4286ef..67b3172f 100644
--- a/lib/towerops/accounts/user_token.ex
+++ b/lib/towerops/accounts/user_token.ex
@@ -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
diff --git a/lib/towerops/organizations.ex b/lib/towerops/organizations.ex
index af68e21c..6dd67176 100644
--- a/lib/towerops/organizations.ex
+++ b/lib/towerops/organizations.ex
@@ -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.
diff --git a/lib/towerops_web/controllers/invitation_controller.ex b/lib/towerops_web/controllers/invitation_controller.ex
new file mode 100644
index 00000000..26969fb1
--- /dev/null
+++ b/lib/towerops_web/controllers/invitation_controller.ex
@@ -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
diff --git a/lib/towerops_web/controllers/user_confirmation_controller.ex b/lib/towerops_web/controllers/user_confirmation_controller.ex
new file mode 100644
index 00000000..afb622a9
--- /dev/null
+++ b/lib/towerops_web/controllers/user_confirmation_controller.ex
@@ -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
diff --git a/lib/towerops_web/controllers/user_confirmation_html.ex b/lib/towerops_web/controllers/user_confirmation_html.ex
new file mode 100644
index 00000000..94ffcf1e
--- /dev/null
+++ b/lib/towerops_web/controllers/user_confirmation_html.ex
@@ -0,0 +1,5 @@
+defmodule ToweropsWeb.UserConfirmationHTML do
+ use ToweropsWeb, :html
+
+ embed_templates "user_confirmation_html/*"
+end
diff --git a/lib/towerops_web/controllers/user_confirmation_html/new.html.heex b/lib/towerops_web/controllers/user_confirmation_html/new.html.heex
new file mode 100644
index 00000000..8319dff6
--- /dev/null
+++ b/lib/towerops_web/controllers/user_confirmation_html/new.html.heex
@@ -0,0 +1,15 @@
+
+ <.link href={~p"/users/log-in"}>Log in |
+ <.link href={~p"/users/register"}>Register
+
+ <.link + href={~p"/users/confirm"} + class="text-blue-600 hover:text-blue-700 dark:text-blue-400" + > + Didn't receive confirmation instructions? + +
diff --git a/lib/towerops_web/live/org/settings_live.ex b/lib/towerops_web/live/org/settings_live.ex index 3e117a1f..3576b410 100644 --- a/lib/towerops_web/live/org/settings_live.ex +++ b/lib/towerops_web/live/org/settings_live.ex @@ -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"} diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex index fb104c24..5d61d02b 100644 --- a/lib/towerops_web/live/org/settings_live.html.heex +++ b/lib/towerops_web/live/org/settings_live.html.heex @@ -44,6 +44,11 @@ Agents ++ Send an invitation email to add someone to this organization. +
++ Invitations that haven't been accepted yet. +
+| Role | +Sent | +Expires | + <%= if @membership.role in [:owner, :admin] do %> +Actions | + <% end %> +|
|---|---|---|---|---|
| {invitation.email} | ++ + {invitation.role} + + | ++ <.timestamp datetime={invitation.inserted_at} timezone={@timezone} format="absolute" /> + | ++ <.timestamp datetime={invitation.expires_at} timezone={@timezone} format="absolute" /> + | + <%= if @membership.role in [:owner, :admin] do %> ++ + | + <% end %> +
+ People who have access to this organization. +
+| User | +Role | +Joined | + <%= if @membership.role in [:owner, :admin] do %> +Actions | + <% end %> +
|---|---|---|---|
|
+
+
+ {member.user.email}
+ |
+ + "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} + + | ++ <.timestamp datetime={member.inserted_at} timezone={@timezone} format="absolute" /> + | + <%= if @membership.role in [:owner, :admin] do %> +
+ <%= if member.role != :owner do %>
+
+
+
+
+ <% end %>
+ |
+ <% end %>
+