User form: persist role, member linking, Forbidden handling

- User resource: update_user accepts role_id, manage_relationship :member
- user_live/form: touch role_id, params_with_member_if_unchanged to avoid unlink
- Handle Forbidden in form, extract error message for display
- user_policies_test and form_test coverage
This commit is contained in:
Moritz 2026-02-03 23:52:20 +01:00
parent 5ed41555e9
commit 8ec4a07103
4 changed files with 196 additions and 8 deletions

View file

@ -8,6 +8,9 @@ defmodule Mv.Accounts.User do
extensions: [AshAuthentication],
authorizers: [Ash.Policy.Authorizer]
require Ash.Query
import Ash.Expr
postgres do
table "users"
repo Mv.Repo
@ -146,9 +149,10 @@ defmodule Mv.Accounts.User do
update :update_user do
description "Updates a user and manages the optional member relationship. To change an existing member link, first remove it (set member to nil), then add the new one."
# Only accept email directly - member_id is NOT in accept list
# This prevents direct foreign key manipulation, forcing use of manage_relationship
accept [:email]
# Accept email and role_id (role_id only used by admins; policy restricts update_user to admins).
# member_id is NOT in accept list - use argument :member for relationship management.
accept [:email, :role_id]
# Allow member to be passed as argument for relationship management
argument :member, :map, allow_nil?: true
@ -387,6 +391,49 @@ defmodule Mv.Accounts.User do
end
end
# Last-admin: prevent the only admin from changing their role (at least one admin required).
validate fn changeset, _context ->
if Ash.Changeset.changing_attribute?(changeset, :role_id) do
current_role_id = changeset.data.role_id
current_role =
Mv.Authorization.Role
|> Ash.get!(current_role_id, authorize?: false)
if current_role.permission_set_name != "admin" do
:ok
else
admin_role_ids =
Mv.Authorization.Role
|> Ash.Query.for_read(:read)
|> Ash.Query.filter(expr(permission_set_name == "admin"))
|> Ash.read!(authorize?: false)
|> Enum.map(& &1.id)
# Count only non-system users with admin role (system user is for internal ops)
system_email = Mv.Helpers.SystemActor.system_user_email()
count =
Mv.Accounts.User
|> Ash.Query.for_read(:read)
|> Ash.Query.filter(expr(role_id in ^admin_role_ids))
|> Ash.Query.filter(expr(email != ^system_email))
|> Ash.count!(authorize?: false)
if count <= 1 do
{:error,
field: :role_id, message: "At least one user must keep the Admin role."}
else
:ok
end
end
else
:ok
end
end,
on: [:update],
where: [action_is(:update_user)]
# Prevent modification of the system actor user (required for internal operations).
# Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests.
validate fn changeset, _context ->

View file

@ -35,6 +35,8 @@ defmodule MvWeb.UserLive.Form do
require Jason
alias Mv.Authorization
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
import MvWeb.Authorization, only: [can?: 3]
@ -49,6 +51,18 @@ defmodule MvWeb.UserLive.Form do
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
<%= if @user && @can_assign_role do %>
<div class="mt-4">
<.input
field={@form[:role_id]}
type="select"
label={gettext("Role")}
options={Enum.map(@roles, &{&1.name, &1.id})}
prompt={gettext("Select role...")}
/>
</div>
<% end %>
<!-- Password Section -->
<div class="mt-6">
@ -300,6 +314,9 @@ defmodule MvWeb.UserLive.Form do
# Only admins can link/unlink users to members (permission docs; prevents privilege escalation).
can_manage_member_linking = can?(actor, :destroy, Mv.Accounts.User)
# Only admins can assign user roles (Role update permission).
can_assign_role = can?(actor, :update, Mv.Authorization.Role)
roles = if can_assign_role, do: load_roles(actor), else: []
{:ok,
socket
@ -307,6 +324,8 @@ defmodule MvWeb.UserLive.Form do
|> assign(user: user)
|> assign(:page_title, page_title)
|> assign(:can_manage_member_linking, can_manage_member_linking)
|> assign(:can_assign_role, can_assign_role)
|> assign(:roles, roles)
|> assign(:show_password_fields, false)
|> assign(:member_search_query, "")
|> assign(:available_members, [])
@ -357,7 +376,10 @@ defmodule MvWeb.UserLive.Form do
def handle_event("save", %{"user" => user_params}, socket) do
actor = current_actor(socket)
# First save the user without member changes
# Include current member in params when not linking/unlinking so update_user's
# manage_relationship(on_missing: :unrelate) does not accidentally unlink.
user_params = params_with_member_if_unchanged(socket, user_params)
case submit_form(socket.assigns.form, user_params, actor) do
{:ok, user} ->
handle_member_linking(socket, user, actor)
@ -529,6 +551,20 @@ defmodule MvWeb.UserLive.Form do
defp get_action_name(:update), do: gettext("updated")
defp get_action_name(other), do: to_string(other)
# When user has a linked member and we are not linking/unlinking, include current member in params
# so update_user's manage_relationship(on_missing: :unrelate) does not unlink the member.
defp params_with_member_if_unchanged(socket, params) do
user = socket.assigns.user
linking = socket.assigns.selected_member_id
unlinking = socket.assigns[:unlink_member]
if user && user.member_id && !linking && !unlinking do
Map.put(params, "member", %{"id" => user.member_id})
else
params
end
end
defp handle_member_link_error(socket, error) do
error_message = extract_error_message(error)
@ -572,7 +608,8 @@ defmodule MvWeb.UserLive.Form do
assigns: %{
user: user,
show_password_fields: show_password_fields,
can_manage_member_linking: can_manage_member_linking
can_manage_member_linking: can_manage_member_linking,
can_assign_role: can_assign_role
}
} = socket
) do
@ -580,16 +617,25 @@ defmodule MvWeb.UserLive.Form do
form =
if user do
# For existing users: admin uses update_user (email + member); non-admin uses update (email only).
# For existing users: admin uses update_user (email + member + role_id); non-admin uses update (email only).
# Password change uses admin_set_password for both.
action =
cond do
show_password_fields -> :admin_set_password
can_manage_member_linking -> :update_user
can_manage_member_linking or can_assign_role -> :update_user
true -> :update
end
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor)
form =
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor)
# Ensure role_id is always included on submit when role dropdown is shown (AshPhoenix.Form
# only submits keys in touched_forms; marking as touched avoids role change being dropped).
if can_assign_role and action == :update_user do
AshPhoenix.Form.touch(form, [:role_id])
else
form
end
else
# For new users, use password registration if password fields are shown
action = if show_password_fields, do: :register_with_password, else: :create_user
@ -668,6 +714,14 @@ defmodule MvWeb.UserLive.Form do
Mv.Membership.Member.filter_by_email_match(members, user_email_str)
end
@spec load_roles(any()) :: [Mv.Authorization.Role.t()]
defp load_roles(actor) do
case Authorization.list_roles(actor: actor) do
{:ok, roles} -> roles
{:error, _} -> []
end
end
# Extract user-friendly error message from Ash.Error
@spec extract_error_message(any()) :: String.t()
defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do