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 @@ + +
+ <.header>Resend confirmation instructions + + <.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 + + +

+ <.link href={~p"/users/log-in"}>Log in | + <.link href={~p"/users/register"}>Register +

+
+
diff --git a/lib/towerops_web/controllers/user_session_controller.ex b/lib/towerops_web/controllers/user_session_controller.ex index 5052ff3c..b2402f42 100644 --- a/lib/towerops_web/controllers/user_session_controller.ex +++ b/lib/towerops_web/controllers/user_session_controller.ex @@ -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") diff --git a/lib/towerops_web/controllers/user_session_html/new.html.heex b/lib/towerops_web/controllers/user_session_html/new.html.heex index c362b81d..8d565222 100644 --- a/lib/towerops_web/controllers/user_session_html/new.html.heex +++ b/lib/towerops_web/controllers/user_session_html/new.html.heex @@ -105,6 +105,15 @@ {t_auth("Send me a login link instead")} + +

+ <.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 +
  • + <.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 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 @@ - <%= if @active_tab != "integrations" do %> + <%= if @active_tab not in ["integrations", "members"] do %> <.form for={@form} id="organization-form" @@ -525,6 +530,199 @@ <% end %> + <%= if @active_tab == "members" do %> +
    + + <%= if @membership.role in [:owner, :admin] do %> +
    +
    +

    + Invite a Team Member +

    +

    + Send an invitation email to add someone to this organization. +

    +
    + +
    + <.form for={@invite_form} id="invite-form" phx-submit="send_invitation"> +
    +
    + <.input + field={@invite_form[:email]} + type="email" + label="Email address" + placeholder="colleague@example.com" + required + /> +
    +
    + <.input + field={@invite_form[:role]} + type="select" + label="Role" + options={[{"Admin", "admin"}, {"Member", "member"}, {"Viewer", "viewer"}]} + /> +
    +
    + +
    +
    + +
    +
    + <% end %> + + + <%= if @pending_invitations != [] do %> +
    +
    +

    + Pending Invitations +

    +

    + Invitations that haven't been accepted yet. +

    +
    + +
    +
    + + + + + + + + <%= if @membership.role in [:owner, :admin] do %> + + <% end %> + + + + + + + + + <%= if @membership.role in [:owner, :admin] do %> + + <% end %> + + +
    EmailRoleSentExpiresActions
    {invitation.email} + + {invitation.role} + + + <.timestamp datetime={invitation.inserted_at} timezone={@timezone} format="absolute" /> + + <.timestamp datetime={invitation.expires_at} timezone={@timezone} format="absolute" /> + + +
    +
    +
    +
    + <% end %> + + +
    +
    +

    + Members +

    +

    + People who have access to this organization. +

    +
    + +
    +
    + + + + + + + <%= if @membership.role in [:owner, :admin] do %> + + <% end %> + + + + + + + + <%= if @membership.role in [:owner, :admin] do %> + + <% end %> + + +
    UserRoleJoinedActions
    +
    +
    {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 member.role != :owner do %> +
    +
    + +
    + +
    + <% end %> +
    +
    +
    +
    +
    + <% end %> + <%= if @active_tab == "integrations" do %>
    + 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 diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index f6602453..e3a82d96 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -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 diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex index c0e18580..4de727d5 100644 --- a/test/support/fixtures/accounts_fixtures.ex +++ b/test/support/fixtures/accounts_fixtures.ex @@ -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 diff --git a/test/towerops_web/live/org/settings_live_members_test.exs b/test/towerops_web/live/org/settings_live_members_test.exs new file mode 100644 index 00000000..a3309b27 --- /dev/null +++ b/test/towerops_web/live/org/settings_live_members_test.exs @@ -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