fix: color contrast dark mode and keyboard moadals
This commit is contained in:
parent
5516c7fe62
commit
c71c7d6ed6
14 changed files with 1067 additions and 740 deletions
|
|
@ -3016,6 +3016,8 @@ end
|
||||||
- [ ] Tables have proper structure (th, scope, caption)
|
- [ ] Tables have proper structure (th, scope, caption)
|
||||||
- [ ] ARIA labels used for icon-only buttons
|
- [ ] ARIA labels used for icon-only buttons
|
||||||
- [ ] Modals/dialogs: focus moves into modal, aria-labelledby, keyboard dismiss (Escape)
|
- [ ] Modals/dialogs: focus moves into modal, aria-labelledby, keyboard dismiss (Escape)
|
||||||
|
- [ ] ARIA state attributes use string values `"true"` / `"false"` (not boolean), e.g. `aria-selected`, `aria-pressed`, `aria-expanded`.
|
||||||
|
- [ ] Tabs: when using `role="tablist"` / `role="tab"`, use roving tabindex (only active tab `tabindex="0"`) and ArrowLeft/ArrowRight to switch tabs.
|
||||||
|
|
||||||
### 8.11 Modals and Dialogs
|
### 8.11 Modals and Dialogs
|
||||||
|
|
||||||
|
|
@ -3043,7 +3045,8 @@ Use a consistent, keyboard-accessible pattern for all confirmation and form moda
|
||||||
**Closing:**
|
**Closing:**
|
||||||
|
|
||||||
- Cancel button closes the modal (e.g. `phx-click="cancel_delete_modal"`).
|
- Cancel button closes the modal (e.g. `phx-click="cancel_delete_modal"`).
|
||||||
- Optionally support Escape to close via `phx-window-keydown` on the LiveView/LiveComponent.
|
- **MUST** support Escape to close (WCAG / WAI-ARIA dialog pattern): add `phx-keydown="dialog_keydown"` on the `<dialog>` and handle `dialog_keydown` with `key: "Escape"` to close (same effect as Cancel).
|
||||||
|
- **MUST** return focus to the trigger element when the modal closes (WCAG 2.4.3): give the trigger button a stable `id`, use the `FocusRestore` hook on a parent element, and on close (Cancel or Escape) call `push_event(socket, "focus_restore", %{id: "trigger-id"})` so keyboard users land where they started (e.g. "Delete member" button).
|
||||||
|
|
||||||
**Reference implementation:** Delete data field modal in `CustomFieldLive.IndexComponent` (input + `phx-mounted={JS.focus()}` on input; `aria-labelledby` on dialog). Delete role modal in `RoleLive.Show` (no input; `phx-mounted={JS.focus()}` on Cancel button).
|
**Reference implementation:** Delete data field modal in `CustomFieldLive.IndexComponent` (input + `phx-mounted={JS.focus()}` on input; `aria-labelledby` on dialog). Delete role modal in `RoleLive.Show` (no input; `phx-mounted={JS.focus()}` on Cancel button).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -538,9 +538,14 @@
|
||||||
|
|
||||||
/* Dark theme: primary is purple; ensure content is light and meets 4.5:1. */
|
/* Dark theme: primary is purple; ensure content is light and meets 4.5:1. */
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
--color-primary-content: oklch(0.97 0.02 277);
|
|
||||||
--color-error: oklch(55% 0.253 17.585);
|
--color-error: oklch(55% 0.253 17.585);
|
||||||
--color-error-content: oklch(98% 0 0);
|
--color-error-content: oklch(98% 0 0);
|
||||||
|
|
||||||
|
--color-primary: oklch(72% 0.17 45);
|
||||||
|
--color-primary-content: oklch(0.18 0.02 47);
|
||||||
|
|
||||||
|
--color-secondary: oklch(48% 0.233 277.117);
|
||||||
|
--color-secondary-content: oklch(98% 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* This file is for your main application CSS */
|
/* This file is for your main application CSS */
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,32 @@ Hooks.TableRowKeydown = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button)
|
||||||
|
Hooks.FocusRestore = {
|
||||||
|
mounted() {
|
||||||
|
this.handleEvent("focus_restore", ({id}) => {
|
||||||
|
const el = document.getElementById(id)
|
||||||
|
if (el) el.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TabListKeydown hook: WCAG tab pattern — prevent default for ArrowLeft/ArrowRight so the server can handle tab switch (roving tabindex)
|
||||||
|
Hooks.TabListKeydown = {
|
||||||
|
mounted() {
|
||||||
|
this.handleKeydown = (e) => {
|
||||||
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.el.addEventListener('keydown', this.handleKeydown)
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyed() {
|
||||||
|
this.el.removeEventListener('keydown', this.handleKeydown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SidebarState hook: Manages sidebar expanded/collapsed state
|
// SidebarState hook: Manages sidebar expanded/collapsed state
|
||||||
Hooks.SidebarState = {
|
Hooks.SidebarState = {
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
|
||||||
|
|
@ -477,7 +477,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="button"
|
role="button"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={@open}
|
aria-expanded={if @open, do: "true", else: "false"}
|
||||||
aria-controls={@id}
|
aria-controls={@id}
|
||||||
aria-label={@button_label}
|
aria-label={@button_label}
|
||||||
class={["btn"] ++ @button_focus_classes ++ [@button_class]}
|
class={["btn"] ++ @button_focus_classes ++ [@button_class]}
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<.button
|
<.button
|
||||||
|
id="delete-custom-field-trigger"
|
||||||
type="button"
|
type="button"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
phx-click="request_delete"
|
phx-click="request_delete"
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
class="modal modal-open"
|
class="modal modal-open"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-labelledby="delete-custom-field-modal-title"
|
aria-labelledby="delete-custom-field-modal-title"
|
||||||
|
phx-keydown="dialog_keydown"
|
||||||
>
|
>
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 id="delete-custom-field-modal-title" class="text-lg font-bold">
|
<h3 id="delete-custom-field-modal-title" class="text-lg font-bold">
|
||||||
|
|
@ -226,6 +227,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
socket
|
socket
|
||||||
|
|
||||||
id ->
|
id ->
|
||||||
|
send(self(), {:custom_field_delete_modal_open, true})
|
||||||
|
|
||||||
custom_field =
|
custom_field =
|
||||||
Ash.get!(Mv.Membership.CustomField, id,
|
Ash.get!(Mv.Membership.CustomField, id,
|
||||||
load: [:assigned_members_count],
|
load: [:assigned_members_count],
|
||||||
|
|
@ -290,6 +293,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
actor: actor
|
actor: actor
|
||||||
)
|
)
|
||||||
|
|
||||||
|
send(self(), {:custom_field_delete_modal_open, true})
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:custom_field_to_delete, custom_field)
|
|> assign(:custom_field_to_delete, custom_field)
|
||||||
|
|
@ -310,6 +315,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
if socket.assigns.slug_confirmation == custom_field.slug do
|
if socket.assigns.slug_confirmation == custom_field.slug do
|
||||||
case Ash.destroy(custom_field, actor: actor) do
|
case Ash.destroy(custom_field, actor: actor) do
|
||||||
:ok ->
|
:ok ->
|
||||||
|
send(self(), {:custom_field_delete_modal_open, false})
|
||||||
send(self(), {:custom_field_deleted, custom_field})
|
send(self(), {:custom_field_deleted, custom_field})
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|
@ -320,6 +326,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
|> stream_delete(:custom_fields, custom_field)}
|
|> stream_delete(:custom_fields, custom_field)}
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
|
send(self(), {:custom_field_delete_modal_open, false})
|
||||||
send(self(), {:custom_field_delete_error, error})
|
send(self(), {:custom_field_delete_error, error})
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|
@ -329,6 +336,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
|> assign(:slug_confirmation, "")}
|
|> assign(:slug_confirmation, "")}
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
|
send(self(), {:custom_field_delete_modal_open, false})
|
||||||
send(self(), :custom_field_slug_mismatch)
|
send(self(), :custom_field_slug_mismatch)
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|
@ -341,10 +349,22 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("cancel_delete", _params, socket) do
|
def handle_event("cancel_delete", _params, socket) do
|
||||||
{:noreply,
|
send(self(), {:custom_field_delete_modal_open, false})
|
||||||
socket
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
|> assign(:show_delete_modal, false)
|
end
|
||||||
|> assign(:custom_field_to_delete, nil)
|
|
||||||
|> assign(:slug_confirmation, "")}
|
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||||
|
send(self(), {:custom_field_delete_modal_open, false})
|
||||||
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
|
defp close_delete_modal_and_restore_focus(socket) do
|
||||||
|
socket
|
||||||
|
|> assign(:show_delete_modal, false)
|
||||||
|
|> assign(:custom_field_to_delete, nil)
|
||||||
|
|> assign(:slug_confirmation, "")
|
||||||
|
|> push_event("focus_restore", %{id: "delete-custom-field-trigger"})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,32 @@ defmodule MvWeb.DatafieldsLive do
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Datafields"))
|
|> assign(:page_title, gettext("Datafields"))
|
||||||
|> assign(:settings, settings)
|
|> assign(:settings, settings)
|
||||||
|> assign(:active_editing_section, nil)}
|
|> assign(:active_editing_section, nil)
|
||||||
|
|> assign(:custom_field_delete_modal_open, false)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("window_keydown", %{"key" => key}, socket)
|
||||||
|
when key in ["Escape", "Esc"] do
|
||||||
|
if socket.assigns[:custom_field_delete_modal_open] do
|
||||||
|
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
||||||
|
id: "custom-fields-component",
|
||||||
|
show_delete_modal: false,
|
||||||
|
custom_field_to_delete: nil,
|
||||||
|
slug_confirmation: ""
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:custom_field_delete_modal_open, false)
|
||||||
|
|> push_event("focus_restore", %{id: "delete-custom-field-trigger"})}
|
||||||
|
else
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
@ -35,8 +58,14 @@ defmodule MvWeb.DatafieldsLive do
|
||||||
</:subtitle>
|
</:subtitle>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<%!-- Overview: both sections with form_section wrappers --%>
|
<%!-- Overview: both sections with form_section wrappers; FocusRestore for custom field delete modal --%>
|
||||||
<div :if={@active_editing_section == nil} class="mt-6 space-y-6">
|
<div
|
||||||
|
:if={@active_editing_section == nil}
|
||||||
|
id="datafields-focus-root"
|
||||||
|
class="mt-6 space-y-6"
|
||||||
|
phx-hook="FocusRestore"
|
||||||
|
phx-window-keydown={if @custom_field_delete_modal_open, do: "window_keydown", else: nil}
|
||||||
|
>
|
||||||
<.form_section title={gettext("Personal Data")}>
|
<.form_section title={gettext("Personal Data")}>
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.MemberFieldLive.IndexComponent}
|
module={MvWeb.MemberFieldLive.IndexComponent}
|
||||||
|
|
@ -63,7 +92,13 @@ defmodule MvWeb.DatafieldsLive do
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :if={@active_editing_section == :custom_fields} class="mt-6">
|
<div
|
||||||
|
:if={@active_editing_section == :custom_fields}
|
||||||
|
id="datafields-focus-root"
|
||||||
|
class="mt-6"
|
||||||
|
phx-hook="FocusRestore"
|
||||||
|
phx-window-keydown={if @custom_field_delete_modal_open, do: "window_keydown", else: nil}
|
||||||
|
>
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.CustomFieldLive.IndexComponent}
|
module={MvWeb.CustomFieldLive.IndexComponent}
|
||||||
id="custom-fields-component"
|
id="custom-fields-component"
|
||||||
|
|
@ -74,6 +109,11 @@ defmodule MvWeb.DatafieldsLive do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:custom_field_delete_modal_open, open}, socket) do
|
||||||
|
{:noreply, assign(socket, :custom_field_delete_modal_open, open)}
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
|
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
|
||||||
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,12 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<div class="mt-6 space-y-6">
|
<div
|
||||||
|
id="group-show-focus-root"
|
||||||
|
class="mt-6 space-y-6"
|
||||||
|
phx-hook="FocusRestore"
|
||||||
|
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||||
|
>
|
||||||
<%!-- Group Information --%>
|
<%!-- Group Information --%>
|
||||||
<div class="max-w-2xl space-y-6 mb-6">
|
<div class="max-w-2xl space-y-6 mb-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -353,6 +358,7 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<.button
|
<.button
|
||||||
|
id="delete-group-trigger"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="open_delete_modal"
|
phx-click="open_delete_modal"
|
||||||
|
|
@ -373,6 +379,7 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
class="modal modal-open"
|
class="modal modal-open"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-labelledby="delete-group-modal-title"
|
aria-labelledby="delete-group-modal-title"
|
||||||
|
phx-keydown="dialog_keydown"
|
||||||
>
|
>
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 id="delete-group-modal-title" class="text-lg font-bold mb-4">
|
<h3 id="delete-group-modal-title" class="text-lg font-bold mb-4">
|
||||||
|
|
@ -453,12 +460,25 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("cancel_delete", _params, socket) do
|
def handle_event("cancel_delete", _params, socket) do
|
||||||
{:noreply,
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
socket
|
|
||||||
|> assign(:show_delete_modal, false)
|
|
||||||
|> assign(:name_confirmation, "")}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||||
|
if socket.assigns[:show_delete_modal] do
|
||||||
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
|
else
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
|
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||||
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("update_name_confirmation", %{"name" => name}, socket) do
|
def handle_event("update_name_confirmation", %{"name" => name}, socket) do
|
||||||
{:noreply, assign(socket, :name_confirmation, name)}
|
{:noreply, assign(socket, :name_confirmation, name)}
|
||||||
|
|
@ -939,6 +959,13 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp close_delete_modal_and_restore_focus(socket) do
|
||||||
|
socket
|
||||||
|
|> assign(:show_delete_modal, false)
|
||||||
|
|> assign(:name_confirmation, "")
|
||||||
|
|> push_event("focus_restore", %{id: "delete-group-trigger"})
|
||||||
|
end
|
||||||
|
|
||||||
defp perform_group_deletion(socket, group, actor) do
|
defp perform_group_deletion(socket, group, actor) do
|
||||||
case Membership.destroy_group(group, actor: actor) do
|
case Membership.destroy_group(group, actor: actor) do
|
||||||
:ok ->
|
:ok ->
|
||||||
|
|
|
||||||
|
|
@ -38,300 +38,311 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
|
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
|
||||||
|
<div
|
||||||
|
id="member-form-focus-root"
|
||||||
|
phx-hook="FocusRestore"
|
||||||
|
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||||
|
>
|
||||||
<.header>
|
<.header>
|
||||||
<:leading>
|
<:leading>
|
||||||
<.button navigate={return_path(@return_to, @member)} variant="neutral">
|
<.button navigate={return_path(@return_to, @member)} variant="neutral">
|
||||||
<.icon name="hero-arrow-left" class="size-4" />
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
{gettext("Back")}
|
{gettext("Back")}
|
||||||
</.button>
|
</.button>
|
||||||
</:leading>
|
</:leading>
|
||||||
<%= if @member do %>
|
<%= if @member do %>
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
||||||
<% else %>
|
<% else %>
|
||||||
{gettext("New Member")}
|
{gettext("New Member")}
|
||||||
<% end %>
|
<% end %>
|
||||||
<:actions>
|
<:actions>
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||||
{gettext("Save")}
|
{gettext("Save")}
|
||||||
</.button>
|
</.button>
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<div class="mt-6 space-y-6">
|
<div class="mt-6 space-y-6">
|
||||||
<%!-- Tab navigation: same styling as member show; only Contact Data tab (no Membership Fees on edit) --%>
|
<%!-- Tab navigation: same styling as member show; only Contact Data tab (no Membership Fees on edit) --%>
|
||||||
<div
|
<div
|
||||||
role="tablist"
|
role="tablist"
|
||||||
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
|
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
|
||||||
>
|
|
||||||
<button
|
|
||||||
id="member-tab-contact"
|
|
||||||
role="tab"
|
|
||||||
type="button"
|
|
||||||
tabindex="0"
|
|
||||||
aria-selected="true"
|
|
||||||
aria-controls="member-tabpanel-contact"
|
|
||||||
class="tab tab-active flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<.icon name="hero-identification" class="size-4 shrink-0" />
|
<button
|
||||||
{gettext("Contact Data")}
|
id="member-tab-contact"
|
||||||
</button>
|
role="tab"
|
||||||
</div>
|
type="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-selected="true"
|
||||||
|
aria-controls="member-tabpanel-contact"
|
||||||
|
class="tab tab-active flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<.icon name="hero-identification" class="size-4 shrink-0" />
|
||||||
|
{gettext("Contact Data")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<%!-- Contact Data Tab Content (same structure as member show) --%>
|
<%!-- Contact Data Tab Content (same structure as member show) --%>
|
||||||
<div
|
<div
|
||||||
id="member-tabpanel-contact"
|
id="member-tabpanel-contact"
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
aria-labelledby="member-tab-contact"
|
aria-labelledby="member-tab-contact"
|
||||||
>
|
>
|
||||||
<%!-- Personal Data and Custom Fields Row --%>
|
<%!-- Personal Data and Custom Fields Row --%>
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
<%!-- Personal Data Section --%>
|
<%!-- Personal Data Section --%>
|
||||||
<div>
|
<div>
|
||||||
<.form_section title={gettext("Personal Data")}>
|
<.form_section title={gettext("Personal Data")}>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<%!-- Name Row --%>
|
<%!-- Name Row --%>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<div class="w-48">
|
<div class="w-48">
|
||||||
<.input
|
<.input
|
||||||
field={@form[:first_name]}
|
field={@form[:first_name]}
|
||||||
label={gettext("First Name")}
|
label={gettext("First Name")}
|
||||||
required={@member_field_required_map[: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>
|
||||||
<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 --%>
|
<%!-- Address: Country, Postal Code, City in one row --%>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<div class="w-48">
|
<div class="w-48">
|
||||||
<.input field={@form[:country]} label={gettext("Country")} />
|
<.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>
|
||||||
<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 --%>
|
<%!-- Street and Nr. below --%>
|
||||||
<div class="flex gap-4">
|
<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">
|
<div class="w-64">
|
||||||
<.input field={@form[:street]} label={gettext("Street")} />
|
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-24">
|
|
||||||
<.input field={@form[:house_number]} label={gettext("Nr.")} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Email --%>
|
<%!-- Membership Dates Row --%>
|
||||||
<div class="w-64">
|
<div class="flex gap-4">
|
||||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
<div class="w-36">
|
||||||
</div>
|
<.input
|
||||||
|
field={@form[:join_date]}
|
||||||
<%!-- Membership Dates Row --%>
|
label={gettext("Join Date")}
|
||||||
<div class="flex gap-4">
|
type="date"
|
||||||
<div class="w-36">
|
required={@member_field_required_map[:join_date]}
|
||||||
<.input
|
/>
|
||||||
field={@form[:join_date]}
|
</div>
|
||||||
label={gettext("Join Date")}
|
<div class="w-36">
|
||||||
type="date"
|
<.input
|
||||||
required={@member_field_required_map[:join_date]}
|
field={@form[:exit_date]}
|
||||||
/>
|
label={gettext("Exit Date")}
|
||||||
|
type="date"
|
||||||
|
required={@member_field_required_map[:exit_date]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-36">
|
|
||||||
|
<%!-- Notes --%>
|
||||||
|
<div>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:exit_date]}
|
field={@form[:notes]}
|
||||||
label={gettext("Exit Date")}
|
label={gettext("Notes")}
|
||||||
type="date"
|
type="textarea"
|
||||||
required={@member_field_required_map[:exit_date]}
|
required={@member_field_required_map[:notes]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<.input
|
<label class="label">
|
||||||
field={@form[:notes]}
|
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
|
||||||
label={gettext("Notes")}
|
</label>
|
||||||
type="textarea"
|
<select
|
||||||
required={@member_field_required_map[:notes]}
|
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>
|
||||||
</div>
|
</div>
|
||||||
</.form_section>
|
</.form_section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Custom Fields Section --%>
|
<%!-- Bottom Action Buttons --%>
|
||||||
<%= if Enum.any?(@custom_fields) do %>
|
<div class="flex justify-end gap-4 mt-6">
|
||||||
<div>
|
<.button navigate={return_path(@return_to, @member)} variant="neutral" type="button">
|
||||||
<.form_section title={gettext("Custom Fields")}>
|
{gettext("Cancel")}
|
||||||
<div class="grid grid-cols-2 gap-4">
|
</.button>
|
||||||
<%!-- Render in sorted order by finding the form for each sorted custom field --%>
|
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||||
<%= for cf <- @sorted_custom_fields do %>
|
{gettext("Save Member")}
|
||||||
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
|
</.button>
|
||||||
<%= if f_cfv[:custom_field_id].value == cf.id do %>
|
</div>
|
||||||
<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 --%>
|
<%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%>
|
||||||
<div class="max-w-xl">
|
<%= if @member && can?(@current_user, :destroy, @member) do %>
|
||||||
<.form_section title={gettext("Membership Fee")}>
|
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||||
<div class="space-y-4">
|
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||||
<div>
|
{gettext("Danger zone")}
|
||||||
<label class="label">
|
</h2>
|
||||||
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
</label>
|
<p class="text-base-content/70 mb-4">
|
||||||
<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(
|
{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>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</.form_section>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<%!-- 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(
|
|
||||||
"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="open_delete_modal"
|
|
||||||
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 %>
|
|
||||||
|
|
||||||
<%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
|
||||||
<%= if @member && assigns[:show_delete_modal] do %>
|
|
||||||
<dialog
|
|
||||||
id="delete-member-form-modal"
|
|
||||||
class="modal modal-open"
|
|
||||||
role="dialog"
|
|
||||||
aria-labelledby="delete-member-form-modal-title"
|
|
||||||
>
|
|
||||||
<div class="modal-box">
|
|
||||||
<h3 id="delete-member-form-modal-title" class="text-lg font-bold">
|
|
||||||
{gettext("Delete Member")}
|
|
||||||
</h3>
|
|
||||||
<p class="py-4">
|
|
||||||
{gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
|
|
||||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<div class="modal-action">
|
|
||||||
<.button
|
<.button
|
||||||
type="button"
|
id="delete-member-form-trigger"
|
||||||
variant="neutral"
|
|
||||||
phx-click="cancel_delete_modal"
|
|
||||||
phx-mounted={JS.focus()}
|
|
||||||
id="delete-member-form-modal-cancel"
|
|
||||||
aria-label={gettext("Cancel")}
|
|
||||||
>
|
|
||||||
{gettext("Cancel")}
|
|
||||||
</.button>
|
|
||||||
<.button
|
|
||||||
type="button"
|
|
||||||
variant="danger"
|
variant="danger"
|
||||||
phx-click={JS.push("delete", value: %{id: @member.id})}
|
type="button"
|
||||||
aria-label={gettext("Delete member")}
|
phx-click="open_delete_modal"
|
||||||
|
data-testid="member-delete"
|
||||||
|
aria-label={
|
||||||
|
gettext("Delete member %{name}",
|
||||||
|
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{gettext("Delete")}
|
<.icon name="hero-trash" class="size-4" />
|
||||||
|
{gettext("Delete member")}
|
||||||
</.button>
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</dialog>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
|
<%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
||||||
|
<%= if @member && assigns[:show_delete_modal] do %>
|
||||||
|
<dialog
|
||||||
|
id="delete-member-form-modal"
|
||||||
|
class="modal modal-open"
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="delete-member-form-modal-title"
|
||||||
|
phx-keydown="dialog_keydown"
|
||||||
|
>
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 id="delete-member-form-modal-title" class="text-lg font-bold">
|
||||||
|
{gettext("Delete Member")}
|
||||||
|
</h3>
|
||||||
|
<p class="py-4">
|
||||||
|
{gettext(
|
||||||
|
"Are you sure you want to delete %{name}? This action cannot be undone.",
|
||||||
|
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div class="modal-action">
|
||||||
|
<.button
|
||||||
|
type="button"
|
||||||
|
variant="neutral"
|
||||||
|
phx-click="cancel_delete_modal"
|
||||||
|
phx-mounted={JS.focus()}
|
||||||
|
id="delete-member-form-modal-cancel"
|
||||||
|
aria-label={gettext("Cancel")}
|
||||||
|
>
|
||||||
|
{gettext("Cancel")}
|
||||||
|
</.button>
|
||||||
|
<.button
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
phx-click={JS.push("delete", value: %{id: @member.id})}
|
||||||
|
aria-label={gettext("Delete member")}
|
||||||
|
>
|
||||||
|
{gettext("Delete")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</.form>
|
</.form>
|
||||||
|
|
@ -461,9 +472,25 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("cancel_delete_modal", _params, socket) do
|
def handle_event("cancel_delete_modal", _params, socket) do
|
||||||
{:noreply, assign(socket, :show_delete_modal, false)}
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||||
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
|
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||||
|
if socket.assigns[:show_delete_modal] do
|
||||||
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
|
else
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
member = socket.assigns.member
|
member = socket.assigns.member
|
||||||
|
|
@ -511,6 +538,12 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp close_delete_modal_and_restore_focus(socket) do
|
||||||
|
socket
|
||||||
|
|> assign(:show_delete_modal, false)
|
||||||
|
|> push_event("focus_restore", %{id: "delete-member-form-trigger"})
|
||||||
|
end
|
||||||
|
|
||||||
defp handle_save_success(socket, member) do
|
defp handle_save_success(socket, member) do
|
||||||
notify_parent({:saved, member})
|
notify_parent({:saved, member})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,18 +55,26 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<div class="mt-6 space-y-6">
|
<div
|
||||||
<%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%>
|
id="member-show-focus-root"
|
||||||
|
class="mt-6 space-y-6"
|
||||||
|
phx-hook="FocusRestore"
|
||||||
|
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||||
|
>
|
||||||
|
<%!-- Tab Navigation: roving tabindex (only active tab tabindex="0"), ArrowLeft/ArrowRight (WCAG tab pattern) --%>
|
||||||
<div
|
<div
|
||||||
|
id="member-tablist"
|
||||||
role="tablist"
|
role="tablist"
|
||||||
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
|
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
|
||||||
|
phx-hook="TabListKeydown"
|
||||||
|
phx-keydown="tab_keydown"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
id="member-tab-contact"
|
id="member-tab-contact"
|
||||||
role="tab"
|
role="tab"
|
||||||
type="button"
|
type="button"
|
||||||
tabindex="0"
|
tabindex={if @active_tab == :contact, do: "0", else: "-1"}
|
||||||
aria-selected={@active_tab == :contact}
|
aria-selected={if @active_tab == :contact, do: "true", else: "false"}
|
||||||
aria-controls="member-tabpanel-contact"
|
aria-controls="member-tabpanel-contact"
|
||||||
class={[
|
class={[
|
||||||
"tab flex items-center gap-2",
|
"tab flex items-center gap-2",
|
||||||
|
|
@ -82,8 +90,8 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
id="member-tab-membership_fees"
|
id="member-tab-membership_fees"
|
||||||
role="tab"
|
role="tab"
|
||||||
type="button"
|
type="button"
|
||||||
tabindex="0"
|
tabindex={if @active_tab == :membership_fees, do: "0", else: "-1"}
|
||||||
aria-selected={@active_tab == :membership_fees}
|
aria-selected={if @active_tab == :membership_fees, do: "true", else: "false"}
|
||||||
aria-controls="member-tabpanel-membership_fees"
|
aria-controls="member-tabpanel-membership_fees"
|
||||||
class={[
|
class={[
|
||||||
"tab flex items-center gap-2",
|
"tab flex items-center gap-2",
|
||||||
|
|
@ -315,6 +323,7 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<.button
|
<.button
|
||||||
|
id="delete-member-trigger"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
phx-click="open_delete_modal"
|
phx-click="open_delete_modal"
|
||||||
data-testid="member-delete"
|
data-testid="member-delete"
|
||||||
|
|
@ -338,6 +347,7 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
class="modal modal-open"
|
class="modal modal-open"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-labelledby="delete-member-modal-title"
|
aria-labelledby="delete-member-modal-title"
|
||||||
|
phx-keydown="dialog_keydown"
|
||||||
>
|
>
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 id="delete-member-modal-title" class="text-lg font-bold">
|
<h3 id="delete-member-modal-title" class="text-lg font-bold">
|
||||||
|
|
@ -434,6 +444,21 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("tab_keydown", %{"key" => key}, socket)
|
||||||
|
when key in ["ArrowLeft", "ArrowRight"] do
|
||||||
|
new_tab =
|
||||||
|
case {key, socket.assigns.active_tab} do
|
||||||
|
{"ArrowRight", :contact} -> :membership_fees
|
||||||
|
{"ArrowLeft", :membership_fees} -> :contact
|
||||||
|
_ -> socket.assigns.active_tab
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, assign(socket, :active_tab, new_tab)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("tab_keydown", _params, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("open_delete_modal", _params, socket) do
|
def handle_event("open_delete_modal", _params, socket) do
|
||||||
{:noreply, assign(socket, :show_delete_modal, true)}
|
{:noreply, assign(socket, :show_delete_modal, true)}
|
||||||
|
|
@ -441,9 +466,26 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("cancel_delete_modal", _params, socket) do
|
def handle_event("cancel_delete_modal", _params, socket) do
|
||||||
{:noreply, assign(socket, :show_delete_modal, false)}
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Escape closes modal (WCAG). phx-window-keydown ensures Escape is captured regardless of focus.
|
||||||
|
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||||
|
if socket.assigns[:show_delete_modal] do
|
||||||
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
|
else
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
|
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||||
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
member = socket.assigns.member
|
member = socket.assigns.member
|
||||||
|
|
@ -493,6 +535,13 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
{:noreply, assign(socket, :vereinfacht_receipts, response)}
|
{:noreply, assign(socket, :vereinfacht_receipts, response)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# WCAG 2.4.3: when modal closes, return focus to the trigger (Delete member button)
|
||||||
|
defp close_delete_modal_and_restore_focus(socket) do
|
||||||
|
socket
|
||||||
|
|> assign(:show_delete_modal, false)
|
||||||
|
|> push_event("focus_restore", %{id: "delete-member-trigger"})
|
||||||
|
end
|
||||||
|
|
||||||
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash
|
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:put_flash, type, message}, socket) do
|
def handle_info({:put_flash, type, message}, socket) do
|
||||||
|
|
|
||||||
|
|
@ -231,7 +231,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
phx-value-status="paid"
|
phx-value-status="paid"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class={cycle_status_btn_class(cycle.status, :paid)}
|
class={cycle_status_btn_class(cycle.status, :paid)}
|
||||||
aria-pressed={cycle.status == :paid}
|
aria-pressed={if cycle.status == :paid, do: "true", else: "false"}
|
||||||
title={gettext("Mark as paid")}
|
title={gettext("Mark as paid")}
|
||||||
>
|
>
|
||||||
<.icon name="hero-check-circle" class="size-4" />
|
<.icon name="hero-check-circle" class="size-4" />
|
||||||
|
|
@ -244,7 +244,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
phx-value-status="suspended"
|
phx-value-status="suspended"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class={cycle_status_btn_class(cycle.status, :suspended)}
|
class={cycle_status_btn_class(cycle.status, :suspended)}
|
||||||
aria-pressed={cycle.status == :suspended}
|
aria-pressed={if cycle.status == :suspended, do: "true", else: "false"}
|
||||||
title={gettext("Mark as suspended")}
|
title={gettext("Mark as suspended")}
|
||||||
>
|
>
|
||||||
<.icon name="hero-pause-circle" class="size-4" />
|
<.icon name="hero-pause-circle" class="size-4" />
|
||||||
|
|
@ -257,7 +257,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
phx-value-status="unpaid"
|
phx-value-status="unpaid"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class={cycle_status_btn_class(cycle.status, :unpaid)}
|
class={cycle_status_btn_class(cycle.status, :unpaid)}
|
||||||
aria-pressed={cycle.status == :unpaid}
|
aria-pressed={if cycle.status == :unpaid, do: "true", else: "false"}
|
||||||
title={gettext("Mark as unpaid")}
|
title={gettext("Mark as unpaid")}
|
||||||
>
|
>
|
||||||
<.icon name="hero-x-circle" class="size-4" />
|
<.icon name="hero-x-circle" class="size-4" />
|
||||||
|
|
@ -301,6 +301,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
class="modal modal-open"
|
class="modal modal-open"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-labelledby="edit-cycle-amount-modal-title"
|
aria-labelledby="edit-cycle-amount-modal-title"
|
||||||
|
phx-keydown="dialog_keydown"
|
||||||
>
|
>
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 id="edit-cycle-amount-modal-title" class="text-lg font-bold">
|
<h3 id="edit-cycle-amount-modal-title" class="text-lg font-bold">
|
||||||
|
|
@ -347,6 +348,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
class="modal modal-open"
|
class="modal modal-open"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-labelledby="delete-cycle-modal-title"
|
aria-labelledby="delete-cycle-modal-title"
|
||||||
|
phx-keydown="dialog_keydown"
|
||||||
>
|
>
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 id="delete-cycle-modal-title" class="text-lg font-bold">{gettext("Delete Cycle")}</h3>
|
<h3 id="delete-cycle-modal-title" class="text-lg font-bold">{gettext("Delete Cycle")}</h3>
|
||||||
|
|
@ -388,6 +390,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
class="modal modal-open"
|
class="modal modal-open"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-labelledby="delete-all-cycles-modal-title"
|
aria-labelledby="delete-all-cycles-modal-title"
|
||||||
|
phx-keydown="dialog_keydown"
|
||||||
>
|
>
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 id="delete-all-cycles-modal-title" class="text-lg font-bold text-error">
|
<h3 id="delete-all-cycles-modal-title" class="text-lg font-bold text-error">
|
||||||
|
|
@ -450,6 +453,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
class="modal modal-open"
|
class="modal modal-open"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-labelledby="create-cycle-modal-title"
|
aria-labelledby="create-cycle-modal-title"
|
||||||
|
phx-keydown="dialog_keydown"
|
||||||
>
|
>
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 id="create-cycle-modal-title" class="text-lg font-bold">{gettext("Create Cycle")}</h3>
|
<h3 id="create-cycle-modal-title" class="text-lg font-bold">{gettext("Create Cycle")}</h3>
|
||||||
|
|
@ -917,6 +921,35 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
|> assign(:create_cycle_error, nil)}
|
|> assign(:create_cycle_error, nil)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("dialog_keydown", %{"key" => "Escape"}, socket) do
|
||||||
|
socket =
|
||||||
|
cond do
|
||||||
|
socket.assigns[:editing_cycle] ->
|
||||||
|
assign(socket, :editing_cycle, nil)
|
||||||
|
|
||||||
|
socket.assigns[:deleting_cycle] ->
|
||||||
|
assign(socket, :deleting_cycle, nil)
|
||||||
|
|
||||||
|
socket.assigns[:deleting_all_cycles] ->
|
||||||
|
socket
|
||||||
|
|> assign(:deleting_all_cycles, false)
|
||||||
|
|> assign(:delete_all_confirmation, "")
|
||||||
|
|
||||||
|
socket.assigns[:creating_cycle] ->
|
||||||
|
socket
|
||||||
|
|> assign(:creating_cycle, false)
|
||||||
|
|> assign(:create_cycle_date, nil)
|
||||||
|
|> assign(:create_cycle_error, nil)
|
||||||
|
|
||||||
|
true ->
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
def handle_event("update_create_cycle_date", %{"date" => date_str}, socket) do
|
def handle_event("update_create_cycle_date", %{"date" => date_str}, socket) do
|
||||||
date =
|
date =
|
||||||
case Date.from_iso8601(date_str) do
|
case Date.from_iso8601(date_str) do
|
||||||
|
|
|
||||||
|
|
@ -101,9 +101,25 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("cancel_delete_modal", _params, socket) do
|
def handle_event("cancel_delete_modal", _params, socket) do
|
||||||
{:noreply, assign(socket, :show_delete_modal, false)}
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||||
|
if socket.assigns[:show_delete_modal] do
|
||||||
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
|
else
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
|
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||||
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
defp handle_delete_role(role, socket) do
|
defp handle_delete_role(role, socket) do
|
||||||
if role.is_system_role do
|
if role.is_system_role do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|
@ -167,6 +183,12 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
recalculate_user_count(role, actor)
|
recalculate_user_count(role, actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp close_delete_modal_and_restore_focus(socket) do
|
||||||
|
socket
|
||||||
|
|> assign(:show_delete_modal, false)
|
||||||
|
|> push_event("focus_restore", %{id: "delete-role-trigger"})
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
@ -198,94 +220,102 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<.list>
|
<div
|
||||||
<:item title={gettext("Name")}>{@role.name}</:item>
|
id="role-show-focus-root"
|
||||||
<:item title={gettext("Description")}>
|
phx-hook="FocusRestore"
|
||||||
<%= if @role.description do %>
|
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||||
{@role.description}
|
>
|
||||||
<% else %>
|
<.list>
|
||||||
<span class="text-base-content/70 italic">{gettext("No description")}</span>
|
<:item title={gettext("Name")}>{@role.name}</:item>
|
||||||
<% end %>
|
<:item title={gettext("Description")}>
|
||||||
</:item>
|
<%= if @role.description do %>
|
||||||
<:item title={gettext("Permission Set")}>
|
{@role.description}
|
||||||
<.badge variant={permission_set_badge_variant(@role.permission_set_name)}>
|
<% else %>
|
||||||
{@role.permission_set_name}
|
<span class="text-base-content/70 italic">{gettext("No description")}</span>
|
||||||
</.badge>
|
<% end %>
|
||||||
</:item>
|
</:item>
|
||||||
<:item title={gettext("System Role")}>
|
<:item title={gettext("Permission Set")}>
|
||||||
<.badge :if={@role.is_system_role} variant="warning">
|
<.badge variant={permission_set_badge_variant(@role.permission_set_name)}>
|
||||||
{gettext("Yes")}
|
{@role.permission_set_name}
|
||||||
</.badge>
|
</.badge>
|
||||||
<.badge :if={!@role.is_system_role} variant="neutral">
|
</:item>
|
||||||
{gettext("No")}
|
<:item title={gettext("System Role")}>
|
||||||
</.badge>
|
<.badge :if={@role.is_system_role} variant="warning">
|
||||||
</:item>
|
{gettext("Yes")}
|
||||||
</.list>
|
</.badge>
|
||||||
|
<.badge :if={!@role.is_system_role} variant="neutral">
|
||||||
|
{gettext("No")}
|
||||||
|
</.badge>
|
||||||
|
</:item>
|
||||||
|
</.list>
|
||||||
|
|
||||||
<%!-- Danger zone: canonical pattern (same as member show) --%>
|
<%!-- Danger zone: canonical pattern (same as member show) --%>
|
||||||
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %>
|
<%= 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">
|
<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">
|
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||||
{gettext("Danger zone")}
|
{gettext("Danger zone")}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
<p class="text-base-content/70 mb-4">
|
<p class="text-base-content/70 mb-4">
|
||||||
{gettext(
|
{gettext(
|
||||||
"Deleting this role cannot be undone. Users assigned to this role must be reassigned first."
|
"Deleting this role cannot be undone. Users assigned to this role must be reassigned first."
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<.button
|
|
||||||
variant="danger"
|
|
||||||
phx-click="open_delete_modal"
|
|
||||||
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 %>
|
|
||||||
|
|
||||||
<%!-- Delete Role Confirmation Modal (WCAG: focus moves into modal, keyboard confirm/cancel) --%>
|
|
||||||
<%= if assigns[:show_delete_modal] do %>
|
|
||||||
<dialog
|
|
||||||
id="delete-role-modal"
|
|
||||||
class="modal modal-open"
|
|
||||||
role="dialog"
|
|
||||||
aria-labelledby="delete-role-modal-title"
|
|
||||||
>
|
|
||||||
<div class="modal-box">
|
|
||||||
<h3 id="delete-role-modal-title" class="text-lg font-bold">{gettext("Delete Role")}</h3>
|
|
||||||
<p class="py-4">
|
|
||||||
{gettext(
|
|
||||||
"Are you sure you want to delete the role %{name}? This action cannot be undone.",
|
|
||||||
name: @role.name
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<div class="modal-action">
|
|
||||||
<.button
|
<.button
|
||||||
type="button"
|
id="delete-role-trigger"
|
||||||
variant="neutral"
|
|
||||||
phx-click="cancel_delete_modal"
|
|
||||||
phx-mounted={JS.focus()}
|
|
||||||
id="delete-role-modal-cancel"
|
|
||||||
aria-label={gettext("Cancel")}
|
|
||||||
>
|
|
||||||
{gettext("Cancel")}
|
|
||||||
</.button>
|
|
||||||
<.button
|
|
||||||
type="button"
|
|
||||||
variant="danger"
|
variant="danger"
|
||||||
phx-click={JS.push("delete", value: %{id: @role.id})}
|
phx-click="open_delete_modal"
|
||||||
aria-label={gettext("Delete role")}
|
data-testid="role-delete"
|
||||||
|
aria-label={gettext("Delete role %{name}", name: @role.name)}
|
||||||
>
|
>
|
||||||
{gettext("Delete")}
|
<.icon name="hero-trash" class="size-4" />
|
||||||
|
{gettext("Delete role")}
|
||||||
</.button>
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</dialog>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
|
<%!-- Delete Role Confirmation Modal (WCAG: focus moves into modal, keyboard confirm/cancel) --%>
|
||||||
|
<%= if assigns[:show_delete_modal] do %>
|
||||||
|
<dialog
|
||||||
|
id="delete-role-modal"
|
||||||
|
class="modal modal-open"
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="delete-role-modal-title"
|
||||||
|
phx-keydown="dialog_keydown"
|
||||||
|
>
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 id="delete-role-modal-title" class="text-lg font-bold">{gettext("Delete Role")}</h3>
|
||||||
|
<p class="py-4">
|
||||||
|
{gettext(
|
||||||
|
"Are you sure you want to delete the role %{name}? This action cannot be undone.",
|
||||||
|
name: @role.name
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div class="modal-action">
|
||||||
|
<.button
|
||||||
|
type="button"
|
||||||
|
variant="neutral"
|
||||||
|
phx-click="cancel_delete_modal"
|
||||||
|
phx-mounted={JS.focus()}
|
||||||
|
id="delete-role-modal-cancel"
|
||||||
|
aria-label={gettext("Cancel")}
|
||||||
|
>
|
||||||
|
{gettext("Cancel")}
|
||||||
|
</.button>
|
||||||
|
<.button
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
phx-click={JS.push("delete", value: %{id: @role.id})}
|
||||||
|
aria-label={gettext("Delete role")}
|
||||||
|
>
|
||||||
|
{gettext("Delete")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -65,315 +65,323 @@ defmodule MvWeb.UserLive.Form do
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="user-form-focus-root"
|
||||||
|
phx-hook="FocusRestore"
|
||||||
|
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||||
|
>
|
||||||
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
|
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
|
||||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||||
|
|
||||||
<%= if @user && @can_assign_role do %>
|
<%= if @user && @can_assign_role do %>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<.input
|
|
||||||
field={@form[:role_id]}
|
|
||||||
type="select"
|
|
||||||
label={gettext("Role")}
|
|
||||||
options={Enum.map(@roles, &{&1.name, &1.id})}
|
|
||||||
prompt={gettext("Select role...")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<!-- Password Section -->
|
|
||||||
<div class="mt-6">
|
|
||||||
<label class="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="set_password"
|
|
||||||
phx-click="toggle_password_section"
|
|
||||||
checked={@show_password_fields}
|
|
||||||
class="checkbox checkbox-sm"
|
|
||||||
/>
|
|
||||||
<span class="text-sm font-medium">
|
|
||||||
{if @user, do: gettext("Change Password"), else: gettext("Set Password")}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<%= if @show_password_fields do %>
|
|
||||||
<div class="p-4 mt-4 space-y-4 rounded-lg bg-gray-50">
|
|
||||||
<%= if @user && MvWeb.Helpers.UserHelpers.has_oidc?(@user) do %>
|
|
||||||
<div class="p-3 mb-4 border border-red-300 rounded-lg bg-red-50" role="alert">
|
|
||||||
<p class="text-sm font-semibold text-red-800">
|
|
||||||
{gettext("SSO / OIDC user")}
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-sm text-red-700">
|
|
||||||
{gettext(
|
|
||||||
"This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<.input
|
<.input
|
||||||
field={@form[:password]}
|
field={@form[:role_id]}
|
||||||
label={gettext("Password")}
|
type="select"
|
||||||
type="password"
|
label={gettext("Role")}
|
||||||
required
|
options={Enum.map(@roles, &{&1.name, &1.id})}
|
||||||
autocomplete="new-password"
|
prompt={gettext("Select role...")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Only show password confirmation for new users (register_with_password) -->
|
|
||||||
<%= if !@user do %>
|
|
||||||
<.input
|
|
||||||
field={@form[:password_confirmation]}
|
|
||||||
label={gettext("Confirm Password")}
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
autocomplete="new-password"
|
|
||||||
/>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="text-sm text-gray-600">
|
|
||||||
<p><strong>{gettext("Password requirements")}:</strong></p>
|
|
||||||
<ul class="mt-1 space-y-1 text-xs list-disc list-inside">
|
|
||||||
<li>{gettext("At least 8 characters")}</li>
|
|
||||||
<li>{gettext("Include both letters and numbers")}</li>
|
|
||||||
<li>{gettext("Consider using special characters")}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= if @user && @can_manage_member_linking do %>
|
|
||||||
<div class="p-3 mt-3 border border-orange-200 rounded bg-orange-50">
|
|
||||||
<p class="text-sm text-orange-800">
|
|
||||||
<strong>{gettext("Admin Note")}:</strong> {gettext(
|
|
||||||
"As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
|
||||||
<%= if @user do %>
|
|
||||||
<div class="p-4 mt-4 rounded-lg bg-blue-50">
|
|
||||||
<p class="text-sm text-blue-800">
|
|
||||||
<strong>{gettext("Note")}:</strong> {gettext(
|
|
||||||
"Check 'Change Password' above to set a new password for this user."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<div class="p-4 mt-4 rounded-lg bg-yellow-50">
|
|
||||||
<p class="text-sm text-yellow-800">
|
|
||||||
<strong>{gettext("Note")}:</strong> {gettext(
|
|
||||||
"User will be created without a password. Check 'Set Password' to add one."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
|
||||||
|
<!-- Password Section -->
|
||||||
<!-- Member Linking Section (admin only: only admins can link/unlink users to members) -->
|
|
||||||
<%= if @can_manage_member_linking do %>
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
|
<label class="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="set_password"
|
||||||
|
phx-click="toggle_password_section"
|
||||||
|
checked={@show_password_fields}
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{if @user, do: gettext("Change Password"), else: gettext("Set Password")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<%= if @user && @user.member && !@unlink_member do %>
|
<%= if @show_password_fields do %>
|
||||||
<!-- Show linked member with unlink button -->
|
<div class="p-4 mt-4 space-y-4 rounded-lg bg-gray-50">
|
||||||
<div class="p-4 border border-green-200 rounded-lg bg-green-50">
|
<%= if @user && MvWeb.Helpers.UserHelpers.has_oidc?(@user) do %>
|
||||||
<div class="flex items-center justify-between">
|
<div class="p-3 mb-4 border border-red-300 rounded-lg bg-red-50" role="alert">
|
||||||
<div>
|
<p class="text-sm font-semibold text-red-800">
|
||||||
<p class="font-medium text-green-900">
|
{gettext("SSO / OIDC user")}
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
|
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-green-700">{@user.member.email}</p>
|
<p class="mt-1 text-sm text-red-700">
|
||||||
</div>
|
{gettext(
|
||||||
<.button
|
"This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
|
||||||
type="button"
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
phx-click="unlink_member"
|
|
||||||
>
|
|
||||||
{gettext("Unlink Member")}
|
|
||||||
</.button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<%= if @unlink_member do %>
|
|
||||||
<!-- Show unlink pending message -->
|
|
||||||
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
|
|
||||||
<p class="text-sm text-yellow-800">
|
|
||||||
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
|
|
||||||
"Member will be unlinked when you save. Cannot select new member until saved."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<!-- Show member search/selection for unlinked users -->
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="member-search-input"
|
|
||||||
role="combobox"
|
|
||||||
phx-hook="ComboBox"
|
|
||||||
phx-focus="show_member_dropdown"
|
|
||||||
phx-change="search_members"
|
|
||||||
phx-debounce="300"
|
|
||||||
phx-window-keydown="member_dropdown_keydown"
|
|
||||||
value={@member_search_query}
|
|
||||||
placeholder={gettext("Search for a member to link...")}
|
|
||||||
class="w-full input"
|
|
||||||
name="member_search"
|
|
||||||
disabled={@unlink_member}
|
|
||||||
aria-label={gettext("Search for member to link")}
|
|
||||||
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
|
|
||||||
aria-autocomplete="list"
|
|
||||||
aria-controls="member-dropdown"
|
|
||||||
aria-expanded={to_string(@show_member_dropdown)}
|
|
||||||
aria-activedescendant={
|
|
||||||
if @focused_member_index,
|
|
||||||
do: "member-option-#{@focused_member_index}",
|
|
||||||
else: nil
|
|
||||||
}
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<%= if length(@available_members) > 0 do %>
|
|
||||||
<div
|
|
||||||
id="member-dropdown"
|
|
||||||
role="listbox"
|
|
||||||
aria-label={gettext("Available members")}
|
|
||||||
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"}
|
|
||||||
phx-click-away="hide_member_dropdown"
|
|
||||||
>
|
|
||||||
<%= for {member, index} <- Enum.with_index(@available_members) do %>
|
|
||||||
<div
|
|
||||||
id={"member-option-#{index}"}
|
|
||||||
role="option"
|
|
||||||
tabindex="0"
|
|
||||||
aria-selected={to_string(@focused_member_index == index)}
|
|
||||||
phx-click="select_member"
|
|
||||||
phx-value-id={member.id}
|
|
||||||
data-member-id={member.id}
|
|
||||||
class={[
|
|
||||||
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
|
|
||||||
if(@focused_member_index == index,
|
|
||||||
do: "bg-base-300",
|
|
||||||
else: "hover:bg-base-200"
|
|
||||||
)
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<p class="font-medium">
|
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-base-content/70">{member.email}</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
|
|
||||||
<div class="p-3 border border-yellow-200 rounded bg-yellow-50">
|
|
||||||
<p class="text-sm text-yellow-800">
|
|
||||||
<strong>{gettext("Note")}:</strong> {gettext(
|
|
||||||
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
|
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<.input
|
||||||
|
field={@form[:password]}
|
||||||
|
label={gettext("Password")}
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Only show password confirmation for new users (register_with_password) -->
|
||||||
|
<%= if !@user do %>
|
||||||
|
<.input
|
||||||
|
field={@form[:password_confirmation]}
|
||||||
|
label={gettext("Confirm Password")}
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%= if @selected_member_id && @selected_member_name do %>
|
<div class="text-sm text-gray-600">
|
||||||
<div
|
<p><strong>{gettext("Password requirements")}:</strong></p>
|
||||||
id="member-selected"
|
<ul class="mt-1 space-y-1 text-xs list-disc list-inside">
|
||||||
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50"
|
<li>{gettext("At least 8 characters")}</li>
|
||||||
>
|
<li>{gettext("Include both letters and numbers")}</li>
|
||||||
<p class="text-sm text-blue-800">
|
<li>{gettext("Consider using special characters")}</li>
|
||||||
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
|
</ul>
|
||||||
</p>
|
</div>
|
||||||
<p class="mt-1 text-xs text-blue-600">
|
|
||||||
{gettext("Save to confirm linking.")}
|
<%= if @user && @can_manage_member_linking do %>
|
||||||
|
<div class="p-3 mt-3 border border-orange-200 rounded bg-orange-50">
|
||||||
|
<p class="text-sm text-orange-800">
|
||||||
|
<strong>{gettext("Admin Note")}:</strong> {gettext(
|
||||||
|
"As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<%= if @user do %>
|
||||||
|
<div class="p-4 mt-4 rounded-lg bg-blue-50">
|
||||||
|
<p class="text-sm text-blue-800">
|
||||||
|
<strong>{gettext("Note")}:</strong> {gettext(
|
||||||
|
"Check 'Change Password' above to set a new password for this user."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="p-4 mt-4 rounded-lg bg-yellow-50">
|
||||||
|
<p class="text-sm text-yellow-800">
|
||||||
|
<strong>{gettext("Note")}:</strong> {gettext(
|
||||||
|
"User will be created without a password. Check 'Set Password' to add one."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
|
||||||
|
<!-- Member Linking Section (admin only: only admins can link/unlink users to members) -->
|
||||||
|
<%= if @can_manage_member_linking do %>
|
||||||
|
<div class="mt-6">
|
||||||
|
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
|
||||||
|
|
||||||
<%!-- Danger zone: canonical pattern (same as member form) --%>
|
<%= if @user && @user.member && !@unlink_member do %>
|
||||||
<%= if @user && can?(@current_user, :destroy, @user) && !Mv.Helpers.SystemActor.system_user?(@user) do %>
|
<!-- Show linked member with unlink button -->
|
||||||
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
<div class="p-4 border border-green-200 rounded-lg bg-green-50">
|
||||||
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
<div class="flex items-center justify-between">
|
||||||
{gettext("Danger zone")}
|
<div>
|
||||||
</h2>
|
<p class="font-medium text-green-900">
|
||||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
|
||||||
<p class="text-base-content/70 mb-4">
|
</p>
|
||||||
{gettext(
|
<p class="text-sm text-green-700">{@user.member.email}</p>
|
||||||
"Deleting this user cannot be undone. The user account and any linked member association will be affected."
|
</div>
|
||||||
)}
|
<.button
|
||||||
</p>
|
type="button"
|
||||||
<.button
|
variant="danger"
|
||||||
type="button"
|
size="sm"
|
||||||
variant="danger"
|
phx-click="unlink_member"
|
||||||
phx-click="open_delete_modal"
|
>
|
||||||
data-testid="user-delete"
|
{gettext("Unlink Member")}
|
||||||
aria-label={gettext("Delete user %{email}", email: @user.email)}
|
</.button>
|
||||||
>
|
</div>
|
||||||
<.icon name="hero-trash" class="size-4" />
|
</div>
|
||||||
{gettext("Delete user")}
|
<% else %>
|
||||||
</.button>
|
<%= if @unlink_member do %>
|
||||||
|
<!-- Show unlink pending message -->
|
||||||
|
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
|
||||||
|
<p class="text-sm text-yellow-800">
|
||||||
|
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
|
||||||
|
"Member will be unlinked when you save. Cannot select new member until saved."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<!-- Show member search/selection for unlinked users -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="member-search-input"
|
||||||
|
role="combobox"
|
||||||
|
phx-hook="ComboBox"
|
||||||
|
phx-focus="show_member_dropdown"
|
||||||
|
phx-change="search_members"
|
||||||
|
phx-debounce="300"
|
||||||
|
phx-window-keydown="member_dropdown_keydown"
|
||||||
|
value={@member_search_query}
|
||||||
|
placeholder={gettext("Search for a member to link...")}
|
||||||
|
class="w-full input"
|
||||||
|
name="member_search"
|
||||||
|
disabled={@unlink_member}
|
||||||
|
aria-label={gettext("Search for member to link")}
|
||||||
|
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-controls="member-dropdown"
|
||||||
|
aria-expanded={to_string(@show_member_dropdown)}
|
||||||
|
aria-activedescendant={
|
||||||
|
if @focused_member_index,
|
||||||
|
do: "member-option-#{@focused_member_index}",
|
||||||
|
else: nil
|
||||||
|
}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<%= if length(@available_members) > 0 do %>
|
||||||
|
<div
|
||||||
|
id="member-dropdown"
|
||||||
|
role="listbox"
|
||||||
|
aria-label={gettext("Available members")}
|
||||||
|
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"}
|
||||||
|
phx-click-away="hide_member_dropdown"
|
||||||
|
>
|
||||||
|
<%= for {member, index} <- Enum.with_index(@available_members) do %>
|
||||||
|
<div
|
||||||
|
id={"member-option-#{index}"}
|
||||||
|
role="option"
|
||||||
|
tabindex="0"
|
||||||
|
aria-selected={to_string(@focused_member_index == index)}
|
||||||
|
phx-click="select_member"
|
||||||
|
phx-value-id={member.id}
|
||||||
|
data-member-id={member.id}
|
||||||
|
class={[
|
||||||
|
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
|
||||||
|
if(@focused_member_index == index,
|
||||||
|
do: "bg-base-300",
|
||||||
|
else: "hover:bg-base-200"
|
||||||
|
)
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<p class="font-medium">
|
||||||
|
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-base-content/70">{member.email}</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
|
||||||
|
<div class="p-3 border border-yellow-200 rounded bg-yellow-50">
|
||||||
|
<p class="text-sm text-yellow-800">
|
||||||
|
<strong>{gettext("Note")}:</strong> {gettext(
|
||||||
|
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if @selected_member_id && @selected_member_name do %>
|
||||||
|
<div
|
||||||
|
id="member-selected"
|
||||||
|
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-blue-800">
|
||||||
|
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-blue-600">
|
||||||
|
{gettext("Save to confirm linking.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
<%!-- Danger zone: canonical pattern (same as member form) --%>
|
||||||
<%= if @user && assigns[:show_delete_modal] do %>
|
<%= if @user && can?(@current_user, :destroy, @user) && !Mv.Helpers.SystemActor.system_user?(@user) do %>
|
||||||
<dialog
|
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||||
id="delete-user-form-modal"
|
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||||
class="modal modal-open"
|
{gettext("Danger zone")}
|
||||||
role="dialog"
|
</h2>
|
||||||
aria-labelledby="delete-user-form-modal-title"
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
>
|
<p class="text-base-content/70 mb-4">
|
||||||
<div class="modal-box">
|
{gettext(
|
||||||
<h3 id="delete-user-form-modal-title" class="text-lg font-bold">
|
"Deleting this user cannot be undone. The user account and any linked member association will be affected."
|
||||||
{gettext("Delete User")}
|
)}
|
||||||
</h3>
|
</p>
|
||||||
<p class="py-4">
|
|
||||||
{gettext(
|
|
||||||
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
|
||||||
email: @user.email
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<div class="modal-action">
|
|
||||||
<.button
|
|
||||||
type="button"
|
|
||||||
variant="neutral"
|
|
||||||
phx-click="cancel_delete_modal"
|
|
||||||
phx-mounted={JS.focus()}
|
|
||||||
id="delete-user-form-modal-cancel"
|
|
||||||
aria-label={gettext("Cancel")}
|
|
||||||
>
|
|
||||||
{gettext("Cancel")}
|
|
||||||
</.button>
|
|
||||||
<.button
|
<.button
|
||||||
|
id="delete-user-form-trigger"
|
||||||
type="button"
|
type="button"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
phx-click={JS.push("delete", value: %{id: @user.id})}
|
phx-click="open_delete_modal"
|
||||||
aria-label={gettext("Delete user")}
|
data-testid="user-delete"
|
||||||
|
aria-label={gettext("Delete user %{email}", email: @user.email)}
|
||||||
>
|
>
|
||||||
{gettext("Delete")}
|
<.icon name="hero-trash" class="size-4" />
|
||||||
|
{gettext("Delete user")}
|
||||||
</.button>
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</dialog>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
||||||
<.button navigate={return_path(@return_to, @user)} variant="neutral">
|
<%= if @user && assigns[:show_delete_modal] do %>
|
||||||
{gettext("Cancel")}
|
<dialog
|
||||||
</.button>
|
id="delete-user-form-modal"
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
class="modal modal-open"
|
||||||
{gettext("Save User")}
|
role="dialog"
|
||||||
</.button>
|
aria-labelledby="delete-user-form-modal-title"
|
||||||
</div>
|
phx-keydown="dialog_keydown"
|
||||||
</.form>
|
>
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 id="delete-user-form-modal-title" class="text-lg font-bold">
|
||||||
|
{gettext("Delete User")}
|
||||||
|
</h3>
|
||||||
|
<p class="py-4">
|
||||||
|
{gettext(
|
||||||
|
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
||||||
|
email: @user.email
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div class="modal-action">
|
||||||
|
<.button
|
||||||
|
type="button"
|
||||||
|
variant="neutral"
|
||||||
|
phx-click="cancel_delete_modal"
|
||||||
|
phx-mounted={JS.focus()}
|
||||||
|
id="delete-user-form-modal-cancel"
|
||||||
|
aria-label={gettext("Cancel")}
|
||||||
|
>
|
||||||
|
{gettext("Cancel")}
|
||||||
|
</.button>
|
||||||
|
<.button
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
phx-click={JS.push("delete", value: %{id: @user.id})}
|
||||||
|
aria-label={gettext("Delete user")}
|
||||||
|
>
|
||||||
|
{gettext("Delete")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
<% 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>
|
||||||
|
</div>
|
||||||
|
</.form>
|
||||||
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
@ -496,9 +504,25 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("cancel_delete_modal", _params, socket) do
|
def handle_event("cancel_delete_modal", _params, socket) do
|
||||||
{:noreply, assign(socket, :show_delete_modal, false)}
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||||
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
|
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||||
|
if socket.assigns[:show_delete_modal] do
|
||||||
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
|
else
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
user = socket.assigns.user
|
user = socket.assigns.user
|
||||||
|
|
@ -660,6 +684,12 @@ defmodule MvWeb.UserLive.Form do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp close_delete_modal_and_restore_focus(socket) do
|
||||||
|
socket
|
||||||
|
|> assign(:show_delete_modal, false)
|
||||||
|
|> push_event("focus_restore", %{id: "delete-user-form-trigger"})
|
||||||
|
end
|
||||||
|
|
||||||
defp handle_member_linking(socket, user, actor) do
|
defp handle_member_linking(socket, user, actor) do
|
||||||
result = perform_member_link_action(socket, user, actor)
|
result = perform_member_link_action(socket, user, actor)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,98 +58,106 @@ defmodule MvWeb.UserLive.Show do
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<.list>
|
<div
|
||||||
<:item title={gettext("Email")}>{@user.email}</:item>
|
id="user-show-focus-root"
|
||||||
<:item title={gettext("Role")}>{@user.role.name}</:item>
|
phx-hook="FocusRestore"
|
||||||
<:item title={gettext("Password Authentication")}>
|
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
|
||||||
{if MvWeb.Helpers.UserHelpers.has_password?(@user),
|
>
|
||||||
do: gettext("Enabled"),
|
<.list>
|
||||||
else: gettext("Not enabled")}
|
<:item title={gettext("Email")}>{@user.email}</:item>
|
||||||
</:item>
|
<:item title={gettext("Role")}>{@user.role.name}</:item>
|
||||||
<:item title={gettext("OIDC")}>
|
<:item title={gettext("Password Authentication")}>
|
||||||
{if MvWeb.Helpers.UserHelpers.has_oidc?(@user),
|
{if MvWeb.Helpers.UserHelpers.has_password?(@user),
|
||||||
do: gettext("Linked"),
|
do: gettext("Enabled"),
|
||||||
else: gettext("Not linked")}
|
else: gettext("Not enabled")}
|
||||||
</:item>
|
</:item>
|
||||||
<:item title={gettext("Linked Member")}>
|
<:item title={gettext("OIDC")}>
|
||||||
<%= if @user.member do %>
|
{if MvWeb.Helpers.UserHelpers.has_oidc?(@user),
|
||||||
<.link
|
do: gettext("Linked"),
|
||||||
navigate={~p"/members/#{@user.member}"}
|
else: gettext("Not linked")}
|
||||||
class="text-blue-600 underline hover:text-blue-800"
|
</:item>
|
||||||
>
|
<:item title={gettext("Linked Member")}>
|
||||||
<.icon name="hero-users" class="inline w-4 h-4 mr-1" />
|
<%= if @user.member do %>
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
|
<.link
|
||||||
</.link>
|
navigate={~p"/members/#{@user.member}"}
|
||||||
<% else %>
|
class="text-blue-600 underline hover:text-blue-800"
|
||||||
<span class="italic text-gray-500">{gettext("No member linked")}</span>
|
|
||||||
<% 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="open_delete_modal"
|
|
||||||
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 %>
|
|
||||||
|
|
||||||
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
|
||||||
<%= if assigns[:show_delete_modal] do %>
|
|
||||||
<dialog
|
|
||||||
id="delete-user-modal"
|
|
||||||
class="modal modal-open"
|
|
||||||
role="dialog"
|
|
||||||
aria-labelledby="delete-user-modal-title"
|
|
||||||
>
|
|
||||||
<div class="modal-box">
|
|
||||||
<h3 id="delete-user-modal-title" class="text-lg font-bold">{gettext("Delete User")}</h3>
|
|
||||||
<p class="py-4">
|
|
||||||
{gettext(
|
|
||||||
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
|
||||||
email: @user.email
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<div class="modal-action">
|
|
||||||
<.button
|
|
||||||
type="button"
|
|
||||||
variant="neutral"
|
|
||||||
phx-click="cancel_delete_modal"
|
|
||||||
phx-mounted={JS.focus()}
|
|
||||||
id="delete-user-modal-cancel"
|
|
||||||
aria-label={gettext("Cancel")}
|
|
||||||
>
|
>
|
||||||
{gettext("Cancel")}
|
<.icon name="hero-users" class="inline w-4 h-4 mr-1" />
|
||||||
</.button>
|
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
|
||||||
|
</.link>
|
||||||
|
<% else %>
|
||||||
|
<span class="italic text-gray-500">{gettext("No member linked")}</span>
|
||||||
|
<% 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
|
<.button
|
||||||
type="button"
|
id="delete-user-trigger"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
phx-click={JS.push("delete", value: %{id: @user.id})}
|
phx-click="open_delete_modal"
|
||||||
aria-label={gettext("Delete user")}
|
data-testid="user-delete"
|
||||||
|
aria-label={gettext("Delete user %{email}", email: @user.email)}
|
||||||
>
|
>
|
||||||
{gettext("Delete")}
|
<.icon name="hero-trash" class="size-4" />
|
||||||
|
{gettext("Delete user")}
|
||||||
</.button>
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</dialog>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
|
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
|
||||||
|
<%= if assigns[:show_delete_modal] do %>
|
||||||
|
<dialog
|
||||||
|
id="delete-user-modal"
|
||||||
|
class="modal modal-open"
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="delete-user-modal-title"
|
||||||
|
phx-keydown="dialog_keydown"
|
||||||
|
>
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 id="delete-user-modal-title" class="text-lg font-bold">{gettext("Delete User")}</h3>
|
||||||
|
<p class="py-4">
|
||||||
|
{gettext(
|
||||||
|
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
||||||
|
email: @user.email
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div class="modal-action">
|
||||||
|
<.button
|
||||||
|
type="button"
|
||||||
|
variant="neutral"
|
||||||
|
phx-click="cancel_delete_modal"
|
||||||
|
phx-mounted={JS.focus()}
|
||||||
|
id="delete-user-modal-cancel"
|
||||||
|
aria-label={gettext("Cancel")}
|
||||||
|
>
|
||||||
|
{gettext("Cancel")}
|
||||||
|
</.button>
|
||||||
|
<.button
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
phx-click={JS.push("delete", value: %{id: @user.id})}
|
||||||
|
aria-label={gettext("Delete user")}
|
||||||
|
>
|
||||||
|
{gettext("Delete")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
@ -182,9 +190,25 @@ defmodule MvWeb.UserLive.Show do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("cancel_delete_modal", _params, socket) do
|
def handle_event("cancel_delete_modal", _params, socket) do
|
||||||
{:noreply, assign(socket, :show_delete_modal, false)}
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||||
|
if socket.assigns[:show_delete_modal] do
|
||||||
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
|
else
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
|
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
||||||
|
{:noreply, close_delete_modal_and_restore_focus(socket)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
user = socket.assigns.user
|
user = socket.assigns.user
|
||||||
|
|
@ -229,4 +253,10 @@ defmodule MvWeb.UserLive.Show do
|
||||||
|> assign(:show_delete_modal, false)}
|
|> assign(:show_delete_modal, false)}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp close_delete_modal_and_restore_focus(socket) do
|
||||||
|
socket
|
||||||
|
|> assign(:show_delete_modal, false)
|
||||||
|
|> push_event("focus_restore", %{id: "delete-user-trigger"})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue