Merge branch 'main' into feat/421_accessibility
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
carla 2026-02-26 08:49:55 +01:00
commit 73382c2c3f
49 changed files with 3415 additions and 1950 deletions

View file

@ -60,15 +60,15 @@ defmodule MvWeb.CoreComponents do
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="z-50 toast toast-top toast-end"
class="pointer-events-auto"
{@rest}
>
<div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info",
@kind == :error && "alert-error",
@kind == :success && "bg-green-500 text-white",
@kind == :warning && "bg-blue-100 text-blue-800 border border-blue-300"
@kind == :success && "alert-success",
@kind == :warning && "alert-warning"
]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
@ -90,33 +90,71 @@ defmodule MvWeb.CoreComponents do
@doc """
Renders a button with navigation support.
## Variants (Design Guidelines §5.2)
- primary (main CTA)
- secondary (supporting)
- neutral (cancel/back)
- ghost (low emphasis; table/toolbars)
- outline (alternative CTA)
- danger (destructive)
- link (inline; rare)
- icon (icon-only)
## Sizes
- sm, md (default), lg
## Examples
<.button>Send!</.button>
<.button phx-click="go" variant="primary">Send!</.button>
<.button navigate={~p"/"}>Home</.button>
<.button navigate={~p"/"} variant="secondary">Home</.button>
<.button variant="ghost" size="sm">Edit</.button>
<.button disabled={true}>Disabled</.button>
"""
attr :rest, :global, include: ~w(href navigate patch method data-testid)
attr :variant, :string, values: ~w(primary)
attr :rest, :global, include: ~w(href navigate patch method data-testid form)
attr :variant, :string,
values: ~w(primary secondary neutral ghost outline danger link icon),
default: "primary"
attr :size, :string, values: ~w(sm md lg), default: "md"
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
slot :inner_block, required: true
def button(assigns) do
rest = assigns.rest
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
variant = assigns[:variant] || "primary"
size = assigns[:size] || "md"
variant_classes = %{
"primary" => "btn-primary",
"secondary" => "btn-secondary",
"neutral" => "btn-neutral",
"ghost" => "btn-ghost",
"outline" => "btn-outline",
"danger" => "btn-error",
"link" => "btn-link",
"icon" => "btn-ghost btn-square"
}
size_classes = %{
"sm" => "btn-sm",
"md" => "",
"lg" => "btn-lg"
}
base_class = Map.fetch!(variant_classes, variant)
size_class = size_classes[size]
btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
assigns = assign(assigns, :btn_class, btn_class)
if rest[:href] || rest[:navigate] || rest[:patch] do
# For links, we can't use disabled attribute, so we use btn-disabled class
# DaisyUI's btn-disabled provides the same styling as :disabled on buttons
link_class =
if assigns[:disabled],
do: ["btn", assigns.class, "btn-disabled"],
else: ["btn", assigns.class]
do: ["btn", btn_class, "btn-disabled"],
else: ["btn", btn_class]
# Prevent interaction when disabled
# Remove navigation attributes to prevent "Open in new tab", "Copy link" etc.
link_attrs =
if assigns[:disabled] do
rest
@ -138,7 +176,7 @@ defmodule MvWeb.CoreComponents do
"""
else
~H"""
<button class={["btn", @class]} disabled={@disabled} {@rest}>
<button class={["btn", @btn_class]} disabled={@disabled} {@rest}>
{render_slot(@inner_block)}
</button>
"""
@ -240,6 +278,42 @@ defmodule MvWeb.CoreComponents do
defp badge_style_class("outline"), do: "badge-outline"
defp badge_style_class(_), do: nil
@doc """
Wraps content with a DaisyUI tooltip. Use for icon-only actions, truncated content,
or status badges that need explanation (Design Guidelines §8.2).
## Examples
<.tooltip content={gettext("Edit")}>
<.button variant="icon" size="sm"><.icon name="hero-pencil" /></.button>
</.tooltip>
<.tooltip content={@full_name} position="top">
<span class="truncate max-w-32">{@full_name}</span>
</.tooltip>
"""
attr :content, :string, required: true, doc: "Tooltip text (data-tip)"
attr :position, :string,
values: ~w(top bottom left right),
default: "bottom"
attr :wrap_class, :string, default: nil, doc: "Additional classes for the wrapper"
slot :inner_block, required: true
def tooltip(assigns) do
position_class = "tooltip tooltip-#{assigns.position}"
wrap_class = [position_class, assigns.wrap_class] |> Enum.reject(&is_nil/1) |> Enum.join(" ")
assigns = assign(assigns, :wrap_class, wrap_class)
~H"""
<div class={@wrap_class} data-tip={@content}>
{render_slot(@inner_block)}
</div>
"""
end
@doc """
Renders a dropdown menu.
@ -532,7 +606,7 @@ defmodule MvWeb.CoreComponents do
{@rest}
/>{@label}<span
:if={@is_required}
class="text-red-700 tooltip tooltip-right"
class="text-error tooltip tooltip-right"
data-tip={gettext("This field is required")}
>*</span>
</span>
@ -551,7 +625,7 @@ defmodule MvWeb.CoreComponents do
<span :if={@label} class="mb-1 label">
{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
class="text-error tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
@ -580,7 +654,7 @@ defmodule MvWeb.CoreComponents do
<span :if={@label} class="mb-1 label">
{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
class="text-error tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
@ -609,7 +683,7 @@ defmodule MvWeb.CoreComponents do
<span :if={@label} class="mb-1 label">
{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
class="text-error tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
@ -654,17 +728,24 @@ defmodule MvWeb.CoreComponents do
@doc """
Renders a header with title.
Use the `:leading` slot for the Back button (left side, consistent with data fields).
Use the `:actions` slot for primary actions (e.g. Save) on the right.
"""
attr :class, :string, default: nil
slot :leading, doc: "Content on the left (e.g. Back button)"
slot :inner_block, required: true
slot :subtitle
slot :actions
def header(assigns) do
~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4", @class]}>
<div>
<header class={["flex items-center gap-6 pb-4", @class]}>
<div :if={@leading != []} class="shrink-0">
{render_slot(@leading)}
</div>
<div class="min-w-0 flex-1">
<h1 class="text-xl font-semibold leading-8">
{render_slot(@inner_block)}
</h1>
@ -672,7 +753,9 @@ defmodule MvWeb.CoreComponents do
{render_slot(@subtitle)}
</p>
</div>
<div class="flex gap-4 justify-end">{render_slot(@actions)}</div>
<div :if={@actions != []} class="shrink-0 flex gap-4 justify-end">
{render_slot(@actions)}
</div>
</header>
"""
end
@ -680,18 +763,51 @@ defmodule MvWeb.CoreComponents do
@doc ~S"""
Renders a table with generic styling.
When `row_click` is set, clicking a row (or a data cell) triggers the handler.
Rows with `row_click` get a subtle hover and focus-within outline (theme-friendly ring).
When `selected_row_id` is set and matches a row's id (via `row_value_id` or `row_item.(row).id`),
that row gets a stronger selected outline (ring-primary) for accessibility (not color-only).
The action column has no phx-click on its `<td>`, so action buttons do not trigger row navigation.
For interactive elements inside other columns (e.g. checkboxes, buttons), use
`Phoenix.LiveView.JS.stop_propagation()` in the element's phx-click so the row click is not fired.
## Examples
<.table id="users" rows={@users}>
<:col :let={user} label="id">{user.id}</:col>
<:col :let={user} label="username">{user.username}</:col>
</.table>
<.table id="members" rows={@members} row_click={fn m -> JS.navigate(~p"/members/#{m}") end} selected_row_id={@selected_member_id}>
<:col :let={m} label="Name">{m.name}</:col>
</.table>
"""
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :selected_row_id, :any,
default: nil,
doc:
"when set, the row whose id equals this value gets selected styling (single row, e.g. from URL)"
attr :row_selected?, :any,
default: nil,
doc:
"optional; function (row_item) -> boolean to mark multiple rows as selected (e.g. checkbox selection); overrides selected_row_id when set"
attr :row_tooltip, :string,
default: nil,
doc:
"optional; when row_click is set, tooltip text for the row (e.g. gettext(\"Click to view\")). Shown as title on hover and as sr-only for screen readers."
attr :row_value_id, :any,
default: nil,
doc:
"optional; function (row) -> id for comparing with selected_row_id; defaults to row_item.(row).id"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
@ -703,6 +819,11 @@ defmodule MvWeb.CoreComponents do
attr :sort_field, :any, default: nil, doc: "current sort field"
attr :sort_order, :atom, default: nil, doc: "current sort order"
attr :sticky_header, :boolean,
default: false,
doc:
"when true, thead th get lg:sticky lg:top-0 bg-base-100 z-10 for use inside a scroll container on desktop"
slot :col, required: true do
attr :label, :string
attr :class, :string
@ -720,6 +841,12 @@ defmodule MvWeb.CoreComponents do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
# Function to get the row's value id for selected_row_id comparison (no extra DB reads)
row_value_id_fn =
assigns[:row_value_id] || fn row -> assigns.row_item.(row).id end
assigns = assign(assigns, :row_value_id_fn, row_value_id_fn)
~H"""
<div class="overflow-auto">
<table class="table table-zebra">
@ -727,12 +854,12 @@ defmodule MvWeb.CoreComponents do
<tr>
<th
:for={col <- @col}
class={Map.get(col, :class)}
class={table_th_class(col, @sticky_header)}
aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)}
>
{col[:label]}
</th>
<th :for={dyn_col <- @dynamic_cols}>
<th :for={dyn_col <- @dynamic_cols} class={table_th_sticky_class(@sticky_header)}>
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
@ -742,15 +869,21 @@ defmodule MvWeb.CoreComponents do
sort_order={@sort_order}
/>
</th>
<th :if={@action != []}>
<th :if={@action != []} class={table_th_sticky_class(@sticky_header)}>
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<tr
:for={row <- @rows}
id={@row_id && @row_id.(row)}
class={table_row_tr_class(@row_click, table_row_selected?(assigns, row))}
data-selected={table_row_selected?(assigns, row) && "true"}
title={@row_click && @row_tooltip}
>
<td
:for={col <- @col}
:for={{col, col_idx} <- Enum.with_index(@col)}
phx-click={
(col[:col_click] && col[:col_click].(@row_item.(row))) ||
(@row_click && @row_click.(row))
@ -784,6 +917,9 @@ defmodule MvWeb.CoreComponents do
Enum.join(classes, " ")
}
>
<%= if col_idx == 0 && @row_click && @row_tooltip do %>
<span class="sr-only">{@row_tooltip}</span>
<% end %>
{render_slot(col, @row_item.(row))}
</td>
<td
@ -817,6 +953,43 @@ defmodule MvWeb.CoreComponents do
"""
end
# Returns true if the row is selected (via row_selected?/1 or selected_row_id match).
defp table_row_selected?(assigns, row) do
item = assigns.row_item.(row)
if assigns[:row_selected?] do
assigns.row_selected?.(item)
else
assigns[:selected_row_id] != nil and
assigns.row_value_id_fn.(row) == assigns.selected_row_id
end
end
# Returns CSS classes for table row: hover/focus-within outline when row_click is set,
# and stronger selected outline when selected (WCAG: not color-only).
# Hover/focus-within are omitted for the selected row so the selected ring stays visible.
defp table_row_tr_class(row_click, selected?) do
has_row_click? = not is_nil(row_click)
base = []
base =
if has_row_click? and not selected?,
do:
base ++
[
"hover:ring-2",
"hover:ring-inset",
"hover:ring-base-content/10",
"focus-within:ring-2",
"focus-within:ring-inset",
"focus-within:ring-base-content/10"
],
else: base
base = if selected?, do: base ++ ["ring-2", "ring-inset", "ring-primary"], else: base
Enum.join(base, " ")
end
defp table_th_aria_sort(col, sort_field, sort_order) do
col_sort = Map.get(col, :sort_field)
@ -827,6 +1000,18 @@ defmodule MvWeb.CoreComponents do
end
end
# Combines column class with optional sticky header classes (desktop only; theme-friendly bg).
defp table_th_class(col, sticky_header) do
base = Map.get(col, :class)
sticky = if sticky_header, do: "lg:sticky lg:top-0 bg-base-100 z-10", else: nil
[base, sticky] |> Enum.filter(& &1) |> Enum.join(" ")
end
defp table_th_sticky_class(true),
do: "lg:sticky lg:top-0 bg-base-100 z-10"
defp table_th_sticky_class(_), do: nil
@doc """
Renders a data list.

View file

@ -115,7 +115,11 @@ defmodule MvWeb.Layouts do
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite" class="z-50 flex flex-col gap-2 toast toast-top toast-end">
<div
id={@id}
aria-live="polite"
class="z-50 toast toast-bottom toast-end flex flex-col gap-2 pointer-events-none"
>
<.flash kind={:success} flash={@flash} />
<.flash kind={:warning} flash={@flash} />
<.flash kind={:info} flash={@flash} />

View file

@ -72,7 +72,7 @@
<div
id="flash-group-root"
aria-live="polite"
class="z-50 flex flex-col gap-2 toast toast-top toast-end"
class="z-50 flex flex-col gap-2 toast toast-bottom toast-end"
>
<.flash id="flash-success-root" kind={:success} flash={@flash} />
<.flash id="flash-warning-root" kind={:warning} flash={@flash} />

View file

@ -31,7 +31,7 @@ defmodule MvWeb.AuthController do
|> store_in_session(user)
# If your resource has a different name, update the assign name here (i.e :current_admin)
|> assign(:current_user, user)
|> put_flash(:info, message)
|> put_flash(:success, message)
|> redirect(to: return_to)
end
@ -322,7 +322,7 @@ defmodule MvWeb.AuthController do
conn
|> clear_session(:mv)
|> put_flash(:info, gettext("You are now signed out"))
|> put_flash(:success, gettext("You are now signed out"))
|> redirect(to: return_to)
end
end

View file

@ -81,7 +81,7 @@ defmodule MvWeb.LinkOidcAccountLive do
socket
|> put_flash(
:info,
:success,
dgettext("auth", "Account activated! Redirecting to complete sign-in...")
)
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/oidc")
@ -217,7 +217,7 @@ defmodule MvWeb.LinkOidcAccountLive do
{:noreply,
socket
|> put_flash(
:info,
:success,
dgettext(
"auth",
"Your OIDC account has been successfully linked! Redirecting to complete sign-in..."

View file

@ -188,7 +188,7 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
defp find_custom_field_name(id, _field_string, custom_fields) do
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == id end) do
nil -> gettext("Custom Field %{id}", id: id)
nil -> gettext("Datafield %{id}", id: id)
custom_field -> custom_field.name
end
end

View file

@ -65,11 +65,11 @@ defmodule MvWeb.Components.MemberFilterComponent do
phx-key="Escape"
phx-target={@myself}
>
<button
<.button
type="button"
tabindex="0"
variant="secondary"
class={[
"btn gap-2",
"gap-2",
(@cycle_status_filter || map_size(@group_filters) > 0 ||
active_boolean_filters_count(@boolean_filters) > 0) &&
"btn-active"
@ -107,8 +107,8 @@ defmodule MvWeb.Components.MemberFilterComponent do
>
{@member_count}
</.badge>
</button>
</.button>
<!--
NOTE: We use a div panel instead of ul.menu/li structure to avoid DaisyUI menu styles
(padding, display, hover, font sizes) that would interfere with our form controls.
@ -182,7 +182,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
</div>
</fieldset>
</div>
<!-- Groups: one row per group with All / Yes / No (like Custom Fields) -->
<div :if={length(@groups) > 0} class="mb-4">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
@ -249,11 +249,11 @@ defmodule MvWeb.Components.MemberFilterComponent do
</fieldset>
</div>
</div>
<!-- Custom Fields Group -->
<div :if={length(@boolean_custom_fields) > 0} class="mb-2">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
{gettext("Custom Fields")}
{gettext("Individual datafields")}
</div>
<div class="max-h-60 overflow-y-auto pr-2">
<fieldset
@ -316,25 +316,27 @@ defmodule MvWeb.Components.MemberFilterComponent do
</fieldset>
</div>
</div>
<!-- Footer -->
<div class="mt-4 flex justify-between pt-3 border-t border-base-200">
<button
<.button
type="button"
variant="neutral"
size="sm"
phx-click="reset_filters"
phx-target={@myself}
class="btn btn-sm"
>
{gettext("Reset")}
</button>
<button
{gettext("Clear filters")}
</.button>
<.button
type="button"
variant="primary"
size="sm"
phx-click="close_dropdown"
phx-target={@myself}
class="btn btn-primary btn-sm"
>
{gettext("Close")}
</button>
</.button>
</div>
</form>
</div>
@ -459,7 +461,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
boolean_filter_label(boolean_custom_fields, boolean_filters)
true ->
gettext("All")
gettext("Apply filters")
end
end

View file

@ -19,25 +19,28 @@ defmodule MvWeb.Components.SortHeaderComponent do
@impl true
def render(assigns) do
~H"""
<div class="tooltip tooltip-bottom" data-tip={aria_sort(@field, @sort_field, @sort_order)}>
<button
type="button"
aria-label={aria_sort(@field, @sort_field, @sort_order)}
class="btn btn-ghost select-none"
phx-click="sort"
phx-value-field={@field}
data-testid={@field}
>
{@label}
<%= if @sort_field == @field do %>
<.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} />
<% else %>
<.icon
name="hero-chevron-up-down"
class="opacity-40"
/>
<% end %>
</button>
<div>
<.tooltip content={aria_sort(@field, @sort_field, @sort_order)} position="bottom">
<.button
type="button"
variant="ghost"
aria-label={aria_sort(@field, @sort_field, @sort_order)}
class="select-none"
phx-click="sort"
phx-value-field={@field}
data-testid={@field}
>
{@label}
<%= if @sort_field == @field do %>
<.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} />
<% else %>
<.icon
name="hero-chevron-up-down"
class="opacity-40"
/>
<% end %>
</.button>
</.tooltip>
</div>
"""
end

View file

@ -24,11 +24,13 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
<div class="flex items-center gap-4 mb-4">
<.button
type="button"
variant="neutral"
phx-click="cancel"
phx-target={@myself}
aria-label={gettext("Back to settings")}
>
<.icon name="hero-arrow-left" class="w-4 h-4" />
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<h3 class="card-title">
{if @custom_field, do: gettext("Edit Data Field"), else: gettext("New Data Field")}
@ -96,8 +98,35 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
label={gettext("Show in overview")}
/>
<%= if @custom_field do %>
<%!-- Danger zone: canonical pattern (same as member form) --%>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4">
{gettext(
"Deleting this data field cannot be undone. All datafield values for this field will be permanently removed."
)}
</p>
<.button
type="button"
variant="danger"
phx-click="request_delete"
phx-target={@myself}
data-testid="custom-field-delete"
aria-label={gettext("Delete data field %{name}", name: @custom_field.name)}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete data field")}
</.button>
</div>
</section>
<% end %>
<div class="justify-end mt-4 card-actions">
<.button type="button" phx-click="cancel" phx-target={@myself}>
<.button type="button" variant="neutral" phx-click="cancel" phx-target={@myself}>
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
@ -168,6 +197,15 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
{:noreply, socket}
end
@impl true
def handle_event("request_delete", _params, socket) do
if custom_field = socket.assigns[:custom_field] do
send(self(), {:open_delete_modal_for, custom_field})
end
{:noreply, socket}
end
defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do
form =
if custom_field do

View file

@ -59,6 +59,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
end
}
row_tooltip={gettext("Click to edit datafield")}
>
<:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col>
@ -95,22 +96,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
{gettext("No")}
</.badge>
</:col>
<:action :let={{_id, custom_field}}>
<.link phx-click={
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
}>
{gettext("Edit")}
</.link>
</:action>
<:action :let={{_id, custom_field}}>
<.link phx-click={
JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)
}>
{gettext("Delete")}
</.link>
</:action>
</.table>
</div>
@ -164,17 +149,17 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
</div>
<div class="modal-action">
<button phx-click="cancel_delete" phx-target={@myself} class="btn">
<.button variant="neutral" phx-click="cancel_delete" phx-target={@myself}>
{gettext("Cancel")}
</button>
<button
</.button>
<.button
variant="danger"
phx-click="confirm_delete"
phx-target={@myself}
class="btn btn-error"
disabled={@slug_confirmation != @custom_field_to_delete.slug}
>
{gettext("Delete Custom Field and All Values")}
</button>
{gettext("Delete Datafields and All Values")}
</.button>
</div>
</div>
</dialog>
@ -222,16 +207,38 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
# Get actor from assigns or fall back to socket assigns
actor = Map.get(assigns, :actor, socket.assigns[:actor])
{:ok,
socket
|> assign(assigns)
|> assign_new(:show_form, fn -> false end)
|> assign_new(:form_id, fn -> "custom-field-form-new" end)
|> assign_new(:editing_custom_field, fn -> nil end)
|> assign_new(:show_delete_modal, fn -> false end)
|> assign_new(:custom_field_to_delete, fn -> nil end)
|> assign_new(:slug_confirmation, fn -> "" end)
|> stream(:custom_fields, stream_custom_fields(actor, self()), reset: true)}
socket =
socket
|> assign(assigns)
|> assign_new(:show_form, fn -> false end)
|> assign_new(:form_id, fn -> "custom-field-form-new" end)
|> assign_new(:editing_custom_field, fn -> nil end)
|> assign_new(:show_delete_modal, fn -> false end)
|> assign_new(:custom_field_to_delete, fn -> nil end)
|> assign_new(:slug_confirmation, fn -> "" end)
|> stream(:custom_fields, stream_custom_fields(actor, self()), reset: true)
# Open delete modal when requested from form (e.g. Danger zone in FormComponent)
socket =
case Map.get(assigns, :open_delete_for_id) do
nil ->
socket
id ->
custom_field =
Ash.get!(Mv.Membership.CustomField, id,
load: [:assigned_members_count],
actor: actor
)
socket
|> assign(:show_delete_modal, true)
|> assign(:custom_field_to_delete, custom_field)
|> assign(:slug_confirmation, "")
|> assign(:open_delete_for_id, nil)
end
{:ok, socket}
end
@impl true

View file

@ -64,12 +64,12 @@ defmodule MvWeb.DatafieldsLive do
{:noreply,
socket
|> assign(:active_editing_section, nil)
|> put_flash(:info, gettext("Data field %{action} successfully", action: action))}
|> put_flash(:success, gettext("Data field %{action} successfully", action: action))}
end
@impl true
def handle_info({:custom_field_deleted, _custom_field}, socket) do
{:noreply, put_flash(socket, :info, gettext("Data field deleted successfully"))}
{:noreply, put_flash(socket, :success, gettext("Data field deleted successfully"))}
end
@impl true
@ -101,6 +101,17 @@ defmodule MvWeb.DatafieldsLive do
{:noreply, assign(socket, :active_editing_section, section)}
end
# Open delete modal for custom field (triggered from Danger zone in FormComponent)
@impl true
def handle_info({:open_delete_modal_for, custom_field}, socket) do
send_update(MvWeb.CustomFieldLive.IndexComponent,
id: "custom-fields-component",
open_delete_for_id: custom_field.id
)
{:noreply, socket}
end
@impl true
def handle_info({:member_field_saved, _member_field, action}, socket) do
{:ok, updated_settings} = Membership.get_settings()
@ -115,7 +126,7 @@ defmodule MvWeb.DatafieldsLive do
socket
|> assign(:settings, updated_settings)
|> assign(:active_editing_section, nil)
|> put_flash(:info, gettext("Member field %{action} successfully", action: action))}
|> put_flash(:success, gettext("Member field %{action} successfully", action: action))}
end
@impl true

View file

@ -95,7 +95,7 @@ defmodule MvWeb.GlobalSettingsLive do
</div>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Settings")}
{gettext("Save Name")}
</.button>
</.form>
</.form_section>
@ -183,18 +183,18 @@ defmodule MvWeb.GlobalSettingsLive do
<.button
:if={Mv.Config.vereinfacht_configured?()}
type="button"
variant="outline"
phx-click="test_vereinfacht_connection"
phx-disable-with={gettext("Testing...")}
class="btn-outline"
>
{gettext("Test Integration")}
</.button>
<.button
:if={Mv.Config.vereinfacht_configured?()}
type="button"
variant="outline"
phx-click="sync_vereinfacht_contacts"
phx-disable-with={gettext("Syncing...")}
class="btn-outline"
>
{gettext("Sync all members without Vereinfacht contact")}
</.button>
@ -361,20 +361,21 @@ defmodule MvWeb.GlobalSettingsLive do
errors_with_names = enrich_sync_errors(errors)
result = %{synced: synced, errors: errors_with_names}
{flash_kind, flash_message} =
if(errors_with_names == [],
do: {:success, gettext("Synced %{count} member(s) to Vereinfacht.", count: synced)},
else:
{:warning,
gettext("Synced %{count} member(s). %{error_count} failed.",
count: synced,
error_count: length(errors_with_names)
)}
)
socket =
socket
|> assign(:last_vereinfacht_sync_result, result)
|> put_flash(
:info,
if(errors_with_names == [],
do: gettext("Synced %{count} member(s) to Vereinfacht.", count: synced),
else:
gettext("Synced %{count} member(s). %{error_count} failed.",
count: synced,
error_count: length(errors_with_names)
)
)
)
|> put_flash(flash_kind, flash_message)
{:noreply, socket}
@ -413,7 +414,7 @@ defmodule MvWeb.GlobalSettingsLive do
|> assign(:oidc_client_secret_set, present?(fresh_settings.oidc_client_secret))
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|> assign(:vereinfacht_test_result, test_result)
|> put_flash(:info, gettext("Settings updated successfully"))
|> put_flash(:success, gettext("Settings updated successfully"))
|> assign_form()
{:noreply, socket}

View file

@ -78,30 +78,56 @@ defmodule MvWeb.GroupLive.Form do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.form for={@form} id="group-form" phx-change="validate" phx-submit="save">
<%!-- Header with Back button, Title, and Save button --%>
<div class="flex items-center justify-between gap-4 pb-4">
<.button navigate={return_path(@return_to, @group)} type="button">
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<.header>
<:leading>
<.button navigate={return_path(@return_to, @group)} variant="neutral">
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
</:leading>
{@page_title}
<:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</:actions>
</.header>
<h1 class="text-2xl font-bold text-center flex-1">
{@page_title}
</h1>
<div class="mt-6 space-y-6">
<div class="max-w-2xl space-y-4">
<.input field={@form[:name]} label={gettext("Name")} required />
<.input
field={@form[:description]}
type="textarea"
label={gettext("Description")}
rows="4"
/>
</div>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</div>
<div class="max-w-2xl space-y-4">
<.input field={@form[:name]} label={gettext("Name")} required />
<.input
field={@form[:description]}
type="textarea"
label={gettext("Description")}
rows="4"
/>
<%!-- Danger zone: canonical pattern (same as member form) --%>
<%= if @group && can?(@current_user, :destroy, @group) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4">
{gettext(
"Deleting this group cannot be undone. All member-group associations will be permanently removed."
)}
</p>
<.button
variant="danger"
navigate={~p"/groups/#{@group.slug}?confirm_delete=1"}
data-testid="group-form-delete-btn"
aria-label={gettext("Delete group %{name}", name: @group.name)}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete group")}
</.button>
</div>
</section>
<% end %>
</div>
</.form>
</Layouts.app>
@ -129,7 +155,7 @@ defmodule MvWeb.GroupLive.Form do
socket =
socket
|> put_flash(:info, gettext("Group saved successfully."))
|> put_flash(:success, gettext("Group saved successfully."))
|> push_navigate(to: redirect_path)
{:noreply, socket}

View file

@ -39,72 +39,47 @@ defmodule MvWeb.GroupLive.Index do
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{gettext("Groups")}</h1>
<%= if can?(@current_user, :create, Mv.Membership.Group) do %>
<.button navigate={~p"/groups/new"} variant="primary">
<.icon name="hero-plus" class="size-4 mr-2" />
{gettext("Create Group")}
</.button>
<.header>
{gettext("Groups")}
<:actions>
<%= if can?(@current_user, :create, Mv.Membership.Group) do %>
<.button navigate={~p"/groups/new"} variant="primary">
<.icon name="hero-plus" class="size-4 mr-2" />
{gettext("Create Group")}
</.button>
<% end %>
</:actions>
</.header>
<div class="mt-6 space-y-6">
<%= if Enum.empty?(@groups) do %>
<div class="text-center py-12">
<p class="text-base-content/60 italic">{gettext("No groups")}</p>
</div>
<% else %>
<.table
id="groups-table"
rows={@groups}
row_id={fn group -> "group-#{group.id}" end}
row_click={fn group -> JS.navigate(~p"/groups/#{group.slug}") end}
row_tooltip={gettext("Click for group details")}
>
<:col :let={group} label={gettext("Name")}>
{group.name}
</:col>
<:col :let={group} label={gettext("Description")}>
<%= if group.description do %>
{group.description}
<% else %>
<span class="text-base-content/50 italic"></span>
<% end %>
</:col>
<:col :let={group} label={gettext("Members")} class="text-right">
{group.member_count || 0}
</:col>
</.table>
<% end %>
</div>
<%= if Enum.empty?(@groups) do %>
<div class="text-center py-12">
<p class="text-base-content/70">{gettext("No groups")}</p>
</div>
<% else %>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>{gettext("Name")}</th>
<th>{gettext("Description")}</th>
<th>{gettext("Members")}</th>
<th>{gettext("Actions")}</th>
</tr>
</thead>
<tbody>
<%= for group <- @groups do %>
<tr>
<td>
{group.name}
</td>
<td>
<%= if group.description do %>
{group.description}
<% else %>
<span class="text-base-content/50 italic"></span>
<% end %>
</td>
<td>
<%= if group.member_count do %>
{group.member_count}
<% else %>
0
<% end %>
</td>
<td>
<div class="flex gap-2">
<.link navigate={~p"/groups/#{group.slug}"} class="btn btn-sm btn-ghost">
{gettext("View")}
</.link>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<.link
navigate={~p"/groups/#{group.slug}/edit"}
class="btn btn-sm btn-ghost"
>
{gettext("Edit")}
</.link>
<% end %>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</Layouts.app>
"""
end

View file

@ -39,18 +39,18 @@ defmodule MvWeb.GroupLive.Show do
end
@impl true
def handle_params(%{"slug" => slug}, _url, socket) do
def handle_params(%{"slug" => slug} = params, _url, socket) do
actor = current_actor(socket)
# Check if user can read groups
if can?(actor, :read, Mv.Membership.Group) do
load_group_by_slug(socket, slug, actor)
load_group_by_slug(socket, slug, actor, params)
else
{:noreply, redirect(socket, to: ~p"/members")}
end
end
defp load_group_by_slug(socket, slug, actor) do
defp load_group_by_slug(socket, slug, actor, params) do
# Load group with members and member_count
# Using explicit load ensures efficient preloading of members relationship
require Ash.Query
@ -68,10 +68,16 @@ defmodule MvWeb.GroupLive.Show do
|> redirect(to: ~p"/groups")}
{:ok, group} ->
{:noreply,
socket
|> assign(:page_title, group.name)
|> assign(:group, group)}
open_delete = params["confirm_delete"] == "1" && can?(actor, :destroy, group)
socket =
socket
|> assign(:page_title, group.name)
|> assign(:group, group)
|> assign(:show_delete_modal, open_delete)
|> assign(:name_confirmation, "")
{:noreply, socket}
{:error, _error} ->
{:noreply,
@ -85,51 +91,44 @@ defmodule MvWeb.GroupLive.Show do
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<%!-- Header with Back button, Name, and Edit/Delete buttons --%>
<div class="flex items-center justify-between gap-4 pb-4">
<.button navigate={~p"/groups"} aria-label={gettext("Back to groups list")}>
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<h1 class="text-2xl font-bold text-center flex-1">
{@group.name}
</h1>
<div class="flex gap-2">
<.header>
<:leading>
<.button
navigate={~p"/groups"}
variant="neutral"
aria-label={gettext("Back to groups list")}
>
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
</:leading>
{@group.name}
<:actions>
<%= if can?(@current_user, :update, @group) do %>
<.button
variant="primary"
navigate={~p"/groups/#{@group.slug}/edit"}
data-testid="group-show-edit-btn"
>
{gettext("Edit")}
<.icon name="hero-pencil-square" /> {gettext("Edit group")}
</.button>
<% end %>
<%= if can?(@current_user, :destroy, @group) do %>
<.button
class="btn-error"
phx-click="open_delete_modal"
data-testid="group-show-delete-btn"
>
{gettext("Delete")}
</.button>
<% end %>
</div>
</div>
</:actions>
</.header>
<%!-- Group Information --%>
<div class="max-w-2xl space-y-6 mb-6">
<div>
<h2 class="text-lg font-semibold mb-2">{gettext("Description")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<%= if @group.description && String.trim(@group.description) != "" do %>
<p class="whitespace-pre-wrap">{@group.description}</p>
<% else %>
<p class="text-base-content/50 italic">{gettext("No description")}</p>
<% end %>
<div class="mt-6 space-y-6">
<%!-- Group Information --%>
<div class="max-w-2xl space-y-6 mb-6">
<div>
<h2 class="text-lg font-semibold mb-2">{gettext("Description")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<%= if @group.description && String.trim(@group.description) != "" do %>
<p class="whitespace-pre-wrap">{@group.description}</p>
<% else %>
<p class="text-base-content/50 italic">{gettext("No description")}</p>
<% end %>
</div>
</div>
</div>
<div>
<h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2>
@ -150,22 +149,26 @@ defmodule MvWeb.GroupLive.Show do
<form phx-change="search_members" class="flex-1">
<div class="relative">
<div class="input input-bordered join-item w-full flex flex-wrap gap-1 items-center py-1 px-2">
<%= for member <- @selected_members do %>
<%= for member <- @selected_members do %>
<.badge variant="primary" style="outline" class="flex items-center gap-1">
{MvWeb.Helpers.MemberHelpers.display_name(member)}
<button
type="button"
class="btn btn-ghost btn-xs p-0 h-4 w-4 min-h-0"
phx-click="remove_selected_member"
phx-value-member_id={member.id}
aria-label={
gettext("Remove %{name}",
name: MvWeb.Helpers.MemberHelpers.display_name(member)
)
}
>
<.icon name="hero-x-mark" class="size-3" />
</button>
<.tooltip content={gettext("Remove")} position="top">
<.button
type="button"
variant="icon"
size="sm"
phx-click="remove_selected_member"
phx-value-member_id={member.id}
aria-label={
gettext("Remove %{name}",
name: MvWeb.Helpers.MemberHelpers.display_name(member)
)
}
class="p-0 h-4 w-4 min-h-0"
>
<.icon name="hero-x-mark" class="size-3" />
</.button>
</.tooltip>
</.badge>
<% end %>
<input
@ -236,24 +239,26 @@ defmodule MvWeb.GroupLive.Show do
<% end %>
</div>
</form>
<button
<.button
type="button"
class="btn btn-primary join-item"
variant="primary"
phx-click="add_selected_members"
data-testid="group-show-add-selected-members-btn"
disabled={Enum.empty?(@selected_member_ids)}
aria-label={gettext("Add members")}
class="join-item"
>
<.icon name="hero-plus" class="size-5" />
</button>
<button
</.button>
<.button
type="button"
class="btn join-item"
variant="neutral"
phx-click="hide_add_member_input"
aria-label={gettext("Cancel")}
class="join-item"
>
{gettext("Cancel")}
</button>
</.button>
</div>
<% else %>
<.button
@ -268,135 +273,164 @@ defmodule MvWeb.GroupLive.Show do
<% end %>
<%= if Enum.empty?(@group.members || []) do %>
<p class="text-base-content/50 italic" data-testid="group-show-no-members">
{gettext("No members in this group")}
</p>
<% else %>
<div class="overflow-x-auto" data-testid="group-show-members-table">
<table class="table table-zebra">
<thead>
<tr>
<th>{gettext("Name")}</th>
<th>{gettext("Email")}</th>
<%= if can?(@current_user, :update, @group) do %>
<th class="w-0">{gettext("Actions")}</th>
<% end %>
</tr>
</thead>
<tbody>
<%= for member <- @group.members do %>
<p class="text-base-content/50 italic" data-testid="group-show-no-members">
{gettext("No members in this group")}
</p>
<% else %>
<div class="overflow-x-auto" data-testid="group-show-members-table">
<table class="table table-zebra">
<thead>
<tr>
<td>
<.link
navigate={~p"/members/#{member.id}"}
class="link link-primary"
>
{MvWeb.Helpers.MemberHelpers.display_name(member)}
</.link>
</td>
<td>
<%= if member.email do %>
<a
href={"mailto:#{member.email}"}
class="text-blue-700 hover:text-blue-800 underline"
>
{member.email}
</a>
<% else %>
<span class="text-base-content/50 italic"></span>
<% end %>
</td>
<th>{gettext("Name")}</th>
<th>{gettext("Email")}</th>
<%= if can?(@current_user, :update, @group) do %>
<td>
<button
type="button"
class="btn btn-ghost btn-sm text-error"
phx-click="remove_member"
phx-value-member_id={member.id}
data-testid="group-show-remove-member"
aria-label={gettext("Remove member from group")}
data-tooltip={gettext("Remove")}
>
<.icon name="hero-trash" class="size-4" />
</button>
</td>
<th class="w-0">{gettext("Actions")}</th>
<% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</thead>
<tbody>
<%= for member <- @group.members do %>
<tr>
<td>
<.link
navigate={~p"/members/#{member.id}"}
class="link link-primary"
>
{MvWeb.Helpers.MemberHelpers.display_name(member)}
</.link>
</td>
<td>
<%= if member.email do %>
<a
href={"mailto:#{member.email}"}
class="link link-primary"
>
{member.email}
</a>
<% else %>
<span class="text-base-content/50 italic"></span>
<% end %>
</td>
<%= if can?(@current_user, :update, @group) do %>
<td>
<.tooltip content={gettext("Remove")} position="left">
<.button
type="button"
variant="danger"
size="sm"
phx-click="remove_member"
phx-value-member_id={member.id}
data-testid="group-show-remove-member"
aria-label={gettext("Remove member from group")}
>
<.icon name="hero-trash" class="size-4" />
</.button>
</.tooltip>
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
</div>
</div>
</div>
<%!-- Delete Confirmation Modal --%>
<%= if assigns[:show_delete_modal] do %>
<dialog id="delete-group-modal" class="modal modal-open" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold mb-4">{gettext("Delete Group")}</h3>
<p class="mb-4">
{gettext("Are you sure you want to delete this group? This action cannot be undone.")}
</p>
<%= if @group.member_count && @group.member_count > 0 do %>
<div class="alert alert-warning mb-4">
<.icon name="hero-exclamation-triangle" class="size-5" />
<span>
{ngettext(
"This group has %{count} member. All member-group associations will be permanently deleted.",
"This group has %{count} members. All member-group associations will be permanently deleted.",
@group.member_count,
count: @group.member_count
)}
</span>
</div>
<% end %>
<div>
<label for="group-name-confirmation" class="label">
<span class="label-text">
{gettext("To confirm deletion, please enter the group name:")}
</span>
</label>
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
{@group.name}
</div>
<form phx-change="update_name_confirmation">
<input
id="group-name-confirmation"
name="name"
type="text"
value={@name_confirmation || ""}
placeholder={gettext("Enter the group name to confirm")}
autocomplete="off"
phx-debounce="200"
class="w-full input input-bordered"
/>
</form>
</div>
<div class="modal-action">
<button
<%!-- Danger zone: canonical pattern (same as member show) --%>
<%= if can?(@current_user, :destroy, @group) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4">
{gettext(
"Deleting this group cannot be undone. All member-group associations will be permanently removed."
)}
</p>
<.button
variant="danger"
type="button"
class="btn"
phx-click="cancel_delete"
aria-label={gettext("Cancel")}
phx-click="open_delete_modal"
data-testid="group-show-delete-btn"
aria-label={gettext("Delete group %{name}", name: @group.name)}
>
{gettext("Cancel")}
</button>
<button
type="button"
class="btn btn-error"
phx-click="confirm_delete"
phx-value-slug={@group.slug}
disabled={(@name_confirmation || "") != @group.name}
aria-label={gettext("Delete group")}
>
{gettext("Delete")}
</button>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete group")}
</.button>
</div>
</div>
</dialog>
<% end %>
</section>
<% end %>
<%!-- Delete Confirmation Modal --%>
<%= if assigns[:show_delete_modal] do %>
<dialog id="delete-group-modal" class="modal modal-open" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold mb-4">{gettext("Delete Group")}</h3>
<p class="mb-4">
{gettext("Are you sure you want to delete this group? This action cannot be undone.")}
</p>
<%= if @group.member_count && @group.member_count > 0 do %>
<div class="alert alert-warning mb-4">
<.icon name="hero-exclamation-triangle" class="size-5" />
<span>
{ngettext(
"This group has %{count} member. All member-group associations will be permanently deleted.",
"This group has %{count} members. All member-group associations will be permanently deleted.",
@group.member_count,
count: @group.member_count
)}
</span>
</div>
<% end %>
<div>
<label for="group-name-confirmation" class="label">
<span class="label-text">
{gettext("To confirm deletion, please enter the group name:")}
</span>
</label>
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
{@group.name}
</div>
<form phx-change="update_name_confirmation">
<input
id="group-name-confirmation"
name="name"
type="text"
value={@name_confirmation || ""}
placeholder={gettext("Enter the group name to confirm")}
autocomplete="off"
phx-debounce="200"
class="w-full input input-bordered"
/>
</form>
</div>
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_delete"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</.button>
<.button
type="button"
variant="danger"
phx-click="confirm_delete"
phx-value-slug={@group.slug}
disabled={(@name_confirmation || "") != @group.name}
aria-label={gettext("Delete group")}
>
{gettext("Delete")}
</.button>
</div>
</div>
</dialog>
<% end %>
</div>
</Layouts.app>
"""
end
@ -900,7 +934,7 @@ defmodule MvWeb.GroupLive.Show do
:ok ->
{:noreply,
socket
|> put_flash(:info, gettext("Group deleted successfully."))
|> put_flash(:success, gettext("Group deleted successfully."))
|> redirect(to: ~p"/groups")}
{:error, error} ->

View file

@ -42,11 +42,13 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
<div class="flex items-center gap-4 mb-4">
<.button
type="button"
variant="neutral"
phx-click="cancel"
phx-target={@myself}
aria-label={gettext("Back to Settings")}
aria-label={gettext("Back to settings")}
>
<.icon name="hero-arrow-left" class="w-4 h-4" />
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<h3 class="card-title">
{gettext("Edit Field: %{field}", field: @field_label)}
@ -176,7 +178,7 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
</div>
<div class="justify-end mt-4 card-actions">
<.button type="button" phx-click="cancel" phx-target={@myself}>
<.button type="button" variant="neutral" phx-click="cancel" phx-target={@myself}>
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary">

View file

@ -52,6 +52,12 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
:if={!@show_form}
id="member_fields"
rows={@member_fields}
row_click={
fn {field_name, _field_data} ->
JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself)
end
}
row_tooltip={gettext("Click to edit datafield")}
>
<:col :let={{_field_name, field_data}} label={gettext("Name")}>
{MemberFields.label(field_data.field)}
@ -86,16 +92,6 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
{gettext("No")}
</.badge>
</:col>
<:action :let={{_field_name, field_data}}>
<.link
phx-click="edit_member_field"
phx-value-field={Atom.to_string(field_data.field)}
phx-target={@myself}
>
{gettext("Edit")}
</.link>
</:action>
</.table>
</div>
"""

View file

@ -6,7 +6,6 @@ defmodule MvWeb.MemberLive.Form do
- Create new members with personal information
- Edit existing member details
- Grouped sections for better organization
- Tab navigation (Payments tab disabled, coming soon)
- Manage custom properties (dynamic fields, displayed sorted by name)
- Real-time validation with visual feedback
@ -21,6 +20,7 @@ defmodule MvWeb.MemberLive.Form do
"""
use MvWeb, :live_view
require Logger
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
alias Mv.Membership
@ -38,222 +38,248 @@ defmodule MvWeb.MemberLive.Form do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
<%!-- Header with Back button, Name display, and Save button --%>
<div class="flex items-center justify-between gap-4 pb-4">
<.button navigate={return_path(@return_to, @member)} type="button">
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<.header>
<:leading>
<.button navigate={return_path(@return_to, @member)} variant="neutral">
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
</:leading>
<%= if @member do %>
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
<% else %>
{gettext("New Member")}
<% end %>
<:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</:actions>
</.header>
<h1 class="text-2xl font-bold text-center flex-1">
<%= if @member do %>
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
<% else %>
{gettext("New Member")}
<% end %>
</h1>
<div class="mt-6 space-y-6">
<%!-- Tab navigation: Payments tab not shown on new/edit (only on member show) --%>
<div role="tablist" class="tabs tabs-bordered">
<button type="button" role="tab" class="tab tab-active" aria-selected="true">
<.icon name="hero-identification" class="size-4 mr-2" />
{gettext("Contact Data")}
</button>
</div>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</div>
<%!-- Tab Navigation --%>
<div role="tablist" class="tabs tabs-bordered mb-6">
<button type="button" role="tab" class="tab tab-active" aria-selected="true">
<.icon name="hero-identification" class="size-4 mr-2" />
{gettext("Contact Data")}
</button>
<button
type="button"
role="tab"
class="tab"
disabled
aria-disabled="true"
title={gettext("Coming soon")}
>
<.icon name="hero-credit-card" class="size-4 mr-2" />
{gettext("Payments")}
</button>
</div>
<%!-- Personal Data and Custom Fields Row --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<%!-- Personal Data Section --%>
<div>
<.form_section title={gettext("Personal Data")}>
<div class="space-y-4">
<%!-- Name Row --%>
<div class="flex gap-4">
<div class="w-48">
<.input
field={@form[:first_name]}
label={gettext("First Name")}
required={@member_field_required_map[:first_name]}
/>
<%!-- Personal Data and Custom Fields Row --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<%!-- Personal Data Section --%>
<div>
<.form_section title={gettext("Personal Data")}>
<div class="space-y-4">
<%!-- Name Row --%>
<div class="flex gap-4">
<div class="w-48">
<.input
field={@form[:first_name]}
label={gettext("First Name")}
required={@member_field_required_map[:first_name]}
/>
</div>
<div class="w-48">
<.input
field={@form[:last_name]}
label={gettext("Last Name")}
required={@member_field_required_map[:last_name]}
/>
</div>
</div>
<div class="w-48">
<.input
field={@form[:last_name]}
label={gettext("Last Name")}
required={@member_field_required_map[:last_name]}
/>
</div>
</div>
<%!-- Address: Country, Postal Code, City in one row --%>
<div class="flex gap-4">
<div class="w-48">
<.input field={@form[:country]} label={gettext("Country")} />
<%!-- Address: Country, Postal Code, City in one row --%>
<div class="flex gap-4">
<div class="w-48">
<.input field={@form[:country]} label={gettext("Country")} />
</div>
<div class="w-24">
<.input
field={@form[:postal_code]}
label={gettext("Postal Code")}
required={@member_field_required_map[:postal_code]}
/>
</div>
<div class="w-48">
<.input field={@form[:city]} label={gettext("City")} />
</div>
</div>
<div class="w-24">
<.input
field={@form[:postal_code]}
label={gettext("Postal Code")}
required={@member_field_required_map[:postal_code]}
/>
</div>
<div class="w-48">
<.input field={@form[:city]} label={gettext("City")} />
</div>
</div>
<%!-- Street and Nr. below --%>
<div class="flex gap-4">
<%!-- Street and Nr. below --%>
<div class="flex gap-4">
<div class="w-64">
<.input field={@form[:street]} label={gettext("Street")} />
</div>
<div class="w-24">
<.input field={@form[:house_number]} label={gettext("Nr.")} />
</div>
</div>
<%!-- Email --%>
<div class="w-64">
<.input field={@form[:street]} label={gettext("Street")} />
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
</div>
<div class="w-24">
<.input field={@form[:house_number]} label={gettext("Nr.")} />
</div>
</div>
<%!-- Email --%>
<div class="w-64">
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
</div>
<%!-- Membership Dates Row --%>
<div class="flex gap-4">
<div class="w-36">
<.input
field={@form[:join_date]}
label={gettext("Join Date")}
type="date"
required={@member_field_required_map[:join_date]}
/>
<%!-- Membership Dates Row --%>
<div class="flex gap-4">
<div class="w-36">
<.input
field={@form[:join_date]}
label={gettext("Join Date")}
type="date"
required={@member_field_required_map[:join_date]}
/>
</div>
<div class="w-36">
<.input
field={@form[:exit_date]}
label={gettext("Exit Date")}
type="date"
required={@member_field_required_map[:exit_date]}
/>
</div>
</div>
<div class="w-36">
<%!-- Notes --%>
<div>
<.input
field={@form[:exit_date]}
label={gettext("Exit Date")}
type="date"
required={@member_field_required_map[:exit_date]}
field={@form[:notes]}
label={gettext("Notes")}
type="textarea"
required={@member_field_required_map[:notes]}
/>
</div>
</div>
</.form_section>
</div>
<%!-- Notes --%>
<%!-- Custom Fields Section --%>
<%= if Enum.any?(@custom_fields) do %>
<div>
<.form_section title={gettext("Custom Fields")}>
<div class="grid grid-cols-2 gap-4">
<%!-- Render in sorted order by finding the form for each sorted custom field --%>
<%= for cf <- @sorted_custom_fields do %>
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
<%= if f_cfv[:custom_field_id].value == cf.id do %>
<div class={if cf.value_type == :boolean, do: "flex items-end", else: ""}>
<.inputs_for :let={value_form} field={f_cfv[:value]}>
<.input
field={value_form[:value]}
label={cf.name}
type={custom_field_input_type(cf.value_type)}
required={cf.required}
/>
</.inputs_for>
<input
type="hidden"
name={f_cfv[:custom_field_id].name}
value={f_cfv[:custom_field_id].value}
/>
</div>
<% end %>
</.inputs_for>
<% end %>
</div>
</.form_section>
</div>
<% end %>
</div>
<%!-- Membership Fee Section --%>
<div class="max-w-xl">
<.form_section title={gettext("Membership Fee")}>
<div class="space-y-4">
<div>
<.input
field={@form[:notes]}
label={gettext("Notes")}
type="textarea"
required={@member_field_required_map[:notes]}
/>
<label class="label">
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
</label>
<select
class="select select-bordered w-full"
name={@form[:membership_fee_type_id].name}
phx-change="validate"
value={@form[:membership_fee_type_id].value || ""}
>
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
<option value="">{gettext("Select a membership fee type")}</option>
<%= for fee_type <- @available_fee_types do %>
<option
value={fee_type.id}
selected={fee_type.id == @form[:membership_fee_type_id].value}
>
{fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
fee_type.interval
)})
</option>
<% end %>
</select>
<%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
<p class="text-error text-sm mt-1">{msg}</p>
<% end %>
<%= if @interval_warning do %>
<div class="alert alert-warning mt-2">
<.icon name="hero-exclamation-triangle" class="size-5" />
<span>{@interval_warning}</span>
</div>
<% end %>
<p class="text-sm text-base-content/60 mt-2">
{gettext(
"Select a membership fee type for this member. Members can only switch between types with the same interval."
)}
</p>
</div>
</div>
</.form_section>
</div>
<%!-- Custom Fields Section --%>
<%= if Enum.any?(@custom_fields) do %>
<div>
<.form_section title={gettext("Custom Fields")}>
<div class="grid grid-cols-2 gap-4">
<%!-- Render in sorted order by finding the form for each sorted custom field --%>
<%= for cf <- @sorted_custom_fields do %>
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
<%= if f_cfv[:custom_field_id].value == cf.id do %>
<div class={if cf.value_type == :boolean, do: "flex items-end", else: ""}>
<.inputs_for :let={value_form} field={f_cfv[:value]}>
<.input
field={value_form[:value]}
label={cf.name}
type={custom_field_input_type(cf.value_type)}
required={cf.required}
/>
</.inputs_for>
<input
type="hidden"
name={f_cfv[:custom_field_id].name}
value={f_cfv[:custom_field_id].value}
/>
</div>
<% end %>
</.inputs_for>
<% end %>
</div>
</.form_section>
</div>
<% end %>
</div>
<%!-- Bottom Action Buttons --%>
<div class="flex justify-end gap-4 mt-6">
<.button navigate={return_path(@return_to, @member)} variant="neutral" type="button">
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save Member")}
</.button>
</div>
<%!-- Membership Fee Section --%>
<div class="max-w-xl">
<.form_section title={gettext("Membership Fee")}>
<div class="space-y-4">
<div>
<label class="label">
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
</label>
<select
class="select select-bordered w-full"
name={@form[:membership_fee_type_id].name}
phx-change="validate"
value={@form[:membership_fee_type_id].value || ""}
>
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
<option value="">{gettext("Select a membership fee type")}</option>
<%= for fee_type <- @available_fee_types do %>
<option
value={fee_type.id}
selected={fee_type.id == @form[:membership_fee_type_id].value}
>
{fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
fee_type.interval
)})
</option>
<% end %>
</select>
<%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
<p class="text-error text-sm mt-1">{msg}</p>
<% end %>
<%= if @interval_warning do %>
<div class="alert alert-warning mt-2">
<.icon name="hero-exclamation-triangle" class="size-5" />
<span>{@interval_warning}</span>
</div>
<% end %>
<p class="text-sm text-base-content/60 mt-2">
<%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%>
<%= if @member && can?(@current_user, :destroy, @member) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4">
{gettext(
"Select a membership fee type for this member. Members can only switch between types with the same interval."
"Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
)}
</p>
<.button
variant="danger"
type="button"
phx-click="delete"
phx-value-id={@member.id}
data-confirm={
gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
)
}
data-testid="member-delete"
aria-label={
gettext("Delete member %{name}",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
)
}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete member")}
</.button>
</div>
</div>
</.form_section>
</div>
<%!-- Bottom Action Buttons --%>
<div class="flex justify-end gap-4 mt-6">
<.button navigate={return_path(@return_to, @member)} type="button">
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save Member")}
</.button>
</section>
<% end %>
</div>
</.form>
</Layouts.app>
@ -374,6 +400,41 @@ defmodule MvWeb.MemberLive.Form do
end
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
member = socket.assigns.member
actor = current_actor(socket)
cond do
is_nil(member) ->
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
to_string(id) != to_string(member.id) ->
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
true ->
handle_member_delete_destroy(socket, member, actor)
end
end
defp handle_member_delete_destroy(socket, member, actor) do
case Ash.destroy(member, actor: actor) do
:ok ->
{:noreply,
socket
|> put_flash(:success, gettext("Member deleted successfully"))
|> push_navigate(to: ~p"/members")}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(socket, :error, gettext("You do not have permission to delete this member"))}
{:error, error} ->
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
{:noreply, put_flash(socket, :error, format_destroy_error(error))}
end
end
defp handle_save_success(socket, member) do
notify_parent({:saved, member})
@ -386,7 +447,7 @@ defmodule MvWeb.MemberLive.Form do
socket =
socket
|> put_flash(:info, flash_message)
|> put_flash(:success, flash_message)
|> maybe_put_vereinfacht_sync_flash(member.id)
|> push_navigate(to: return_path(socket.assigns.return_to, member))
@ -421,6 +482,19 @@ defmodule MvWeb.MemberLive.Form do
end
end
defp format_destroy_error(%Ash.Error.Invalid{errors: errors}) do
error_messages =
Enum.map(errors, fn
%{field: field, message: message} -> "#{field}: #{message}"
%{message: message} -> message
_ -> inspect(errors)
end)
Enum.join(error_messages, ", ")
end
defp format_destroy_error(error), do: inspect(error)
defp handle_save_error(socket, form) do
# Always show a flash message when save fails
# Field-level validation errors are displayed in form fields, but flash provides additional feedback

View file

@ -15,7 +15,6 @@ defmodule MvWeb.MemberLive.Index do
- `sort_order` - Sort direction (:asc or :desc)
## Events
- `delete` - Remove a member from the database
- `select_member` - Toggle individual member selection
- `select_all` - Toggle selection of all visible members
- `copy_emails` - Copy email addresses of selected members to clipboard
@ -123,6 +122,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:groups, groups)
|> assign(:boolean_custom_field_filters, %{})
|> assign(:selected_members, MapSet.new())
|> assign(:selected_member_id, nil)
|> assign(:settings, settings)
|> assign(:custom_fields_visible, custom_fields_visible)
|> assign(:all_custom_fields, all_custom_fields)
@ -157,48 +157,14 @@ defmodule MvWeb.MemberLive.Index do
Handles member-related UI events.
## Supported events:
- `"delete"` - Removes a member from the database
- `"select_member"` - Toggles individual member selection
- `"select_all"` - Toggles selection of all visible members
- `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
"""
@impl true
def handle_event("delete", %{"id" => id}, socket) do
actor = current_actor(socket)
case Ash.get(Mv.Membership.Member, id, actor: actor) do
{:ok, member} ->
case Ash.destroy(member, actor: actor) do
:ok ->
updated_members = Enum.reject(socket.assigns.members, &(&1.id == id))
{:noreply,
socket
|> assign(:members, updated_members)
|> put_flash(:info, gettext("Member deleted successfully"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this member")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
{:error, %Ash.Error.Forbidden{} = _error} ->
{:noreply,
put_flash(socket, :error, gettext("You do not have permission to access this member"))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
def handle_event("select_row_and_navigate", %{"id" => id}, socket) do
# Navigate to member show. Back button on show page uses ?highlight=id so returning to index shows row as selected.
{:noreply, push_navigate(socket, to: ~p"/members/#{id}")}
end
@impl true
@ -343,22 +309,6 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
end
# Helper to format errors for display
defp format_error(%Ash.Error.Invalid{errors: errors}) do
error_messages =
Enum.map(errors, fn error ->
case error do
%{field: field, message: message} -> "#{field}: #{message}"
%{message: message} -> message
_ -> inspect(error)
end
end)
Enum.join(error_messages, ", ")
end
defp format_error(error), do: inspect(error)
# -----------------------------------------------------------------
# Handle Infos from Child Components
# -----------------------------------------------------------------
@ -656,6 +606,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:member_fields_visible_db, visible_member_fields_db)
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> assign(:selected_member_id, parse_highlight_param(params["highlight"]))
next_sig = build_signature(socket)
@ -855,6 +806,18 @@ defmodule MvWeb.MemberLive.Index do
end
end
# Parses optional "highlight" URL param (member id for selected row styling). Returns nil if missing or invalid.
defp parse_highlight_param(nil), do: nil
defp parse_highlight_param(""), do: nil
defp parse_highlight_param(id) when is_binary(id) do
if String.length(id) <= @max_uuid_length and match?({:ok, _}, Ecto.UUID.cast(id)),
do: id,
else: nil
end
defp parse_highlight_param(_), do: nil
defp merge_fields_param_from_uri(params, nil), do: params
defp merge_fields_param_from_uri(params, %URI{query: query}) when is_binary(query) do

View file

@ -9,7 +9,7 @@
selected_count={@selected_count}
/>
<.button
class="secondary"
variant="secondary"
id="copy-emails-btn"
phx-hook="CopyToClipboard"
phx-click="copy_emails"
@ -20,7 +20,7 @@
{gettext("Copy email addresses")} ({@selected_count})
</.button>
<.button
class="secondary"
variant="secondary"
id="open-email-btn"
href={"mailto:?bcc=" <> @mailto_bcc}
disabled={not @any_selected?}
@ -54,13 +54,12 @@
boolean_filters={@boolean_custom_field_filters}
member_count={length(@members)}
/>
<button
<.button
type="button"
variant="secondary"
class={["gap-2", @show_current_cycle && "btn-active"]}
phx-click="toggle_cycle_view"
class={[
"btn gap-2",
@show_current_cycle && "btn-active"
]}
data-testid="toggle-cycle-view"
aria-label={
if(@show_current_cycle,
do: gettext("Current Cycle Payment Status"),
@ -81,7 +80,7 @@
else: gettext("Last Cycle Payment Status")
)}
</span>
</button>
</.button>
<.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent}
id="field-visibility-dropdown"
@ -91,40 +90,53 @@
/>
</div>
<.table
id="members"
rows={@members}
row_id={fn member -> "row-#{member.id}" end}
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
dynamic_cols={@dynamic_cols}
sort_field={@sort_field}
sort_order={@sort_order}
<%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%>
<div
class="lg:max-h-[calc(100vh-14rem)] lg:overflow-auto min-h-0"
data-testid="members-table-scroll"
role="region"
aria-label={gettext("Members table")}
>
<.table
id="members"
rows={@members}
sticky_header={true}
row_id={fn member -> "row-#{member.id}" end}
row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end}
row_tooltip={gettext("Click for member details")}
row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end}
dynamic_cols={@dynamic_cols}
sort_field={@sort_field}
sort_order={@sort_order}
>
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
<:col
:let={member}
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
label={
~H"""
<:col
:let={member}
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
label={
~H"""
<.input
type="checkbox"
name="select_all"
phx-click="select_all"
checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())}
aria-label={gettext("Select all members")}
role="checkbox"
/>
"""
}
>
<.input
type="checkbox"
name="select_all"
phx-click="select_all"
checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())}
aria-label={gettext("Select all members")}
name={member.id}
checked={MapSet.member?(@selected_members, member.id)}
aria-label={gettext("Select member")}
role="checkbox"
/>
"""
}
>
<.input
type="checkbox"
name={member.id}
checked={MapSet.member?(@selected_members, member.id)}
aria-label={gettext("Select member")}
role="checkbox"
/>
</:col>
<:col
:let={member}
@ -400,26 +412,11 @@
</:col>
<:action :let={member}>
<div class="sr-only">
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
<.link navigate={~p"/members/#{member}"} data-testid="member-show-link">
{gettext("Show")}
</.link>
</div>
<%= if can?(@current_user, :update, member) do %>
<.link navigate={~p"/members/#{member}/edit"} data-testid="member-edit">
{gettext("Edit")}
</.link>
<% end %>
</:action>
<:action :let={member}>
<%= if can?(@current_user, :destroy, member) do %>
<.link
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")}
data-confirm={gettext("Are you sure?")}
data-testid="member-delete"
>
{gettext("Delete")}
</.link>
<% end %>
</:action>
</.table>
</div>
</Layouts.app>

View file

@ -30,235 +30,311 @@ defmodule MvWeb.MemberLive.Show do
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<%!-- Header with Back button, Name, and Edit button --%>
<div class="flex items-center justify-between gap-4 pb-4">
<.button navigate={~p"/members"} aria-label={gettext("Back to members list")}>
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<h1 class="text-2xl font-bold text-center flex-1">
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
</h1>
<%= if can?(@current_user, :update, @member) do %>
<.header>
<:leading>
<.button
variant="primary"
navigate={~p"/members/#{@member}/edit?return_to=show"}
data-testid="member-edit"
navigate={~p"/members?highlight=#{@member.id}"}
variant="neutral"
aria-label={gettext("Back to members list")}
>
{gettext("Edit Member")}
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<% end %>
</div>
</:leading>
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
<:actions>
<%= if can?(@current_user, :update, @member) do %>
<.button
variant="primary"
navigate={~p"/members/#{@member}/edit?return_to=show"}
data-testid="member-edit"
>
<.icon name="hero-pencil-square" /> {gettext("Edit member")}
</.button>
<% end %>
</:actions>
</.header>
<%!-- Tab Navigation --%>
<div role="tablist" class="tabs tabs-bordered mb-6">
<button
role="tab"
class={[
"tab",
if(@active_tab == :contact, do: "tab-active", else: "!text-gray-800")
]}
aria-selected={@active_tab == :contact}
phx-click="switch_tab"
phx-value-tab="contact"
<div class="mt-6 space-y-6">
<%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%>
<div
role="tablist"
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
>
<.icon name="hero-identification" class="size-4 mr-2" />
{gettext("Contact Data")}
</button>
<button
role="tab"
class={[
"tab",
if(@active_tab == :membership_fees, do: "tab-active", else: "!text-gray-800")
]}
aria-selected={@active_tab == :membership_fees}
phx-click="switch_tab"
phx-value-tab="membership_fees"
>
<.icon name="hero-credit-card" class="size-4 mr-2" />
{gettext("Membership Fees")}
</button>
</div>
<button
id="member-tab-contact"
role="tab"
type="button"
tabindex="0"
aria-selected={@active_tab == :contact}
aria-controls="member-tabpanel-contact"
class={[
"tab flex items-center gap-2",
if(@active_tab == :contact, do: "tab-active", else: "text-base-content/70")
]}
phx-click="switch_tab"
phx-value-tab="contact"
>
<.icon name="hero-identification" class="size-4 shrink-0" />
{gettext("Contact Data")}
</button>
<button
id="member-tab-membership_fees"
role="tab"
type="button"
tabindex="0"
aria-selected={@active_tab == :membership_fees}
aria-controls="member-tabpanel-membership_fees"
class={[
"tab flex items-center gap-2",
if(@active_tab == :membership_fees, do: "tab-active", else: "text-base-content/70")
]}
phx-click="switch_tab"
phx-value-tab="membership_fees"
>
<.icon name="hero-credit-card" class="size-4 shrink-0" />
{gettext("Membership Fees")}
</button>
</div>
<%= if @active_tab == :contact do %>
<%!-- Contact Data Tab Content --%>
<%!-- Personal Data and Custom Fields Row --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<%!-- Personal Data Section --%>
<div>
<.section_box title={gettext("Personal Data")}>
<div class="space-y-4">
<%!-- Name Row --%>
<div class="flex gap-6">
<.data_field label={gettext("First Name")} value={@member.first_name} class="w-48" />
<.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" />
</div>
<%= if @active_tab == :contact do %>
<%!-- Contact Data Tab Content --%>
<div
id="member-tabpanel-contact"
role="tabpanel"
aria-labelledby="member-tab-contact"
>
<%!-- Personal Data and Custom Fields Row --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<%!-- Personal Data Section --%>
<div>
<.section_box title={gettext("Personal Data")}>
<div class="space-y-4">
<%!-- Name Row --%>
<div class="flex gap-6">
<.data_field
label={gettext("First Name")}
value={@member.first_name}
class="w-48"
/>
<.data_field
label={gettext("Last Name")}
value={@member.last_name}
class="w-48"
/>
</div>
<%!-- Address --%>
<div>
<.data_field label={gettext("Address")} value={format_address(@member)} />
</div>
<%!-- Address --%>
<div>
<.data_field label={gettext("Address")} value={format_address(@member)} />
</div>
<%!-- Email --%>
<div>
<.data_field label={gettext("Email")}>
<a
href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"}
class="text-blue-700 hover:text-blue-800 underline"
>
{@member.email}
</a>
</.data_field>
</div>
<%!-- Email --%>
<div>
<.data_field label={gettext("Email")}>
<a
href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"}
class="text-blue-700 hover:text-blue-800 underline"
>
{@member.email}
</a>
</.data_field>
</div>
<%!-- Membership Dates Row --%>
<div class="flex gap-6">
<.data_field
label={gettext("Join Date")}
value={format_date(@member.join_date)}
class="w-28"
/>
<.data_field
label={gettext("Exit Date")}
value={format_date(@member.exit_date)}
class="w-28"
/>
</div>
<%!-- Membership Dates Row --%>
<div class="flex gap-6">
<.data_field
label={gettext("Join Date")}
value={format_date(@member.join_date)}
class="w-28"
/>
<.data_field
label={gettext("Exit Date")}
value={format_date(@member.exit_date)}
class="w-28"
/>
</div>
<%!-- Linked User: only show when current user can see other users (e.g. admin).
<%!-- Linked User: only show when current user can see other users (e.g. admin).
read_only cannot see linked user, so hide the section to avoid "No user linked" when
a user is linked but not visible. --%>
<%= if can_access_page?(@current_user, "/users") do %>
<div>
<.data_field label={gettext("Linked User")}>
<%= if @member.user do %>
<.link
navigate={~p"/users/#{@member.user}"}
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
>
<.icon name="hero-user" class="size-4" />
{@member.user.email}
</.link>
<%= if can_access_page?(@current_user, "/users") do %>
<div>
<.data_field label={gettext("Linked User")}>
<%= if @member.user do %>
<.link
navigate={~p"/users/#{@member.user}"}
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
>
<.icon name="hero-user" class="size-4" />
{@member.user.email}
</.link>
<% else %>
<span class="text-base-content/70 italic">
{gettext("No user linked")}
</span>
<% end %>
</.data_field>
</div>
<% end %>
<%!-- Groups (in Personal Data) --%>
<% groups = @member.groups || [] %>
<div>
<.data_field label={gettext("Groups")}>
<%= if Enum.empty?(groups) do %>
<span class="text-base-content/70 italic">{gettext("No groups")}</span>
<% else %>
<div class="flex flex-wrap gap-2">
<%= for group <- groups do %>
<.button
variant="outline"
size="sm"
navigate={~p"/groups/#{group.slug}"}
aria-label={gettext("Member of group %{name}", name: group.name)}
>
{group.name}
</.button>
<% end %>
</div>
<% end %>
</.data_field>
</div>
<%!-- Notes --%>
<%= if @member.notes && String.trim(@member.notes) != "" do %>
<div>
<.data_field label={gettext("Notes")}>
<p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p>
</.data_field>
</div>
<% end %>
</div>
</.section_box>
</div>
<%!-- Custom Fields Section --%>
<%= if Enum.any?(@custom_fields) do %>
<div>
<.section_box title={gettext("Custom Fields")}>
<div class="grid grid-cols-2 gap-4">
<%= for custom_field <- @custom_fields do %>
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
<.data_field label={custom_field.name}>
{format_custom_field_value(cfv, custom_field.value_type)}
</.data_field>
<% end %>
</div>
</.section_box>
</div>
<% end %>
</div>
<%!-- Payment Data Section --%>
<div class="w-full">
<.section_box title={gettext("Payment Data")}>
<%= if @member.membership_fee_type do %>
<div class="flex gap-6 flex-wrap">
<.data_field
label={gettext("Type")}
value={@member.membership_fee_type.name}
class="min-w-32"
/>
<.data_field
label={gettext("Membership Fee")}
value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)}
class="min-w-24"
/>
<.data_field
label={gettext("Payment Interval")}
value={
MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval)
}
class="min-w-32"
/>
<.data_field label={gettext("Last Cycle")} class="min-w-32">
<%= if @member.last_cycle_status do %>
<.badge variant={MembershipFeeHelpers.status_variant(@member.last_cycle_status)}>
{format_status_label(@member.last_cycle_status)}
</.badge>
<% else %>
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
<.badge variant="neutral">{gettext("No cycles")}</.badge>
<% end %>
</.data_field>
<.data_field label={gettext("Current Cycle")} class="min-w-36">
<%= if @member.current_cycle_status do %>
<.badge variant={
MembershipFeeHelpers.status_variant(@member.current_cycle_status)
}>
{format_status_label(@member.current_cycle_status)}
</.badge>
<% else %>
<.badge variant="neutral">{gettext("No cycles")}</.badge>
<% end %>
</.data_field>
</div>
<% end %>
<%!-- Groups (in Personal Data) --%>
<% groups = @member.groups || [] %>
<div>
<.data_field label={gettext("Groups")}>
<%= if Enum.empty?(groups) do %>
<span class="text-base-content/70 italic">{gettext("No groups")}</span>
<% else %>
<div class="flex flex-wrap gap-2">
<%= for group <- groups do %>
<.link
navigate={~p"/groups/#{group.slug}"}
class="btn btn-xs btn-outline btn-primary"
aria-label={gettext("Member of group %{name}", name: group.name)}
>
{group.name}
</.link>
<% end %>
</div>
<% end %>
</.data_field>
</div>
<%!-- Notes --%>
<%= if @member.notes && String.trim(@member.notes) != "" do %>
<div>
<.data_field label={gettext("Notes")}>
<p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p>
</.data_field>
<% else %>
<div class="text-base-content/70 italic">
{gettext("No membership fee type assigned")}
</div>
<% end %>
</div>
</.section_box>
</div>
<%!-- Custom Fields Section --%>
<%= if Enum.any?(@custom_fields) do %>
<div>
<.section_box title={gettext("Custom Fields")}>
<div class="grid grid-cols-2 gap-4">
<%= for custom_field <- @custom_fields do %>
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
<.data_field label={custom_field.name}>
{format_custom_field_value(cfv, custom_field.value_type)}
</.data_field>
<% end %>
</div>
</.section_box>
</div>
<% end %>
</div>
</div>
<% end %>
<%!-- Payment Data Section --%>
<div class="w-full">
<.section_box title={gettext("Payment Data")}>
<%= if @member.membership_fee_type do %>
<div class="flex gap-6 flex-wrap">
<.data_field
label={gettext("Type")}
value={@member.membership_fee_type.name}
class="min-w-32"
/>
<.data_field
label={gettext("Membership Fee")}
value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)}
class="min-w-24"
/>
<.data_field
label={gettext("Payment Interval")}
value={MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval)}
class="min-w-32"
/>
<.data_field label={gettext("Last Cycle")} class="min-w-32">
<%= if @member.last_cycle_status do %>
<.badge variant={MembershipFeeHelpers.status_variant(@member.last_cycle_status)}>
{format_status_label(@member.last_cycle_status)}
</.badge>
<% else %>
<.badge variant="neutral">{gettext("No cycles")}</.badge>
<% end %>
</.data_field>
<.data_field label={gettext("Current Cycle")} class="min-w-36">
<%= if @member.current_cycle_status do %>
<.badge variant={
MembershipFeeHelpers.status_variant(@member.current_cycle_status)
}>
{format_status_label(@member.current_cycle_status)}
</.badge>
<% else %>
<.badge variant="neutral">{gettext("No cycles")}</.badge>
<% end %>
</.data_field>
</div>
<% else %>
<div class="text-base-content/70 italic">
{gettext("No membership fee type assigned")}
</div>
<% end %>
</.section_box>
</div>
<% end %>
<%= if @active_tab == :membership_fees do %>
<%!-- Membership Fees Tab Content --%>
<div
id="member-tabpanel-membership_fees"
role="tabpanel"
aria-labelledby="member-tab-membership_fees"
>
<.live_component
module={MvWeb.MemberLive.Show.MembershipFeesComponent}
id={"membership-fees-#{@member.id}"}
member={@member}
current_user={@current_user}
vereinfacht_receipts={@vereinfacht_receipts}
/>
</div>
<% end %>
<%= if @active_tab == :membership_fees do %>
<%!-- Membership Fees Tab Content --%>
<.live_component
module={MvWeb.MemberLive.Show.MembershipFeesComponent}
id={"membership-fees-#{@member.id}"}
member={@member}
current_user={@current_user}
vereinfacht_receipts={@vereinfacht_receipts}
/>
<% end %>
<%!-- Danger zone: same section pattern as section_box (h2 outside border) --%>
<%= if can?(@current_user, :destroy, @member) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4">
{gettext(
"Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
)}
</p>
<.button
variant="danger"
phx-click="delete"
phx-value-id={@member.id}
data-confirm={
gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
)
}
data-testid="member-delete"
aria-label={
gettext("Delete member %{name}",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
)
}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete member")}
</.button>
</div>
</section>
<% end %>
</div>
</Layouts.app>
"""
end
@ -320,6 +396,37 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :active_tab, :membership_fees)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
member = socket.assigns.member
actor = current_actor(socket)
if to_string(id) != to_string(member.id) do
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
else
case Ash.destroy(member, actor: actor) do
:ok ->
{:noreply,
socket
|> put_flash(:success, gettext("Member deleted successfully"))
|> push_navigate(to: ~p"/members")}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this member")
)}
{:error, error} ->
require Logger
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
{:noreply, put_flash(socket, :error, format_error(error))}
end
end
end
def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do
response =
case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do
@ -350,6 +457,19 @@ defmodule MvWeb.MemberLive.Show do
defp page_title(:show), do: gettext("Show Member")
defp page_title(:edit), do: gettext("Edit Member")
defp format_error(%Ash.Error.Invalid{errors: errors}) do
error_messages =
Enum.map(errors, fn
%{field: field, message: message} -> "#{field}: #{message}"
%{message: message} -> message
_ -> inspect(errors)
end)
Enum.join(error_messages, ", ")
end
defp format_error(error), do: inspect(error)
# -----------------------------------------------------------------
# Helper Components
# -----------------------------------------------------------------
@ -403,7 +523,7 @@ defmodule MvWeb.MemberLive.Show do
~H"""
<a
href={"mailto:#{@email}"}
class="text-blue-700 hover:text-blue-800 underline"
class="link link-primary"
>
{@display}
</a>

View file

@ -66,14 +66,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" />
</.link>
<div>
<button
<.button
type="button"
variant="ghost"
size="sm"
phx-click="load_vereinfacht_receipts"
phx-value-contact_id={@member.vereinfacht_contact_id}
class="btn btn-sm btn-ghost"
>
{gettext("Show bookings/receipts from Vereinfacht")}
</button>
</.button>
</div>
<%= if @vereinfacht_receipts do %>
<div
@ -148,9 +149,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</.button>
<.button
:if={Enum.any?(@cycles) and @can_destroy_cycle}
variant="outline"
size="sm"
phx-click="delete_all_cycles"
phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete all cycles")}
>
<.icon name="hero-trash" class="size-4" />
@ -158,9 +160,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</.button>
<.button
:if={@member.membership_fee_type != nil and @can_create_cycle}
variant="primary"
size="sm"
phx-click="open_create_cycle_modal"
phx-target={@myself}
class="btn btn-sm btn-primary"
title={gettext("Create a new cycle manually")}
>
<.icon name="hero-plus" class="size-4" />
@ -257,17 +260,18 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</div>
<% end %>
<%= if @can_destroy_cycle do %>
<button
<.button
type="button"
variant="outline"
size="sm"
phx-click="delete_cycle"
phx-value-cycle_id={cycle.id}
phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete cycle")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
</button>
</.button>
<% end %>
</div>
</:action>
@ -307,10 +311,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
/>
</div>
<div class="modal-action">
<button type="button" phx-click="cancel_edit_amount" phx-target={@myself} class="btn">
<.button
type="button"
variant="neutral"
phx-click="cancel_edit_amount"
phx-target={@myself}
>
{gettext("Cancel")}
</button>
<button type="submit" class="btn btn-primary">{gettext("Save")}</button>
</.button>
<.button type="submit" variant="primary">{gettext("Save")}</.button>
</div>
</form>
</div>
@ -332,17 +341,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
)} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)}
</p>
<div class="modal-action">
<button phx-click="cancel_delete_cycle" phx-target={@myself} class="btn">
<.button variant="neutral" phx-click="cancel_delete_cycle" phx-target={@myself}>
{gettext("Cancel")}
</button>
<button
</.button>
<.button
variant="danger"
phx-click="confirm_delete_cycle"
phx-value-cycle_id={@deleting_cycle.id}
phx-target={@myself}
class="btn btn-error"
>
{gettext("Delete")}
</button>
</.button>
</div>
</div>
</dialog>
@ -383,20 +392,20 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
/>
</div>
<div class="modal-action">
<button phx-click="cancel_delete_all_cycles" phx-target={@myself} class="btn">
<.button variant="neutral" phx-click="cancel_delete_all_cycles" phx-target={@myself}>
{gettext("Cancel")}
</button>
<button
</.button>
<.button
variant="danger"
phx-click="confirm_delete_all_cycles"
phx-target={@myself}
class="btn btn-error"
disabled={
String.trim(String.downcase(@delete_all_confirmation)) !=
String.downcase(gettext("Yes"))
}
>
{gettext("Delete All")}
</button>
</.button>
</div>
</div>
</dialog>
@ -470,10 +479,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</div>
<% end %>
<div class="modal-action">
<button type="button" phx-click="cancel_create_cycle" phx-target={@myself} class="btn">
<.button
type="button"
variant="neutral"
phx-click="cancel_create_cycle"
phx-target={@myself}
>
{gettext("Cancel")}
</button>
<button type="submit" class="btn btn-primary">{gettext("Create")}</button>
</.button>
<.button type="submit" variant="primary">{gettext("Create")}</.button>
</div>
</form>
</div>
@ -546,7 +560,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
get_available_fee_types(updated_member, actor)
)
|> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type removed"))}
|> put_flash(:success, gettext("Membership fee type removed"))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
@ -605,7 +619,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
get_available_fee_types(updated_member, actor)
)
|> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
|> put_flash(:success, gettext("Membership fee type updated. Cycles regenerated."))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
@ -633,7 +647,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
{:noreply,
socket
|> assign(:cycles, updated_cycles)
|> put_flash(:info, gettext("Cycle status updated"))}
|> put_flash(:success, gettext("Cycle status updated"))}
{:error, %Ash.Error.Invalid{} = error} ->
error_msg =
@ -689,7 +703,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:member, updated_member)
|> assign(:cycles, cycles)
|> assign(:regenerating, false)
|> put_flash(:info, gettext("Cycles regenerated successfully"))}
|> put_flash(:success, gettext("Cycles regenerated successfully"))}
{:error, error} ->
{:noreply,
@ -739,7 +753,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
socket
|> assign(:cycles, updated_cycles)
|> assign(:editing_cycle, nil)
|> put_flash(:info, gettext("Cycle amount updated"))}
|> put_flash(:success, gettext("Cycle amount updated"))}
{:error, error} ->
{:noreply,
@ -778,7 +792,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
socket
|> assign(:cycles, updated_cycles)
|> assign(:deleting_cycle, nil)
|> put_flash(:info, gettext("Cycle deleted"))}
|> put_flash(:success, gettext("Cycle deleted"))}
{:ok, _destroyed} ->
# Handle case where return_destroyed? is true
@ -788,7 +802,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
socket
|> assign(:cycles, updated_cycles)
|> assign(:deleting_cycle, nil)
|> put_flash(:info, gettext("Cycle deleted"))}
|> put_flash(:success, gettext("Cycle deleted"))}
{:error, error} ->
{:noreply,
@ -934,7 +948,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:creating_cycle, false)
|> assign(:create_cycle_date, nil)
|> assign(:create_cycle_error, nil)
|> put_flash(:info, gettext("Cycle created successfully"))}
|> put_flash(:success, gettext("Cycle created successfully"))}
{:error, error} ->
{:noreply,
@ -997,7 +1011,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:member, updated_member)
|> assign(:cycles, updated_cycles)
|> reset_modal.()
|> put_flash(:info, gettext("All cycles deleted"))}
|> put_flash(:success, gettext("All cycles deleted"))}
{:ok, _} ->
{:noreply,

View file

@ -82,7 +82,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
{:noreply,
socket
|> assign(:settings, updated_settings)
|> put_flash(:info, gettext("Settings saved successfully."))
|> put_flash(:success, gettext("Settings saved successfully."))
|> assign_form()}
{:error, form} ->
@ -105,7 +105,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
socket
|> assign(:membership_fee_types, updated_types)
|> assign(:member_counts, updated_counts)
|> put_flash(:info, gettext("Membership fee type deleted"))}
|> put_flash(:success, gettext("Membership fee type deleted"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
@ -239,10 +239,10 @@ defmodule MvWeb.MembershipFeeSettingsLive do
<div class="divider"></div>
<button type="submit" class="btn btn-primary w-full">
<.button type="submit" variant="primary" class="w-full">
<.icon name="hero-check" class="size-5" />
{gettext("Save Settings")}
</button>
</.button>
</.form>
</div>
</div>
@ -333,24 +333,27 @@ defmodule MvWeb.MembershipFeeSettingsLive do
</:col>
<:action :let={mft}>
<.link
navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
class="btn btn-ghost btn-xs"
aria-label={gettext("Edit membership fee type")}
>
<.icon name="hero-pencil" class="size-4" />
</.link>
<.tooltip content={gettext("Edit membership fee type")} position="left">
<.button
variant="ghost"
size="sm"
navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
aria-label={gettext("Edit membership fee type")}
>
<.icon name="hero-pencil" class="size-4" />
</.button>
</.tooltip>
</:action>
<:action :let={mft}>
<div
<.tooltip
:if={get_member_count(mft, @member_counts) > 0}
class="tooltip tooltip-left"
data-tip={
content={
gettext("Cannot delete - %{count} member(s) assigned",
count: get_member_count(mft, @member_counts)
)
}
position="left"
>
<button
phx-click="delete"
@ -366,17 +369,18 @@ defmodule MvWeb.MembershipFeeSettingsLive do
>
<.icon name="hero-trash" class="size-4" />
</button>
</div>
<button
</.tooltip>
<.button
:if={get_member_count(mft, @member_counts) == 0}
variant="danger"
size="sm"
phx-click="delete"
phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")}
class="btn btn-ghost btn-xs text-error"
aria-label={gettext("Delete Membership Fee Type")}
>
<.icon name="hero-trash" class="size-4" />
</button>
</.button>
</:action>
</.table>

View file

@ -27,10 +27,26 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
<:leading>
<.button navigate={return_path(@return_to, @membership_fee_type)} variant="neutral">
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
</:leading>
{@page_title}
<:subtitle>
{gettext("Use this form to manage membership fee types in your database.")}
</:subtitle>
<:actions>
<.button
form="membership-fee-type-form"
phx-disable-with={gettext("Saving...")}
variant="primary"
type="submit"
>
{gettext("Save")}
</.button>
</:actions>
</.header>
<.form
@ -176,20 +192,20 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
</div>
<div class="modal-action">
<button
<.button
type="button"
variant="neutral"
phx-click="cancel_amount_change"
class="btn"
>
{gettext("Cancel")}
</button>
<button
</.button>
<.button
type="button"
variant="primary"
phx-click="confirm_amount_change"
class="btn btn-primary"
>
{gettext("Confirm Change")}
</button>
</.button>
</div>
</div>
</dialog>
@ -317,7 +333,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
socket =
socket
|> put_flash(:info, gettext("Membership fee type saved successfully"))
|> put_flash(:success, gettext("Membership fee type saved successfully"))
|> push_navigate(to: return_path(socket.assigns.return_to, membership_fee_type))
{:noreply, socket}

View file

@ -78,24 +78,27 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
</:col>
<:action :let={mft}>
<.link
navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
class="btn btn-ghost btn-xs"
aria-label={gettext("Edit membership fee type")}
>
<.icon name="hero-pencil" class="size-4" />
</.link>
<.tooltip content={gettext("Edit membership fee type")} position="left">
<.button
variant="ghost"
size="sm"
navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
aria-label={gettext("Edit membership fee type")}
>
<.icon name="hero-pencil" class="size-4" />
</.button>
</.tooltip>
</:action>
<:action :let={mft}>
<div
<.tooltip
:if={get_member_count(mft, @member_counts) > 0}
class="tooltip tooltip-left"
data-tip={
content={
gettext("Cannot delete - %{count} member(s) assigned",
count: get_member_count(mft, @member_counts)
)
}
position="left"
>
<button
phx-click="delete"
@ -111,17 +114,18 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
>
<.icon name="hero-trash" class="size-4" />
</button>
</div>
<button
</.tooltip>
<.button
:if={get_member_count(mft, @member_counts) == 0}
variant="danger"
size="sm"
phx-click="delete"
phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")}
class="btn btn-ghost btn-xs text-error"
aria-label={gettext("Delete Membership Fee Type")}
>
<.icon name="hero-trash" class="size-4" />
</button>
</.button>
</:action>
</.table>
@ -145,7 +149,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
socket
|> assign(:membership_fee_types, updated_types)
|> assign(:member_counts, updated_counts)
|> put_flash(:info, gettext("Membership fee type deleted"))}
|> put_flash(:success, gettext("Membership fee type deleted"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,

View file

@ -21,66 +21,70 @@ defmodule MvWeb.RoleLive.Form do
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{@page_title}
<:subtitle>{gettext("Use this form to manage roles in your database.")}</:subtitle>
</.header>
<.form class="max-w-xl" for={@form} id="role-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} type="text" label={gettext("Name")} required />
<.header>
<:leading>
<.button navigate={return_path(@return_to, @role)} variant="neutral">
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
</:leading>
{@page_title}
<:subtitle>{gettext("Use this form to manage roles in your database.")}</:subtitle>
<:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</:actions>
</.header>
<.input
field={@form[:description]}
type="textarea"
label={gettext("Description")}
rows="3"
/>
<div class="mt-6 space-y-6">
<.input field={@form[:name]} type="text" label={gettext("Name")} required />
<div class="form-control">
<label class="label" for="role-form_permission_set_name">
<span class="label-text font-semibold">
{gettext("Permission Set")}
<span class="text-red-700">*</span>
</span>
</label>
<select
class={[
"select select-bordered w-full",
@form.errors[:permission_set_name] && "select-error"
]}
name="role[permission_set_name]"
id="role-form_permission_set_name"
required
aria-label={gettext("Permission Set")}
>
<option value="">{gettext("Select permission set")}</option>
<%= for permission_set <- all_permission_sets() do %>
<option
value={permission_set}
selected={@form[:permission_set_name].value == permission_set}
>
{format_permission_set_option(permission_set)}
</option>
<.input
field={@form[:description]}
type="textarea"
label={gettext("Description")}
rows="3"
/>
<div class="form-control">
<label class="label" for="role-form_permission_set_name">
<span class="label-text font-semibold">
{gettext("Permission Set")}
<span class="text-red-700">*</span>
</span>
</label>
<select
class={[
"select select-bordered w-full",
@form.errors[:permission_set_name] && "select-error"
]}
name="role[permission_set_name]"
id="role-form_permission_set_name"
required
aria-label={gettext("Permission Set")}
>
<option value="">{gettext("Select permission set")}</option>
<%= for permission_set <- all_permission_sets() do %>
<option
value={permission_set}
selected={@form[:permission_set_name].value == permission_set}
>
{format_permission_set_option(permission_set)}
</option>
<% end %>
</select>
<%= if @form.errors[:permission_set_name] do %>
<%= for error <- List.wrap(@form.errors[:permission_set_name]) do %>
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{msg}
</p>
<% end %>
<% end %>
</select>
<%= if @form.errors[:permission_set_name] do %>
<%= for error <- List.wrap(@form.errors[:permission_set_name]) do %>
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{msg}
</p>
<% end %>
<% end %>
</div>
<div class="mt-4">
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save Role")}
</.button>
<.button navigate={return_path(@return_to, @role)} type="button">
{gettext("Cancel")}
</.button>
</div>
</div>
</.form>
</Layouts.app>
@ -175,7 +179,7 @@ defmodule MvWeb.RoleLive.Form do
socket =
socket
|> put_flash(:info, gettext("Role saved successfully."))
|> put_flash(:success, gettext("Role saved successfully."))
|> push_navigate(to: redirect_path)
{:noreply, socket}

View file

@ -5,11 +5,8 @@ defmodule MvWeb.RoleLive.Index do
## Features
- List all roles with name, description, permission_set_name, is_system_role
- Create new roles
- Navigate to role details and edit forms
- Delete non-system roles
## Events
- `delete` - Remove a role from the database (only non-system roles)
- Navigate to role details (row click) and edit from details header
- Delete only via Danger zone on role show page
## Security
Only admins can access this page (enforced by authorization).
@ -37,83 +34,6 @@ defmodule MvWeb.RoleLive.Index do
|> assign(:user_counts, user_counts)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
case Authorization.get_role(id, actor: socket.assigns.current_user) do
{:ok, role} ->
handle_delete_role(role, id, socket)
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("Role not found.")
)}
{:error, error} ->
error_message = format_error(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to delete role: %{error}", error: error_message)
)}
end
end
defp handle_delete_role(role, id, socket) do
if role.is_system_role do
{:noreply,
put_flash(
socket,
:error,
gettext("System roles cannot be deleted.")
)}
else
user_count = recalculate_user_count(role, socket.assigns.current_user)
if user_count > 0 do
{:noreply,
put_flash(
socket,
:error,
gettext(
"Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.",
count: user_count
)
)}
else
perform_role_deletion(role, id, socket)
end
end
end
defp perform_role_deletion(role, id, socket) do
case Authorization.destroy_role(role, actor: socket.assigns.current_user) do
:ok ->
updated_roles = Enum.reject(socket.assigns.roles, &(&1.id == id))
updated_counts = Map.delete(socket.assigns.user_counts, id)
{:noreply,
socket
|> assign(:roles, updated_roles)
|> assign(:user_counts, updated_counts)
|> put_flash(:info, gettext("Role deleted successfully."))}
{:error, error} ->
error_message = format_error(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to delete role: %{error}", error: error_message)
)}
end
end
@spec load_roles(map() | nil) :: [Mv.Authorization.Role.t()]
defp load_roles(actor) do
opts = MvWeb.LiveHelpers.ash_actor_opts(actor)
@ -154,15 +74,4 @@ defmodule MvWeb.RoleLive.Index do
defp get_user_count(role, user_counts) do
Map.get(user_counts, role.id, 0)
end
# Recalculates user count for a specific role (used before deletion)
@spec recalculate_user_count(Mv.Authorization.Role.t(), map() | nil) :: non_neg_integer()
defp recalculate_user_count(role, actor) do
opts = opts_with_actor([], actor, Mv.Accounts)
case Ash.count(Accounts.User |> Ash.Query.filter(role_id == ^role.id), opts) do
{:ok, count} -> count
_ -> 0
end
end
end

View file

@ -17,6 +17,7 @@
id="roles"
rows={@roles}
row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end}
row_tooltip={gettext("Click for role details")}
>
<:col :let={role} label={gettext("Name")}>
<div class="flex items-center gap-2">
@ -53,46 +54,5 @@
<:col :let={role} label={gettext("Users")}>
<.badge variant="neutral">{get_user_count(role, @user_counts)}</.badge>
</:col>
<:action :let={role}>
<div class="sr-only">
<.link navigate={~p"/admin/roles/#{role}"}>{gettext("Show")}</.link>
</div>
<%= if can?(@current_user, :update, Mv.Authorization.Role) do %>
<.link navigate={~p"/admin/roles/#{role}/edit"} class="btn btn-ghost btn-sm">
<.icon name="hero-pencil" class="size-4" />
{gettext("Edit")}
</.link>
<% end %>
</:action>
<:action :let={role}>
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not role.is_system_role do %>
<.link
phx-click={JS.push("delete", value: %{id: role.id}) |> hide("#row-#{role.id}")}
data-confirm={gettext("Are you sure?")}
class="btn btn-ghost btn-sm text-error"
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
</.link>
<% else %>
<div
:if={role.is_system_role}
class="tooltip tooltip-left"
data-tip={gettext("System roles cannot be deleted")}
>
<button
class="btn btn-ghost btn-sm text-error opacity-50 cursor-not-allowed"
disabled={true}
aria-label={gettext("Cannot delete system role")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
</button>
</div>
<% end %>
</:action>
</.table>
</Layouts.app>

View file

@ -124,7 +124,7 @@ defmodule MvWeb.RoleLive.Show do
:ok ->
{:noreply,
socket
|> put_flash(:info, gettext("Role deleted successfully."))
|> put_flash(:success, gettext("Role deleted successfully."))
|> push_navigate(to: ~p"/admin/roles")}
{:error, error} ->
@ -161,27 +161,28 @@ defmodule MvWeb.RoleLive.Show do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
<:leading>
<.button
navigate={~p"/admin/roles"}
variant="neutral"
aria-label={gettext("Back to roles list")}
>
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
</:leading>
{gettext("Role")} {@role.name}
<:subtitle>{gettext("Role details and permissions.")}</:subtitle>
<:actions>
<.button navigate={~p"/admin/roles"} aria-label={gettext("Back to roles list")}>
<.icon name="hero-arrow-left" />
<span class="sr-only">{gettext("Back to roles list")}</span>
</.button>
<%= if can?(@current_user, :update, Mv.Authorization.Role) do %>
<.button variant="primary" navigate={~p"/admin/roles/#{@role}/edit"}>
<.icon name="hero-pencil-square" /> {gettext("Edit Role")}
</.button>
<% end %>
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %>
<.link
phx-click={JS.push("delete", value: %{id: @role.id})}
data-confirm={gettext("Are you sure?")}
class="btn btn-error"
<.button
variant="primary"
navigate={~p"/admin/roles/#{@role}/edit"}
data-testid="role-show-edit-btn"
>
<.icon name="hero-trash" /> {gettext("Delete Role")}
</.link>
<.icon name="hero-pencil-square" /> {gettext("Edit role")}
</.button>
<% end %>
</:actions>
</.header>
@ -209,6 +210,37 @@ defmodule MvWeb.RoleLive.Show do
</.badge>
</:item>
</.list>
<%!-- Danger zone: canonical pattern (same as member show) --%>
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4">
{gettext(
"Deleting this role cannot be undone. Users assigned to this role must be reassigned first."
)}
</p>
<.button
variant="danger"
phx-click={JS.push("delete", value: %{id: @role.id})}
data-confirm={
gettext(
"Are you sure you want to delete the role %{name}? This action cannot be undone.",
name: @role.name
)
}
data-testid="role-delete"
aria-label={gettext("Delete role %{name}", name: @role.name)}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete role")}
</.button>
</div>
</section>
<% end %>
</Layouts.app>
"""
end

View file

@ -39,14 +39,31 @@ defmodule MvWeb.UserLive.Form do
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
import MvWeb.Authorization, only: [can?: 3]
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
<:leading>
<.button navigate={return_path(@return_to, @user)} variant="neutral">
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
</:leading>
{@page_title}
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>
<:actions>
<.button
form="user-form"
phx-disable-with={gettext("Saving...")}
variant="primary"
type="submit"
>
{gettext("Save User")}
</.button>
</:actions>
</.header>
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
@ -167,13 +184,14 @@ defmodule MvWeb.UserLive.Form do
</p>
<p class="text-sm text-green-700">{@user.member.email}</p>
</div>
<button
<.button
type="button"
variant="danger"
size="sm"
phx-click="unlink_member"
class="btn btn-sm btn-error"
>
{gettext("Unlink Member")}
</button>
</.button>
</div>
</div>
<% else %>
@ -280,11 +298,46 @@ defmodule MvWeb.UserLive.Form do
</div>
<% end %>
<%!-- Danger zone: canonical pattern (same as member form) --%>
<%= if @user && can?(@current_user, :destroy, @user) && !Mv.Helpers.SystemActor.system_user?(@user) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4">
{gettext(
"Deleting this user cannot be undone. The user account and any linked member association will be affected."
)}
</p>
<.button
type="button"
variant="danger"
phx-click="delete"
phx-value-id={@user.id}
data-confirm={
gettext(
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
email: @user.email
)
}
data-testid="user-delete"
aria-label={gettext("Delete user %{email}", email: @user.email)}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete user")}
</.button>
</div>
</section>
<% end %>
<div class="mt-4">
<.button navigate={return_path(@return_to, @user)} variant="neutral">
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save User")}
</.button>
<.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}</.button>
</div>
</.form>
</Layouts.app>
@ -401,6 +454,26 @@ defmodule MvWeb.UserLive.Form do
end
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
user = socket.assigns.user
actor = current_actor(socket)
cond do
is_nil(user) ->
{:noreply, put_flash(socket, :error, gettext("User not found"))}
to_string(id) != to_string(user.id) ->
{:noreply, put_flash(socket, :error, gettext("User not found"))}
Mv.Helpers.SystemActor.system_user?(user) ->
{:noreply, put_flash(socket, :error, gettext("System user cannot be deleted."))}
true ->
handle_user_delete_destroy(socket, user, actor)
end
end
@impl true
def handle_event("show_member_dropdown", _params, socket) do
{:noreply, assign(socket, show_member_dropdown: true)}
@ -511,6 +584,23 @@ defmodule MvWeb.UserLive.Form do
{:noreply, socket}
end
defp handle_user_delete_destroy(socket, user, actor) do
case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do
:ok ->
{:noreply,
socket
|> put_flash(:success, gettext("User deleted successfully"))
|> push_navigate(to: ~p"/users")}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(socket, :error, gettext("You do not have permission to delete this user"))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_ash_error(error))}
end
end
defp handle_member_linking(socket, user, actor) do
result = perform_member_link_action(socket, user, actor)
@ -553,7 +643,7 @@ defmodule MvWeb.UserLive.Form do
socket =
socket
|> put_flash(:info, gettext("User %{action} successfully", action: action))
|> put_flash(:success, gettext("User %{action} successfully", action: action))
|> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
{:noreply, socket}

View file

@ -5,18 +5,12 @@ defmodule MvWeb.UserLive.Index do
## Features
- List all users with email and linked member
- Sort users by email (default)
- Delete users
- Navigate to user details and edit forms
- Bulk selection for future batch operations
- Navigate to user details (row click) and edit from details header
- Delete only via Danger zone on user show/edit
## Relationships
Displays linked member information when a user is connected to a member account.
## Events
- `delete` - Remove a user from the database
- `select_user` - Toggle individual user selection
- `select_all` - Toggle selection of all visible users
## Security
User deletion requires admin permissions (enforced by Ash policies).
"""
@ -26,7 +20,6 @@ defmodule MvWeb.UserLive.Index do
import MvWeb.LiveHelpers, only: [current_actor: 1]
require Ash.Query
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
@impl true
def mount(_params, _session, socket) do
@ -44,63 +37,7 @@ defmodule MvWeb.UserLive.Index do
|> assign(:page_title, gettext("Listing Users"))
|> assign(:sort_field, :email)
|> assign(:sort_order, :asc)
|> assign(:users, sorted)
|> assign(:selected_users, [])}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
actor = current_actor(socket)
case Ash.get(Mv.Accounts.User, id, domain: Mv.Accounts, actor: actor) do
{:ok, user} ->
case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do
:ok ->
updated_users = Enum.reject(socket.assigns.users, &(&1.id == id))
{:noreply,
socket
|> assign(:users, updated_users)
|> put_flash(:info, gettext("User deleted successfully"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this user")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_ash_error(error))}
end
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply, put_flash(socket, :error, gettext("User not found"))}
{:error, %Ash.Error.Forbidden{} = _error} ->
{:noreply,
put_flash(socket, :error, gettext("You do not have permission to access this user"))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_ash_error(error))}
end
end
# Selects one user in the list of users
@impl true
def handle_event("select_user", %{"id" => id}, socket) do
# Normalize ID to string for consistent comparison
id_str = to_string(id)
selected =
if id_str in socket.assigns.selected_users do
List.delete(socket.assigns.selected_users, id_str)
else
[id_str | socket.assigns.selected_users]
end
{:noreply, assign(socket, :selected_users, selected)}
|> assign(:users, sorted)}
end
# Sorts the list of users according to a field, when you click on the column header
@ -127,24 +64,6 @@ defmodule MvWeb.UserLive.Index do
|> assign(:users, sorted_users)}
end
# Selects all users in the list of users
@impl true
def handle_event("select_all", _params, socket) do
users = socket.assigns.users
# Normalize IDs to strings for consistent comparison
all_ids = Enum.map(users, &to_string(&1.id))
selected =
if Enum.sort(socket.assigns.selected_users) == Enum.sort(all_ids) do
[]
else
all_ids
end
{:noreply, assign(socket, :selected_users, selected)}
end
defp toggle_order(:asc), do: :desc
defp toggle_order(:desc), do: :asc
defp sort_fun(:asc), do: &<=/2

View file

@ -15,36 +15,10 @@
rows={@users}
row_id={fn user -> "row-#{user.id}" end}
row_click={fn user -> JS.navigate(~p"/users/#{user}") end}
row_tooltip={gettext("Click for user details")}
sort_field={@sort_field}
sort_order={@sort_order}
>
<:col
:let={user}
label={
~H"""
<.input
type="checkbox"
name="select_all"
phx-click="select_all"
checked={Enum.sort(@selected_users) == Enum.map(@users, &to_string(&1.id)) |> Enum.sort()}
aria-label={gettext("Select all users")}
role="checkbox"
/>
"""
}
>
<.input
type="checkbox"
name={to_string(user.id)}
phx-click="select_user"
phx-value-id={to_string(user.id)}
checked={to_string(user.id) in @selected_users}
phx-capture-click
phx-stop-propagation
aria-label={gettext("Select user")}
role="checkbox"
/>
</:col>
<:col
:let={user}
sort_field={:email}
@ -83,29 +57,5 @@
<span class="text-base-content/70">—</span>
<% end %>
</:col>
<:action :let={user}>
<div class="sr-only">
<.link navigate={~p"/users/#{user}"}>{gettext("Show")}</.link>
</div>
<%= if can?(@current_user, :update, user) do %>
<.link navigate={~p"/users/#{user}/edit"} data-testid="user-edit">
{gettext("Edit")}
</.link>
<% end %>
</:action>
<:action :let={user}>
<%= if can?(@current_user, :destroy, user) do %>
<.link
phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")}
data-confirm={gettext("Are you sure?")}
data-testid="user-delete"
>
{gettext("Delete")}
</.link>
<% end %>
</:action>
</.table>
</Layouts.app>

View file

@ -27,20 +27,27 @@ defmodule MvWeb.UserLive.Show do
use MvWeb, :live_view
import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
<:leading>
<.button
navigate={~p"/users"}
variant="neutral"
aria-label={gettext("Back to users list")}
>
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
</:leading>
{gettext("User")} {@user.email}
<:subtitle>{gettext("This is a user record from your database.")}</:subtitle>
<:actions>
<.button navigate={~p"/users"} aria-label={gettext("Back to users list")}>
<.icon name="hero-arrow-left" />
<span class="sr-only">{gettext("Back to users list")}</span>
</.button>
<%= if can?(@current_user, :update, @user) do %>
<.button
variant="primary"
@ -80,6 +87,38 @@ defmodule MvWeb.UserLive.Show do
<% end %>
</:item>
</.list>
<%!-- Danger zone: canonical pattern (same as member show) --%>
<%= if can?(@current_user, :destroy, @user) and not Mv.Helpers.SystemActor.system_user?(@user) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4">
{gettext(
"Deleting this user cannot be undone. The user account and any linked member association will be affected."
)}
</p>
<.button
variant="danger"
phx-click="delete"
phx-value-id={@user.id}
data-confirm={
gettext(
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
email: @user.email
)
}
data-testid="user-delete"
aria-label={gettext("Delete user %{email}", email: @user.email)}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete user")}
</.button>
</div>
</section>
<% end %>
</Layouts.app>
"""
end
@ -103,4 +142,38 @@ defmodule MvWeb.UserLive.Show do
|> assign(:user, user)}
end
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
user = socket.assigns.user
actor = current_actor(socket)
cond do
to_string(id) != to_string(user.id) ->
{:noreply, put_flash(socket, :error, gettext("User not found"))}
Mv.Helpers.SystemActor.system_user?(user) ->
{:noreply, put_flash(socket, :error, gettext("System user cannot be deleted."))}
true ->
handle_user_delete_destroy(socket, user, actor)
end
end
defp handle_user_delete_destroy(socket, user, actor) do
case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do
:ok ->
{:noreply,
socket
|> put_flash(:success, gettext("User deleted successfully"))
|> push_navigate(to: ~p"/users")}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(socket, :error, gettext("You do not have permission to delete this user"))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_ash_error(error))}
end
end
end