CRITICAL: - Membership: remove :role/:org_id/:user_id from mass-assignment cast; use explicit create_changeset/4 and role_update_changeset/2 - GraphQL member resolver: add authorize_invite/3 checking admin/owner role and role hierarchy - REST invitations controller: add auth check for invite creation HIGH: - ApiToken: remove :organization_id/:user_id from cast; use explicit create_changeset/4 MEDIUM: - Move 8 LiveView Ecto queries into context modules (Admin, Alerts, Coverages, OnCall, Snmp) - Replace Process.put/Process.get with socket assigns for unresolved_alert_count (user_auth + layouts + 50 templates) - Add batch get_utilization_for_interfaces/1 to eliminate N+1 capacity queries in device show - Replace Process.sleep with Process.monitor/assert_receive or Process.send_after in 5 test files LOW: - Add handle_params/3 to UserResetPasswordLive, UserRegistrationLive, StatusPageLive - Remove redundant Repo.preload calls; add preloads to list_site_devices/1 - Fix @impl annotations and credo nesting warnings
109 lines
3.3 KiB
Elixir
109 lines
3.3 KiB
Elixir
defmodule ToweropsWeb.Api.V1.InvitationsController do
|
|
@moduledoc "API controller for organization invitations."
|
|
use ToweropsWeb, :controller
|
|
|
|
import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1]
|
|
|
|
alias Towerops.Organizations
|
|
|
|
def index(conn, _params) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
invitations =
|
|
organization_id
|
|
|> Organizations.list_pending_invitations()
|
|
|> Enum.map(&format_invitation/1)
|
|
|
|
json(conn, %{data: invitations})
|
|
end
|
|
|
|
def create(conn, %{"email" => email, "role" => role}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
user = conn.assigns[:current_user]
|
|
|
|
case authorize_invite(organization_id, user, role) do
|
|
{:ok, _} ->
|
|
attrs = %{
|
|
organization_id: organization_id,
|
|
email: email,
|
|
role: role,
|
|
invited_by_id: user.id,
|
|
token: Ecto.UUID.generate(),
|
|
expires_at: DateTime.utc_now() |> DateTime.add(7 * 86_400, :second) |> DateTime.truncate(:second)
|
|
}
|
|
|
|
case Organizations.create_invitation(attrs) do
|
|
{:ok, invitation} ->
|
|
conn
|
|
|> put_status(:created)
|
|
|> json(%{data: format_invitation(Towerops.Repo.preload(invitation, :invited_by))})
|
|
|
|
{:error, changeset} ->
|
|
conn
|
|
|> put_status(:unprocessable_entity)
|
|
|> json(%{errors: translate_errors(changeset)})
|
|
end
|
|
|
|
{:error, reason} ->
|
|
conn |> put_status(:forbidden) |> json(%{error: reason})
|
|
end
|
|
end
|
|
|
|
def create(conn, _params) do
|
|
conn |> put_status(:bad_request) |> json(%{error: "Missing 'email' and 'role' parameters"})
|
|
end
|
|
|
|
defp authorize_invite(_org_id, nil, _role), do: {:error, "authentication required"}
|
|
|
|
defp authorize_invite(org_id, user, _role) do
|
|
membership = Organizations.get_membership(org_id, user.id)
|
|
|
|
case membership do
|
|
nil ->
|
|
{:error, "not a member of this organization"}
|
|
|
|
%{role: member_role} when member_role not in [:owner, :admin] ->
|
|
{:error, "only admins and owners can send invitations"}
|
|
|
|
_ ->
|
|
{:ok, membership}
|
|
end
|
|
end
|
|
|
|
def delete(conn, %{"id" => id}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
invitation = Towerops.Repo.get!(Towerops.Organizations.Invitation, id)
|
|
|
|
if invitation.organization_id == organization_id do
|
|
case Organizations.delete_invitation(invitation) do
|
|
{:ok, _} -> conn |> put_status(:no_content) |> send_resp(204, "")
|
|
{:error, _} -> conn |> put_status(:unprocessable_entity) |> json(%{error: "Could not delete invitation"})
|
|
end
|
|
else
|
|
conn |> put_status(:not_found) |> json(%{error: "Invitation not found"})
|
|
end
|
|
rescue
|
|
Ecto.NoResultsError ->
|
|
conn |> put_status(:not_found) |> json(%{error: "Invitation not found"})
|
|
end
|
|
|
|
defp format_invitation(invitation) do
|
|
%{
|
|
id: invitation.id,
|
|
email: invitation.email,
|
|
role: invitation.role,
|
|
expires_at: invitation.expires_at,
|
|
invited_by_email: get_in_loaded(invitation, :invited_by, :email),
|
|
inserted_at: invitation.inserted_at
|
|
}
|
|
end
|
|
|
|
defp get_in_loaded(struct, assoc, field) do
|
|
case Map.get(struct, assoc) do
|
|
%Ecto.Association.NotLoaded{} -> nil
|
|
nil -> nil
|
|
loaded -> Map.get(loaded, field)
|
|
end
|
|
end
|
|
end
|