WIP feat: member user relation
This commit is contained in:
parent
997691746a
commit
4e6f5a517a
35 changed files with 1208 additions and 192 deletions
|
|
@ -21,4 +21,15 @@ defmodule Mv.Accounts do
|
|||
|
||||
resource Mv.Accounts.Token
|
||||
end
|
||||
|
||||
@doc """
|
||||
Register a new user with password using AshAuthentication's standard action.
|
||||
This creates a user and the notifier will automatically create a member.
|
||||
"""
|
||||
def register_with_password(params) do
|
||||
# Use AshAuthentication's standard register_with_password action
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, params)
|
||||
|> Ash.create(domain: __MODULE__)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ defmodule Mv.Accounts.User do
|
|||
use Ash.Resource,
|
||||
domain: Mv.Accounts,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshAuthentication]
|
||||
extensions: [AshAuthentication],
|
||||
notifiers: [Mv.Accounts.User.MemberCreationNotifier]
|
||||
|
||||
# authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
|
|
@ -64,11 +65,11 @@ defmodule Mv.Accounts.User do
|
|||
defaults [:read, :create, :destroy, :update]
|
||||
|
||||
create :create_user do
|
||||
accept [:email]
|
||||
accept [:email, :member_id]
|
||||
end
|
||||
|
||||
update :update_user do
|
||||
accept [:email]
|
||||
accept [:email, :member_id]
|
||||
end
|
||||
|
||||
# Admin action for direct password changes in admin panel
|
||||
|
|
@ -121,9 +122,16 @@ defmodule Mv.Accounts.User do
|
|||
|
||||
# Global validations - applied to all relevant actions
|
||||
validations do
|
||||
# Password strength policy: minimum 8 characters for all password-related actions
|
||||
# Password strength policy: minimum 8 characters
|
||||
# Note: register_with_password has built-in AshAuthentication validation, but admin_set_password doesn't
|
||||
validate string_length(:password, min: 8) do
|
||||
where action_is([:register_with_password, :admin_set_password])
|
||||
# Only needed for admin actions, AshAuthentication handles register_with_password
|
||||
where action_is([:admin_set_password])
|
||||
end
|
||||
|
||||
# Email uniqueness for registration actions
|
||||
validate attribute_does_not_equal(:email, nil) do
|
||||
where action_is([:register_with_password, :register_with_rauthy])
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -143,6 +151,7 @@ defmodule Mv.Accounts.User do
|
|||
attribute :email, :ci_string, allow_nil?: false, public?: true
|
||||
attribute :hashed_password, :string, sensitive?: true, allow_nil?: true
|
||||
attribute :oidc_id, :string, allow_nil?: true
|
||||
attribute :admin?, :boolean, allow_nil?: false, default: false, public?: true
|
||||
end
|
||||
|
||||
relationships do
|
||||
|
|
@ -152,6 +161,7 @@ defmodule Mv.Accounts.User do
|
|||
identities do
|
||||
identity :unique_email, [:email]
|
||||
identity :unique_oidc_id, [:oidc_id]
|
||||
identity :unique_member_id, [:member_id]
|
||||
end
|
||||
|
||||
# You can customize this if you wish, but this is a safe default that
|
||||
|
|
|
|||
71
lib/accounts/user/member_creation_notifier.ex
Normal file
71
lib/accounts/user/member_creation_notifier.ex
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
defmodule Mv.Accounts.User.MemberCreationNotifier do
|
||||
@moduledoc """
|
||||
Notifier that automatically creates a member for newly registered users.
|
||||
|
||||
This runs after user creation/registration and ensures every user has an associated member.
|
||||
It's designed to work with AshAuthentication without interfering with LiveView integration.
|
||||
"""
|
||||
use Ash.Notifier
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Ash.Notifier
|
||||
def notify(%Ash.Notifier.Notification{
|
||||
action: %{name: action_name},
|
||||
resource: Mv.Accounts.User,
|
||||
data: user
|
||||
})
|
||||
when action_name in [:register_with_password, :register_with_rauthy, :create_user] do
|
||||
# Only create member if user doesn't already have one
|
||||
if should_create_member?(user) do
|
||||
create_member_for_user(user)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@impl Ash.Notifier
|
||||
def notify(_), do: :ok
|
||||
|
||||
defp should_create_member?(user) do
|
||||
# Check if user has a member_id and if that member actually exists
|
||||
case user.member_id do
|
||||
nil ->
|
||||
true
|
||||
|
||||
member_id ->
|
||||
case Ash.get(Mv.Membership.Member, member_id, domain: Mv.Membership) do
|
||||
{:ok, _member} -> false
|
||||
{:error, _} -> true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp create_member_for_user(user) do
|
||||
member_params = %{
|
||||
email: to_string(user.email),
|
||||
first_name: "User",
|
||||
last_name: "Generated"
|
||||
}
|
||||
|
||||
case Mv.Membership.create_member(member_params) do
|
||||
{:ok, member} ->
|
||||
# Update user with member_id
|
||||
case Ash.Changeset.for_update(user, :update_user, %{member_id: member.id})
|
||||
|> Ash.update(domain: Mv.Accounts) do
|
||||
{:ok, _updated_user} ->
|
||||
Logger.info(
|
||||
"Successfully created and assigned member #{member.id} to user #{user.id}"
|
||||
)
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning(
|
||||
"Failed to assign member #{member.id} to user #{user.id}: #{inspect(error)}"
|
||||
)
|
||||
end
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning("Failed to create member for user #{user.id}: #{inspect(error)}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -170,5 +170,6 @@ defmodule Mv.Membership.Member do
|
|||
|
||||
relationships do
|
||||
has_many :properties, Mv.Membership.Property
|
||||
has_one :user, Mv.Accounts.User, destination_attribute: :member_id
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ defmodule Mv.Membership do
|
|||
resource Mv.Membership.Member do
|
||||
define :create_member, action: :create_member
|
||||
define :list_members, action: :read
|
||||
define :get_member!, action: :read, get_by: [:id]
|
||||
define :update_member, action: :update_member
|
||||
define :destroy_member, action: :destroy
|
||||
end
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ defmodule MvWeb.Layouts do
|
|||
|
||||
"""
|
||||
attr :flash, :map, required: true, doc: "the map of flash messages"
|
||||
attr :current_user, :map, default: nil, doc: "the current user"
|
||||
|
||||
attr :current_scope, :map,
|
||||
default: nil,
|
||||
|
|
@ -33,7 +34,7 @@ defmodule MvWeb.Layouts do
|
|||
|
||||
def app(assigns) do
|
||||
~H"""
|
||||
<.navbar />
|
||||
<.navbar current_user={@current_user} />
|
||||
<main class="px-4 py-20 sm:px-6 lg:px-16">
|
||||
<div class="mx-auto max-full space-y-4">
|
||||
{render_slot(@inner_block)}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ defmodule MvWeb.Layouts.Navbar do
|
|||
use Phoenix.Component
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
attr :current_user, :map, default: nil
|
||||
|
||||
def navbar(assigns) do
|
||||
~H"""
|
||||
<header class="navbar bg-base-100 shadow-sm">
|
||||
|
|
@ -65,7 +67,7 @@ defmodule MvWeb.Layouts.Navbar do
|
|||
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow"
|
||||
>
|
||||
<li>
|
||||
<a>
|
||||
<a :if={@current_user} href={"/users/#{@current_user.id}"}>
|
||||
{gettext("Profil")}
|
||||
</a>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ defmodule MvWeb.MemberLive.Form do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash}>
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
|
|
@ -155,7 +155,7 @@ defmodule MvWeb.MemberLive.Form do
|
|||
AshPhoenix.Form.for_update(
|
||||
member,
|
||||
:update_member,
|
||||
api: Mv.Membership,
|
||||
domain: Mv.Membership,
|
||||
as: "member",
|
||||
params: params,
|
||||
forms: [auto?: true]
|
||||
|
|
@ -172,7 +172,7 @@ defmodule MvWeb.MemberLive.Form do
|
|||
AshPhoenix.Form.for_create(
|
||||
Mv.Membership.Member,
|
||||
:create_member,
|
||||
api: Mv.Membership,
|
||||
domain: Mv.Membership,
|
||||
as: "member",
|
||||
params: %{"properties" => socket.assigns[:initial_properties]},
|
||||
forms: [auto?: true]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<Layouts.app flash={@flash}>
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Members")}
|
||||
<:actions>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ defmodule MvWeb.MemberLive.Show do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash}>
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{@member.first_name} {@member.last_name}
|
||||
<:subtitle>{gettext("This is a member record from your database.")}</:subtitle>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ defmodule MvWeb.PropertyLive.Form do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash}>
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>{gettext("Use this form to manage property records in your database.")}</:subtitle>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ defmodule MvWeb.PropertyLive.Index do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash}>
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
Listing Properties
|
||||
<:actions>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ defmodule MvWeb.PropertyLive.Show do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash}>
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
Property {@property.id}
|
||||
<:subtitle>This is a property record from your database.</:subtitle>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ defmodule MvWeb.PropertyTypeLive.Form do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash}>
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ defmodule MvWeb.PropertyTypeLive.Index do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash}>
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
Listing Property types
|
||||
<:actions>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ defmodule MvWeb.PropertyTypeLive.Show do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash}>
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
Property type {@property_type.id}
|
||||
<:subtitle>This is a property_type record from your database.</:subtitle>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash}>
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>
|
||||
|
|
@ -13,6 +13,53 @@ defmodule MvWeb.UserLive.Form do
|
|||
<.form for={@form} id="user-form" phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||
|
||||
<!-- Member Assignment Section -->
|
||||
<div class="mt-6 space-y-4">
|
||||
<h3 class="text-lg font-medium">{gettext("Member Assignment")}</h3>
|
||||
|
||||
<label class="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="member_assignment_mode"
|
||||
value="create_new"
|
||||
phx-click="set_member_mode"
|
||||
phx-value-mode="create_new"
|
||||
checked={@member_assignment_mode == "create_new"}
|
||||
class="radio radio-sm"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
{gettext("Create new member automatically")}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="member_assignment_mode"
|
||||
value="assign_existing"
|
||||
phx-click="set_member_mode"
|
||||
phx-value-mode="assign_existing"
|
||||
checked={@member_assignment_mode == "assign_existing"}
|
||||
class="radio radio-sm"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
{gettext("Assign to existing member")}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<%= if @member_assignment_mode == "assign_existing" do %>
|
||||
<div class="ml-6 mt-2">
|
||||
<.input
|
||||
field={@form[:member_id]}
|
||||
label={gettext("Select Member")}
|
||||
type="select"
|
||||
options={@available_members}
|
||||
prompt={gettext("Choose a member...")}
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Password Section -->
|
||||
<div class="mt-6">
|
||||
<label class="flex items-center space-x-2">
|
||||
|
|
@ -109,12 +156,25 @@ defmodule MvWeb.UserLive.Form do
|
|||
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
|
||||
page_title = action <> " " <> gettext("User")
|
||||
|
||||
# Load available members that have no user assigned
|
||||
{:ok, available_members} = Mv.Membership.list_members()
|
||||
available_members_with_user = Ash.load!(available_members, :user)
|
||||
|
||||
available_member_options =
|
||||
available_members_with_user
|
||||
|> Enum.filter(fn member -> is_nil(member.user) end)
|
||||
|> Enum.map(fn member ->
|
||||
{"#{member.first_name} #{member.last_name} (#{member.email})", member.id}
|
||||
end)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:return_to, return_to(params["return_to"]))
|
||||
|> assign(user: user)
|
||||
|> assign(:page_title, page_title)
|
||||
|> assign(:show_password_fields, false)
|
||||
|> assign(:member_assignment_mode, "create_new")
|
||||
|> assign(:available_members, available_member_options)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
|
|
@ -133,6 +193,15 @@ defmodule MvWeb.UserLive.Form do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("set_member_mode", %{"mode" => mode}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:member_assignment_mode, mode)
|
||||
|> assign_form()
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||
{:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, user_params))}
|
||||
end
|
||||
|
|
@ -161,14 +230,30 @@ defmodule MvWeb.UserLive.Form do
|
|||
if user do
|
||||
# For existing users, use admin password action if password fields are shown
|
||||
action = if show_password_fields, do: :admin_set_password, else: :update_user
|
||||
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user")
|
||||
|
||||
AshPhoenix.Form.for_update(user, action,
|
||||
as: "user",
|
||||
actor: socket.assigns.current_user,
|
||||
domain: Mv.Accounts
|
||||
)
|
||||
else
|
||||
# For new users, use password registration if password fields are shown
|
||||
action = if show_password_fields, do: :register_with_password, else: :create_user
|
||||
|
||||
# Only include member_id if assign_existing mode is selected AND not using password action
|
||||
accept =
|
||||
if socket.assigns.member_assignment_mode == "assign_existing" and
|
||||
not show_password_fields do
|
||||
[:email, :member_id]
|
||||
else
|
||||
[:email]
|
||||
end
|
||||
|
||||
AshPhoenix.Form.for_create(Mv.Accounts.User, action,
|
||||
as: "user",
|
||||
actor: socket.assigns.current_user,
|
||||
domain: Mv.Accounts,
|
||||
as: "user"
|
||||
accept: accept
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ defmodule MvWeb.UserLive.Index do
|
|||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts)
|
||||
users =
|
||||
Ash.read!(Mv.Accounts.User, domain: Mv.Accounts)
|
||||
|> Ash.load!(:member)
|
||||
|
||||
sorted = Enum.sort_by(users, & &1.email)
|
||||
|
||||
{:ok,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<Layouts.app flash={@flash}>
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Listing Users")}
|
||||
<:actions>
|
||||
|
|
@ -49,6 +49,11 @@
|
|||
>
|
||||
{user.email}
|
||||
</:col>
|
||||
<:col :let={user} label={gettext("Member")}>
|
||||
{if user.member,
|
||||
do: "#{user.member.first_name} #{user.member.last_name}",
|
||||
else: gettext("No member")}
|
||||
</:col>
|
||||
<:col :let={user} label={gettext("OIDC ID")}>{user.oidc_id}</:col>
|
||||
|
||||
<:action :let={user}>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ defmodule MvWeb.UserLive.Show do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash}>
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("User")} {@user.email}
|
||||
<:subtitle>{gettext("This is a user record from your database.")}</:subtitle>
|
||||
|
|
@ -22,6 +22,14 @@ defmodule MvWeb.UserLive.Show do
|
|||
<.list>
|
||||
<:item title={gettext("ID")}>{@user.id}</:item>
|
||||
<:item title={gettext("Email")}>{@user.email}</:item>
|
||||
<:item title={gettext("Member")}>
|
||||
<div :if={@user.member}>
|
||||
<.link navigate={~p"/members/#{@user.member.id}"} class="link link-primary">
|
||||
{@user.member.first_name} {@user.member.last_name} ({@user.member.email})
|
||||
</.link>
|
||||
</div>
|
||||
<span :if={!@user.member}>{gettext("No member assigned")}</span>
|
||||
</:item>
|
||||
<:item title={gettext("OIDC ID")}>{@user.oidc_id || gettext("Not set")}</:item>
|
||||
<:item title={gettext("Password Authentication")}>
|
||||
{if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")}
|
||||
|
|
@ -33,9 +41,13 @@ defmodule MvWeb.UserLive.Show do
|
|||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
user =
|
||||
Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts)
|
||||
|> Ash.load!(:member)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Show User"))
|
||||
|> assign(:user, Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts))}
|
||||
|> assign(:user, user)}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue