Merge remote-tracking branch 'origin/main' into feature/372-groups-management
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
commit
3eb4cde0b7
17 changed files with 330 additions and 38 deletions
|
|
@ -86,7 +86,13 @@ defmodule Mv.Accounts.User do
|
||||||
# - :create_user (for manual user creation with optional member link)
|
# - :create_user (for manual user creation with optional member link)
|
||||||
# - :register_with_password (for password-based registration)
|
# - :register_with_password (for password-based registration)
|
||||||
# - :register_with_rauthy (for OIDC-based registration)
|
# - :register_with_rauthy (for OIDC-based registration)
|
||||||
defaults [:read, :destroy]
|
defaults [:read]
|
||||||
|
|
||||||
|
destroy :destroy do
|
||||||
|
primary? true
|
||||||
|
# Required because custom validation (system actor protection) cannot run atomically
|
||||||
|
require_atomic? false
|
||||||
|
end
|
||||||
|
|
||||||
# Primary generic update action:
|
# Primary generic update action:
|
||||||
# - Selected by AshAdmin's generated "Edit" UI and generic AshPhoenix
|
# - Selected by AshAdmin's generated "Edit" UI and generic AshPhoenix
|
||||||
|
|
@ -169,6 +175,13 @@ defmodule Mv.Accounts.User do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Internal update used only by SystemActor/bootstrap and tests to assign role to system user.
|
||||||
|
# Not protected by system-user validation so bootstrap can run.
|
||||||
|
update :update_internal do
|
||||||
|
accept []
|
||||||
|
require_atomic? false
|
||||||
|
end
|
||||||
|
|
||||||
# Admin action for direct password changes in admin panel
|
# Admin action for direct password changes in admin panel
|
||||||
# Uses the official Ash Authentication HashPasswordChange with correct context
|
# Uses the official Ash Authentication HashPasswordChange with correct context
|
||||||
update :admin_set_password do
|
update :admin_set_password do
|
||||||
|
|
@ -181,6 +194,11 @@ defmodule Mv.Accounts.User do
|
||||||
|
|
||||||
# Use the official Ash Authentication password change
|
# Use the official Ash Authentication password change
|
||||||
change AshAuthentication.Strategy.Password.HashPasswordChange
|
change AshAuthentication.Strategy.Password.HashPasswordChange
|
||||||
|
|
||||||
|
# Sync email changes to linked member when email is changed (e.g. form changes both)
|
||||||
|
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||||
|
where [changing(:email)]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Action to link an OIDC account to an existing password-only user
|
# Action to link an OIDC account to an existing password-only user
|
||||||
|
|
@ -359,6 +377,21 @@ defmodule Mv.Accounts.User do
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# 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 ->
|
||||||
|
if Mv.Helpers.SystemActor.system_user?(changeset.data) do
|
||||||
|
{:error,
|
||||||
|
field: :email,
|
||||||
|
message:
|
||||||
|
"Cannot modify system actor user. This user is required for internal operations."}
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
on: [:update, :destroy],
|
||||||
|
where: [action_is([:update, :update_user, :admin_set_password, :destroy])]
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_oidc_id_present(changeset, _context) do
|
def validate_oidc_id_present(changeset, _context) do
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,31 @@ defmodule Mv.Helpers.SystemActor do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns whether the given user is the system actor user (case-insensitive email match).
|
||||||
|
|
||||||
|
Use this instead of ad-hoc `to_string(user.email) == system_user_email()` so
|
||||||
|
comparisons are consistent and case-insensitive everywhere.
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
- `boolean()` - true if user's email matches system user email (case-insensitive)
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> Mv.Helpers.SystemActor.system_user?(user_with_system_email)
|
||||||
|
true
|
||||||
|
iex> Mv.Helpers.SystemActor.system_user?(other_user)
|
||||||
|
false
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec system_user?(Mv.Accounts.User.t() | map() | nil) :: boolean()
|
||||||
|
def system_user?(%{email: email}) when not is_nil(email) do
|
||||||
|
normalized_email(to_string(email)) == normalized_system_user_email()
|
||||||
|
end
|
||||||
|
|
||||||
|
def system_user?(_), do: false
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the email address of the system user.
|
Returns the email address of the system user.
|
||||||
|
|
||||||
|
|
@ -191,6 +216,11 @@ defmodule Mv.Helpers.SystemActor do
|
||||||
@spec system_user_email() :: String.t()
|
@spec system_user_email() :: String.t()
|
||||||
def system_user_email, do: system_user_email_config()
|
def system_user_email, do: system_user_email_config()
|
||||||
|
|
||||||
|
# Case-insensitive normalized form for comparisons
|
||||||
|
defp normalized_system_user_email, do: normalized_email(system_user_email_config())
|
||||||
|
|
||||||
|
defp normalized_email(email) when is_binary(email), do: String.downcase(email)
|
||||||
|
|
||||||
# Returns the system user email from environment variable or default
|
# Returns the system user email from environment variable or default
|
||||||
# This allows configuration via SYSTEM_ACTOR_EMAIL env var
|
# This allows configuration via SYSTEM_ACTOR_EMAIL env var
|
||||||
@spec system_user_email_config() :: String.t()
|
@spec system_user_email_config() :: String.t()
|
||||||
|
|
@ -368,7 +398,7 @@ defmodule Mv.Helpers.SystemActor do
|
||||||
upsert_identity: :unique_email,
|
upsert_identity: :unique_email,
|
||||||
authorize?: false
|
authorize?: false
|
||||||
)
|
)
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update_internal, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|> Ash.update!(authorize?: false)
|
|> Ash.update!(authorize?: false)
|
||||||
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
||||||
|
|
|
||||||
25
lib/mv_web/error_helpers.ex
Normal file
25
lib/mv_web/error_helpers.ex
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
defmodule MvWeb.ErrorHelpers do
|
||||||
|
@moduledoc """
|
||||||
|
Shared helpers for formatting errors in the web layer.
|
||||||
|
|
||||||
|
Use `format_ash_error/1` for Ash errors so behaviour stays consistent
|
||||||
|
(e.g. handling Invalid errors whose entries may lack a `:message` field).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Formats an Ash error for display to the user.
|
||||||
|
|
||||||
|
Handles `Ash.Error.Invalid` by joining error messages; entries without
|
||||||
|
a `:message` field are inspected to avoid FunctionClauseError.
|
||||||
|
Other errors are inspected.
|
||||||
|
"""
|
||||||
|
@spec format_ash_error(Ash.Error.t() | term()) :: String.t()
|
||||||
|
def format_ash_error(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
|
||||||
|
Enum.map_join(errors, ", ", fn
|
||||||
|
%{message: message} when is_binary(message) -> message
|
||||||
|
other -> inspect(other)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_ash_error(error), do: inspect(error)
|
||||||
|
end
|
||||||
|
|
@ -264,12 +264,31 @@ defmodule MvWeb.UserLive.Form do
|
||||||
def mount(params, _session, socket) do
|
def mount(params, _session, socket) do
|
||||||
actor = current_actor(socket)
|
actor = current_actor(socket)
|
||||||
|
|
||||||
user =
|
case load_user_or_redirect(params["id"], actor, socket) do
|
||||||
case params["id"] do
|
{:redirect, socket} ->
|
||||||
nil -> nil
|
{:ok, socket}
|
||||||
id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
|
|
||||||
end
|
|
||||||
|
|
||||||
|
{:ok, user} ->
|
||||||
|
mount_continue(user, params, socket)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_user_or_redirect(nil, _actor, _socket), do: {:ok, nil}
|
||||||
|
|
||||||
|
defp load_user_or_redirect(id, actor, socket) do
|
||||||
|
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
|
||||||
|
|
||||||
|
if Mv.Helpers.SystemActor.system_user?(user) do
|
||||||
|
{:redirect,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, gettext("This user cannot be edited."))
|
||||||
|
|> push_navigate(to: ~p"/users")}
|
||||||
|
else
|
||||||
|
{:ok, user}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp mount_continue(user, params, socket) do
|
||||||
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
|
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
|
||||||
page_title = action <> " " <> gettext("User")
|
page_title = action <> " " <> gettext("User")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,18 @@ defmodule MvWeb.UserLive.Index do
|
||||||
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
actor = current_actor(socket)
|
actor = current_actor(socket)
|
||||||
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts, load: [:member], actor: actor)
|
|
||||||
|
users =
|
||||||
|
Mv.Accounts.User
|
||||||
|
|> Ash.Query.filter(email != ^Mv.Helpers.SystemActor.system_user_email())
|
||||||
|
|> Ash.read!(domain: Mv.Accounts, load: [:member], actor: actor)
|
||||||
|
|
||||||
sorted = Enum.sort_by(users, & &1.email)
|
sorted = Enum.sort_by(users, & &1.email)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
|
|
@ -64,7 +72,7 @@ defmodule MvWeb.UserLive.Index do
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
{:noreply, put_flash(socket, :error, format_ash_error(error))}
|
||||||
end
|
end
|
||||||
|
|
||||||
{:error, %Ash.Error.Query.NotFound{}} ->
|
{:error, %Ash.Error.Query.NotFound{}} ->
|
||||||
|
|
@ -75,7 +83,7 @@ defmodule MvWeb.UserLive.Index do
|
||||||
put_flash(socket, :error, gettext("You do not have permission to access this user"))}
|
put_flash(socket, :error, gettext("You do not have permission to access this user"))}
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
{:noreply, put_flash(socket, :error, format_ash_error(error))}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -137,12 +145,4 @@ defmodule MvWeb.UserLive.Index do
|
||||||
defp toggle_order(:desc), do: :asc
|
defp toggle_order(:desc), do: :asc
|
||||||
defp sort_fun(:asc), do: &<=/2
|
defp sort_fun(:asc), do: &<=/2
|
||||||
defp sort_fun(:desc), do: &>=/2
|
defp sort_fun(:desc), do: &>=/2
|
||||||
|
|
||||||
defp format_error(%Ash.Error.Invalid{errors: errors}) do
|
|
||||||
Enum.map_join(errors, ", ", fn %{message: message} -> message end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp format_error(error) do
|
|
||||||
inspect(error)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -75,9 +75,16 @@ defmodule MvWeb.UserLive.Show do
|
||||||
actor = current_actor(socket)
|
actor = current_actor(socket)
|
||||||
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
|
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
|
||||||
|
|
||||||
{:ok,
|
if Mv.Helpers.SystemActor.system_user?(user) do
|
||||||
socket
|
{:ok,
|
||||||
|> assign(:page_title, gettext("Show User"))
|
socket
|
||||||
|> assign(:user, user)}
|
|> put_flash(:error, gettext("This user cannot be viewed."))
|
||||||
|
|> push_navigate(to: ~p"/users")}
|
||||||
|
else
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, gettext("Show User"))
|
||||||
|
|> assign(:user, user)}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
2
mix.exs
2
mix.exs
|
|
@ -76,7 +76,7 @@ defmodule Mv.MixProject do
|
||||||
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
|
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
|
||||||
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
|
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
|
||||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||||
{:picosat_elixir, "~> 0.1", only: [:dev, :test]},
|
{:picosat_elixir, "~> 0.1"},
|
||||||
{:ecto_commons, "~> 0.3"},
|
{:ecto_commons, "~> 0.3"},
|
||||||
{:slugify, "~> 1.3"},
|
{:slugify, "~> 1.3"},
|
||||||
{:nimble_csv, "~> 1.0"}
|
{:nimble_csv, "~> 1.0"}
|
||||||
|
|
|
||||||
|
|
@ -2252,3 +2252,13 @@ msgid "Total: %{count} member"
|
||||||
msgid_plural "Total: %{count} members"
|
msgid_plural "Total: %{count} members"
|
||||||
msgstr[0] "Insgesamt: %{count} Mitglied"
|
msgstr[0] "Insgesamt: %{count} Mitglied"
|
||||||
msgstr[1] "Insgesamt: %{count} Mitglieder"
|
msgstr[1] "Insgesamt: %{count} Mitglieder"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This user cannot be edited."
|
||||||
|
msgstr "Dieser Benutzer kann nicht bearbeitet werden."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This user cannot be viewed."
|
||||||
|
msgstr "Dieser Benutzer kann nicht angezeigt werden."
|
||||||
|
|
|
||||||
|
|
@ -2253,3 +2253,13 @@ msgid "Total: %{count} member"
|
||||||
msgid_plural "Total: %{count} members"
|
msgid_plural "Total: %{count} members"
|
||||||
msgstr[0] ""
|
msgstr[0] ""
|
||||||
msgstr[1] ""
|
msgstr[1] ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This user cannot be edited."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This user cannot be viewed."
|
||||||
|
msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -2253,3 +2253,13 @@ msgid "Total: %{count} member"
|
||||||
msgid_plural "Total: %{count} members"
|
msgid_plural "Total: %{count} members"
|
||||||
msgstr[0] ""
|
msgstr[0] ""
|
||||||
msgstr[1] ""
|
msgstr[1] ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This user cannot be edited."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This user cannot be viewed."
|
||||||
|
msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
defmodule Mv.Repo.Migrations.EnsureSystemActorUserExists do
|
||||||
|
@moduledoc """
|
||||||
|
Ensures the system actor user always exists.
|
||||||
|
|
||||||
|
The system actor is used for systemic operations (email sync, cycle generation,
|
||||||
|
background jobs). It is created by seeds in development; in production seeds
|
||||||
|
may not run, so this migration guarantees the user exists.
|
||||||
|
|
||||||
|
Creates a user with email "system@mila.local" (default from Mv.Helpers.SystemActor)
|
||||||
|
and the Admin role. The user has no password and no OIDC ID, so it cannot log in.
|
||||||
|
"""
|
||||||
|
use Ecto.Migration
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
@system_user_email "system@mila.local"
|
||||||
|
|
||||||
|
def up do
|
||||||
|
admin_role_id = ensure_admin_role_exists()
|
||||||
|
ensure_system_actor_user_exists(admin_role_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
# Not reversible - do not delete system user on rollback
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ensure_admin_role_exists do
|
||||||
|
case repo().one(from(r in "roles", where: r.name == "Admin", select: r.id)) do
|
||||||
|
nil ->
|
||||||
|
execute("""
|
||||||
|
INSERT INTO roles (id, name, description, permission_set_name, is_system_role, inserted_at, updated_at)
|
||||||
|
VALUES (uuid_generate_v7(), 'Admin', 'Administrator with full access', 'admin', false, (now() AT TIME ZONE 'utc'), (now() AT TIME ZONE 'utc'))
|
||||||
|
""")
|
||||||
|
|
||||||
|
role_id = repo().one(from(r in "roles", where: r.name == "Admin", select: r.id))
|
||||||
|
IO.puts("✅ Created 'Admin' role (was missing)")
|
||||||
|
role_id
|
||||||
|
|
||||||
|
role_id ->
|
||||||
|
role_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ensure_system_actor_user_exists(_admin_role_id) do
|
||||||
|
case repo().one(from(u in "users", where: u.email == ^@system_user_email, select: u.id)) do
|
||||||
|
nil ->
|
||||||
|
# Use subquery for role_id to avoid nil/empty-string UUID (CI can lag after role insert)
|
||||||
|
execute("""
|
||||||
|
INSERT INTO users (id, email, hashed_password, oidc_id, member_id, role_id)
|
||||||
|
SELECT uuid_generate_v7(), '#{@system_user_email}', NULL, NULL, NULL, r.id
|
||||||
|
FROM roles r
|
||||||
|
WHERE r.name = 'Admin'
|
||||||
|
LIMIT 1
|
||||||
|
""")
|
||||||
|
|
||||||
|
IO.puts("✅ Created system actor user (#{@system_user_email})")
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -268,9 +268,9 @@ case Accounts.User
|
||||||
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
|
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
|
||||||
{:ok, existing_system_user} when not is_nil(existing_system_user) ->
|
{:ok, existing_system_user} when not is_nil(existing_system_user) ->
|
||||||
# System user already exists - ensure it has admin role
|
# System user already exists - ensure it has admin role
|
||||||
# Use authorize?: false for bootstrap
|
# Use authorize?: false for bootstrap; :update_internal bypasses system-user modification block
|
||||||
existing_system_user
|
existing_system_user
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update_internal, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|> Ash.update!(authorize?: false)
|
|> Ash.update!(authorize?: false)
|
||||||
|
|
||||||
|
|
@ -287,7 +287,7 @@ case Accounts.User
|
||||||
upsert_identity: :unique_email,
|
upsert_identity: :unique_email,
|
||||||
authorize?: false
|
authorize?: false
|
||||||
)
|
)
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update_internal, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|> Ash.update!(authorize?: false)
|
|> Ash.update!(authorize?: false)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,43 @@ defmodule Mv.Accounts.UserEmailSyncTest do
|
||||||
{:ok, member_after_unlink} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
|
{:ok, member_after_unlink} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
|
||||||
assert member_after_unlink.email == "user@example.com"
|
assert member_after_unlink.email == "user@example.com"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "admin_set_password with email change syncs to linked member", %{actor: actor} do
|
||||||
|
# Create member and user linked to it (with password so admin_set_password applies)
|
||||||
|
{:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor)
|
||||||
|
|
||||||
|
{:ok, user} =
|
||||||
|
Mv.Accounts.User
|
||||||
|
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||||
|
email: "user@example.com",
|
||||||
|
password: "initialpass123"
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
{:ok, user} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update_user, %{member: %{id: member.id}})
|
||||||
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
|
assert user.member_id == member.id
|
||||||
|
{:ok, m} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
|
||||||
|
assert m.email == "user@example.com"
|
||||||
|
|
||||||
|
# Change both email and password via admin_set_password (e.g. user form "Change Password")
|
||||||
|
{:ok, updated_user} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:admin_set_password, %{
|
||||||
|
email: "newemail@example.com",
|
||||||
|
password: "newpassword123"
|
||||||
|
})
|
||||||
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
|
assert to_string(updated_user.email) == "newemail@example.com"
|
||||||
|
|
||||||
|
# Member email must be synced (Option A: SyncUserEmailToMember on admin_set_password)
|
||||||
|
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor)
|
||||||
|
assert synced_member.email == "newemail@example.com"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "AshAuthentication compatibility" do
|
describe "AshAuthentication compatibility" do
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,14 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
# Deletes a user row directly via SQL, bypassing Ash validations.
|
||||||
|
# Use only in tests when setting up "no system user" / "no users" scenarios;
|
||||||
|
# Ash.destroy! forbids deleting the system actor user.
|
||||||
|
defp delete_user_bypass_ash(user) do
|
||||||
|
id = Ecto.UUID.dump!(user.id)
|
||||||
|
Ecto.Adapters.SQL.query!(Mv.Repo, "DELETE FROM users WHERE id = $1", [id])
|
||||||
|
end
|
||||||
|
|
||||||
# Helper function to ensure admin role exists
|
# Helper function to ensure admin role exists
|
||||||
defp ensure_admin_role do
|
defp ensure_admin_role do
|
||||||
case Authorization.list_roles() do
|
case Authorization.list_roles() do
|
||||||
|
|
@ -49,7 +57,7 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
|
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
|
||||||
{:ok, user} when not is_nil(user) ->
|
{:ok, user} when not is_nil(user) ->
|
||||||
user
|
user
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update_internal, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|> Ash.update!(authorize?: false)
|
|> Ash.update!(authorize?: false)
|
||||||
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
||||||
|
|
@ -60,7 +68,7 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
upsert_identity: :unique_email,
|
upsert_identity: :unique_email,
|
||||||
authorize?: false
|
authorize?: false
|
||||||
)
|
)
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update_internal, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|> Ash.update!(authorize?: false)
|
|> Ash.update!(authorize?: false)
|
||||||
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
||||||
|
|
@ -124,7 +132,7 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||||
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
||||||
{:ok, user} when not is_nil(user) ->
|
{:ok, user} when not is_nil(user) ->
|
||||||
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
delete_user_bypass_ash(user)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -163,7 +171,7 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||||
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
||||||
{:ok, user} when not is_nil(user) ->
|
{:ok, user} when not is_nil(user) ->
|
||||||
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
delete_user_bypass_ash(user)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -177,7 +185,7 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
|> Ash.Query.filter(email == ^admin_email)
|
|> Ash.Query.filter(email == ^admin_email)
|
||||||
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
||||||
{:ok, user} when not is_nil(user) ->
|
{:ok, user} when not is_nil(user) ->
|
||||||
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
delete_user_bypass_ash(user)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -227,7 +235,7 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||||
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
||||||
{:ok, user} when not is_nil(user) ->
|
{:ok, user} when not is_nil(user) ->
|
||||||
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
delete_user_bypass_ash(user)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -241,7 +249,7 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
|> Ash.Query.filter(email == ^admin_email)
|
|> Ash.Query.filter(email == ^admin_email)
|
||||||
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
||||||
{:ok, user} when not is_nil(user) ->
|
{:ok, user} when not is_nil(user) ->
|
||||||
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
delete_user_bypass_ash(user)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -275,7 +283,7 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||||
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
||||||
{:ok, user} when not is_nil(user) ->
|
{:ok, user} when not is_nil(user) ->
|
||||||
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
delete_user_bypass_ash(user)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -314,7 +322,7 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||||
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
||||||
{:ok, user} when not is_nil(user) ->
|
{:ok, user} when not is_nil(user) ->
|
||||||
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
delete_user_bypass_ash(user)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -328,7 +336,7 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
|> Ash.Query.filter(email == ^admin_email)
|
|> Ash.Query.filter(email == ^admin_email)
|
||||||
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
||||||
{:ok, user} when not is_nil(user) ->
|
{:ok, user} when not is_nil(user) ->
|
||||||
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
delete_user_bypass_ash(user)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -365,9 +373,9 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
|
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Assign wrong role to system user
|
# Assign wrong role to system user (use :update_internal so bootstrap-style update is allowed)
|
||||||
system_user
|
system_user
|
||||||
|> Ash.Changeset.for_update(:update, %{})
|
|> Ash.Changeset.for_update(:update_internal, %{})
|
||||||
|> Ash.Changeset.manage_relationship(:role, read_only_role, type: :append_and_remove)
|
|> Ash.Changeset.manage_relationship(:role, read_only_role, type: :append_and_remove)
|
||||||
|> Ash.update!(actor: system_actor)
|
|> Ash.update!(actor: system_actor)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -154,4 +154,14 @@ defmodule MvWeb.UserLive.ShowTest do
|
||||||
assert html =~ gettext("Show User") || html =~ to_string(user.email)
|
assert html =~ gettext("Show User") || html =~ to_string(user.email)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "system actor user" do
|
||||||
|
test "redirects to user list when viewing system actor user", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
|
||||||
|
assert {:error, {:live_redirect, %{to: "/users"}}} =
|
||||||
|
live(conn, ~p"/users/#{system_actor.id}")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -420,4 +420,14 @@ defmodule MvWeb.UserLive.FormTest do
|
||||||
assert is_nil(updated_user.member)
|
assert is_nil(updated_user.member)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "system actor user" do
|
||||||
|
test "redirects to user list when editing system actor user", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
conn = conn_with_oidc_user(conn, %{email: "admin@example.com"})
|
||||||
|
|
||||||
|
assert {:error, {:live_redirect, %{to: "/users"}}} =
|
||||||
|
live(conn, "/users/#{system_actor.id}/edit")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -405,6 +405,27 @@ defmodule MvWeb.UserLive.IndexTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "system actor user" do
|
||||||
|
test "does not show system actor user in list", %{conn: conn} do
|
||||||
|
# Ensure system actor exists (e.g. via get_system_actor in conn_with_oidc_user)
|
||||||
|
_system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
system_email = Mv.Helpers.SystemActor.system_user_email()
|
||||||
|
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, _view, html} = live(conn, "/users")
|
||||||
|
|
||||||
|
refute html =~ system_email,
|
||||||
|
"System actor user (#{system_email}) must not appear in the user list"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "destroying system actor user returns error", %{current_user: current_user} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
assert {:error, %Ash.Error.Invalid{}} =
|
||||||
|
Ash.destroy(system_actor, domain: Mv.Accounts, actor: current_user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "member linking display" do
|
describe "member linking display" do
|
||||||
test "displays linked member name in user list", %{conn: conn} do
|
test "displays linked member name in user list", %{conn: conn} do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue