defmodule ToweropsWeb.Org.SettingsLive do @moduledoc false use ToweropsWeb, :live_view alias Towerops.Accounts.UserNotifier alias Towerops.Admin.AuditLogger alias Towerops.Agents alias Towerops.Billing alias Towerops.Gaiia.Client, as: GaiiaClient alias Towerops.Integrations alias Towerops.Integrations.Integration alias Towerops.Organizations alias Towerops.Organizations.SubscriptionLimits alias Towerops.Preseem.Client, as: PreseemClient alias Towerops.Sonar.Client, as: SonarClient alias Towerops.Splynx.Client, as: SplynxClient alias Towerops.Visp.Client, as: VispClient require Logger @billing_provider_ids ~w(gaiia sonar splynx visp) @impl true def mount(_params, _session, socket) do organization = socket.assigns.current_scope.organization user = socket.assigns.current_scope.user available_agents = Agents.list_organization_agent_tokens(organization.id) assignment_breakdown = Agents.Stats.get_device_assignment_breakdown(organization.id) membership = Organizations.get_membership(organization.id, user.id) user_orgs_count = user.id |> Organizations.list_user_organizations() |> length() AuditLogger.log_org_data_accessed(nil, user.id, organization.id, "view_settings") changeset = Organizations.change_organization(organization) # Billing information {current_devices, device_limit} = SubscriptionLimits.device_quota(organization) {:ok, cost_info} = Billing.estimated_monthly_cost(organization) {:ok, socket |> assign(:page_title, t("Organization Settings")) |> assign(:organization, organization) |> assign(:membership, membership) |> assign(:user_orgs_count, user_orgs_count) |> assign(:available_agents, available_agents) |> assign(:assignment_breakdown, assignment_breakdown) |> assign(:form, to_form(changeset)) |> assign(:active_tab, "general") # Billing tab assigns |> assign(:current_devices, current_devices) |> assign(:device_limit, device_limit) |> assign(:estimated_cost, cost_info) # Members tab assigns - loaded lazily |> assign(:members, []) |> assign(:pending_invitations, []) |> assign(:invite_form, to_form(%{"email" => "", "role" => "technician"})) # Integration assigns - loaded lazily when tab is selected |> assign(:integrations, %{}) |> assign(:timezone, socket.assigns.current_scope.timezone)} end @impl true def handle_params(params, _url, socket) do tab = Map.get(params, "tab", "general") socket = socket |> assign(:active_tab, tab) |> maybe_load_integrations(tab) {:noreply, socket} end defp maybe_load_integrations(socket, "general") do integrations = load_integrations(socket.assigns.organization.id) assign(socket, :integrations, integrations) 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, _tab), do: socket # === Org settings events === @impl true def handle_event("validate", %{"organization" => org_params}, socket) do changeset = socket.assigns.organization |> Organizations.change_organization(org_params) |> Map.put(:action, :validate) {:noreply, assign(socket, :form, to_form(changeset))} end @impl true def handle_event("save", %{"organization" => org_params}, socket) do require_admin_or_owner(socket, fn -> case Organizations.update_organization(socket.assigns.organization, org_params) do {:ok, organization} -> changeset = Organizations.change_organization(organization) {:noreply, socket |> put_flash(:info, t("Settings saved successfully")) |> assign(:organization, organization) |> assign(:form, to_form(changeset))} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign(socket, :form, to_form(changeset))} end end) end @impl true def handle_event("apply_snmp_to_all", _params, socket) do require_admin_or_owner(socket, fn -> {count, _} = Organizations.apply_snmp_config_to_all_equipment(socket.assigns.organization.id) {:noreply, put_flash(socket, :info, t("Applied SNMP configuration to %{count} device records across all sites", count: count))} end) end @impl true def handle_event("apply_agent_to_all", _params, socket) do require_admin_or_owner(socket, fn -> {count, _} = Organizations.apply_agent_to_all_equipment(socket.assigns.organization.id) {:noreply, put_flash(socket, :info, t("Applied default agent to %{count} device records across all sites", count: count))} end) end # === Members tab events === @impl true def handle_event("send_invitation", %{"email" => email, "role" => role}, socket) do require_admin_or_owner(socket, fn -> 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 |> Towerops.Repo.preload(:invited_by) |> Map.put(:organization, organization) accept_url = url(~p"/invitations/#{invitation.token}") UserNotifier.deliver_invitation_email(invitation, accept_url) {:noreply, socket |> put_flash(:info, t("Invitation sent to %{email}", email: email)) |> assign(:pending_invitations, Organizations.list_pending_invitations(organization.id)) |> assign(:invite_form, to_form(%{"email" => "", "role" => "technician"}))} {:error, changeset} -> {:noreply, put_flash(socket, :error, t("Failed to send invitation: %{errors}", errors: error_messages(changeset)))} end 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, t("Invitation cancelled")) |> assign(:pending_invitations, Organizations.list_pending_invitations(organization.id))} else {:noreply, put_flash(socket, :error, t("Invitation not found"))} end end @impl true def handle_event("remove_member", %{"user-id" => user_id}, socket) do require_admin_or_owner(socket, fn -> organization = socket.assigns.organization case Organizations.remove_member(organization.id, user_id) do {:ok, _} -> {:noreply, socket |> put_flash(:info, t("Member removed")) |> assign(:members, Organizations.list_organization_members(organization.id))} {:error, :cannot_remove_owner} -> {:noreply, put_flash(socket, :error, t("Cannot remove the organization owner"))} {:error, _} -> {:noreply, put_flash(socket, :error, t("Failed to remove member"))} end end) end @impl true def handle_event("change_role", %{"user-id" => user_id, "role" => role}, socket) do require_admin_or_owner(socket, fn -> organization = socket.assigns.organization case Organizations.update_member_role(organization.id, user_id, role) do {:ok, _} -> {:noreply, socket |> put_flash(:info, t("Role updated")) |> assign(:members, Organizations.list_organization_members(organization.id))} {:error, :cannot_change_owner_role} -> {:noreply, put_flash(socket, :error, t("Cannot change the owner's role"))} {:error, _} -> {:noreply, put_flash(socket, :error, t("Failed to update role"))} end end) end @impl true def handle_event("toggle_default_org", _params, socket) do user = socket.assigns.current_scope.user organization = socket.assigns.organization membership = socket.assigns.membership if membership.is_default do {:noreply, put_flash(socket, :error, t("This is already your default organization"))} else case Organizations.set_default_organization(user.id, organization.id) do {:ok, _} -> updated_membership = Organizations.get_membership(organization.id, user.id) {:noreply, socket |> put_flash(:info, "#{organization.name} is now your default organization") |> assign(:membership, updated_membership)} {:error, _} -> {:noreply, put_flash(socket, :error, t("Failed to set default organization"))} end end end # === Integration events === @impl true def handle_event("configure", %{"provider" => provider}, socket) do if socket.assigns.configuring == provider do {:noreply, socket |> assign(:configuring, nil) |> assign(:integration_form, nil) |> assign(:test_result, nil)} else integration = Map.get(socket.assigns.integrations, provider) form = case integration do nil -> %Integration{} |> Integrations.change_integration(%{provider: provider}) |> to_form() existing -> existing |> Integrations.change_integration(%{}) |> to_form() end socket = if provider == "netbox" do assign(socket, :netbox_config, load_netbox_config(integration)) else assign(socket, :integration_credentials, load_integration_credentials(integration, provider)) end {:noreply, socket |> assign(:configuring, provider) |> assign(:integration_form, form) |> assign(:test_result, nil)} end end @impl true def handle_event("close_config", _params, socket) do {:noreply, socket |> assign(:configuring, nil) |> assign(:integration_form, nil) |> assign(:test_result, nil)} end @impl true def handle_event("set_netbox_sync_direction", %{"direction" => direction}, socket) do netbox_config = Map.put(socket.assigns.netbox_config, "sync_direction", direction) {:noreply, assign(socket, :netbox_config, netbox_config)} end @impl true def handle_event("validate_integration", %{"integration" => params}, socket) do integration = get_current_integration(socket) changeset = integration |> Integrations.change_integration(normalize_params(params, integration)) |> Map.put(:action, :validate) socket = case socket.assigns.configuring do "netbox" -> assign(socket, :netbox_config, %{ "url" => Map.get(params, "url", ""), "api_token" => Map.get(params, "api_token", ""), "sync_direction" => Map.get(params, "sync_direction", "pull"), "sync_devices" => Map.get(params, "sync_devices", "true") == "true", "sync_sites" => Map.get(params, "sync_sites", "true") == "true", "sync_ip_addresses" => Map.get(params, "sync_ip_addresses", "false") == "true", "sync_interfaces" => Map.get(params, "sync_interfaces", "false") == "true", "device_role_filter" => Map.get(params, "device_role_filter", ""), "site_filter" => Map.get(params, "site_filter", ""), "tag_filter" => Map.get(params, "tag_filter", "") }) provider -> assign(socket, :integration_credentials, extract_credentials_from_params(params, provider)) end {:noreply, assign(socket, :integration_form, to_form(changeset))} end @impl true def handle_event("save_integration", %{"integration" => params}, socket) do organization = socket.assigns.organization provider = socket.assigns.configuring existing = Map.get(socket.assigns.integrations, provider) attrs = normalize_params(params, existing) result = case existing do nil -> Integrations.create_integration( organization.id, attrs |> Map.put(:provider, provider) |> Map.put(:enabled, true) ) integration -> Integrations.update_integration(integration, attrs) end case result do {:ok, _integration} -> integrations = load_integrations(organization.id) {:noreply, socket |> assign(:integrations, integrations) |> assign(:active_billing_provider, active_billing_provider(integrations)) |> assign(:configuring, nil) |> assign(:integration_form, nil) |> assign(:test_result, nil) |> put_flash(:info, t("Integration saved successfully"))} {:error, changeset} -> {:noreply, assign(socket, :integration_form, to_form(changeset))} end end @impl true def handle_event("test_connection", _params, socket) do result = do_test_connection(socket.assigns.configuring, socket) {:noreply, assign(socket, :test_result, result)} end @impl true def handle_event("save_webhook_secret", %{"value" => secret}, socket) do case Map.get(socket.assigns.integrations, "gaiia") do nil -> {:noreply, socket} integration -> updated_credentials = Map.put(integration.credentials, "webhook_secret", String.trim(secret)) case Integrations.update_integration(integration, %{credentials: updated_credentials}) do {:ok, _updated} -> integrations = load_integrations(socket.assigns.organization.id) {:noreply, socket |> assign(:integrations, integrations) |> assign(:active_billing_provider, active_billing_provider(integrations)) |> put_flash(:info, t("Webhook secret saved"))} {:error, _changeset} -> {:noreply, put_flash(socket, :error, t("Failed to save webhook secret"))} end end end def handle_event("save_gaiia_app_url", %{"value" => url}, socket) do case Map.get(socket.assigns.integrations, "gaiia") do nil -> {:noreply, socket} integration -> updated_credentials = Map.put(integration.credentials, "app_url", String.trim(url)) case Integrations.update_integration(integration, %{credentials: updated_credentials}) do {:ok, _updated} -> integrations = load_integrations(socket.assigns.organization.id) {:noreply, socket |> assign(:integrations, integrations) |> assign(:active_billing_provider, active_billing_provider(integrations)) |> put_flash(:info, t("Gaiia app URL saved"))} {:error, _changeset} -> {:noreply, put_flash(socket, :error, t("Failed to save Gaiia app URL"))} end end end @impl true def handle_event("toggle_enabled", %{"provider" => provider}, socket) do case Map.get(socket.assigns.integrations, provider) do nil -> {:noreply, socket} integration -> case Integrations.update_integration(integration, %{enabled: !integration.enabled}) do {:ok, _updated} -> integrations = load_integrations(socket.assigns.organization.id) {:noreply, socket |> assign(:integrations, integrations) |> assign(:active_billing_provider, active_billing_provider(integrations)) |> put_flash(:info, t("Integration updated"))} {:error, _changeset} -> {:noreply, put_flash(socket, :error, t("Failed to update integration"))} end end end # === Billing events === @impl true def handle_event("upgrade_to_paid", _params, socket) do org = socket.assigns.organization success_url = url(~p"/orgs/#{org.slug}/settings?tab=billing&status=success") cancel_url = url(~p"/orgs/#{org.slug}/settings?tab=billing&status=cancelled") case Billing.create_checkout_session(org, success_url: success_url, cancel_url: cancel_url ) do {:ok, checkout_url} -> {:noreply, redirect(socket, external: checkout_url)} {:error, reason} -> Logger.error("Failed to create checkout session: #{inspect(reason)}") {:noreply, put_flash(socket, :error, t("Failed to start checkout. Please try again later."))} end end @impl true def handle_event("manage_billing", _params, socket) do org = socket.assigns.organization return_url = url(~p"/orgs/#{org.slug}/settings?tab=billing") case Billing.create_portal_session(org, return_url) do {:ok, portal_url} -> {:noreply, redirect(socket, external: portal_url)} {:error, :no_stripe_customer} -> {:noreply, put_flash( socket, :error, t("No billing account found. Please upgrade to a paid plan first.") )} {:error, reason} -> Logger.error("Failed to create portal session: #{inspect(reason)}") {:noreply, put_flash(socket, :error, t("Failed to open billing portal. Please try again later."))} end end # === Private helpers (integrations) === defp do_test_connection("netbox", socket) do {url, token} = extract_netbox_credentials(socket) with :ok <- validate_present(url, t("Please enter your NetBox URL first")), :ok <- validate_present(token, t("Please enter your NetBox API token first")) do format_connection_result(Towerops.NetBox.Client.test_connection(url, token)) end end defp do_test_connection("sonar", socket) do {instance_url, api_token} = extract_sonar_credentials(socket) with :ok <- validate_present(instance_url, t("Please enter your Sonar instance URL first")), :ok <- validate_present(api_token, t("Please enter your Sonar API token first")) do format_connection_result(SonarClient.test_connection(instance_url, api_token)) end end defp do_test_connection("splynx", socket) do {instance_url, api_key, api_secret} = extract_splynx_credentials(socket) with :ok <- validate_present(instance_url, t("Please enter your Splynx instance URL first")), :ok <- validate_present(api_key, t("Please enter your Splynx API key first")), :ok <- validate_present(api_secret, t("Please enter your Splynx API secret first")) do format_connection_result(SplynxClient.test_connection(instance_url, api_key, api_secret)) end end defp do_test_connection(provider, socket) do api_key = extract_api_key(socket) with :ok <- validate_present(api_key, t("Please enter an API key first")) do format_connection_result(test_provider_connection(provider, api_key)) end end defp validate_present(nil, msg), do: {:error, msg} defp validate_present("", msg), do: {:error, msg} defp validate_present(_, _msg), do: :ok # === Private helpers (integrations) === defp test_provider_connection("preseem", api_key), do: PreseemClient.test_connection(api_key) defp test_provider_connection("gaiia", api_key), do: GaiiaClient.test_connection(api_key) defp test_provider_connection("pagerduty", api_key), do: Towerops.PagerDuty.Client.test_connection(api_key) defp test_provider_connection("visp", api_key), do: VispClient.test_connection(api_key) defp test_provider_connection(_, _api_key), do: {:error, "Unknown provider"} defp load_integrations(organization_id) do organization_id |> Integrations.list_integrations() |> Map.new(fn integration -> {integration.provider, integration} end) end defp active_billing_provider(integrations) do Enum.find_value(@billing_provider_ids, fn id -> case Map.get(integrations, id) do %{enabled: true} -> id _ -> nil end end) end defp get_current_integration(socket) do provider = socket.assigns.configuring case Map.get(socket.assigns.integrations, provider) do nil -> %Integration{} integration -> integration end end defp normalize_params(params, existing_integration) do provider = case existing_integration do %Integration{provider: p} -> p _ -> Map.get(params, "provider", "") end case provider do "netbox" -> normalize_netbox_params(params, existing_integration) "sonar" -> normalize_sonar_params(params) "splynx" -> normalize_splynx_params(params) _ -> normalize_standard_params(params, existing_integration) end end defp normalize_standard_params(params, existing_integration) do api_key = Map.get(params, "api_key", "") sync_interval = Map.get(params, "sync_interval_minutes") # Use form value if provided, otherwise preserve existing webhook_secret = case Map.get(params, "webhook_secret") do secret when is_binary(secret) and secret != "" -> secret _ -> case existing_integration do %Integration{credentials: %{"webhook_secret" => secret}} when is_binary(secret) -> secret _ -> "" end end attrs = %{credentials: %{"api_key" => api_key, "webhook_secret" => webhook_secret}} if sync_interval && sync_interval != "" do Map.put(attrs, :sync_interval_minutes, sync_interval) else attrs end end defp normalize_netbox_params(params, _existing_integration) do sync_interval = Map.get(params, "sync_interval_minutes") credentials = %{ "url" => String.trim(Map.get(params, "url", "")), "api_token" => Map.get(params, "api_token", ""), "sync_direction" => Map.get(params, "sync_direction", "pull"), "sync_devices" => Map.get(params, "sync_devices", "true") == "true", "sync_sites" => Map.get(params, "sync_sites", "true") == "true", "sync_ip_addresses" => Map.get(params, "sync_ip_addresses", "false") == "true", "sync_interfaces" => Map.get(params, "sync_interfaces", "false") == "true", "device_role_filter" => String.trim(Map.get(params, "device_role_filter", "")), "site_filter" => String.trim(Map.get(params, "site_filter", "")), "tag_filter" => String.trim(Map.get(params, "tag_filter", "")) } attrs = %{credentials: credentials} if sync_interval && sync_interval != "" do Map.put(attrs, :sync_interval_minutes, sync_interval) else attrs end end defp get_credential(nil, _key), do: "" defp get_credential(%Integration{credentials: credentials}, key) when is_map(credentials) do Map.get(credentials, key, "") end defp get_credential(_integration, _key), do: "" defp current_integration(socket) do provider = socket.assigns.configuring Map.get(socket.assigns.integrations, provider) end defp extract_netbox_credentials(socket) do # Try form changeset first (user may have typed but not saved) changeset = socket.assigns.integration_form.source form_creds = Ecto.Changeset.get_field(changeset, :credentials) || %{} form_url = Map.get(form_creds, "url", "") form_token = Map.get(form_creds, "api_token", "") # Fall back to saved integration integration = current_integration(socket) saved_url = get_credential(integration, "url") saved_token = get_credential(integration, "api_token") url = if form_url == "", do: saved_url, else: form_url token = if form_token == "", do: saved_token, else: form_token {url, token} end defp extract_api_key(socket) do changeset = socket.assigns.integration_form.source data = Ecto.Changeset.apply_changes(changeset) case data.credentials do %{"api_key" => key} -> key _ -> nil end end defp normalize_sonar_params(params) do sync_interval = Map.get(params, "sync_interval_minutes") attrs = %{ credentials: %{ "instance_url" => String.trim(Map.get(params, "instance_url", "")), "api_token" => Map.get(params, "api_token", "") } } if sync_interval && sync_interval != "" do Map.put(attrs, :sync_interval_minutes, sync_interval) else attrs end end defp normalize_splynx_params(params) do sync_interval = Map.get(params, "sync_interval_minutes") attrs = %{ credentials: %{ "instance_url" => String.trim(Map.get(params, "instance_url", "")), "api_key" => Map.get(params, "api_key", ""), "api_secret" => Map.get(params, "api_secret", "") } } if sync_interval && sync_interval != "" do Map.put(attrs, :sync_interval_minutes, sync_interval) else attrs end end defp extract_sonar_credentials(socket) do changeset = socket.assigns.integration_form.source creds = Ecto.Changeset.get_field(changeset, :credentials) || %{} integration = current_integration(socket) instance_url = creds |> Map.get("instance_url", "") |> non_empty(get_credential(integration, "instance_url")) api_token = creds |> Map.get("api_token", "") |> non_empty(get_credential(integration, "api_token")) {instance_url, api_token} end defp extract_splynx_credentials(socket) do changeset = socket.assigns.integration_form.source creds = Ecto.Changeset.get_field(changeset, :credentials) || %{} integration = current_integration(socket) instance_url = creds |> Map.get("instance_url", "") |> non_empty(get_credential(integration, "instance_url")) api_key = creds |> Map.get("api_key", "") |> non_empty(get_credential(integration, "api_key")) api_secret = creds |> Map.get("api_secret", "") |> non_empty(get_credential(integration, "api_secret")) {instance_url, api_key, api_secret} end defp non_empty("", fallback), do: fallback defp non_empty(nil, fallback), do: fallback defp non_empty(value, _fallback), do: value defp error_messages(changeset) do changeset |> Ecto.Changeset.traverse_errors(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"} defp format_connection_result({:error, {:unexpected_status, status}}), do: {:error, "API returned unexpected status: #{status}"} defp format_connection_result({:error, %{reason: :timeout}}), do: {:error, "Connection timeout - unable to reach API"} defp format_connection_result({:error, %{reason: :econnrefused}}), do: {:error, "Connection refused - check API endpoint"} defp format_connection_result({:error, %{reason: :nxdomain}}), do: {:error, "DNS lookup failed - check API endpoint"} defp format_connection_result({:error, {:graphql_errors, errors}}), do: {:error, "API query failed: #{inspect(Enum.map(errors, & &1["message"]))}"} defp format_connection_result({:error, reason}) do Logger.warning("Integration connection test failed: #{inspect(reason)}") {:error, "Connection failed - please check your API credentials and try again"} end defp default_netbox_config do %{ "url" => "", "api_token" => "", "sync_direction" => "pull", "sync_devices" => true, "sync_sites" => true, "sync_ip_addresses" => false, "sync_interfaces" => false, "device_role_filter" => "", "site_filter" => "", "tag_filter" => "" } end defp load_netbox_config(nil), do: default_netbox_config() defp load_netbox_config(%Integration{credentials: creds}) when is_map(creds) do %{ "url" => Map.get(creds, "url", ""), "api_token" => Map.get(creds, "api_token", ""), "sync_direction" => Map.get(creds, "sync_direction", "pull"), "sync_devices" => Map.get(creds, "sync_devices", true), "sync_sites" => Map.get(creds, "sync_sites", true), "sync_ip_addresses" => Map.get(creds, "sync_ip_addresses", false), "sync_interfaces" => Map.get(creds, "sync_interfaces", false), "device_role_filter" => Map.get(creds, "device_role_filter", ""), "site_filter" => Map.get(creds, "site_filter", ""), "tag_filter" => Map.get(creds, "tag_filter", "") } end defp load_netbox_config(_), do: default_netbox_config() defp load_integration_credentials(integration, "sonar") do %{ "instance_url" => get_credential(integration, "instance_url"), "api_token" => get_credential(integration, "api_token") } end defp load_integration_credentials(integration, "splynx") do %{ "instance_url" => get_credential(integration, "instance_url"), "api_key" => get_credential(integration, "api_key"), "api_secret" => get_credential(integration, "api_secret") } end defp load_integration_credentials(integration, _provider) do %{"api_key" => get_credential(integration, "api_key")} end defp extract_credentials_from_params(params, "sonar") do %{ "instance_url" => Map.get(params, "instance_url", ""), "api_token" => Map.get(params, "api_token", "") } end defp extract_credentials_from_params(params, "splynx") do %{ "instance_url" => Map.get(params, "instance_url", ""), "api_key" => Map.get(params, "api_key", ""), "api_secret" => Map.get(params, "api_secret", "") } end defp extract_credentials_from_params(params, _provider) do %{"api_key" => Map.get(params, "api_key", "")} end defp require_admin_or_owner(socket, fun) do role = socket.assigns.membership.role if role in [:owner, :admin] do fun.() else {:noreply, put_flash(socket, :error, t("You don't have permission to perform this action"))} end end end