Merge remote-tracking branch 'origin/main' into feature/ui-for-adding-members-groups
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/promote/production Build is passing

This commit is contained in:
Simon 2026-02-12 15:16:35 +01:00
commit 2f8a6a2768
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
136 changed files with 9999 additions and 3601 deletions

View file

@ -47,18 +47,19 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
custom_fields = assigns.custom_fields || []
all_items =
Enum.map(extract_member_field_keys(all_fields), fn field ->
%{
value: field_to_string(field),
label: format_field_label(field)
}
end) ++
Enum.map(extract_custom_field_keys(all_fields), fn field ->
%{
value: field,
label: format_custom_field_label(field, custom_fields)
}
end)
(Enum.map(extract_member_field_keys(all_fields), fn field ->
%{
value: field_to_string(field),
label: format_field_label(field)
}
end) ++
Enum.map(extract_custom_field_keys(all_fields), fn field ->
%{
value: field,
label: format_custom_field_label(field, custom_fields)
}
end))
|> Enum.uniq_by(fn item -> item.value end)
assigns = assign(assigns, :all_items, all_items)

View file

@ -7,7 +7,6 @@ defmodule MvWeb.GlobalSettingsLive do
- Manage custom fields
- Real-time form validation
- Success/error feedback
- CSV member import (admin only)
## Settings
- `club_name` - The name of the association/club (required)
@ -15,47 +14,19 @@ defmodule MvWeb.GlobalSettingsLive do
## Events
- `validate` - Real-time form validation
- `save` - Save settings changes
- `start_import` - Start CSV member import (admin only)
## CSV Import
The CSV import feature allows administrators to upload CSV files and import members.
### File Upload
Files are uploaded automatically when selected (`auto_upload: true`). No manual
upload trigger is required.
### Rate Limiting
Currently, there is no rate limiting for CSV imports. Administrators can start
multiple imports in quick succession. This is intentional for bulk data migration
scenarios, but should be monitored in production.
### Limits
- Maximum file size: configurable via `config :mv, csv_import: [max_file_size_mb: ...]`
- Maximum rows: configurable via `config :mv, csv_import: [max_rows: ...]` (excluding header)
- Processing: chunks of 200 rows
- Errors: capped at 50 per import
## Note
Settings is a singleton resource - there is only one settings record.
The club_name can also be set via the `ASSOCIATION_NAME` environment variable.
CSV member import has been moved to the Import/Export page (`/admin/import-export`).
"""
use MvWeb, :live_view
alias Mv.Authorization.Actor
alias Mv.Config
alias Mv.Membership
alias Mv.Membership.Import.MemberCSV
alias MvWeb.Authorization
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
# CSV Import configuration constants
@max_errors 50
@impl true
def mount(_params, session, socket) do
{:ok, settings} = Membership.get_settings()
@ -69,22 +40,8 @@ defmodule MvWeb.GlobalSettingsLive do
|> assign(:page_title, gettext("Settings"))
|> assign(:settings, settings)
|> assign(:active_editing_section, nil)
|> assign(:import_state, nil)
|> assign(:import_progress, nil)
|> assign(:import_status, :idle)
|> assign(:locale, locale)
|> assign(:max_errors, @max_errors)
|> assign(:csv_import_max_rows, Config.csv_import_max_rows())
|> assign(:csv_import_max_file_size_mb, Config.csv_import_max_file_size_mb())
|> assign_form()
# Configure file upload with auto-upload enabled
# Files are uploaded automatically when selected, no need for manual trigger
|> allow_upload(:csv_file,
accept: ~w(.csv),
max_entries: 1,
max_file_size: Config.csv_import_max_file_size_bytes(),
auto_upload: true
)
{:ok, socket}
end
@ -133,211 +90,6 @@ defmodule MvWeb.GlobalSettingsLive do
actor={@current_user}
/>
</.form_section>
<%!-- CSV Import Section (Admin only) --%>
<%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %>
<.form_section title={gettext("Import Members (CSV)")}>
<div role="note" class="alert alert-info mb-4">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<p class="text-sm mb-2">
{gettext(
"Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of memberdate (like e-mail or first name). Unknown data field columns will be ignored with a warning."
)}
</p>
<p class="text-sm">
<.link
href="#custom_fields"
class="link"
data-testid="custom-fields-link"
>
{gettext("Manage Memberdata")}
</.link>
</p>
</div>
</div>
<div class="mb-4">
<p class="text-sm text-base-content/70 mb-2">
{gettext("Download CSV templates:")}
</p>
<ul class="list-disc list-inside space-y-1">
<li>
<.link
href={~p"/templates/member_import_en.csv"}
download="member_import_en.csv"
class="link link-primary"
>
{gettext("English Template")}
</.link>
</li>
<li>
<.link
href={~p"/templates/member_import_de.csv"}
download="member_import_de.csv"
class="link link-primary"
>
{gettext("German Template")}
</.link>
</li>
</ul>
</div>
<.form
id="csv-upload-form"
for={%{}}
multipart={true}
phx-change="validate_csv_upload"
phx-submit="start_import"
data-testid="csv-upload-form"
>
<div class="form-control">
<label for="csv_file" class="label">
<span class="label-text">
{gettext("CSV File")}
</span>
</label>
<.live_file_input
upload={@uploads.csv_file}
id="csv_file"
class="file-input file-input-bordered w-full"
aria-describedby="csv_file_help"
/>
<label class="label" id="csv_file_help">
<span class="label-text-alt">
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
</span>
</label>
</div>
<.button
type="submit"
phx-disable-with={gettext("Starting import...")}
variant="primary"
disabled={
@import_status == :running or
Enum.empty?(@uploads.csv_file.entries) or
@uploads.csv_file.entries |> List.first() |> then(&(&1 && not &1.done?))
}
data-testid="start-import-button"
>
{gettext("Start Import")}
</.button>
</.form>
<%= if @import_status == :running or @import_status == :done do %>
<%= if @import_progress do %>
<div
role="status"
aria-live="polite"
class="mt-4"
data-testid="import-progress-container"
>
<%= if @import_progress.status == :running do %>
<p class="text-sm" data-testid="import-progress-text">
{gettext("Processing chunk %{current} of %{total}...",
current: @import_progress.current_chunk,
total: @import_progress.total_chunks
)}
</p>
<% end %>
<%= if @import_progress.status == :done do %>
<section class="space-y-4" data-testid="import-results-panel">
<h2 class="text-lg font-semibold">
{gettext("Import Results")}
</h2>
<div class="space-y-4">
<div>
<h3 class="text-sm font-semibold mb-2">
{gettext("Summary")}
</h3>
<div class="text-sm space-y-2">
<p>
<.icon
name="hero-check-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Successfully inserted: %{count} member(s)",
count: @import_progress.inserted
)}
</p>
<%= if @import_progress.failed > 0 do %>
<p>
<.icon
name="hero-exclamation-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Failed: %{count} row(s)", count: @import_progress.failed)}
</p>
<% end %>
<%= if @import_progress.errors_truncated? do %>
<p>
<.icon
name="hero-information-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Error list truncated to %{count} entries",
count: @max_errors
)}
</p>
<% end %>
</div>
</div>
<%= if length(@import_progress.errors) > 0 do %>
<div data-testid="import-error-list">
<h3 class="text-sm font-semibold mb-2">
<.icon
name="hero-exclamation-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Errors")}
</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<%= for error <- @import_progress.errors do %>
<li>
{gettext("Line %{line}: %{message}",
line: error.csv_line_number || "?",
message: error.message || gettext("Unknown error")
)}
<%= if error.field do %>
{gettext(" (Field: %{field})", field: error.field)}
<% end %>
</li>
<% end %>
</ul>
</div>
<% end %>
<%= if length(@import_progress.warnings) > 0 do %>
<div class="alert alert-warning">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<h3 class="font-semibold mb-2">
{gettext("Warnings")}
</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<%= for warning <- @import_progress.warnings do %>
<li>{warning}</li>
<% end %>
</ul>
</div>
</div>
<% end %>
</div>
</section>
<% end %>
</div>
<% end %>
<% end %>
</.form_section>
<% end %>
</Layouts.app>
"""
end
@ -370,115 +122,6 @@ defmodule MvWeb.GlobalSettingsLive do
end
end
@impl true
def handle_event("validate_csv_upload", _params, socket) do
{:noreply, socket}
end
@impl true
def handle_event("start_import", _params, socket) do
case check_import_prerequisites(socket) do
{:error, message} ->
{:noreply, put_flash(socket, :error, message)}
:ok ->
process_csv_upload(socket)
end
end
# Checks if import can be started (admin permission, status, upload ready)
defp check_import_prerequisites(socket) do
# Ensure user role is loaded before authorization check
user = socket.assigns[:current_user]
user_with_role = Actor.ensure_loaded(user)
cond do
not Authorization.can?(user_with_role, :create, Mv.Membership.Member) ->
{:error, gettext("Only administrators can import members from CSV files.")}
socket.assigns.import_status == :running ->
{:error, gettext("Import is already running. Please wait for it to complete.")}
Enum.empty?(socket.assigns.uploads.csv_file.entries) ->
{:error, gettext("Please select a CSV file to import.")}
not List.first(socket.assigns.uploads.csv_file.entries).done? ->
{:error,
gettext("Please wait for the file upload to complete before starting the import.")}
true ->
:ok
end
end
# Processes CSV upload and starts import
defp process_csv_upload(socket) do
actor = MvWeb.LiveHelpers.current_actor(socket)
with {:ok, content} <- consume_and_read_csv(socket),
{:ok, import_state} <-
MemberCSV.prepare(content, max_rows: Config.csv_import_max_rows(), actor: actor) do
start_import(socket, import_state)
else
{:error, reason} when is_binary(reason) ->
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to prepare CSV import: %{reason}", reason: reason)
)}
{:error, error} ->
error_message = format_error_message(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to prepare CSV import: %{error}", error: error_message)
)}
end
end
# Starts the import process
defp start_import(socket, import_state) do
progress = initialize_import_progress(import_state)
socket =
socket
|> assign(:import_state, import_state)
|> assign(:import_progress, progress)
|> assign(:import_status, :running)
send(self(), {:process_chunk, 0})
{:noreply, socket}
end
# Initializes import progress structure
defp initialize_import_progress(import_state) do
%{
inserted: 0,
failed: 0,
errors: [],
warnings: import_state.warnings || [],
status: :running,
current_chunk: 0,
total_chunks: length(import_state.chunks),
errors_truncated?: false
}
end
# Formats error messages for display
defp format_error_message(error) do
case error do
%{message: msg} when is_binary(msg) -> msg
%{errors: errors} when is_list(errors) -> inspect(errors)
reason when is_binary(reason) -> reason
other -> inspect(other)
end
end
@impl true
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
send_update(MvWeb.CustomFieldLive.IndexComponent,
@ -558,139 +201,6 @@ defmodule MvWeb.GlobalSettingsLive do
{:noreply, assign(socket, :settings, updated_settings)}
end
@impl true
def handle_info({:process_chunk, idx}, socket) do
case socket.assigns do
%{import_state: import_state, import_progress: progress}
when is_map(import_state) and is_map(progress) ->
if idx >= 0 and idx < length(import_state.chunks) do
start_chunk_processing_task(socket, import_state, progress, idx)
else
handle_chunk_error(socket, :invalid_index, idx)
end
_ ->
# Missing required assigns - mark as error
handle_chunk_error(socket, :missing_state, idx)
end
end
@impl true
def handle_info({:chunk_done, idx, result}, socket) do
case socket.assigns do
%{import_state: import_state, import_progress: progress}
when is_map(import_state) and is_map(progress) ->
handle_chunk_result(socket, import_state, progress, idx, result)
_ ->
# Missing required assigns - mark as error
handle_chunk_error(socket, :missing_state, idx)
end
end
@impl true
def handle_info({:chunk_error, idx, reason}, socket) do
handle_chunk_error(socket, :processing_failed, idx, reason)
end
# Starts async task to process a chunk
# In tests (SQL sandbox mode), runs synchronously to avoid Ecto Sandbox issues
defp start_chunk_processing_task(socket, import_state, progress, idx) do
chunk = Enum.at(import_state.chunks, idx)
# Ensure user role is loaded before using as actor
user = socket.assigns[:current_user]
actor = Actor.ensure_loaded(user)
live_view_pid = self()
# Process chunk with existing error count for capping
opts = [
custom_field_lookup: import_state.custom_field_lookup,
existing_error_count: length(progress.errors),
max_errors: @max_errors,
actor: actor
]
# Get locale from socket for translations in background tasks
locale = socket.assigns[:locale] || "de"
Gettext.put_locale(MvWeb.Gettext, locale)
if Config.sql_sandbox?() do
# Run synchronously in tests to avoid Ecto Sandbox issues with async tasks
{:ok, chunk_result} =
MemberCSV.process_chunk(
chunk,
import_state.column_map,
import_state.custom_field_map,
opts
)
# In test mode, send the message - it will be processed when render() is called
# in the test. The test helper wait_for_import_completion() handles message processing
send(live_view_pid, {:chunk_done, idx, chunk_result})
else
# Start async task to process chunk in production
# Use start_child for fire-and-forget: no monitor, no Task messages
# We only use our own send/2 messages for communication
Task.Supervisor.start_child(Mv.TaskSupervisor, fn ->
# Set locale in task process for translations
Gettext.put_locale(MvWeb.Gettext, locale)
{:ok, chunk_result} =
MemberCSV.process_chunk(
chunk,
import_state.column_map,
import_state.custom_field_map,
opts
)
send(live_view_pid, {:chunk_done, idx, chunk_result})
end)
end
{:noreply, socket}
end
# Handles chunk processing result from async task
defp handle_chunk_result(socket, import_state, progress, idx, chunk_result) do
# Merge progress
new_progress = merge_progress(progress, chunk_result, idx)
socket =
socket
|> assign(:import_progress, new_progress)
|> assign(:import_status, new_progress.status)
# Schedule next chunk or mark as done
socket = schedule_next_chunk(socket, idx, length(import_state.chunks))
{:noreply, socket}
end
# Handles chunk processing errors
defp handle_chunk_error(socket, error_type, idx, reason \\ nil) do
error_message =
case error_type do
:invalid_index ->
gettext("Invalid chunk index: %{idx}", idx: idx)
:missing_state ->
gettext("Import state is missing. Cannot process chunk %{idx}.", idx: idx)
:processing_failed ->
gettext("Failed to process chunk %{idx}: %{reason}",
idx: idx,
reason: inspect(reason)
)
end
socket =
socket
|> assign(:import_status, :error)
|> put_flash(:error, error_message)
{:noreply, socket}
end
defp assign_form(%{assigns: %{settings: settings}} = socket) do
form =
AshPhoenix.Form.for_update(
@ -703,71 +213,4 @@ defmodule MvWeb.GlobalSettingsLive do
assign(socket, form: to_form(form))
end
defp consume_and_read_csv(socket) do
result =
consume_uploaded_entries(socket, :csv_file, fn %{path: path}, _entry ->
case File.read(path) do
{:ok, content} -> {:ok, content}
{:error, reason} -> {:error, Exception.message(reason)}
end
end)
result
|> case do
[content] when is_binary(content) ->
{:ok, content}
[{:ok, content}] when is_binary(content) ->
{:ok, content}
[{:error, reason}] ->
{:error, gettext("Failed to read file: %{reason}", reason: reason)}
[] ->
{:error, gettext("No file was uploaded")}
_other ->
{:error, gettext("Failed to read uploaded file")}
end
end
defp merge_progress(progress, chunk_result, current_chunk_idx) do
# Merge errors with cap of @max_errors overall
all_errors = progress.errors ++ chunk_result.errors
new_errors = Enum.take(all_errors, @max_errors)
errors_truncated? = length(all_errors) > @max_errors
# Merge warnings (optional dedupe - simple append for now)
new_warnings = progress.warnings ++ Map.get(chunk_result, :warnings, [])
# Update status based on whether we're done
# current_chunk_idx is 0-based, so after processing chunk 0, we've processed 1 chunk
chunks_processed = current_chunk_idx + 1
new_status = if chunks_processed >= progress.total_chunks, do: :done, else: :running
%{
inserted: progress.inserted + chunk_result.inserted,
failed: progress.failed + chunk_result.failed,
errors: new_errors,
warnings: new_warnings,
status: new_status,
current_chunk: chunks_processed,
total_chunks: progress.total_chunks,
errors_truncated?: errors_truncated? || chunk_result.errors_truncated?
}
end
defp schedule_next_chunk(socket, current_idx, total_chunks) do
next_idx = current_idx + 1
if next_idx < total_chunks do
# Schedule next chunk
send(self(), {:process_chunk, next_idx})
socket
else
# All chunks processed - status already set to :done in merge_progress
socket
end
end
end

View file

@ -0,0 +1,464 @@
defmodule MvWeb.ImportExportLive do
@moduledoc """
LiveView for importing and exporting members via CSV.
## Features
- CSV member import (admin only)
- Real-time import progress tracking
- Error and warning reporting
- Custom fields support
## CSV Import
The CSV import feature allows administrators to upload CSV files and import members.
### File Upload
Files are uploaded automatically when selected (`auto_upload: true`). No manual
upload trigger is required.
### Rate Limiting
Currently, there is no rate limiting for CSV imports. Administrators can start
multiple imports in quick succession. This is intentional for bulk data migration
scenarios, but should be monitored in production.
### Limits
- Maximum file size: configurable via `config :mv, csv_import: [max_file_size_mb: ...]`
- Maximum rows: configurable via `config :mv, csv_import: [max_rows: ...]` (excluding header)
- Processing: chunks of 200 rows
- Errors: capped at 50 per import
"""
use MvWeb, :live_view
alias Mv.Authorization.Actor
alias Mv.Config
alias Mv.Membership
alias Mv.Membership.Import.ImportRunner
alias Mv.Membership.Import.MemberCSV
alias MvWeb.Authorization
alias MvWeb.ImportExportLive.Components
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
# Maximum number of errors to collect per import to prevent memory issues
# and keep error display manageable. Additional errors are silently dropped
# after this limit is reached.
@max_errors 50
# Maximum length for error messages before truncation
@max_error_message_length 200
@impl true
def mount(_params, session, socket) do
# Get locale from session for translations
locale = session["locale"] || "de"
Gettext.put_locale(MvWeb.Gettext, locale)
# Get club name from settings
club_name =
case Membership.get_settings() do
{:ok, settings} -> settings.club_name
_ -> "Mitgliederverwaltung"
end
socket =
socket
|> assign(:page_title, gettext("Import/Export"))
|> assign(:club_name, club_name)
|> assign(:import_state, nil)
|> assign(:import_progress, nil)
|> assign(:import_status, :idle)
|> assign(:locale, locale)
|> assign(:max_errors, @max_errors)
|> assign(:csv_import_max_rows, Config.csv_import_max_rows())
|> assign(:csv_import_max_file_size_mb, Config.csv_import_max_file_size_mb())
# Configure file upload with auto-upload enabled
# Files are uploaded automatically when selected, no need for manual trigger
|> allow_upload(:csv_file,
accept: ~w(.csv),
max_entries: 1,
max_file_size: Config.csv_import_max_file_size_bytes(),
auto_upload: true
)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user} club_name={@club_name}>
<.header>
{gettext("Import/Export")}
<:subtitle>
{gettext("Import members from CSV files or export member data.")}
</:subtitle>
</.header>
<%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %>
<%!-- CSV Import Section --%>
<.form_section title={gettext("Import Members (CSV)")}>
<Components.custom_fields_notice {assigns} />
<Components.template_links {assigns} />
<Components.import_form {assigns} />
<%= if @import_status == :running or @import_status == :done or @import_status == :error do %>
<Components.import_progress {assigns} />
<% end %>
</.form_section>
<%!-- Export Section (Placeholder) --%>
<.form_section title={gettext("Export Members (CSV)")}>
<div role="note" class="alert alert-info">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<p class="text-sm">
{gettext("Export functionality will be available in a future release.")}
</p>
</div>
</div>
</.form_section>
<% else %>
<div role="alert" class="alert alert-error">
<.icon name="hero-exclamation-circle" class="size-5" aria-hidden="true" />
<div>
<p>{gettext("You do not have permission to access this page.")}</p>
</div>
</div>
<% end %>
</Layouts.app>
"""
end
@impl true
def handle_event("validate_csv_upload", _params, socket) do
{:noreply, socket}
end
@impl true
def handle_event("start_import", _params, socket) do
case check_import_prerequisites(socket) do
{:error, message} ->
{:noreply, put_flash(socket, :error, message)}
:ok ->
process_csv_upload(socket)
end
end
# Checks if all prerequisites for starting an import are met.
#
# Validates:
# - User has admin permissions
# - No import is currently running
# - CSV file is uploaded and ready
#
# Returns `:ok` if all checks pass, `{:error, message}` otherwise.
#
# Note: on_mount :ensure_user_role_loaded already guarantees the role is loaded,
# so ensure_actor_loaded is primarily for clarity.
@spec check_import_prerequisites(Phoenix.LiveView.Socket.t()) ::
:ok | {:error, String.t()}
defp check_import_prerequisites(socket) do
# on_mount already ensures role is loaded, but we keep this for clarity
user_with_role = ensure_actor_loaded(socket)
cond do
not Authorization.can?(user_with_role, :create, Mv.Membership.Member) ->
{:error, gettext("Only administrators can import members from CSV files.")}
socket.assigns.import_status == :running ->
{:error, gettext("Import is already running. Please wait for it to complete.")}
Enum.empty?(socket.assigns.uploads.csv_file.entries) ->
{:error, gettext("Please select a CSV file to import.")}
not List.first(socket.assigns.uploads.csv_file.entries).done? ->
{:error,
gettext("Please wait for the file upload to complete before starting the import.")}
true ->
:ok
end
end
# Processes CSV upload and starts import process.
#
# Reads the uploaded CSV file, prepares it for import, and initiates
# the chunked processing workflow.
@spec process_csv_upload(Phoenix.LiveView.Socket.t()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
defp process_csv_upload(socket) do
actor = MvWeb.LiveHelpers.current_actor(socket)
with {:ok, content} <- consume_and_read_csv(socket),
{:ok, import_state} <-
MemberCSV.prepare(content, max_rows: Config.csv_import_max_rows(), actor: actor) do
start_import(socket, import_state)
else
{:error, reason} when is_binary(reason) ->
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to prepare CSV import: %{reason}", reason: reason)
)}
{:error, error} ->
error_message = format_error_message(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to prepare CSV import: %{reason}", reason: error_message)
)}
end
end
# Starts the import process by initializing progress tracking and scheduling the first chunk.
@spec start_import(Phoenix.LiveView.Socket.t(), map()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
defp start_import(socket, import_state) do
progress = ImportRunner.initial_progress(import_state, max_errors: @max_errors)
socket =
socket
|> assign(:import_state, import_state)
|> assign(:import_progress, progress)
|> assign(:import_status, :running)
send(self(), {:process_chunk, 0})
{:noreply, socket}
end
# Formats error messages for user-friendly display.
#
# Handles various error types including Ash errors, maps with message fields,
# lists of errors, and fallback formatting for unknown types.
@spec format_error_message(any()) :: String.t()
defp format_error_message(error) do
case error do
%Ash.Error.Invalid{} = ash_error ->
format_ash_error(ash_error)
%{message: msg} when is_binary(msg) ->
msg
%{errors: errors} when is_list(errors) ->
format_error_list(errors)
reason when is_binary(reason) ->
reason
other ->
format_unknown_error(other)
end
end
# Formats Ash validation errors for display
defp format_ash_error(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
Enum.map_join(errors, ", ", &format_single_error/1)
end
defp format_ash_error(error) do
format_unknown_error(error)
end
# Formats a list of errors into a readable string
defp format_error_list(errors) do
Enum.map_join(errors, ", ", &format_single_error/1)
end
# Formats a single error item
defp format_single_error(error) when is_map(error) do
Map.get(error, :message) || Map.get(error, :field) || inspect(error, limit: :infinity)
end
defp format_single_error(error) do
to_string(error)
end
# Formats unknown error types with truncation for very long messages
defp format_unknown_error(other) do
error_str = inspect(other, limit: :infinity, pretty: true)
if String.length(error_str) > @max_error_message_length do
String.slice(error_str, 0, @max_error_message_length - 3) <> "..."
else
error_str
end
end
@impl true
def handle_info({:process_chunk, idx}, socket) do
case socket.assigns do
%{import_state: import_state, import_progress: progress}
when is_map(import_state) and is_map(progress) ->
if idx < length(import_state.chunks) do
start_chunk_processing_task(socket, import_state, progress, idx)
else
handle_chunk_error(socket, :invalid_index, idx)
end
_ ->
# Missing required assigns - mark as error
handle_chunk_error(socket, :missing_state, idx)
end
end
@impl true
def handle_info({:chunk_done, idx, result}, socket) do
case socket.assigns do
%{import_state: import_state, import_progress: progress}
when is_map(import_state) and is_map(progress) ->
handle_chunk_result(socket, import_state, progress, idx, result)
_ ->
# Missing required assigns - mark as error
handle_chunk_error(socket, :missing_state, idx)
end
end
@impl true
def handle_info({:chunk_error, idx, reason}, socket) do
handle_chunk_error(socket, :processing_failed, idx, reason)
end
# Starts async task to process a chunk of CSV rows (or runs synchronously in test sandbox).
# Locale must be set in the process that runs the chunk (Gettext is process-local); see run_chunk_with_locale/7.
@spec start_chunk_processing_task(
Phoenix.LiveView.Socket.t(),
map(),
map(),
non_neg_integer()
) :: {:noreply, Phoenix.LiveView.Socket.t()}
defp start_chunk_processing_task(socket, import_state, progress, idx) do
chunk = Enum.at(import_state.chunks, idx)
actor = ensure_actor_loaded(socket)
live_view_pid = self()
locale = socket.assigns[:locale] || "de"
opts = [
custom_field_lookup: import_state.custom_field_lookup,
existing_error_count: length(progress.errors),
max_errors: @max_errors,
actor: actor
]
if Config.sql_sandbox?() do
run_chunk_with_locale(
locale,
chunk,
import_state.column_map,
import_state.custom_field_map,
opts,
live_view_pid,
idx
)
else
Task.Supervisor.start_child(
Mv.TaskSupervisor,
fn ->
run_chunk_with_locale(
locale,
chunk,
import_state.column_map,
import_state.custom_field_map,
opts,
live_view_pid,
idx
)
end
)
end
{:noreply, socket}
end
# Sets Gettext locale in the current process, then processes the chunk.
# Must be called in the process that runs the chunk (sync: LiveView process; async: Task process).
defp run_chunk_with_locale(
locale,
chunk,
column_map,
custom_field_map,
opts,
live_view_pid,
idx
) do
Gettext.put_locale(MvWeb.Gettext, locale)
ImportRunner.process_chunk(chunk, column_map, custom_field_map, opts, live_view_pid, idx)
end
# Handles chunk processing result from async task and schedules the next chunk.
@spec handle_chunk_result(
Phoenix.LiveView.Socket.t(),
map(),
map(),
non_neg_integer(),
map()
) :: {:noreply, Phoenix.LiveView.Socket.t()}
defp handle_chunk_result(socket, import_state, progress, idx, chunk_result) do
new_progress =
ImportRunner.merge_progress(progress, chunk_result, idx, max_errors: @max_errors)
socket =
socket
|> assign(:import_progress, new_progress)
|> assign(:import_status, new_progress.status)
|> maybe_send_next_chunk(idx, length(import_state.chunks))
{:noreply, socket}
end
defp maybe_send_next_chunk(socket, current_idx, total_chunks) do
case ImportRunner.next_chunk_action(current_idx, total_chunks) do
{:send_chunk, next_idx} ->
send(self(), {:process_chunk, next_idx})
socket
:done ->
socket
end
end
# Handles chunk processing errors and updates socket with error status.
@spec handle_chunk_error(
Phoenix.LiveView.Socket.t(),
:invalid_index | :missing_state | :processing_failed,
non_neg_integer(),
any()
) :: {:noreply, Phoenix.LiveView.Socket.t()}
defp handle_chunk_error(socket, error_type, idx, reason \\ nil) do
message = ImportRunner.format_chunk_error(error_type, idx, reason)
socket =
socket
|> assign(:import_status, :error)
|> put_flash(:error, message)
{:noreply, socket}
end
# Consumes uploaded CSV file entries and reads the file content.
@spec consume_and_read_csv(Phoenix.LiveView.Socket.t()) ::
{:ok, String.t()} | {:error, String.t()}
defp consume_and_read_csv(socket) do
raw = consume_uploaded_entries(socket, :csv_file, &ImportRunner.read_file_entry/2)
ImportRunner.parse_consume_result(raw)
end
# Ensures the actor (user with role) is loaded from socket assigns.
#
# Note: on_mount :ensure_user_role_loaded already guarantees the role is loaded,
# so this is primarily for clarity and defensive programming.
@spec ensure_actor_loaded(Phoenix.LiveView.Socket.t()) :: Mv.Accounts.User.t() | nil
defp ensure_actor_loaded(socket) do
user = socket.assigns[:current_user]
# on_mount already ensures role is loaded, but we keep this for clarity
Actor.ensure_loaded(user)
end
end

View file

@ -0,0 +1,272 @@
defmodule MvWeb.ImportExportLive.Components do
@moduledoc """
Function components for the Import/Export LiveView: import form, progress, results,
custom fields notice, and template links. Keeps the main LiveView focused on
mount/handle_event/handle_info and glue code.
"""
use Phoenix.Component
use Gettext, backend: MvWeb.Gettext
import MvWeb.CoreComponents
use Phoenix.VerifiedRoutes,
endpoint: MvWeb.Endpoint,
router: MvWeb.Router,
statics: MvWeb.static_paths()
@doc """
Renders the info box explaining that data fields must exist before import
and linking to Manage Member Data (custom fields).
"""
def custom_fields_notice(assigns) do
~H"""
<div role="note" class="alert alert-info mb-4">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<p class="text-sm mb-2">
{gettext(
"Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
)}
</p>
<p class="text-sm">
<.link
href={~p"/settings#custom_fields"}
class="link"
data-testid="custom-fields-link"
>
{gettext("Manage Member Data")}
</.link>
</p>
</div>
</div>
"""
end
@doc """
Renders download links for English and German CSV templates.
"""
def template_links(assigns) do
~H"""
<div class="mb-4">
<p class="text-sm text-base-content/70 mb-2">
{gettext("Download CSV templates:")}
</p>
<ul class="list-disc list-inside space-y-1">
<li>
<.link
href={~p"/templates/member_import_en.csv"}
download="member_import_en.csv"
class="link link-primary"
>
{gettext("English Template")}
</.link>
</li>
<li>
<.link
href={~p"/templates/member_import_de.csv"}
download="member_import_de.csv"
class="link link-primary"
>
{gettext("German Template")}
</.link>
</li>
</ul>
</div>
"""
end
@doc """
Renders the CSV file upload form and Start Import button.
"""
def import_form(assigns) do
~H"""
<.form
id="csv-upload-form"
for={%{}}
multipart={true}
phx-change="validate_csv_upload"
phx-submit="start_import"
data-testid="csv-upload-form"
>
<div class="form-control">
<label for="csv_file" class="label">
<span class="label-text">
{gettext("CSV File")}
</span>
</label>
<.live_file_input
upload={@uploads.csv_file}
id="csv_file"
class="file-input file-input-bordered w-full"
aria-describedby="csv_file_help"
/>
<p class="label-text-alt mt-1" id="csv_file_help">
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
</p>
</div>
<.button
type="submit"
phx-disable-with={gettext("Starting import...")}
variant="primary"
disabled={import_button_disabled?(@import_status, @uploads.csv_file.entries)}
data-testid="start-import-button"
>
{gettext("Start Import")}
</.button>
</.form>
"""
end
@doc """
Renders import progress text and, when done or aborted, the import results section.
"""
def import_progress(assigns) do
~H"""
<%= if @import_progress do %>
<div
role="status"
aria-live="polite"
aria-atomic="true"
class="mt-4"
data-testid="import-progress-container"
>
<%= if @import_progress.status == :running do %>
<p class="text-sm" data-testid="import-progress-text">
{gettext("Processing chunk %{current} of %{total}...",
current: @import_progress.current_chunk,
total: @import_progress.total_chunks
)}
</p>
<% end %>
<%= if @import_progress.status == :done or @import_status == :error do %>
<.import_results {assigns} />
<% end %>
</div>
<% end %>
"""
end
@doc """
Renders import results summary, error list, and warnings.
Shown when import is done or aborted (:error); heading reflects state.
"""
def import_results(assigns) do
~H"""
<section
class="space-y-4"
data-testid="import-results-panel"
aria-labelledby="import-results-heading"
>
<h2
id="import-results-heading"
class="text-lg font-semibold"
data-testid="import-results-heading"
>
<%= if @import_status == :error do %>
{gettext("Import aborted")}
<% else %>
{gettext("Import Results")}
<% end %>
</h2>
<div class="space-y-4">
<div data-testid="import-summary">
<h3 class="text-sm font-semibold mb-2">
{gettext("Summary")}
</h3>
<div class="text-sm space-y-2">
<p>
<.icon
name="hero-check-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Successfully inserted: %{count} member(s)",
count: @import_progress.inserted
)}
</p>
<%= if @import_progress.failed > 0 do %>
<p>
<.icon
name="hero-exclamation-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Failed: %{count} row(s)", count: @import_progress.failed)}
</p>
<% end %>
<%= if @import_progress.errors_truncated? do %>
<p>
<.icon
name="hero-information-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Error list truncated to %{count} entries", count: @max_errors)}
</p>
<% end %>
</div>
</div>
<%= if length(@import_progress.errors) > 0 do %>
<div
role="alert"
aria-live="assertive"
aria-atomic="true"
data-testid="import-error-list"
>
<h3 class="text-sm font-semibold mb-2">
<.icon
name="hero-exclamation-circle"
class="size-4 inline mr-1"
aria-hidden="true"
/>
{gettext("Errors")}
</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<%= for error <- @import_progress.errors do %>
<li>
{gettext("Line %{line}: %{message}",
line: error.csv_line_number || "?",
message: error.message || gettext("Unknown error")
)}
<%= if error.field do %>
{gettext(" (Field: %{field})", field: error.field)}
<% end %>
</li>
<% end %>
</ul>
</div>
<% end %>
<%= if length(@import_progress.warnings) > 0 do %>
<div class="alert alert-warning" role="alert" data-testid="import-warnings">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<h3 class="font-semibold mb-2">
{gettext("Warnings")}
</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<%= for warning <- @import_progress.warnings do %>
<li>{warning}</li>
<% end %>
</ul>
</div>
</div>
<% end %>
</div>
</section>
"""
end
@doc """
Returns whether the Start Import button should be disabled.
"""
@spec import_button_disabled?(:idle | :running | :done | :error, [map()]) :: boolean()
def import_button_disabled?(:running, _entries), do: true
def import_button_disabled?(_status, []), do: true
def import_button_disabled?(_status, [entry | _]) when not entry.done?, do: true
def import_button_disabled?(_status, _entries), do: false
end

View file

@ -177,7 +177,8 @@ defmodule MvWeb.MemberLive.Form do
phx-change="validate"
value={@form[:membership_fee_type_id].value || ""}
>
<option value="">{gettext("None")}</option>
<%!-- 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}
@ -189,7 +190,8 @@ defmodule MvWeb.MemberLive.Form do
</option>
<% end %>
</select>
<%= for {msg, _opts} <- @form.errors[:membership_fee_type_id] || [] do %>
<%= 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 %>

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,20 @@
<.header>
{gettext("Members")}
<:actions>
<form method="post" action={~p"/members/export.csv"} target="_blank" class="inline">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<input type="hidden" name="payload" value={@export_payload_json} />
<button
type="submit"
class="btn btn-secondary gap-2"
aria-label={gettext("Export members to CSV")}
>
<.icon name="hero-arrow-down-tray" />
{gettext("Export to CSV")} ({if @selected_count == 0,
do: gettext("all"),
else: @selected_count})
</button>
</form>
<.button
class="secondary"
id="copy-emails-btn"
@ -23,9 +37,11 @@
<.icon name="hero-envelope" />
{gettext("Open in email program")}
</.button>
<.button variant="primary" navigate={~p"/members/new"}>
<.icon name="hero-plus" /> {gettext("New Member")}
</.button>
<%= if can?(@current_user, :create, Mv.Membership.Member) do %>
<.button variant="primary" navigate={~p"/members/new"} data-testid="member-new">
<.icon name="hero-plus" /> {gettext("New Member")}
</.button>
<% end %>
</:actions>
</.header>
@ -84,6 +100,7 @@
<.table
id="members"
rows={@members}
row_id={fn member -> "row-#{member.id}" end}
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
dynamic_cols={@dynamic_cols}
sort_field={@sort_field}
@ -279,6 +296,7 @@
</:col>
<:col
:let={member}
:if={:membership_fee_status in @member_fields_visible}
label={gettext("Membership Fee Status")}
>
<%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(
@ -297,16 +315,23 @@
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
</div>
<.link navigate={~p"/members/#{member}/edit"}>{gettext("Edit")}</.link>
<%= if can?(@current_user, :update, member) do %>
<.link navigate={~p"/members/#{member}/edit"} data-testid="member-edit">
{gettext("Edit")}
</.link>
<% end %>
</:action>
<:action :let={member}>
<.link
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")}
data-confirm={gettext("Are you sure?")}
>
{gettext("Delete")}
</.link>
<%= if can?(@current_user, :destroy, member) do %>
<.link
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")}
data-confirm={gettext("Are you sure?")}
data-testid="member-delete"
>
{gettext("Delete")}
</.link>
<% end %>
</:action>
</.table>
</Layouts.app>

View file

@ -18,10 +18,25 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
1. User-specific selection (from URL/Session/Cookie)
2. Global settings (from database)
3. Default (all fields visible)
## Pseudo Member Fields
Overview-only fields that are not in `Mv.Constants.member_fields()` (e.g. computed/UI-only).
They appear in the field dropdown and in `member_fields_visible` but are not domain attributes.
"""
alias Mv.Membership.Helpers.VisibilityConfig
# Single UI key for "Membership Fee Status"; only this appears in the dropdown.
@pseudo_member_fields [:membership_fee_status]
# Export/API may accept this as alias; must not appear in the UI options list.
@export_only_alias :payment_status
defp overview_member_fields do
Mv.Constants.member_fields() ++ @pseudo_member_fields
end
@doc """
Gets all available fields for selection.
@ -39,7 +54,10 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
"""
@spec get_all_available_fields([struct()]) :: [atom() | String.t()]
def get_all_available_fields(custom_fields) do
member_fields = Mv.Constants.member_fields()
member_fields =
overview_member_fields()
|> Enum.reject(fn field -> field == @export_only_alias end)
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
member_fields ++ custom_field_names
@ -115,6 +133,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
field_selection
|> Enum.filter(fn {_field, visible} -> visible end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|> Enum.uniq()
end
def get_visible_fields(_), do: []
@ -132,7 +151,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
"""
@spec get_visible_member_fields(%{String.t() => boolean()}) :: [atom()]
def get_visible_member_fields(field_selection) when is_map(field_selection) do
member_fields = Mv.Constants.member_fields()
member_fields = overview_member_fields()
field_selection
|> Enum.filter(fn {field_string, visible} ->
@ -140,10 +159,61 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
visible && field_atom in member_fields
end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|> Enum.uniq()
end
def get_visible_member_fields(_), do: []
@doc """
Returns the list of computed (UI-only) member field atoms.
These fields are not in the database; they must not be used for Ash query
select/sort. Use this to filter sort options and validate sort_field.
"""
@spec computed_member_fields() :: [atom()]
def computed_member_fields, do: @pseudo_member_fields
@doc """
Visible member fields that are real DB attributes (from `Mv.Constants.member_fields()`).
Use for query select/sort. Not for rendering column visibility (use
`get_visible_member_fields/1` for that).
"""
@spec get_visible_member_fields_db(%{String.t() => boolean()}) :: [atom()]
def get_visible_member_fields_db(field_selection) when is_map(field_selection) do
db_fields = MapSet.new(Mv.Constants.member_fields())
field_selection
|> Enum.filter(fn {field_string, visible} ->
field_atom = to_field_identifier(field_string)
visible && field_atom in db_fields
end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|> Enum.uniq()
end
def get_visible_member_fields_db(_), do: []
@doc """
Visible member fields that are computed/UI-only (e.g. membership_fee_status).
Use for rendering; do not use for query select or sort.
"""
@spec get_visible_member_fields_computed(%{String.t() => boolean()}) :: [atom()]
def get_visible_member_fields_computed(field_selection) when is_map(field_selection) do
computed_set = MapSet.new(@pseudo_member_fields)
field_selection
|> Enum.filter(fn {field_string, visible} ->
field_atom = to_field_identifier(field_string)
visible && field_atom in computed_set
end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|> Enum.uniq()
end
def get_visible_member_fields_computed(_), do: []
@doc """
Gets visible custom fields from field selection.
@ -176,19 +246,23 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
Map.merge(member_visibility, custom_field_visibility)
end
# Gets member field visibility from settings
# Gets member field visibility from settings (domain fields from settings, pseudo fields default true)
defp get_member_field_visibility_from_settings(settings) do
visibility_config =
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
member_fields = Mv.Constants.member_fields()
domain_fields = Mv.Constants.member_fields()
Enum.reduce(member_fields, %{}, fn field, acc ->
field_string = Atom.to_string(field)
# exit_date defaults to false (hidden), all other fields default to true
default_visibility = if field == :exit_date, do: false, else: true
show_in_overview = Map.get(visibility_config, field, default_visibility)
Map.put(acc, field_string, show_in_overview)
domain_map =
Enum.reduce(domain_fields, %{}, fn field, acc ->
field_string = Atom.to_string(field)
default_visibility = if field == :exit_date, do: false, else: true
show_in_overview = Map.get(visibility_config, field, default_visibility)
Map.put(acc, field_string, show_in_overview)
end)
Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc ->
Map.put(acc, Atom.to_string(field), true)
end)
end
@ -203,16 +277,20 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
end)
end
# Converts field string to atom (for member fields) or keeps as string (for custom fields)
# Converts field string to atom (for member fields) or keeps as string (for custom fields).
# Maps export-only alias to canonical UI key so only one option controls the column.
defp to_field_identifier(field_string) when is_binary(field_string) do
if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do
field_string
else
try do
String.to_existing_atom(field_string)
rescue
ArgumentError -> field_string
end
atom =
try do
String.to_existing_atom(field_string)
rescue
ArgumentError -> field_string
end
if atom == @export_only_alias, do: :membership_fee_status, else: atom
end
end

View file

@ -39,9 +39,15 @@ defmodule MvWeb.MemberLive.Show do
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
</h1>
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
{gettext("Edit Member")}
</.button>
<%= if can?(@current_user, :update, @member) do %>
<.button
variant="primary"
navigate={~p"/members/#{@member}/edit?return_to=show"}
data-testid="member-edit"
>
{gettext("Edit Member")}
</.button>
<% end %>
</div>
<%!-- Tab Navigation --%>
@ -119,22 +125,26 @@ defmodule MvWeb.MemberLive.Show do
/>
</div>
<%!-- Linked User --%>
<div>
<.data_field label={gettext("Linked User")}>
<%= if @member.user do %>
<.link
navigate={~p"/users/#{@member.user}"}
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
>
<.icon name="hero-user" class="size-4" />
{@member.user.email}
</.link>
<% else %>
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
<% end %>
</.data_field>
</div>
<%!-- Linked User: only show when current user can see other users (e.g. admin).
read_only cannot see linked user, so hide the section to avoid "No user linked" when
a user is linked but not visible. --%>
<%= if can_access_page?(@current_user, "/users") do %>
<div>
<.data_field label={gettext("Linked User")}>
<%= if @member.user do %>
<.link
navigate={~p"/users/#{@member.user}"}
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
>
<.icon name="hero-user" class="size-4" />
{@member.user.email}
</.link>
<% else %>
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
<% end %>
</.data_field>
</div>
<% end %>
<%!-- Notes --%>
<%= if @member.notes && String.trim(@member.notes) != "" do %>
@ -281,6 +291,23 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :active_tab, :membership_fees)}
end
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash
@impl true
def handle_info({:put_flash, type, message}, socket) do
{:noreply, put_flash(socket, type, message)}
end
# MembershipFeesComponent sends this after cycles are created/deleted/regenerated so parent keeps member in sync
@impl true
def handle_info({:member_updated, updated_member}, socket) do
member =
updated_member
|> Map.put(:last_cycle_status, get_last_cycle_status(updated_member))
|> Map.put(:current_cycle_status, get_current_cycle_status(updated_member))
{:noreply, assign(socket, :member, member)}
end
defp page_title(:show), do: gettext("Show Member")
defp page_title(:edit), do: gettext("Edit Member")

View file

@ -14,6 +14,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
require Ash.Query
import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.Authorization, only: [can?: 3]
alias Mv.Membership
alias Mv.MembershipFees
@ -49,9 +50,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<% end %>
</div>
<%!-- Action Buttons --%>
<%!-- Action Buttons (only when user has permission) --%>
<div class="flex gap-2 mb-4">
<.button
:if={@member.membership_fee_type != nil and @can_create_cycle}
phx-click="regenerate_cycles"
phx-target={@myself}
class={["btn btn-sm btn-outline", if(@regenerating, do: "btn-disabled", else: "")]}
@ -61,7 +63,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
{if(@regenerating, do: gettext("Regenerating..."), else: gettext("Regenerate Cycles"))}
</.button>
<.button
:if={Enum.any?(@cycles)}
:if={Enum.any?(@cycles) and @can_destroy_cycle}
phx-click="delete_all_cycles"
phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
@ -71,7 +73,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
{gettext("Delete All Cycles")}
</.button>
<.button
:if={@member.membership_fee_type}
:if={@member.membership_fee_type != nil and @can_create_cycle}
phx-click="open_create_cycle_modal"
phx-target={@myself}
class="btn btn-sm btn-primary"
@ -103,15 +105,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</:col>
<:col :let={cycle} label={gettext("Amount")}>
<span
class="font-mono cursor-pointer hover:text-primary"
phx-click="edit_cycle_amount"
phx-value-cycle_id={cycle.id}
phx-target={@myself}
title={gettext("Click to edit amount")}
>
{MembershipFeeHelpers.format_currency(cycle.amount)}
</span>
<%= if @can_update_cycle do %>
<span
class="font-mono cursor-pointer hover:text-primary"
phx-click="edit_cycle_amount"
phx-value-cycle_id={cycle.id}
phx-target={@myself}
title={gettext("Click to edit amount")}
>
{MembershipFeeHelpers.format_currency(cycle.amount)}
</span>
<% else %>
<span class="font-mono">{MembershipFeeHelpers.format_currency(cycle.amount)}</span>
<% end %>
</:col>
<:col :let={cycle} label={gettext("Status")}>
@ -125,56 +131,60 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<:action :let={cycle}>
<div class="flex gap-1">
<button
:if={cycle.status != :paid}
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="paid"
phx-target={@myself}
class="btn btn-sm btn-success"
title={gettext("Mark as paid")}
>
<.icon name="hero-check-circle" class="size-4" />
{gettext("Paid")}
</button>
<button
:if={cycle.status != :suspended}
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="suspended"
phx-target={@myself}
class="btn btn-sm btn-outline btn-warning"
title={gettext("Mark as suspended")}
>
<.icon name="hero-pause-circle" class="size-4" />
{gettext("Suspended")}
</button>
<button
:if={cycle.status != :unpaid}
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="unpaid"
phx-target={@myself}
class="btn btn-sm btn-error"
title={gettext("Mark as unpaid")}
>
<.icon name="hero-x-circle" class="size-4" />
{gettext("Unpaid")}
</button>
<button
type="button"
phx-click="delete_cycle"
phx-value-cycle_id={cycle.id}
phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete cycle")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
</button>
<%= if @can_update_cycle do %>
<button
:if={cycle.status != :paid}
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="paid"
phx-target={@myself}
class="btn btn-sm btn-success"
title={gettext("Mark as paid")}
>
<.icon name="hero-check-circle" class="size-4" />
{gettext("Paid")}
</button>
<button
:if={cycle.status != :suspended}
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="suspended"
phx-target={@myself}
class="btn btn-sm btn-outline btn-warning"
title={gettext("Mark as suspended")}
>
<.icon name="hero-pause-circle" class="size-4" />
{gettext("Suspended")}
</button>
<button
:if={cycle.status != :unpaid}
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="unpaid"
phx-target={@myself}
class="btn btn-sm btn-error"
title={gettext("Mark as unpaid")}
>
<.icon name="hero-x-circle" class="size-4" />
{gettext("Unpaid")}
</button>
<% end %>
<%= if @can_destroy_cycle do %>
<button
type="button"
phx-click="delete_cycle"
phx-value-cycle_id={cycle.id}
phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete cycle")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
</button>
<% end %>
</div>
</:action>
</.table>
@ -408,11 +418,19 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
# Get available fee types (filtered to same interval if member has a type)
available_fee_types = get_available_fee_types(member, actor)
# Permission flags for cycle actions (so read_only does not see create/update/destroy UI)
can_create_cycle = can?(actor, :create, MembershipFeeCycle)
can_destroy_cycle = can?(actor, :destroy, MembershipFeeCycle)
can_update_cycle = can?(actor, :update, MembershipFeeCycle)
{:ok,
socket
|> assign(assigns)
|> assign(:cycles, cycles)
|> assign(:available_fee_types, available_fee_types)
|> assign(:can_create_cycle, can_create_cycle)
|> assign(:can_destroy_cycle, can_destroy_cycle)
|> assign(:can_update_cycle, can_update_cycle)
|> assign_new(:interval_warning, fn -> nil end)
|> assign_new(:editing_cycle, fn -> nil end)
|> assign_new(:deleting_cycle, fn -> nil end)
@ -439,7 +457,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:cycles, [])
|> assign(
:available_fee_types,
get_available_fee_types(updated_member, current_actor(socket))
get_available_fee_types(updated_member, actor)
)
|> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type removed"))}
@ -470,13 +488,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
if interval_warning do
{:noreply, assign(socket, :interval_warning, interval_warning)}
else
actor = current_actor(socket)
case update_member_fee_type(member, fee_type_id, actor) do
{:ok, updated_member} ->
# Reload member with cycles
actor = current_actor(socket)
updated_member =
updated_member
|> Ash.load!(
@ -502,7 +516,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:cycles, cycles)
|> assign(
:available_fee_types,
get_available_fee_types(updated_member, current_actor(socket))
get_available_fee_types(updated_member, actor)
)
|> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
@ -554,17 +568,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
end
def handle_event("regenerate_cycles", _params, socket) do
# Server-side authorization: do not rely on UI hiding the button (e.g. read_only could trigger via DevTools).
actor = current_actor(socket)
# SECURITY: Only admins can manually regenerate cycles via UI
# Cycle generation itself uses system actor, but UI access should be restricted
if actor.role && actor.role.permission_set_name == "admin" do
if can?(actor, :create, MembershipFeeCycle) do
socket = assign(socket, :regenerating, true)
member = socket.assigns.member
case CycleGenerator.generate_cycles_for_member(member.id) do
{:ok, _new_cycles, _notifications} ->
# Reload member with cycles
actor = current_actor(socket)
updated_member =
@ -602,7 +614,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
else
{:noreply,
socket
|> put_flash(:error, gettext("Only administrators can regenerate cycles"))}
|> assign(:regenerating, false)
|> put_flash(:error, format_error(%Ash.Error.Forbidden{}))}
end
end
@ -722,61 +735,31 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
confirmation = String.trim(String.downcase(socket.assigns.delete_all_confirmation))
expected = String.downcase(gettext("Yes"))
if confirmation != expected do
if confirmation == expected do
member = socket.assigns.member
actor = current_actor(socket)
cycles = socket.assigns.cycles
reset_modal = fn s ->
s
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
end
if can?(actor, :destroy, MembershipFeeCycle) do
do_delete_all_cycles(socket, member, actor, cycles, reset_modal)
else
{:noreply,
socket
|> reset_modal.()
|> put_flash(:error, format_error(%Ash.Error.Forbidden{}))}
end
else
{:noreply,
socket
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
|> put_flash(:error, gettext("Confirmation text does not match"))}
else
member = socket.assigns.member
# Delete all cycles atomically using Ecto query
import Ecto.Query
deleted_count =
Mv.Repo.delete_all(
from c in Mv.MembershipFees.MembershipFeeCycle,
where: c.member_id == ^member.id
)
if deleted_count > 0 do
# Reload member to get updated cycles
actor = current_actor(socket)
updated_member =
member
|> Ash.load!(
[
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
],
actor: actor
)
updated_cycles =
Enum.sort_by(
updated_member.membership_fee_cycles || [],
& &1.cycle_start,
{:desc, Date}
)
send(self(), {:member_updated, updated_member})
{:noreply,
socket
|> assign(:member, updated_member)
|> assign(:cycles, updated_cycles)
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
|> put_flash(:info, gettext("All cycles deleted"))}
else
{:noreply,
socket
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
|> put_flash(:info, gettext("No cycles to delete"))}
end
end
end
@ -895,6 +878,55 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
# Helper functions
defp do_delete_all_cycles(socket, member, actor, cycles, reset_modal) do
result =
Enum.reduce_while(cycles, {:ok, 0}, fn cycle, {:ok, count} ->
case Ash.destroy(cycle, domain: MembershipFees, actor: actor) do
:ok -> {:cont, {:ok, count + 1}}
{:ok, _} -> {:cont, {:ok, count + 1}}
{:error, error} -> {:halt, {:error, error}}
end
end)
case result do
{:ok, deleted_count} when deleted_count > 0 ->
updated_member =
member
|> Ash.load!(
[:membership_fee_type, membership_fee_cycles: [:membership_fee_type]],
actor: actor
)
updated_cycles =
Enum.sort_by(
updated_member.membership_fee_cycles || [],
& &1.cycle_start,
{:desc, Date}
)
send(self(), {:member_updated, updated_member})
{:noreply,
socket
|> assign(:member, updated_member)
|> assign(:cycles, updated_cycles)
|> reset_modal.()
|> put_flash(:info, gettext("All cycles deleted"))}
{:ok, _} ->
{:noreply,
socket
|> reset_modal.()
|> put_flash(:info, gettext("No cycles to delete"))}
{:error, error} ->
{:noreply,
socket
|> reset_modal.()
|> put_flash(:error, format_error(error))}
end
end
defp get_available_fee_types(member, actor) do
all_types =
MembershipFeeType
@ -940,6 +972,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
Enum.map_join(error.errors, ", ", fn e -> e.message end)
end
defp format_error(%Ash.Error.Forbidden{}) do
gettext("You are not allowed to perform this action.")
end
defp format_error(error) when is_binary(error), do: error
defp format_error(_error), do: gettext("An error occurred")

View file

@ -8,17 +8,20 @@ defmodule MvWeb.MembershipFeeSettingsLive do
"""
use MvWeb, :live_view
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Membership
alias Mv.MembershipFees.MembershipFeeType
@impl true
def mount(_params, _session, socket) do
actor = current_actor(socket)
{:ok, settings} = Membership.get_settings()
membership_fee_types =
MembershipFeeType
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
|> Ash.read!(domain: Mv.MembershipFees, actor: actor)
{:ok,
socket

View file

@ -200,10 +200,12 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
@impl true
def mount(params, _session, socket) do
actor = current_actor(socket)
membership_fee_type =
case params["id"] do
nil -> nil
id -> Ash.get!(MembershipFeeType, id, domain: MembershipFees)
id -> Ash.get!(MembershipFeeType, id, domain: MembershipFees, actor: actor)
end
page_title =

View file

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

View file

@ -35,7 +35,7 @@ defmodule MvWeb.UserLive.Index do
users =
Mv.Accounts.User
|> Ash.Query.filter(email != ^Mv.Helpers.SystemActor.system_user_email())
|> Ash.read!(domain: Mv.Accounts, load: [:member], actor: actor)
|> Ash.read!(domain: Mv.Accounts, load: [:member, :role], actor: actor)
sorted = Enum.sort_by(users, & &1.email)

View file

@ -2,13 +2,22 @@
<.header>
{gettext("Listing Users")}
<:actions>
<.button variant="primary" navigate={~p"/users/new"}>
<.icon name="hero-plus" /> {gettext("New User")}
</.button>
<%= if can?(@current_user, :create, Mv.Accounts.User) do %>
<.button variant="primary" navigate={~p"/users/new"} data-testid="user-new">
<.icon name="hero-plus" /> {gettext("New User")}
</.button>
<% end %>
</:actions>
</.header>
<.table id="users" rows={@users} row_click={fn user -> JS.navigate(~p"/users/#{user}") end}>
<.table
id="users"
rows={@users}
row_id={fn user -> "row-#{user.id}" end}
row_click={fn user -> JS.navigate(~p"/users/#{user}") end}
sort_field={@sort_field}
sort_order={@sort_order}
>
<:col
:let={user}
label={
@ -38,6 +47,7 @@
</:col>
<:col
:let={user}
sort_field={:email}
label={
sort_button(%{
field: :email,
@ -49,11 +59,28 @@
>
{user.email}
</:col>
<:col :let={user} label={gettext("Role")}>
{user.role.name}
</:col>
<:col :let={user} label={gettext("Linked Member")}>
<%= if user.member do %>
{MvWeb.Helpers.MemberHelpers.display_name(user.member)}
<% else %>
<span class="text-base-content/50">{gettext("No member linked")}</span>
<span class="text-base-content/70">{gettext("No member linked")}</span>
<% end %>
</:col>
<:col :let={user} label={gettext("Password")}>
<%= if MvWeb.Helpers.UserHelpers.has_password?(user) do %>
<span>{gettext("Enabled")}</span>
<% else %>
<span class="text-base-content/70">—</span>
<% end %>
</:col>
<:col :let={user} label={gettext("OIDC")}>
<%= if MvWeb.Helpers.UserHelpers.has_oidc?(user) do %>
<span>{gettext("Linked")}</span>
<% else %>
<span class="text-base-content/70">—</span>
<% end %>
</:col>
@ -62,16 +89,23 @@
<.link navigate={~p"/users/#{user}"}>{gettext("Show")}</.link>
</div>
<.link navigate={~p"/users/#{user}/edit"}>{gettext("Edit")}</.link>
<%= if can?(@current_user, :update, user) do %>
<.link navigate={~p"/users/#{user}/edit"} data-testid="user-edit">
{gettext("Edit")}
</.link>
<% end %>
</:action>
<:action :let={user}>
<.link
phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")}
data-confirm={gettext("Are you sure?")}
>
{gettext("Delete")}
</.link>
<%= if can?(@current_user, :destroy, user) do %>
<.link
phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")}
data-confirm={gettext("Are you sure?")}
data-testid="user-delete"
>
{gettext("Delete")}
</.link>
<% end %>
</:action>
</.table>
</Layouts.app>

View file

@ -41,16 +41,30 @@ defmodule MvWeb.UserLive.Show do
<.icon name="hero-arrow-left" />
<span class="sr-only">{gettext("Back to users list")}</span>
</.button>
<.button variant="primary" navigate={~p"/users/#{@user}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> {gettext("Edit User")}
</.button>
<%= if can?(@current_user, :update, @user) do %>
<.button
variant="primary"
navigate={~p"/users/#{@user}/edit?return_to=show"}
data-testid="user-edit"
>
<.icon name="hero-pencil-square" /> {gettext("Edit User")}
</.button>
<% end %>
</:actions>
</.header>
<.list>
<:item title={gettext("Email")}>{@user.email}</:item>
<:item title={gettext("Role")}>{@user.role.name}</:item>
<:item title={gettext("Password Authentication")}>
{if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")}
{if MvWeb.Helpers.UserHelpers.has_password?(@user),
do: gettext("Enabled"),
else: gettext("Not enabled")}
</:item>
<:item title={gettext("OIDC")}>
{if MvWeb.Helpers.UserHelpers.has_oidc?(@user),
do: gettext("Linked"),
else: gettext("Not linked")}
</:item>
<:item title={gettext("Linked Member")}>
<%= if @user.member do %>
@ -73,7 +87,9 @@ defmodule MvWeb.UserLive.Show do
@impl true
def mount(%{"id" => id}, _session, socket) do
actor = current_actor(socket)
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
user =
Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member, :role], actor: actor)
if Mv.Helpers.SystemActor.system_user?(user) do
{:ok,