Packages

phoenix_kit

1.7.201
1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web live users user_details.ex
Raw

lib/phoenix_kit_web/live/users/user_details.ex

defmodule PhoenixKitWeb.Live.Users.UserDetails do
@moduledoc """
LiveView for displaying detailed user information.
Displays user profile information in a tabbed interface with:
- Profile tab: Basic info, status, roles, registration details
- Connections tab: Follower/following/connection stats (if enabled)
- Notes tab: Admin notes about the user
"""
use PhoenixKitWeb, :live_view
@compile {:no_warn_undefined, PhoenixKitUserConnections}
alias PhoenixKit.Admin.Events
alias PhoenixKit.Settings
alias PhoenixKit.Users.AdminNote
alias PhoenixKit.Users.Auth
alias PhoenixKit.Users.CustomFields
alias PhoenixKit.Users.Roles
alias PhoenixKit.Utils.Date, as: UtilsDate
alias PhoenixKit.Utils.Routes
@impl true
def mount(%{"id" => user_uuid}, _session, socket) do
project_title = Settings.get_project_title()
user = Auth.get_user_with_roles(user_uuid)
case user do
nil ->
{:ok,
socket
|> put_flash(:error, gettext("User not found"))
|> push_navigate(to: Routes.path("/admin/users"))}
user ->
if connected?(socket), do: Events.subscribe_to_users()
custom_field_definitions = CustomFields.list_field_definitions()
# Load connections stats if module is available and enabled
{connections_enabled, connections_stats} =
if Code.ensure_loaded?(PhoenixKitUserConnections) and
PhoenixKitUserConnections.enabled?() do
{true,
%{
followers: PhoenixKitUserConnections.followers_count(user),
following: PhoenixKitUserConnections.following_count(user),
connections: PhoenixKitUserConnections.connections_count(user),
pending: PhoenixKitUserConnections.pending_requests_count(user),
blocked: length(PhoenixKitUserConnections.list_blocked(user))
}}
else
{false, nil}
end
# Load admin notes
admin_notes = Auth.list_admin_notes(user)
# Load date/time format settings once, mirroring the Users list, so
# Registered/Last Updated/Email Confirmed can show full date+time in
# the admin's own timezone instead of the date-only, timezone-naive
# UtilsDate.format_datetime_with_user_format/1.
date_time_settings =
Settings.get_settings_cached(
["date_format", "time_format", "time_zone"],
%{
"date_format" => "Y-m-d",
"time_format" => "H:i",
"time_zone" => "0"
}
)
geolocation_tracking_enabled =
Settings.get_boolean_setting("track_registration_geolocation", false)
# Organization accounts
org_accounts_enabled =
Settings.get_boolean_setting("enable_organization_accounts", false)
organization_members =
if org_accounts_enabled && user.account_type == "organization" do
Auth.list_organization_members(user.uuid)
else
[]
end
socket =
socket
|> assign(:user, user)
|> assign(:page_title, user_display_name(user))
|> assign(:page_section, gettext("Users"))
|> assign(:page_section_path, Routes.path("/admin/users"))
|> assign(:project_title, project_title)
|> assign(:date_time_settings, date_time_settings)
|> assign(:geolocation_tracking_enabled, geolocation_tracking_enabled)
|> assign(:active_tab, "profile")
|> assign(:custom_field_definitions, custom_field_definitions)
|> assign(:show_delete_modal, false)
|> assign(:connections_enabled, connections_enabled)
|> assign(:connections_stats, connections_stats)
|> assign(:admin_notes, admin_notes)
|> assign(:note_form, to_form(Auth.change_admin_note(%AdminNote{})))
|> assign(:editing_note_uuid, nil)
|> assign(:org_accounts_enabled, org_accounts_enabled)
|> assign(:organization_members, organization_members)
|> assign(:show_role_modal, false)
|> assign(:all_roles, Roles.list_roles())
|> assign(:confirmation_modal, %{show: false})
{:ok, socket}
end
end
@impl true
def handle_event("change_tab", %{"tab" => tab}, socket) do
{:noreply, assign(socket, :active_tab, tab)}
end
@impl true
def handle_event("show_delete_modal", _params, socket) do
{:noreply, assign(socket, :show_delete_modal, true)}
end
@impl true
def handle_event("hide_delete_modal", _params, socket) do
{:noreply, assign(socket, :show_delete_modal, false)}
end
# Roles, confirmation, and active-status actions mirror the row menu on the
# Users list (PhoenixKitWeb.Live.Users.Users) so every action offered there
# is also available from this page — same context calls, same guard rails
# (self-modification block, last-Owner protection via Roles.sync_user_roles/3).
@impl true
def handle_event("show_role_management", _params, socket) do
current_user = socket.assigns.phoenix_kit_current_user
user = socket.assigns.user
if current_user.uuid == user.uuid do
{:noreply, put_flash(socket, :error, gettext("Cannot modify your own roles"))}
else
{:noreply, assign(socket, :show_role_modal, true)}
end
end
@impl true
def handle_event("hide_role_management", _params, socket) do
{:noreply, assign(socket, :show_role_modal, false)}
end
@impl true
def handle_event("sync_user_roles", params, socket) do
user = socket.assigns.user
selected_roles = Map.get(params, "roles", %{})
role_names = Map.values(selected_roles)
actor = socket.assigns.phoenix_kit_current_user
case Roles.sync_user_roles(user, role_names, actor: actor) do
{:ok, %{roles_before: roles_before, roles_after: roles_after}} ->
added = roles_after -- roles_before
removed = roles_before -- roles_after
if added != [] or removed != [] do
log_roles_updated(actor, user, roles_before, roles_after, added, removed)
end
{:noreply,
socket
|> put_flash(:info, gettext("User roles updated successfully"))
|> assign(:show_role_modal, false)
|> assign(:user, Auth.get_user_with_roles(user.uuid))}
{:error, reason} ->
{:noreply, put_flash(socket, :error, format_role_error_message(reason))}
end
end
@impl true
def handle_event(
"request_status_toggle",
%{"is_active" => is_active},
socket
) do
is_active_bool = is_active == "true"
confirmation_modal = %{
show: true,
title: gettext("Confirm Status Change"),
message:
if(is_active_bool,
do: gettext("Are you sure you want to deactivate this user?"),
else: gettext("Are you sure you want to activate this user?")
),
button_text: if(is_active_bool, do: gettext("Deactivate"), else: gettext("Activate")),
action: "toggle_user_status"
}
{:noreply, assign(socket, :confirmation_modal, confirmation_modal)}
end
@impl true
def handle_event(
"request_confirmation_toggle",
%{"is_confirmed" => is_confirmed},
socket
) do
is_confirmed_bool = is_confirmed == "true"
confirmation_modal = %{
show: true,
title: gettext("Confirm Email Status Change"),
message:
if(is_confirmed_bool,
do: gettext("Are you sure you want to unconfirm this user's email?"),
else: gettext("Are you sure you want to confirm this user's email?")
),
button_text: if(is_confirmed_bool, do: gettext("Unconfirm"), else: gettext("Confirm")),
action: "toggle_user_confirmation"
}
{:noreply, assign(socket, :confirmation_modal, confirmation_modal)}
end
@impl true
def handle_event("cancel_confirmation", _params, socket) do
{:noreply, assign(socket, :confirmation_modal, %{show: false})}
end
@impl true
def handle_event("confirm_action", %{"action" => action}, socket) do
socket = assign(socket, :confirmation_modal, %{show: false})
case action do
"toggle_user_status" -> toggle_user_status_safely(socket)
"toggle_user_confirmation" -> toggle_user_confirmation_safely(socket)
_ -> {:noreply, socket}
end
end
@impl true
def handle_event("delete_user", _params, socket) do
user = socket.assigns.user
current_user = socket.assigns.phoenix_kit_current_scope.user
opts = %{
current_user: current_user,
ip_address: socket.assigns[:ip_address],
user_agent: socket.assigns[:user_agent]
}
case Auth.delete_user(user, opts) do
{:ok, _result} ->
{:noreply,
socket
|> assign(:show_delete_modal, false)
|> put_flash(:info, gettext("User deleted successfully"))
|> push_navigate(to: Routes.path("/admin/users"))}
{:error, :cannot_delete_self} ->
{:noreply,
socket
|> assign(:show_delete_modal, false)
|> put_flash(:error, gettext("Cannot delete your own account"))}
{:error, :cannot_delete_last_owner} ->
{:noreply,
socket
|> assign(:show_delete_modal, false)
|> put_flash(:error, gettext("Cannot delete the last system owner"))}
{:error, _reason} ->
{:noreply,
socket
|> assign(:show_delete_modal, false)
|> put_flash(:error, gettext("Failed to delete user"))}
end
end
# Admin Notes Events
@impl true
def handle_event("add_note", %{"admin_note" => note_params}, socket) do
current_user = socket.assigns.phoenix_kit_current_scope.user
user = socket.assigns.user
case Auth.create_admin_note(user, current_user, note_params) do
{:ok, _note} ->
admin_notes = Auth.list_admin_notes(user)
{:noreply,
socket
|> assign(:admin_notes, admin_notes)
|> assign(:note_form, to_form(Auth.change_admin_note(%AdminNote{})))
|> put_flash(:info, gettext("Note added successfully"))}
{:error, changeset} ->
{:noreply, assign(socket, :note_form, to_form(changeset))}
end
end
@impl true
def handle_event("edit_note", %{"uuid" => note_uuid}, socket) do
note = Enum.find(socket.assigns.admin_notes, &(to_string(&1.uuid) == note_uuid))
if note do
changeset = Auth.change_admin_note(note)
{:noreply,
socket
|> assign(:editing_note_uuid, note_uuid)
|> assign(:note_form, to_form(changeset))}
else
{:noreply, socket}
end
end
@impl true
def handle_event("cancel_edit", _params, socket) do
{:noreply,
socket
|> assign(:editing_note_uuid, nil)
|> assign(:note_form, to_form(Auth.change_admin_note(%AdminNote{})))}
end
@impl true
def handle_event("update_note", %{"admin_note" => note_params}, socket) do
note_uuid = socket.assigns.editing_note_uuid
note = Enum.find(socket.assigns.admin_notes, &(to_string(&1.uuid) == note_uuid))
if note do
case Auth.update_admin_note(note, note_params) do
{:ok, _note} ->
admin_notes = Auth.list_admin_notes(socket.assigns.user)
{:noreply,
socket
|> assign(:admin_notes, admin_notes)
|> assign(:editing_note_uuid, nil)
|> assign(:note_form, to_form(Auth.change_admin_note(%AdminNote{})))
|> put_flash(:info, gettext("Note updated successfully"))}
{:error, changeset} ->
{:noreply, assign(socket, :note_form, to_form(changeset))}
end
else
{:noreply, socket}
end
end
@impl true
def handle_event("delete_note", %{"uuid" => note_uuid}, socket) do
note = Enum.find(socket.assigns.admin_notes, &(to_string(&1.uuid) == note_uuid))
if note do
admin = socket.assigns[:phoenix_kit_current_user]
case Auth.delete_admin_note(note, admin) do
{:ok, _} ->
admin_notes = Auth.list_admin_notes(socket.assigns.user)
{:noreply,
socket
|> assign(:admin_notes, admin_notes)
|> put_flash(:info, gettext("Note deleted successfully"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, gettext("Failed to delete note"))}
end
else
{:noreply, socket}
end
end
@impl true
def handle_event("remove_member", %{"uuid" => member_uuid}, socket) do
member = Auth.get_user!(member_uuid)
case Auth.remove_from_organization(member) do
{:ok, _} ->
members = Auth.list_organization_members(socket.assigns.user.uuid)
{:noreply,
socket
|> assign(:organization_members, members)
|> put_flash(:info, gettext("Member removed from organization"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, gettext("Failed to remove member"))}
end
end
# --- PubSub Handlers ---
@impl true
def handle_info({:user_updated, user}, socket) do
{:noreply, maybe_refresh_user(socket, user)}
end
@impl true
def handle_info({:user_role_assigned, user, _role}, socket) do
{:noreply, maybe_refresh_user(socket, user)}
end
@impl true
def handle_info({:user_role_removed, user, _role}, socket) do
{:noreply, maybe_refresh_user(socket, user)}
end
@impl true
def handle_info({:user_roles_synced, user, _roles}, socket) do
{:noreply, maybe_refresh_user(socket, user)}
end
@impl true
def handle_info({:user_confirmed, user}, socket) do
{:noreply, maybe_refresh_user(socket, user)}
end
@impl true
def handle_info({:user_unconfirmed, user}, socket) do
{:noreply, maybe_refresh_user(socket, user)}
end
@impl true
def handle_info({:user_deleted, user}, socket) do
if user.uuid == socket.assigns.user.uuid do
{:noreply,
socket
|> put_flash(:info, gettext("This user has been deleted"))
|> push_navigate(to: Routes.path("/admin/users"))}
else
{:noreply, socket}
end
end
@impl true
def handle_info({:user_created, _user}, socket), do: {:noreply, socket}
@impl true
def handle_info(_msg, socket), do: {:noreply, socket}
defp maybe_refresh_user(socket, event_user) do
if event_user.uuid == socket.assigns.user.uuid do
case Auth.get_user_with_roles(event_user.uuid) do
nil ->
socket
|> put_flash(:info, gettext("This user has been deleted"))
|> push_navigate(to: Routes.path("/admin/users"))
user ->
socket = assign(socket, :user, user)
if socket.assigns.org_accounts_enabled && user.account_type == "organization" do
assign(socket, :organization_members, Auth.list_organization_members(user.uuid))
else
socket
end
end
else
socket
end
end
defp toggle_user_status_safely(socket) do
user = socket.assigns.user
new_status = !user.is_active
case Auth.update_user_status(user, %{"is_active" => new_status}) do
{:ok, updated_user} ->
status_text = if new_status, do: "activated", else: "deactivated"
admin = socket.assigns.phoenix_kit_current_user
PhoenixKit.Activity.log(%{
action: "user.status_changed",
module: "users",
mode: "manual",
actor_uuid: admin.uuid,
resource_type: "user",
resource_uuid: updated_user.uuid,
target_uuid: updated_user.uuid,
metadata: %{"status" => status_text, "actor_role" => "admin"}
})
flash_msg =
if new_status,
do: gettext("User activated successfully"),
else: gettext("User deactivated successfully")
{:noreply,
socket
|> put_flash(:info, flash_msg)
|> assign(:user, Auth.get_user_with_roles(updated_user.uuid))}
{:error, :cannot_deactivate_last_owner} ->
{:noreply, put_flash(socket, :error, gettext("Cannot deactivate the last system owner"))}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, gettext("Failed to update user status"))}
end
end
defp toggle_user_confirmation_safely(socket) do
user = socket.assigns.user
admin = socket.assigns.phoenix_kit_current_user
case Auth.toggle_user_confirmation(user) do
{:ok, updated_user} ->
action =
if updated_user.confirmed_at,
do: "user.email_confirmed",
else: "user.email_unconfirmed"
PhoenixKit.Activity.log(%{
action: action,
module: "users",
mode: "manual",
actor_uuid: admin.uuid,
resource_type: "user",
resource_uuid: updated_user.uuid,
target_uuid: updated_user.uuid,
metadata: %{"method" => "manual", "actor_role" => "admin"}
})
email_flash_msg =
if updated_user.confirmed_at,
do: gettext("User email confirmed successfully"),
else: gettext("User email unconfirmed successfully")
{:noreply,
socket
|> put_flash(:info, email_flash_msg)
|> assign(:user, Auth.get_user_with_roles(updated_user.uuid))}
{:error, _changeset} ->
{:noreply,
put_flash(socket, :error, gettext("Failed to update user confirmation status"))}
end
end
defp log_roles_updated(admin, user, current_roles, new_roles, added, removed) do
metadata =
%{"actor_role" => "admin"}
|> maybe_put_role_diff("added", added)
|> maybe_put_role_diff("removed", removed)
|> Map.put("roles_from", Enum.join(Enum.sort(current_roles), ", "))
|> Map.put("roles_to", Enum.join(Enum.sort(new_roles), ", "))
PhoenixKit.Activity.log(%{
action: "user.roles_updated",
module: "users",
mode: "manual",
actor_uuid: admin.uuid,
resource_type: "user",
resource_uuid: user.uuid,
target_uuid: user.uuid,
metadata: metadata
})
end
defp maybe_put_role_diff(map, _key, []), do: map
defp maybe_put_role_diff(map, key, roles), do: Map.put(map, key, Enum.join(roles, ", "))
defp format_role_error_message(reason) do
case reason do
:cannot_remove_last_owner -> gettext("Cannot remove the last system owner")
:owner_role_protected -> gettext("Owner role cannot be assigned manually")
:role_not_found -> gettext("Role not found")
_ -> gettext("Failed to update user role")
end
end
defp target_is_owner?(user), do: Enum.any?(user.roles || [], &(&1.name == "Owner"))
defp user_display_name(user) do
cond do
user.first_name && user.last_name ->
"#{user.first_name} #{user.last_name}"
user.first_name ->
user.first_name
user.username ->
user.username
true ->
user.email
end
end
# Full date+time (in the admin's own timezone), for the Account Information
# card where there's room — unlike the Users list's compact date-only
# columns, and unlike UtilsDate.format_datetime_with_user_format/1 (despite
# the name, it silently drops the time and ignores timezone entirely).
defp format_datetime(nil, _current_user, _date_time_settings), do: "-"
defp format_datetime(dt, current_user, date_time_settings) do
date = UtilsDate.format_date_with_user_timezone_cached(dt, current_user, date_time_settings)
time = UtilsDate.format_time_with_user_timezone_cached(dt, current_user, date_time_settings)
"#{date} #{time}"
end
defp format_location(user) do
[user.registration_city, user.registration_region, user.registration_country]
|> Enum.filter(&(&1 && &1 != ""))
|> Enum.join(", ")
|> case do
"" -> nil
location -> location
end
end
defp format_timezone(nil), do: gettext("Not set")
defp format_timezone(offset) when is_binary(offset) do
# Float.parse/1, not Integer.parse/1 — half/45-minute offsets like "5.5"
# (India) or "5.75" (Nepal) are valid stored values (auto-detected from
# the browser, or picked from Settings' "UTC+5:30" style dropdown
# options); Integer.parse/1 silently truncated them to "UTC+5".
case Float.parse(offset) do
{num, _} -> format_timezone_offset(num)
:error -> offset
end
end
defp format_timezone(offset) when is_number(offset), do: format_timezone_offset(offset)
defp format_timezone_offset(offset) do
sign = if offset >= 0, do: "+", else: ""
trimmed =
(offset * 1.0)
|> :erlang.float_to_binary(decimals: 2)
|> String.trim_trailing("0")
|> String.trim_trailing(".")
"UTC#{sign}#{trimmed}"
end
defp get_custom_field_value(user, field_key) do
case user.custom_fields do
nil -> nil
fields -> Map.get(fields, field_key)
end
end
defp format_custom_field_value(nil, _type, _field_key), do: "-"
defp format_custom_field_value("", _type, _field_key), do: "-"
defp format_custom_field_value(value, "boolean", _field_key) do
case value do
true -> gettext("Yes")
"true" -> gettext("Yes")
false -> gettext("No")
"false" -> gettext("No")
_ -> to_string(value)
end
end
defp format_custom_field_value(value, type, field_key)
when type in ["select", "radio", "checkbox"] do
CustomFields.get_option_text(field_key, value) || to_string(value)
end
defp format_custom_field_value(value, _type, _field_key), do: to_string(value)
end