diff --git a/.drone.yml b/.drone.yml index 2c8d504..9eb78f0 100644 --- a/.drone.yml +++ b/.drone.yml @@ -84,7 +84,7 @@ steps: # Fetch dependencies - mix deps.get # Run fast tests (excludes slow/performance and UI tests) - - mix test --exclude slow --exclude ui + - mix test --exclude slow --exclude ui --max-cases 2 - name: rebuild-cache image: drillster/drone-volume-cache diff --git a/.env.example b/.env.example index 04e9dbd..c9cc51e 100644 --- a/.env.example +++ b/.env.example @@ -36,3 +36,4 @@ ASSOCIATION_NAME="Sportsclub XYZ" # VEREINFACHT_API_URL=https://api.verein.visuel.dev/api/v1 # VEREINFACHT_API_KEY=your-api-key # VEREINFACHT_CLUB_ID=2 +# VEREINFACHT_APP_URL=https://app.verein.visuel.dev diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index cc58ca9..439eee8 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -385,6 +385,8 @@ def process_user(user), do: {:ok, perform_action(user)} ### 2.3 Error Handling +**No silent failures:** When an error path assigns a fallback (e.g. empty list, unchanged assigns), always log the error with enough context (e.g. `inspect(error)`, slug, action) and/or set a user-visible flash. Do not only assign the fallback without logging. + **Use Tagged Tuples:** ```elixir @@ -623,6 +625,10 @@ defmodule MvWeb.MemberLive.Index do end ``` +**LiveView load budget:** Keep UI-triggered events cheap. `phx-change`, `phx-focus`, and `phx-keydown` must **not** perform database reads by default; work from assigns (e.g. filter in memory) or defer reads to an explicit commit step (e.g. "Add", "Save", "Submit"). Perform DB reads or reloads only on commit events, not on every keystroke or focus. If a read in an event is unavoidable, do at most one deliberate read, document why, and prefer debounce/throttle. + +**LiveView size:** When a LiveView accumulates many features and event handlers (CRUD + add/remove + search + keyboard + modals), extract sub-flows into LiveComponents. The parent keeps auth, initial load, and a single reload after child actions; the component owns the sub-flow and notifies the parent when data changes. + **Component Design:** ```elixir @@ -1258,6 +1264,8 @@ end ### 3.12 Internationalization: Gettext +**German (de):** Use informal address (“duzen”). All user-facing German text should address the user as “du” (e.g. “Bitte versuche es erneut”, “Deine Einstellungen”), not “Sie”. + **Define Translations:** ```elixir @@ -1267,6 +1275,9 @@ gettext("Welcome to Mila") # With interpolation gettext("Hello, %{name}!", name: user.name) +# Plural: always pass count binding when message uses %{count} +ngettext("Found %{count} member", "Found %{count} members", @count, count: @count) + # Domain-specific translations dgettext("auth", "Sign in with email") ``` @@ -1507,6 +1518,8 @@ defmodule MvWeb.MemberLive.IndexTest do end ``` +**LiveView test standards:** Prefer selector-based assertions (`has_element?` on `data-testid`, stable IDs, or semantic markers) over free-text matching (`html =~ "..."`) or broad regex. For repeated flows (e.g. "open add member", "search", "select"), use helpers in `test/support/`. If delete is a LiveView event, test that event and assert both UI and data; if delete uses JS `data-confirm` + non-LV submit, cover the real delete path in a context/service test and add at most one smoke test in the UI. Do not assert or document "single request" or "DB-level sort" without measuring (e.g. query count or timing). + #### 4.3.5 Component Tests Test function components: @@ -1876,6 +1889,8 @@ policies do end ``` +**Record-based authorization:** When a concrete record is available, use `can?(actor, :action, record)` (e.g. `can?(@current_user, :update, @group)`). Use `can?(actor, :action, Resource)` only when no specific record exists (e.g. "can create any group"). In events: resolve the record from assigns, run `can?`, then mutate; avoid an extra DB read just for a "freshness" check if the assign is already the source of truth. + **Actor Handling in LiveViews:** Always use the `current_actor/1` helper for consistent actor access: @@ -2707,7 +2722,9 @@ Building accessible applications ensures that all users, including those with di ### 8.2 ARIA Labels and Roles -**Use ARIA Attributes When Necessary:** +**Terminology and semantics:** Use the same terms in UI, tests, and docs (e.g. "modal" vs "inline confirmation"). If the implementation is inline, do not call it a modal in tests or docs. + +**Use ARIA Attributes When Necessary:** Use `role="status"` only for live regions that present advisory status (e.g. search result count, progress). Do not use it on links, buttons, or purely static labels; use semantic HTML or the correct role for the widget. Attach `phx-debounce` / `phx-throttle` to the element that triggers the event (e.g. input), not to the whole form, unless the intent is form-wide. ```heex @@ -2931,11 +2948,11 @@ end **Announce Dynamic Content:** ```heex - +
<%= if @searched do %> - <%= ngettext("Found %{count} member", "Found %{count} members", @count) %> + <%= ngettext("Found %{count} member", "Found %{count} members", @count, count: @count) %> <% end %>
diff --git a/Dockerfile b/Dockerfile index 7a01d21..57d296f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,25 +7,25 @@ # This file is based on these images: # # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image -# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20250317-slim - for the release image +# - https://hub.docker.com/_/debian?tab=tags&page=1&name=trixie-20260202-slim - for the release image # - https://pkgs.org/ - resource for finding needed packages -# - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim +# - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim # -ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim" -ARG RUNNER_IMAGE="debian:bullseye-20250317-slim" +ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim" +ARG RUNNER_IMAGE="debian:trixie-20260202-slim" FROM ${BUILDER_IMAGE} AS builder # install build dependencies RUN apt-get update -y && apt-get install -y build-essential git \ - && apt-get clean && rm -f /var/lib/apt/lists/*_* + && apt-get clean && rm -f /var/lib/apt/lists/*_* # prepare build dir WORKDIR /app # install hex + rebar RUN mix local.hex --force && \ - mix local.rebar --force + mix local.rebar --force # set build ENV ENV MIX_ENV="prod" @@ -64,7 +64,7 @@ RUN mix release FROM ${RUNNER_IMAGE} RUN apt-get update -y && \ - apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \ + apt-get install -y libstdc++6 openssl libncurses6 locales ca-certificates \ && apt-get clean && rm -f /var/lib/apt/lists/*_* # Set the locale diff --git a/assets/css/app.css b/assets/css/app.css index 132a8f5..0149c5d 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -24,7 +24,7 @@ @plugin "../vendor/daisyui-theme" { name: "dark"; default: false; - prefersdark: true; + prefersdark: false; color-scheme: "dark"; --color-base-100: oklch(30.33% 0.016 252.42); --color-base-200: oklch(25.26% 0.014 253.1); diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index ab4ad60..411e95d 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -10,7 +10,7 @@ defmodule Mv.Membership.CustomField do ## Attributes - `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday") - `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile") - - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`) + - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`). Immutable after creation. - `description` - Optional human-readable description - `required` - If true, all members must have this custom field (future feature) - `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted @@ -28,6 +28,7 @@ defmodule Mv.Membership.CustomField do ## Constraints - Name must be unique across all custom fields - Name maximum length: 100 characters + - `value_type` cannot be changed after creation (immutable) - Deleting a custom field will cascade delete all associated custom field values ## Calculations @@ -59,7 +60,7 @@ defmodule Mv.Membership.CustomField do end actions do - defaults [:read, :update] + defaults [:read] default_accept [:name, :value_type, :description, :required, :show_in_overview] create :create do @@ -68,6 +69,19 @@ defmodule Mv.Membership.CustomField do validate string_length(:slug, min: 1) end + update :update do + accept [:name, :description, :required, :show_in_overview] + require_atomic? false + + validate fn changeset, _context -> + if Ash.Changeset.changing_attribute?(changeset, :value_type) do + {:error, field: :value_type, message: "cannot be changed after creation"} + else + :ok + end + end + end + destroy :destroy_with_values do primary? true end diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 6ab6668..76ed471 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -333,10 +333,10 @@ defmodule Mv.Membership.Member do authorize_if Mv.Authorization.Checks.HasPermission end - # Internal sync action: allow setting vereinfacht_contact_id (used only by SyncContact change). + # Internal sync action: only SystemActor may set vereinfacht_contact_id (used by SyncContact change). policy action(:set_vereinfacht_contact_id) do - description "Allow internal sync to set Vereinfacht contact ID" - authorize_if always() + description "Only system actor may set Vereinfacht contact ID" + authorize_if Mv.Authorization.Checks.ActorIsSystemUser end # CREATE/UPDATE: Forbid member–user link unless admin, then check permissions diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index 40ef985..33445d3 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -72,7 +72,8 @@ defmodule Mv.Membership.Setting do :default_membership_fee_type_id, :vereinfacht_api_url, :vereinfacht_api_key, - :vereinfacht_club_id + :vereinfacht_club_id, + :vereinfacht_app_url ] end @@ -87,7 +88,8 @@ defmodule Mv.Membership.Setting do :default_membership_fee_type_id, :vereinfacht_api_url, :vereinfacht_api_key, - :vereinfacht_club_id + :vereinfacht_club_id, + :vereinfacht_app_url ] end @@ -251,6 +253,13 @@ defmodule Mv.Membership.Setting do description "Vereinfacht club ID for multi-tenancy" end + attribute :vereinfacht_app_url, :string do + allow_nil? true + public? true + + description "Vereinfacht app base URL for contact view links (e.g. https://app.verein.visuel.dev)" + end + timestamps() end diff --git a/lib/mv/authorization/checks/actor_is_system_user.ex b/lib/mv/authorization/checks/actor_is_system_user.ex new file mode 100644 index 0000000..a614a83 --- /dev/null +++ b/lib/mv/authorization/checks/actor_is_system_user.ex @@ -0,0 +1,15 @@ +defmodule Mv.Authorization.Checks.ActorIsSystemUser do + @moduledoc """ + Policy check: true only when the actor is the system user (e.g. system@mila.local). + + Used to restrict internal actions (e.g. Member.set_vereinfacht_contact_id) so that + only code paths using SystemActor can perform them, not regular admins. + """ + use Ash.Policy.SimpleCheck + + @impl true + def describe(_opts), do: "actor is the system user" + + @impl true + def match?(actor, _context, _opts), do: Mv.Helpers.SystemActor.system_user?(actor) +end diff --git a/lib/mv/config.ex b/lib/mv/config.ex index f6f6ec7..d2ad66c 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -178,6 +178,37 @@ defmodule Mv.Config do env_or_setting("VEREINFACHT_CLUB_ID", :vereinfacht_club_id) end + @doc """ + Returns the Vereinfacht app base URL for contact view links (frontend, not API). + + Reads from `VEREINFACHT_APP_URL` env first, then from Settings. + Used to build links like https://app.verein.visuel.dev/en/admin/finances/contacts/{id}. + If not set, derived from API URL by replacing host \"api.\" with \"app.\" when possible. + """ + @spec vereinfacht_app_url() :: String.t() | nil + def vereinfacht_app_url do + env_or_setting("VEREINFACHT_APP_URL", :vereinfacht_app_url) || + derive_app_url_from_api_url(vereinfacht_api_url()) + end + + defp derive_app_url_from_api_url(nil), do: nil + + defp derive_app_url_from_api_url(api_url) when is_binary(api_url) do + api_url = String.trim(api_url) + uri = URI.parse(api_url) + host = uri.host || "" + + if String.starts_with?(host, "api.") do + app_host = "app." <> String.slice(host, 4..-1//1) + scheme = uri.scheme || "https" + "#{scheme}://#{app_host}" + else + nil + end + end + + defp derive_app_url_from_api_url(_), do: nil + @doc """ Returns true if Vereinfacht is fully configured (URL, API key, and club ID all set). """ @@ -211,6 +242,11 @@ defmodule Mv.Config do """ def vereinfacht_club_id_env_set?, do: env_set?("VEREINFACHT_CLUB_ID") + @doc """ + Returns true if VEREINFACHT_APP_URL is set (field is read-only in Settings). + """ + def vereinfacht_app_url_env_set?, do: env_set?("VEREINFACHT_APP_URL") + defp env_set?(key) do case System.get_env(key) do nil -> false @@ -241,18 +277,22 @@ defmodule Mv.Config do end @doc """ - Returns the URL to view a finance contact (e.g. in Vereinfacht frontend or API). + Returns the URL to view a finance contact in the Vereinfacht app (frontend). - Uses the configured API base URL and appends /finance-contacts/{id}. - Can be extended later with a dedicated frontend URL setting. + Uses the configured app base URL (or derived from API URL) and appends + /en/admin/finances/contacts/{id}. Returns nil if no app URL can be determined. """ @spec vereinfacht_contact_view_url(String.t()) :: String.t() | nil def vereinfacht_contact_view_url(contact_id) when is_binary(contact_id) do - base = vereinfacht_api_url() + base = vereinfacht_app_url() - if present?(base), - do: base |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}"), - else: nil + if present?(base) do + base + |> String.trim_trailing("/") + |> then(&"#{&1}/en/admin/finances/contacts/#{contact_id}") + else + nil + end end defp present?(nil), do: false diff --git a/lib/mv/membership/member_export.ex b/lib/mv/membership/member_export.ex index e243d40..b4272b0 100644 --- a/lib/mv/membership/member_export.ex +++ b/lib/mv/membership/member_export.ex @@ -16,7 +16,7 @@ defmodule Mv.Membership.MemberExport do alias MvWeb.MemberLive.Index.MembershipFeeStatus @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ - ["membership_fee_status"] + ["membership_fee_status", "groups"] @computed_export_fields ["membership_fee_status"] @computed_insert_after "membership_fee_start_date" @custom_field_prefix Mv.Constants.custom_field_prefix() @@ -323,10 +323,14 @@ defmodule Mv.Membership.MemberExport do |> Enum.filter(&(&1 in @domain_member_field_strings)) |> order_member_fields_like_table() - # final member_fields list (used for column specs order): table order + computed inserted + # Separate groups from other fields (groups is handled as a special field, not a member field) + groups_field = if "groups" in member_fields, do: ["groups"], else: [] + + # final member_fields list (used for column specs order): table order + computed inserted + groups ordered_member_fields = selectable_member_fields |> insert_computed_fields_like_table(computed_fields) + |> then(fn fields -> fields ++ groups_field end) %{ selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")), diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex index ce1e98c..9e0cc7b 100644 --- a/lib/mv/membership/member_export/build.ex +++ b/lib/mv/membership/member_export/build.ex @@ -132,12 +132,15 @@ defmodule Mv.Membership.MemberExport.Build do parsed.computed_fields != [] or "membership_fee_status" in parsed.member_fields + need_groups = "groups" in parsed.member_fields + query = Member |> Ash.Query.new() |> Ash.Query.select(select_fields) |> load_custom_field_values_query(custom_field_ids_union) |> maybe_load_cycles(need_cycles, parsed.show_current_cycle) + |> maybe_load_groups(need_groups) query = if parsed.selected_ids != [] do @@ -241,16 +244,22 @@ defmodule Mv.Membership.MemberExport.Build do defp maybe_sort(query, _field, nil), do: {query, false} defp maybe_sort(query, field, order) when is_binary(field) do - if custom_field_sort?(field) do - {query, true} - else - field_atom = String.to_existing_atom(field) + cond do + field == "groups" -> + # Groups sort → in-memory nach dem Read (wie Tabelle) + {query, true} - if field_atom in (Mv.Constants.member_fields() -- [:notes]) do - {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false} - else - {query, false} - end + custom_field_sort?(field) -> + {query, true} + + true -> + field_atom = String.to_existing_atom(field) + + if field_atom in (Mv.Constants.member_fields() -- [:notes]) do + {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false} + else + {query, false} + end end rescue ArgumentError -> {query, false} @@ -260,11 +269,25 @@ defmodule Mv.Membership.MemberExport.Build do do: [] defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do + if field == "groups" do + sort_members_by_groups_export(members, order) + else + sort_by_custom_field_value(members, field, order, custom_fields) + end + end + + defp sort_by_custom_field_value(members, field, order, custom_fields) do id_str = String.trim_leading(field, @custom_field_prefix) custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - if is_nil(custom_field), do: members + if is_nil(custom_field) do + members + else + sort_members_with_custom_field(members, custom_field, order) + end + end + defp sort_members_with_custom_field(members, custom_field, order) do key_fn = fn member -> cfv = find_cfv(member, custom_field) raw = if cfv, do: cfv.value, else: nil @@ -277,6 +300,26 @@ defmodule Mv.Membership.MemberExport.Build do |> Enum.map(fn {m, _} -> m end) end + defp sort_members_by_groups_export(members, order) do + # Members with groups first, then by first group name alphabetically (min = first by sort order) + # Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2 + first_group_name = fn member -> + (member.groups || []) + |> Enum.map(& &1.name) + |> Enum.min(fn -> nil end) + end + + members + |> Enum.sort_by(fn member -> + name = first_group_name.(member) + # Nil (no groups) sorts last in asc, first in desc + {name == nil, name || ""} + end) + |> then(fn list -> + if order == "desc", do: Enum.reverse(list), else: list + end) + end + defp find_cfv(member, custom_field) do (member.custom_field_values || []) |> Enum.find(fn cfv -> @@ -294,6 +337,13 @@ defmodule Mv.Membership.MemberExport.Build do MembershipFeeStatus.load_cycles_for_members(query, show_current) end + defp maybe_load_groups(query, false), do: query + + defp maybe_load_groups(query, true) do + # Load groups with id and name only (for export formatting) + Ash.Query.load(query, groups: [:id, :name]) + end + defp apply_cycle_status_filter(members, nil, _show_current), do: members defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do @@ -343,6 +393,19 @@ defmodule Mv.Membership.MemberExport.Build do } end) + groups_col = + if "groups" in parsed.member_fields do + [ + %{ + key: :groups, + kind: :groups, + label: label_fn.(:groups) + } + ] + else + [] + end + custom_cols = parsed.custom_field_ids |> Enum.map(fn id -> @@ -361,7 +424,7 @@ defmodule Mv.Membership.MemberExport.Build do end) |> Enum.reject(&is_nil/1) - member_cols ++ computed_cols ++ custom_cols + member_cols ++ computed_cols ++ groups_col ++ custom_cols end defp build_rows(members, columns, custom_fields_by_id) do @@ -391,6 +454,11 @@ defmodule Mv.Membership.MemberExport.Build do if is_binary(value), do: value, else: "" end + defp cell_value(member, %{kind: :groups, key: :groups}, _custom_fields_by_id) do + groups = Map.get(member, :groups) || [] + format_groups(groups) + end + defp key_to_atom(k) when is_atom(k), do: k defp key_to_atom(k) when is_binary(k) do @@ -424,6 +492,15 @@ defmodule Mv.Membership.MemberExport.Build do defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt) defp format_member_value(value), do: to_string(value) + defp format_groups([]), do: "" + + defp format_groups(groups) when is_list(groups) do + groups + |> Enum.map(fn group -> Map.get(group, :name) || "" end) + |> Enum.reject(&(&1 == "")) + |> Enum.join(", ") + end + defp build_meta(members) do %{ generated_at: DateTime.utc_now() |> DateTime.to_iso8601(), diff --git a/lib/mv/membership/members_csv.ex b/lib/mv/membership/members_csv.ex index a0fd463..a47af8d 100644 --- a/lib/mv/membership/members_csv.ex +++ b/lib/mv/membership/members_csv.ex @@ -59,6 +59,11 @@ defmodule Mv.Membership.MembersCSV do if is_binary(value), do: value, else: "" end + defp cell_value(member, %{kind: :groups, key: :groups}) do + groups = Map.get(member, :groups) || [] + format_groups(groups) + end + defp key_to_atom(k) when is_atom(k), do: k defp key_to_atom(k) when is_binary(k) do @@ -97,4 +102,13 @@ defmodule Mv.Membership.MembersCSV do defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt) defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt) defp format_member_value(value), do: to_string(value) + + defp format_groups([]), do: "" + + defp format_groups(groups) when is_list(groups) do + groups + |> Enum.map(fn group -> Map.get(group, :name) || "" end) + |> Enum.reject(&(&1 == "")) + |> Enum.join(", ") + end end diff --git a/lib/mv/vereinfacht/changes/sync_contact.ex b/lib/mv/vereinfacht/changes/sync_contact.ex index 4ea6cc8..99875e0 100644 --- a/lib/mv/vereinfacht/changes/sync_contact.ex +++ b/lib/mv/vereinfacht/changes/sync_contact.ex @@ -7,20 +7,57 @@ defmodule Mv.Vereinfacht.Changes.SyncContact do Runs in `after_transaction` so the member is persisted first. API failures are logged but do not block the member operation. Requires Vereinfacht to be configured (Mv.Config.vereinfacht_configured?/0). + + Only runs when relevant data changed: on create always; on update only when + first_name, last_name, email, street, house_number, postal_code, or city changed, + or when the member has no vereinfacht_contact_id yet (to avoid unnecessary API calls). """ use Ash.Resource.Change require Logger + @synced_attributes [ + :first_name, + :last_name, + :email, + :street, + :house_number, + :postal_code, + :city + ] + @impl true def change(changeset, _opts, _context) do - if Mv.Config.vereinfacht_configured?() do + if Mv.Config.vereinfacht_configured?() and sync_relevant?(changeset) do Ash.Changeset.after_transaction(changeset, &sync_after_transaction/2) else changeset end end + defp sync_relevant?(changeset) do + case changeset.action_type do + :create -> true + :update -> relevant_update?(changeset) + _ -> false + end + end + + defp relevant_update?(changeset) do + any_synced_attr_changed? = + Enum.any?(@synced_attributes, &Ash.Changeset.changing_attribute?(changeset, &1)) + + record = changeset.data + no_contact_id_yet? = record && blank_contact_id?(record.vereinfacht_contact_id) + + any_synced_attr_changed? or no_contact_id_yet? + end + + defp blank_contact_id?(nil), do: true + defp blank_contact_id?(""), do: true + defp blank_contact_id?(s) when is_binary(s), do: String.trim(s) == "" + defp blank_contact_id?(_), do: false + # Ash calls after_transaction with (changeset, result) only - 2 args. defp sync_after_transaction(_changeset, {:ok, member}) do case Mv.Vereinfacht.sync_member(member) do diff --git a/lib/mv/vereinfacht/client.ex b/lib/mv/vereinfacht/client.ex index 72859ac..2aafc7f 100644 --- a/lib/mv/vereinfacht/client.ex +++ b/lib/mv/vereinfacht/client.ex @@ -163,7 +163,16 @@ defmodule Mv.Vereinfacht.Client do defp find_contact_id_by_email_in_list(%{"data" => list}, normalized) when is_list(list) do Enum.find_value(list, fn - %{"id" => id, "attributes" => %{"email" => att_email}} when is_binary(att_email) -> + %{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => true}} + when is_binary(att_email) -> + if att_email |> String.trim() |> String.downcase() == normalized do + normalize_contact_id(id) + else + nil + end + + %{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => "true"}} + when is_binary(att_email) -> if att_email |> String.trim() |> String.downcase() == normalized do normalize_contact_id(id) else @@ -191,16 +200,34 @@ defmodule Mv.Vereinfacht.Client do """ @spec get_contact(String.t()) :: {:ok, map()} | {:error, term()} def get_contact(contact_id) when is_binary(contact_id) do + fetch_contact(contact_id, []) + end + + @doc """ + Fetches a finance contact with receipts (GET /finance-contacts/:id?include=receipts). + + Returns {:ok, receipts} where receipts is a list of maps with :id and :attributes + (and optional :type) for each receipt, or {:error, reason}. + """ + @spec get_contact_with_receipts(String.t()) :: {:ok, [map()]} | {:error, term()} + def get_contact_with_receipts(contact_id) when is_binary(contact_id) do + case fetch_contact(contact_id, include: "receipts") do + {:ok, body} -> {:ok, extract_receipts_from_response(body)} + {:error, _} = err -> err + end + end + + defp fetch_contact(contact_id, query_params) do base_url = base_url() api_key = api_key() if is_nil(base_url) or is_nil(api_key) do {:error, :not_configured} else - url = - base_url - |> String.trim_trailing("/") - |> then(&"#{&1}/finance-contacts/#{contact_id}") + path = + base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}") + + url = build_url_with_params(path, query_params) case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do {:ok, %{status: 200, body: body}} when is_map(body) -> @@ -215,6 +242,38 @@ defmodule Mv.Vereinfacht.Client do end end + defp build_url_with_params(base, []), do: base + + defp build_url_with_params(base, include: value) do + sep = if String.contains?(base, "?"), do: "&", else: "?" + base <> sep <> "include=" <> URI.encode(value, &URI.char_unreserved?/1) + end + + defp extract_receipts_from_response(%{"included" => included}) when is_list(included) do + included + |> Enum.filter(&match?(%{"type" => "receipts"}, &1)) + |> Enum.map(fn %{"id" => id, "attributes" => attrs} = r -> + Map.merge(%{id: id, type: r["type"]}, string_keys_to_atoms(attrs || %{})) + end) + end + + defp extract_receipts_from_response(_), do: [] + + defp string_keys_to_atoms(map) when is_map(map) do + Map.new(map, fn {k, v} -> {to_atom_key(k), v} end) + end + + defp to_atom_key(k) when is_binary(k) do + try do + String.to_existing_atom(k) + rescue + ArgumentError -> String.to_atom(k) + end + end + + defp to_atom_key(k) when is_atom(k), do: k + defp to_atom_key(k), do: to_atom_key(to_string(k)) + defp base_url, do: Mv.Config.vereinfacht_api_url() defp api_key, do: Mv.Config.vereinfacht_api_key() defp club_id, do: Mv.Config.vereinfacht_club_id() @@ -270,6 +329,7 @@ defmodule Mv.Vereinfacht.Client do |> put_attr("zipCode", member |> Map.get(:postal_code)) |> put_attr("city", member |> Map.get(:city)) |> Map.put("contactType", "person") + |> Map.put("isExternal", true) |> Enum.reject(fn {_k, v} -> is_nil(v) end) |> Map.new() end diff --git a/lib/mv/vereinfacht/sync_flash.ex b/lib/mv/vereinfacht/sync_flash.ex index fb062cd..874a717 100644 --- a/lib/mv/vereinfacht/sync_flash.ex +++ b/lib/mv/vereinfacht/sync_flash.ex @@ -35,6 +35,8 @@ defmodule Mv.Vereinfacht.SyncFlash do @doc false def create_table! do + # :public so any process can write (SyncContact runs in LiveView/Ash transaction process, + # not the process that created the table). :protected would restrict writes to the creating process. if :ets.whereis(@table) == :undefined do :ets.new(@table, [:set, :public, :named_table]) end diff --git a/lib/mv_web/auth_overrides.ex b/lib/mv_web/auth_overrides.ex index 1367150..b121c4e 100644 --- a/lib/mv_web/auth_overrides.ex +++ b/lib/mv_web/auth_overrides.ex @@ -45,4 +45,11 @@ defmodule MvWeb.AuthOverrides do Gettext.gettext(MvWeb.Gettext, "or") end) end + + # Hide AshAuthentication's Flash component since we use flash_group in root layout + # This prevents duplicate flash messages + override AshAuthentication.Phoenix.Components.Flash do + set :message_class_info, "hidden" + set :message_class_error, "hidden" + end end diff --git a/lib/mv_web/components/layouts/root.html.heex b/lib/mv_web/components/layouts/root.html.heex index 35e73ab..a47fcc7 100644 --- a/lib/mv_web/components/layouts/root.html.heex +++ b/lib/mv_web/components/layouts/root.html.heex @@ -15,24 +15,98 @@ +
+ <.flash id="flash-success-root" kind={:success} flash={@flash} /> + <.flash id="flash-warning-root" kind={:warning} flash={@flash} /> + <.flash id="flash-info-root" kind={:info} flash={@flash} /> + <.flash id="flash-error-root" kind={:error} flash={@flash} /> + + <.flash + id="client-error-root" + kind={:error} + title={gettext("We can't find the internet")} + phx-disconnected={ + show(".phx-client-error #client-error-root") |> JS.remove_attribute("hidden") + } + phx-connected={hide("#client-error-root") |> JS.set_attribute({"hidden", ""})} + hidden + > + {gettext("Attempting to reconnect")} + <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" /> + + + <.flash + id="server-error-root" + kind={:error} + title={gettext("Something went wrong!")} + phx-disconnected={ + show(".phx-server-error #server-error-root") |> JS.remove_attribute("hidden") + } + phx-connected={hide("#server-error-root") |> JS.set_attribute({"hidden", ""})} + hidden + > + {gettext("Attempting to reconnect")} + <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" /> + +
{@inner_content} diff --git a/lib/mv_web/components/layouts/sidebar.ex b/lib/mv_web/components/layouts/sidebar.ex index 8ed7f03..7d4cce6 100644 --- a/lib/mv_web/components/layouts/sidebar.ex +++ b/lib/mv_web/components/layouts/sidebar.ex @@ -248,12 +248,17 @@ defmodule MvWeb.Layouts.Sidebar do aria-label={gettext("Toggle dark mode")} > <.icon name="hero-sun" class="size-5" aria-hidden="true" /> - +
+ +
+ <.icon name="hero-moon" class="size-5" aria-hidden="true" /> """ diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex index 20a8b20..d9690df 100644 --- a/lib/mv_web/controllers/auth_controller.ex +++ b/lib/mv_web/controllers/auth_controller.ex @@ -45,9 +45,7 @@ defmodule MvWeb.AuthController do - Generic authentication failures """ def failure(conn, activity, reason) do - Logger.warning( - "Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}" - ) + log_failure_safely(activity, reason) case {activity, reason} do {{:rauthy, _action}, reason} -> @@ -57,10 +55,70 @@ defmodule MvWeb.AuthController do handle_authentication_failed(conn, caused_by) _ -> - redirect_with_error(conn, gettext("Incorrect email or password")) + conn + |> put_flash(:error, gettext("Incorrect email or password")) + |> redirect(to: ~p"/sign-in") end end + # Log authentication failures safely, avoiding sensitive data for {:rauthy, _} activities + defp log_failure_safely({:rauthy, _action} = activity, reason) do + # For Assent errors, use safe_assent_meta to avoid logging tokens/URLs with query params + case reason do + %Assent.ServerUnreachableError{} = err -> + meta = safe_assent_meta(err) + message = format_safe_log_message("Authentication failure", activity, meta) + Logger.warning(message) + + %Assent.InvalidResponseError{} = err -> + meta = safe_assent_meta(err) + message = format_safe_log_message("Authentication failure", activity, meta) + Logger.warning(message) + + _ -> + # For other rauthy errors, log only error type, not full details + error_type = get_error_type(reason) + + Logger.warning( + "Authentication failure - Activity: #{inspect(activity)}, Error type: #{error_type}" + ) + end + end + + defp log_failure_safely(activity, reason) do + # For non-rauthy activities, safe to log full reason + Logger.warning( + "Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}" + ) + end + + # Extract safe error type identifier without sensitive data + defp get_error_type(%struct{}), do: "#{struct}" + defp get_error_type(atom) when is_atom(atom), do: inspect(atom) + defp get_error_type(_other), do: "[redacted]" + + # Format safe log message with metadata included in the message string + defp format_safe_log_message(base_message, activity, meta) when is_list(meta) do + activity_str = "Activity: #{inspect(activity)}" + meta_str = format_meta_string(meta) + "#{base_message} - #{activity_str}#{meta_str}" + end + + defp format_meta_string([]), do: "" + + defp format_meta_string(meta) when is_list(meta) do + parts = + Enum.map(meta, fn + {:request_url, url} -> "Request URL: #{url}" + {:status, status} -> "Status: #{status}" + {:http_adapter, adapter} -> "HTTP Adapter: #{inspect(adapter)}" + _ -> nil + end) + |> Enum.filter(&(&1 != nil)) + + if Enum.empty?(parts), do: "", else: " - " <> Enum.join(parts, ", ") + end + # Handle all Rauthy (OIDC) authentication failures defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do handle_oidc_email_collision(conn, errors) @@ -74,14 +132,46 @@ defmodule MvWeb.AuthController do handle_oidc_email_collision(conn, errors) _ -> - redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again.")) + conn + |> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again.")) + |> redirect(to: ~p"/sign-in") end end + # Handle Assent server unreachable errors (network/connectivity issues) + defp handle_rauthy_failure(conn, %Assent.ServerUnreachableError{} = _err) do + # Logging already done safely in failure/3 via log_failure_safely/2 + # No need to log again here to avoid duplicate logs + + conn + |> put_flash( + :error, + gettext("The authentication server is currently unavailable. Please try again later.") + ) + |> redirect(to: ~p"/sign-in") + end + + # Handle Assent invalid response errors (configuration or malformed responses) + defp handle_rauthy_failure(conn, %Assent.InvalidResponseError{} = _err) do + # Logging already done safely in failure/3 via log_failure_safely/2 + # No need to log again here to avoid duplicate logs + + conn + |> put_flash( + :error, + gettext("Authentication configuration error. Please contact the administrator.") + ) + |> redirect(to: ~p"/sign-in") + end + # Catch-all clause for any other error types - defp handle_rauthy_failure(conn, reason) do - Logger.warning("Unhandled Rauthy failure reason: #{inspect(reason)}") - redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again.")) + defp handle_rauthy_failure(conn, _reason) do + # Logging already done safely in failure/3 via log_failure_safely/2 + # No need to log again here to avoid duplicate logs + + conn + |> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again.")) + |> redirect(to: ~p"/sign-in") end # Handle generic AuthenticationFailed errors @@ -93,14 +183,20 @@ defmodule MvWeb.AuthController do You can confirm your account using the link we sent to you, or by resetting your password. """) - redirect_with_error(conn, message) + conn + |> put_flash(:error, message) + |> redirect(to: ~p"/sign-in") else - redirect_with_error(conn, gettext("Authentication failed. Please try again.")) + conn + |> put_flash(:error, gettext("Authentication failed. Please try again.")) + |> redirect(to: ~p"/sign-in") end end defp handle_authentication_failed(conn, _other) do - redirect_with_error(conn, gettext("Authentication failed. Please try again.")) + conn + |> put_flash(:error, gettext("Authentication failed. Please try again.")) + |> redirect(to: ~p"/sign-in") end # Handle OIDC email collision - user needs to verify password to link accounts @@ -112,7 +208,10 @@ defmodule MvWeb.AuthController do nil -> # Check if it's a "different OIDC account" error or email uniqueness error error_message = extract_meaningful_error_message(errors) - redirect_with_error(conn, error_message) + + conn + |> put_flash(:error, error_message) + |> redirect(to: ~p"/sign-in") end end @@ -177,13 +276,47 @@ defmodule MvWeb.AuthController do |> redirect(to: ~p"/auth/link-oidc-account") end - # Generic error redirect helper - defp redirect_with_error(conn, message) do - conn - |> put_flash(:error, message) - |> redirect(to: ~p"/sign-in") + # Extract safe metadata from Assent errors for logging + # Never logs sensitive data: no tokens, secrets, or full request URLs + # Returns keyword list for Logger.warning/2 + defp safe_assent_meta(%{request_url: url} = err) when is_binary(url) do + [ + request_url: redact_url(url), + http_adapter: Map.get(err, :http_adapter) + ] + |> Enum.filter(fn {_key, value} -> not is_nil(value) end) end + # Handle InvalidResponseError which has :response field (HTTPResponse struct) + defp safe_assent_meta(%{response: %{status: status} = response} = err) do + [ + status: status, + http_adapter: Map.get(response, :http_adapter) || Map.get(err, :http_adapter) + ] + |> Enum.filter(fn {_key, value} -> not is_nil(value) end) + end + + defp safe_assent_meta(err) do + # Only extract safe, simple fields + [ + http_adapter: Map.get(err, :http_adapter) + ] + |> Enum.filter(fn {_key, value} -> not is_nil(value) end) + end + + # Redact URL to only show scheme and host, hiding path, query, and fragments + defp redact_url(url) when is_binary(url) do + case URI.parse(url) do + %URI{scheme: scheme, host: host} when not is_nil(scheme) and not is_nil(host) -> + "#{scheme}://#{host}" + + _ -> + "[redacted]" + end + end + + defp redact_url(_), do: "[redacted]" + def sign_out(conn, _params) do return_to = get_session(conn, :return_to) || ~p"/" diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex index 009a985..08bcba7 100644 --- a/lib/mv_web/controllers/member_export_controller.ex +++ b/lib/mv_web/controllers/member_export_controller.ex @@ -18,7 +18,8 @@ defmodule MvWeb.MemberExportController do alias MvWeb.MemberLive.Index.MembershipFeeStatus use Gettext, backend: MvWeb.Gettext - @member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ + ["groups"] @computed_export_fields ["membership_fee_status"] @custom_field_prefix Mv.Constants.custom_field_prefix() @@ -83,6 +84,7 @@ defmodule MvWeb.MemberExportController do domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) selectable = Enum.filter(member_fields, fn f -> f in domain_fields end) computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end) + # "groups" is neither a domain field nor a computed field, it's handled separately {selectable, computed} end @@ -235,12 +237,15 @@ defmodule MvWeb.MemberExportController do need_cycles = parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields + need_groups = "groups" in parsed.member_fields + query = Member |> Ash.Query.new() |> Ash.Query.select(select_fields) |> load_custom_field_values_query(parsed.custom_field_ids) |> maybe_load_cycles(need_cycles, parsed.show_current_cycle) + |> maybe_load_groups(need_groups) query = if parsed.selected_ids != [] do @@ -284,6 +289,13 @@ defmodule MvWeb.MemberExportController do MembershipFeeStatus.load_cycles_for_members(query, show_current) end + defp maybe_load_groups(query, false), do: query + + defp maybe_load_groups(query, true) do + # Load groups with id and name only (for export formatting) + Ash.Query.load(query, groups: [:id, :name]) + end + # Adds computed field values to members (e.g. membership_fee_status) defp add_computed_fields(members, computed_fields, show_current_cycle) do if "membership_fee_status" in computed_fields do @@ -329,17 +341,23 @@ defmodule MvWeb.MemberExportController do defp maybe_sort_export(query, _field, nil), do: {query, false} defp maybe_sort_export(query, field, order) when is_binary(field) do - if custom_field_sort?(field) do - # Custom field sort → in-memory nach dem Read (wie Tabelle) - {query, true} - else - field_atom = String.to_existing_atom(field) + cond do + field == "groups" -> + # Groups sort → in-memory nach dem Read (wie Tabelle) + {query, true} - if field_atom in (Mv.Constants.member_fields() -- [:notes]) do - {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false} - else - {query, false} - end + custom_field_sort?(field) -> + # Custom field sort → in-memory nach dem Read (wie Tabelle) + {query, true} + + true -> + field_atom = String.to_existing_atom(field) + + if field_atom in (Mv.Constants.member_fields() -- [:notes]) do + {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false} + else + {query, false} + end end rescue ArgumentError -> {query, false} @@ -358,6 +376,15 @@ defmodule MvWeb.MemberExportController do defp sort_members_by_custom_field_export(members, field, order, custom_fields) when is_binary(field) do order = order || "asc" + + if field == "groups" do + sort_members_by_groups_export(members, order) + else + sort_by_custom_field_value(members, field, order, custom_fields) + end + end + + defp sort_by_custom_field_value(members, field, order, custom_fields) do id_str = String.trim_leading(field, @custom_field_prefix) custom_field = @@ -387,6 +414,26 @@ defmodule MvWeb.MemberExportController do end end + defp sort_members_by_groups_export(members, order) do + # Members with groups first, then by first group name alphabetically (min = first by sort order) + # Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2 + first_group_name = fn member -> + (member.groups || []) + |> Enum.map(& &1.name) + |> Enum.min(fn -> nil end) + end + + members + |> Enum.sort_by(fn member -> + name = first_group_name.(member) + # Nil (no groups) sorts last in asc, first in desc + {name == nil, name || ""} + end) + |> then(fn list -> + if order == "desc", do: Enum.reverse(list), else: list + end) + end + defp has_non_empty_custom_field_value?(member, custom_field) do case find_cfv(member, custom_field) do nil -> @@ -441,6 +488,19 @@ defmodule MvWeb.MemberExportController do } end) + groups_col = + if "groups" in parsed.member_fields do + [ + %{ + header: groups_field_header(conn), + kind: :groups, + key: :groups + } + ] + else + [] + end + custom_cols = parsed.custom_field_ids |> Enum.map(fn id -> @@ -459,7 +519,7 @@ defmodule MvWeb.MemberExportController do end) |> Enum.reject(&is_nil/1) - member_cols ++ computed_cols ++ custom_cols + member_cols ++ computed_cols ++ groups_col ++ custom_cols end # --- headers: use MemberFields.label for translations --- @@ -499,6 +559,10 @@ defmodule MvWeb.MemberExportController do cf.name end + defp groups_field_header(_conn) do + MemberFields.label(:groups) + end + defp humanize_field(str) do str |> String.replace("_", " ") diff --git a/lib/mv_web/live/components/sort_header_component.ex b/lib/mv_web/live/components/sort_header_component.ex index 3817d90..d548efa 100644 --- a/lib/mv_web/live/components/sort_header_component.ex +++ b/lib/mv_web/live/components/sort_header_component.ex @@ -26,7 +26,6 @@ defmodule MvWeb.Components.SortHeaderComponent do class="btn btn-ghost select-none" phx-click="sort" phx-value-field={@field} - phx-target={@myself} data-testid={@field} > {@label} @@ -43,12 +42,6 @@ defmodule MvWeb.Components.SortHeaderComponent do """ end - @impl true - def handle_event("sort", %{"field" => field_str}, socket) do - send(self(), {:sort, field_str}) - {:noreply, socket} - end - # ------------------------------------------------- # Hilfsfunktionen für ARIA Attribute & Icon SVG # ------------------------------------------------- diff --git a/lib/mv_web/live/custom_field_live/form_component.ex b/lib/mv_web/live/custom_field_live/form_component.ex index b809a1a..f89f767 100644 --- a/lib/mv_web/live/custom_field_live/form_component.ex +++ b/lib/mv_web/live/custom_field_live/form_component.ex @@ -5,7 +5,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do ## Features - Create new custom field definitions - Edit existing custom fields - - Select value type from supported types + - Select value type from supported types (only on create; immutable after creation) - Set required flag - Real-time validation @@ -44,15 +44,50 @@ defmodule MvWeb.CustomFieldLive.FormComponent do > <.input field={@form[:name]} type="text" label={gettext("Name")} /> - <.input - field={@form[:value_type]} - type="select" - label={gettext("Value type")} - options={ - Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of] - |> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end) - } - /> + <%= if @custom_field do %> + <%!-- Show value_type as read-only input when editing (matches Member Field pattern) --%> +
+
+ +
+
+ <% else %> + <%!-- Show value_type as select when creating --%> + <.input + field={@form[:value_type]} + type="select" + label={gettext("Value type")} + options={ + Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[ + :one_of + ] + |> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end) + } + /> + <% end %> + <.input field={@form[:description]} type="text" label={gettext("Description")} /> <.input field={@form[:required]} type="checkbox" label={gettext("Required")} /> <.input @@ -85,8 +120,16 @@ defmodule MvWeb.CustomFieldLive.FormComponent do @impl true def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do + # Remove value_type from params when editing (it's immutable after creation) + cleaned_params = + if socket.assigns[:custom_field] do + Map.delete(custom_field_params, "value_type") + else + custom_field_params + end + {:noreply, - assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))} + assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, cleaned_params))} end @impl true @@ -94,7 +137,15 @@ defmodule MvWeb.CustomFieldLive.FormComponent do # Actor must be passed from parent (IndexComponent); component socket has no current_user actor = socket.assigns[:actor] - case MvWeb.LiveHelpers.submit_form(socket.assigns.form, custom_field_params, actor) do + # Remove value_type from params when editing (it's immutable after creation) + cleaned_params = + if socket.assigns[:custom_field] do + Map.delete(custom_field_params, "value_type") + else + custom_field_params + end + + case MvWeb.LiveHelpers.submit_form(socket.assigns.form, cleaned_params, actor) do {:ok, custom_field} -> action = case socket.assigns.form.source.type do diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 3da4aa6..b841931 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -48,6 +48,7 @@ defmodule MvWeb.GlobalSettingsLive do |> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?()) |> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?()) |> assign(:vereinfacht_club_id_env_set, Mv.Config.vereinfacht_club_id_env_set?()) + |> assign(:vereinfacht_app_url_env_set, Mv.Config.vereinfacht_app_url_env_set?()) |> assign(:vereinfacht_api_key_set, present?(settings.vereinfacht_api_key)) |> assign(:last_vereinfacht_sync_result, nil) |> assign_form() @@ -142,6 +143,18 @@ defmodule MvWeb.GlobalSettingsLive do if(@vereinfacht_club_id_env_set, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2") } /> + <.input + field={@form[:vereinfacht_app_url]} + type="text" + label={gettext("App URL (contact view link)")} + disabled={@vereinfacht_app_url_env_set} + placeholder={ + if(@vereinfacht_app_url_env_set, + do: gettext("From VEREINFACHT_APP_URL"), + else: "https://app.verein.visuel.dev" + ) + } + /> <.button :if={ diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex index 0251fb6..0c7e93e 100644 --- a/lib/mv_web/live/group_live/show.ex +++ b/lib/mv_web/live/group_live/show.ex @@ -17,10 +17,12 @@ defmodule MvWeb.GroupLive.Show do require Logger + import Ash.Expr import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.Authorization alias Mv.Membership + alias MvWeb.Helpers.MemberHelpers, as: MemberHelpers @impl true def mount(_params, _session, socket) do @@ -29,6 +31,7 @@ defmodule MvWeb.GroupLive.Show do |> assign(:show_add_member_input, false) |> assign(:member_search_query, "") |> assign(:available_members, []) + |> assign(:add_member_candidates, []) |> assign(:selected_member_ids, []) |> assign(:selected_members, []) |> assign(:show_member_dropdown, false) @@ -94,13 +97,21 @@ defmodule MvWeb.GroupLive.Show do
- <%= if can?(@current_user, :update, Mv.Membership.Group) do %> - <.button variant="primary" navigate={~p"/groups/#{@group.slug}/edit"}> + <%= if can?(@current_user, :update, @group) do %> + <.button + variant="primary" + navigate={~p"/groups/#{@group.slug}/edit"} + data-testid="group-show-edit-btn" + > {gettext("Edit")} <% end %> - <%= if can?(@current_user, :destroy, Mv.Membership.Group) do %> - <.button class="btn-error" phx-click="open_delete_modal"> + <%= if can?(@current_user, :destroy, @group) do %> + <.button + class="btn-error" + phx-click="open_delete_modal" + data-testid="group-show-delete-btn" + > {gettext("Delete")} <% end %> @@ -123,7 +134,7 @@ defmodule MvWeb.GroupLive.Show do

{gettext("Members")}

-

+

{ngettext( "Total: %{count} member", "Total: %{count} members", @@ -132,7 +143,7 @@ defmodule MvWeb.GroupLive.Show do )}

- <%= if can?(@current_user, :update, Mv.Membership.Group) do %> + <%= if can?(@current_user, :update, @group) do %>
<%= if assigns[:show_add_member_input] do %>
@@ -160,6 +171,7 @@ defmodule MvWeb.GroupLive.Show do @@ -255,15 +268,17 @@ defmodule MvWeb.GroupLive.Show do <% end %> <%= if Enum.empty?(@group.members || []) do %> -

{gettext("No members in this group")}

+

+ {gettext("No members in this group")} +

<% else %> -
+
- <%= if can?(@current_user, :update, Mv.Membership.Group) do %> + <%= if can?(@current_user, :update, @group) do %> <% end %> @@ -291,13 +306,14 @@ defmodule MvWeb.GroupLive.Show do <% end %> - <%= if can?(@current_user, :update, Mv.Membership.Group) do %> + <%= if can?(@current_user, :update, @group) do %>
{gettext("Name")} {gettext("Email")}{gettext("Actions")}
- <%= if @vereinfacht_debug_response do %> + <%= if @vereinfacht_receipts do %>
-
<%= format_vereinfacht_debug_response(@vereinfacht_debug_response) %>
+ <%= if match?({:ok, _}, @vereinfacht_receipts) do %> + <% {_, receipts} = @vereinfacht_receipts %> + <%= if receipts == [] do %> +

{gettext("No receipts")}

+ <% else %> + <% cols = receipt_display_columns(receipts) %> + + + + <%= for {_key, translated_label} <- cols do %> + + <% end %> + + + + <%= for r <- receipts do %> + + <%= for {col_key, _header_key} <- cols do %> + + <% end %> + + <% end %> + +
{translated_label}
{format_receipt_cell(col_key, r[col_key])}
+ <% end %> + <% else %> + <% {:error, reason} = @vereinfacht_receipts %> +

+ {gettext("Error loading receipts: %{reason}", + reason: format_vereinfacht_error(reason) + )} +

+ <% end %>
<% end %> @@ -499,7 +524,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do |> assign_new(:create_cycle_date, fn -> nil end) |> assign_new(:create_cycle_error, fn -> nil end) |> assign_new(:regenerating, fn -> false end) - |> assign_new(:vereinfacht_debug_response, fn -> nil end)} + |> assign_new(:vereinfacht_receipts, fn -> nil end)} end @impl true @@ -1057,23 +1082,138 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do defp format_create_cycle_period(_date, _interval), do: "" - defp format_vereinfacht_debug_response({:ok, body}) when is_map(body) do - Jason.encode!(body, pretty: true) + defp format_vereinfacht_error({:http, status, detail}) when is_binary(detail), + do: "HTTP #{status} – #{detail}" + + defp format_vereinfacht_error({:http, status, _}), do: "HTTP #{status}" + defp format_vereinfacht_error(reason), do: inspect(reason) + + # Ordered receipt columns: {api_key, gettext key for header}. Only columns present in data are shown. + @receipt_column_spec [ + {:amount, "Amount"}, + {:bookingDate, "Booking date"}, + {:createdAt, "Created at"}, + {:receiptType, "Receipt type"}, + {:referenceNumber, "Reference number"}, + {:status, "Status"}, + {:updatedAt, "Updated at"} + ] + + defp receipt_display_columns(receipts) when is_list(receipts) do + keys_in_data = receipts |> Enum.flat_map(&Map.keys/1) |> MapSet.new() + + Enum.filter(@receipt_column_spec, fn {key, _} -> MapSet.member?(keys_in_data, key) end) + |> Enum.map(fn {key, msgid} -> {key, Gettext.gettext(MvWeb.Gettext, msgid)} end) end - defp format_vereinfacht_debug_response({:error, {:http, status, detail}}) - when is_binary(detail) do - "Error: HTTP #{status} – #{detail}" + defp format_receipt_cell(:amount, nil), do: "—" + + defp format_receipt_cell(:amount, val) when is_number(val) do + case Decimal.cast(val) do + {:ok, d} -> MembershipFeeHelpers.format_currency(d) + _ -> to_string(val) + end end - defp format_vereinfacht_debug_response({:error, {:http, status, _}}) do - "Error: HTTP #{status}" + defp format_receipt_cell(:amount, val) when is_binary(val) do + case Decimal.parse(val) do + {d, _} -> MembershipFeeHelpers.format_currency(d) + :error -> val + end end - defp format_vereinfacht_debug_response({:error, reason}) do - "Error: " <> inspect(reason) + defp format_receipt_cell(:amount, val), do: to_string(val) + + defp format_receipt_cell(:status, nil), do: "—" + + defp format_receipt_cell(:status, val) when is_binary(val) do + translate_receipt_status(val) end + defp format_receipt_cell(:status, val), do: translate_receipt_status(to_string(val)) + + defp format_receipt_cell(:receiptType, nil), do: "—" + + defp format_receipt_cell(:receiptType, val) when is_binary(val) do + translate_receipt_type(val) + end + + defp format_receipt_cell(:receiptType, val), do: translate_receipt_type(to_string(val)) + + defp format_receipt_cell(col_key, nil) when col_key in [:bookingDate, :createdAt, :updatedAt], + do: "—" + + defp format_receipt_cell(col_key, val) when col_key in [:bookingDate, :createdAt, :updatedAt] do + format_receipt_date(val) + end + + defp format_receipt_cell(_col_key, val) when is_binary(val), do: val + defp format_receipt_cell(_col_key, val) when is_number(val), do: to_string(val) + + defp format_receipt_cell(_col_key, val) when is_boolean(val), + do: if(val, do: gettext("Yes"), else: gettext("No")) + + defp format_receipt_cell(_col_key, %Date{} = d), do: format_receipt_date_short(d) + defp format_receipt_cell(_col_key, val) when is_map(val) or is_list(val), do: Jason.encode!(val) + defp format_receipt_cell(_col_key, val), do: to_string(val) + + defp format_receipt_date(%Date{} = d), do: format_receipt_date_short(d) + + defp format_receipt_date(val) when is_binary(val) do + case parse_receipt_date(val) do + {:ok, d} -> format_receipt_date_short(d) + _ -> val + end + end + + defp format_receipt_date(val), do: to_string(val) + + # Parses ISO date or datetime string to Date (uses first 10 chars for datetime strings) + defp parse_receipt_date(val) when is_binary(val) do + date_str = if String.length(val) >= 10, do: String.slice(val, 0, 10), else: val + Date.from_iso8601(date_str) + end + + # Format as "12. Dez. 2025" (day. abbreviated month. year) with translated month + defp format_receipt_date_short(%Date{day: day, month: month, year: year}) do + "#{day}. #{receipt_month_abbr(month)} #{year}" + end + + defp receipt_month_abbr(1), do: gettext("Jan.") + defp receipt_month_abbr(2), do: gettext("Feb.") + defp receipt_month_abbr(3), do: gettext("Mar.") + defp receipt_month_abbr(4), do: gettext("Apr.") + defp receipt_month_abbr(5), do: gettext("May") + defp receipt_month_abbr(6), do: gettext("Jun.") + defp receipt_month_abbr(7), do: gettext("Jul.") + defp receipt_month_abbr(8), do: gettext("Aug.") + defp receipt_month_abbr(9), do: gettext("Sep.") + defp receipt_month_abbr(10), do: gettext("Oct.") + defp receipt_month_abbr(11), do: gettext("Nov.") + defp receipt_month_abbr(12), do: gettext("Dec.") + defp receipt_month_abbr(_), do: "" + + # Translate API status values for display (extend as API returns more values) + defp translate_receipt_status("paid"), do: gettext("Paid") + defp translate_receipt_status("unpaid"), do: gettext("Unpaid") + defp translate_receipt_status("suspended"), do: gettext("Suspended") + defp translate_receipt_status("open"), do: gettext("Open") + defp translate_receipt_status("cancelled"), do: gettext("Cancelled") + defp translate_receipt_status("draft"), do: gettext("Draft") + defp translate_receipt_status("incompleted"), do: gettext("Incompleted") + defp translate_receipt_status("completed"), do: gettext("Completed") + defp translate_receipt_status("empty"), do: "—" + defp translate_receipt_status(other), do: other + + # Translate API receipt type values (extend as API returns more values) + defp translate_receipt_type("invoice"), do: gettext("Invoice") + defp translate_receipt_type("receipt"), do: gettext("Receipt") + defp translate_receipt_type("credit_note"), do: gettext("Credit note") + defp translate_receipt_type("credit"), do: gettext("Credit") + defp translate_receipt_type("expense"), do: gettext("Expense") + defp translate_receipt_type("income"), do: gettext("Income") + defp translate_receipt_type(other), do: other + # Helper component for section box attr :title, :string, required: true slot :inner_block, required: true diff --git a/lib/mv_web/translations/member_fields.ex b/lib/mv_web/translations/member_fields.ex index 83ab139..c9b8cad 100644 --- a/lib/mv_web/translations/member_fields.ex +++ b/lib/mv_web/translations/member_fields.ex @@ -29,6 +29,7 @@ defmodule MvWeb.Translations.MemberFields do def label(:postal_code), do: gettext("Postal Code") def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date") def label(:membership_fee_status), do: gettext("Membership Fee Status") + def label(:groups), do: gettext("Groups") # Fallback for unknown fields def label(field) do diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index fb706db..d42857b 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -27,6 +27,7 @@ msgid "Are you sure?" msgstr "Bist du sicher?" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "Attempting to reconnect" msgstr "Verbindung wird wiederhergestellt" @@ -115,11 +116,13 @@ msgid "Show" msgstr "Anzeigen" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "Something went wrong!" msgstr "Etwas ist schiefgelaufen!" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "We can't find the internet" msgstr "Keine Internetverbindung gefunden" @@ -197,6 +200,7 @@ msgstr "Straße" #: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "No" @@ -582,6 +586,16 @@ msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte verifiziere dein Pa msgid "Unable to authenticate with OIDC. Please try again." msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuche es erneut." +#: lib/mv_web/controllers/auth_controller.ex +#, elixir-autogen, elixir-format +msgid "The authentication server is currently unavailable. Please try again later." +msgstr "Der Authentifizierungsserver ist derzeit nicht erreichbar. Bitte versuche es später erneut." + +#: lib/mv_web/controllers/auth_controller.ex +#, elixir-autogen, elixir-format +msgid "Authentication configuration error. Please contact the administrator." +msgstr "Authentifizierungskonfigurationsfehler. Bitte kontaktiere den Administrator." + #: lib/mv_web/controllers/auth_controller.ex #, elixir-autogen, elixir-format msgid "Unable to sign in. Please try again." @@ -2205,6 +2219,7 @@ msgstr "Gruppe erfolgreich gespeichert." #: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Groups" msgstr "Gruppen" @@ -2264,11 +2279,6 @@ msgstr "Nicht berechtigt." msgid "Could not load data fields. Please check your permissions." msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfe deine Berechtigungen." -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Could not load member search. Please try again." -msgstr "Mitgliedersuche konnte nicht geladen werden. Bitte versuchen Sie es erneut." - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Add Member" @@ -2604,6 +2614,16 @@ msgstr "PDF" msgid "Import" msgstr "Import" +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Value type cannot be changed after creation" +msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden." + +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Could not load member list. Please try again." +msgstr "Mitgliederliste konnte nicht geladen werden. Bitte versuche es erneut." + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "API Key" @@ -2619,11 +2639,6 @@ msgstr "API-URL" msgid "Club ID" msgstr "Vereins-ID" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Contact ID: %{id}" -msgstr "Kontakt-ID: %{id}" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "From VEREINFACHT_API_KEY" @@ -2659,11 +2674,6 @@ msgstr "%{count} Mitglied(er) mit Vereinfacht synchronisiert." msgid "Syncing..." msgstr "Synchronisiere..." -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Vereinfacht" -msgstr "Vereinfacht" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Vereinfacht Integration" @@ -2679,16 +2689,6 @@ msgstr "Vereinfacht ist nicht konfiguriert. Bitte API-URL, API-Schlüssel und Ve msgid "View contact in Vereinfacht" msgstr "Kontakt in Vereinfacht anzeigen" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Debug:" -msgstr "Debug:" - -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Load API response" -msgstr "API-Antwort laden" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "%{count} failed" @@ -2730,11 +2730,6 @@ msgstr "Für dieses Mitglied existiert kein Vereinfacht-Kontakt." msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." msgstr "Synchronisieren Sie dieses Mitglied unter Einstellungen (Bereich Vereinfacht) oder speichern Sie das Mitglied erneut, um den Kontakt anzulegen." -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Vereinfacht API response" -msgstr "Vereinfacht" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "(set)" @@ -2771,3 +2766,148 @@ msgstr "Das Postleitzahlenfeld ist erforderlich." msgid "Too Many Attempts." msgstr "Zu viele Versuche." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "App URL (contact view link)" +msgstr "App-URL (Link zur Kontaktansicht)" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "From VEREINFACHT_APP_URL" +msgstr "Aus VEREINFACHT_APP_URL" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Error loading receipts: %{reason}" +msgstr "Belege konnten nicht geladen werden: %{reason}" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "No receipts" +msgstr "Keine Belege" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Show bookings/receipts from Vereinfacht" +msgstr "Buchungen/Belege aus Vereinfacht anzeigen" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht receipts" +msgstr "Vereinfacht-Belege" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Cancelled" +msgstr "Storniert" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Credit" +msgstr "Gutschrift" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Credit note" +msgstr "Gutschrift" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Draft" +msgstr "Entwurf" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Invoice" +msgstr "Rechnung" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Open" +msgstr "Offen" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Receipt" +msgstr "Beleg" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Apr." +msgstr "Apr." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Aug." +msgstr "Aug." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Completed" +msgstr "Abgeschlossen" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Dec." +msgstr "Dez." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Expense" +msgstr "Ausgabe" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Feb." +msgstr "Feb." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Income" +msgstr "Einnahme" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Incompleted" +msgstr "Unvollständig" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jan." +msgstr "Jan." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jul." +msgstr "Jul." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jun." +msgstr "Jun." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Mar." +msgstr "Mär." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "May" +msgstr "Mai" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Nov." +msgstr "Nov." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Oct." +msgstr "Okt." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Sep." +msgstr "Sep." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index ec9563b..ff466ab 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -28,6 +28,7 @@ msgid "Are you sure?" msgstr "" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "Attempting to reconnect" msgstr "" @@ -116,11 +117,13 @@ msgid "Show" msgstr "" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "Something went wrong!" msgstr "" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "We can't find the internet" msgstr "" @@ -198,6 +201,7 @@ msgstr "" #: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "No" @@ -583,6 +587,16 @@ msgstr "" msgid "Unable to authenticate with OIDC. Please try again." msgstr "" +#: lib/mv_web/controllers/auth_controller.ex +#, elixir-autogen, elixir-format +msgid "The authentication server is currently unavailable. Please try again later." +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex +#, elixir-autogen, elixir-format +msgid "Authentication configuration error. Please contact the administrator." +msgstr "" + #: lib/mv_web/controllers/auth_controller.ex #, elixir-autogen, elixir-format msgid "Unable to sign in. Please try again." @@ -2206,6 +2220,7 @@ msgstr "" #: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Groups" msgstr "" @@ -2265,11 +2280,6 @@ msgstr "" msgid "Could not load data fields. Please check your permissions." msgstr "" -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Could not load member search. Please try again." -msgstr "" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Add Member" @@ -2605,6 +2615,16 @@ msgstr "" msgid "Import" msgstr "" +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Value type cannot be changed after creation" +msgstr "" + +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format +msgid "Could not load member list. Please try again." +msgstr "" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "API Key" @@ -2620,11 +2640,6 @@ msgstr "" msgid "Club ID" msgstr "" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Contact ID: %{id}" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "From VEREINFACHT_API_KEY" @@ -2660,11 +2675,6 @@ msgstr "" msgid "Syncing..." msgstr "" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Vereinfacht" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Vereinfacht Integration" @@ -2680,16 +2690,6 @@ msgstr "" msgid "View contact in Vereinfacht" msgstr "" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Debug:" -msgstr "" - -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Load API response" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "%{count} failed" @@ -2730,11 +2730,6 @@ msgstr "" msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." msgstr "" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Vereinfacht API response" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "(set)" @@ -2771,3 +2766,148 @@ msgstr "" msgid "Too Many Attempts." msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "App URL (contact view link)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From VEREINFACHT_APP_URL" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Error loading receipts: %{reason}" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "No receipts" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Show bookings/receipts from Vereinfacht" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht receipts" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Cancelled" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Credit" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Credit note" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Draft" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Invoice" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Open" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Receipt" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Apr." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Aug." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Completed" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Dec." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Expense" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Feb." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Income" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Incompleted" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jan." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jul." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jun." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Mar." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "May" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Nov." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Oct." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Sep." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index ff14c32..e5e0181 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -28,6 +28,7 @@ msgid "Are you sure?" msgstr "" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "Attempting to reconnect" msgstr "" @@ -116,11 +117,13 @@ msgid "Show" msgstr "" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "Something went wrong!" msgstr "" #: lib/mv_web/components/layouts.ex +#: lib/mv_web/components/layouts/root.html.heex #, elixir-autogen, elixir-format msgid "We can't find the internet" msgstr "" @@ -198,6 +201,7 @@ msgstr "" #: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "No" @@ -583,6 +587,16 @@ msgstr "" msgid "Unable to authenticate with OIDC. Please try again." msgstr "" +#: lib/mv_web/controllers/auth_controller.ex +#, elixir-autogen, elixir-format +msgid "The authentication server is currently unavailable. Please try again later." +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex +#, elixir-autogen, elixir-format +msgid "Authentication configuration error. Please contact the administrator." +msgstr "" + #: lib/mv_web/controllers/auth_controller.ex #, elixir-autogen, elixir-format msgid "Unable to sign in. Please try again." @@ -2206,6 +2220,7 @@ msgstr "" #: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Groups" msgstr "" @@ -2265,11 +2280,6 @@ msgstr "" msgid "Could not load data fields. Please check your permissions." msgstr "" -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Could not load member search. Please try again." -msgstr "" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Add Member" @@ -2605,6 +2615,16 @@ msgstr "" msgid "Import" msgstr "" +#: lib/mv_web/live/custom_field_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Value type cannot be changed after creation" +msgstr "" + +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Could not load member list. Please try again." +msgstr "" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "API Key" @@ -2620,11 +2640,6 @@ msgstr "" msgid "Club ID" msgstr "" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Contact ID: %{id}" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "From VEREINFACHT_API_KEY" @@ -2660,11 +2675,6 @@ msgstr "" msgid "Syncing..." msgstr "" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Vereinfacht" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Vereinfacht Integration" @@ -2680,16 +2690,6 @@ msgstr "" msgid "View contact in Vereinfacht" msgstr "" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Debug:" -msgstr "" - -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Load API response" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "%{count} failed" @@ -2730,11 +2730,6 @@ msgstr "" msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." msgstr "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Vereinfacht API response" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "(set)" @@ -2771,3 +2766,148 @@ msgstr "" msgid "Too Many Attempts." msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "App URL (contact view link)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "From VEREINFACHT_APP_URL" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Error loading receipts: %{reason}" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "No receipts" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Show bookings/receipts from Vereinfacht" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Vereinfacht receipts" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Cancelled" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Credit" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Credit note" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Draft" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Invoice" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Open" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Receipt" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Apr." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Aug." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Completed" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Dec." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Expense" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Feb." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Income" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Incompleted" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jan." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jul." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jun." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Mar." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "May" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Nov." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Oct." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Sep." +msgstr "" diff --git a/priv/repo/migrations/20260218190000_add_vereinfacht_app_url.exs b/priv/repo/migrations/20260218190000_add_vereinfacht_app_url.exs new file mode 100644 index 0000000..f10728c --- /dev/null +++ b/priv/repo/migrations/20260218190000_add_vereinfacht_app_url.exs @@ -0,0 +1,15 @@ +defmodule Mv.Repo.Migrations.AddVereinfachtAppUrl do + use Ecto.Migration + + def up do + alter table(:settings) do + add :vereinfacht_app_url, :text + end + end + + def down do + alter table(:settings) do + remove :vereinfacht_app_url + end + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index e96ca6e..6468949 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -3,10 +3,10 @@ # mix run priv/repo/seeds.exs # -alias Mv.Membership alias Mv.Accounts -alias Mv.MembershipFees.MembershipFeeType +alias Mv.Membership alias Mv.MembershipFees.CycleGenerator +alias Mv.MembershipFees.MembershipFeeType require Ash.Query @@ -579,6 +579,39 @@ Enum.with_index(linked_members) end end) +# Create example groups (idempotent: create only if name does not exist) +group_configs = [ + %{name: "Vorstand", description: "Gremium Vorstand"}, + %{name: "Trainer*innen", description: "Alle lizenzierten Trainer*innen"}, + %{name: "Jugend", description: "Jugendbereich"}, + %{name: "Newsletter", description: "Empfänger*innen Newsletter"} +] + +existing_groups = + case Membership.list_groups(actor: admin_user_with_role) do + {:ok, list} -> list + {:error, _} -> [] + end + +existing_names_lower = MapSet.new(existing_groups, &String.downcase(&1.name)) + +seed_groups = + Enum.reduce(group_configs, %{}, fn config, acc -> + name = config.name + + if MapSet.member?(existing_names_lower, String.downcase(name)) do + group = Enum.find(existing_groups, &(String.downcase(&1.name) == String.downcase(name))) + Map.put(acc, name, group) + else + group = + Membership.create_group!(%{name: name, description: config.description}, + actor: admin_user_with_role + ) + + Map.put(acc, name, group) + end + end) + # Create sample custom field values for some members all_members = Ash.read!(Membership.Member, actor: admin_user_with_role) all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role) @@ -587,6 +620,35 @@ all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_rol find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end find_member = fn email -> Enum.find(all_members, &(&1.email == email)) end +# Assign seed members to groups (idempotent: duplicate create_member_group is skipped) +member_group_assignments = [ + {"hans.mueller@example.de", ["Vorstand", "Newsletter"]}, + {"greta.schmidt@example.de", ["Jugend", "Newsletter"]}, + {"friedrich.wagner@example.de", ["Trainer*innen"]}, + {"maria.weber@example.de", ["Newsletter"]}, + {"thomas.klein@example.de", ["Newsletter"]} +] + +Enum.each(member_group_assignments, fn {email, group_names} -> + member = find_member.(email) + + if member do + Enum.each(group_names, fn group_name -> + group = seed_groups[group_name] + + if group do + case Membership.create_member_group( + %{member_id: member.id, group_id: group.id}, + actor: admin_user_with_role + ) do + {:ok, _} -> :ok + {:error, _} -> :ok + end + end + end) + end +end) + # Add custom field values for Hans Müller if hans = find_member.("hans.mueller@example.de") do [ @@ -731,6 +793,7 @@ IO.puts( ) IO.puts(" - Sample members: Hans, Greta, Friedrich") +IO.puts(" - Groups: Vorstand, Trainer*innen, Jugend, Newsletter (with members assigned)") IO.puts( " - Additional users: hans.mueller@example.de, greta.schmidt@example.de, maria.weber@example.de, thomas.klein@example.de" diff --git a/test/membership/custom_field_validation_test.exs b/test/membership/custom_field_validation_test.exs index d0711ad..e642d82 100644 --- a/test/membership/custom_field_validation_test.exs +++ b/test/membership/custom_field_validation_test.exs @@ -8,6 +8,7 @@ defmodule Mv.Membership.CustomFieldValidationTest do - Description length validation (max 500 characters) - Description trimming - Required vs optional fields + - Value type immutability (cannot be changed after creation) """ use Mv.DataCase, async: true @@ -207,4 +208,101 @@ defmodule Mv.Membership.CustomFieldValidationTest do assert [%{field: :value_type}] = changeset.errors end end + + describe "value_type immutability" do + test "rejects attempt to change value_type after creation", %{actor: actor} do + # Create custom field with value_type :string + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string + }) + |> Ash.create(actor: actor) + + original_value_type = custom_field.value_type + assert original_value_type == :string + + # Attempt to update value_type to :integer + assert {:error, %Ash.Error.Invalid{} = error} = + custom_field + |> Ash.Changeset.for_update(:update, %{ + value_type: :integer + }) + |> Ash.update(actor: actor) + + # Verify error message contains expected text + error_message = Exception.message(error) + assert error_message =~ "cannot be changed" or error_message =~ "value_type" + + # Reload and verify value_type remained unchanged + reloaded = Ash.get!(CustomField, custom_field.id, actor: actor) + assert reloaded.value_type == original_value_type + assert reloaded.value_type == :string + end + + test "allows updating other fields while value_type remains unchanged", %{actor: actor} do + # Create custom field with value_type :string + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + description: "Original description" + }) + |> Ash.create(actor: actor) + + original_value_type = custom_field.value_type + assert original_value_type == :string + + # Update other fields (name, description) without touching value_type + {:ok, updated_custom_field} = + custom_field + |> Ash.Changeset.for_update(:update, %{ + name: "updated_name", + description: "Updated description" + }) + |> Ash.update(actor: actor) + + # Verify value_type remained unchanged + assert updated_custom_field.value_type == original_value_type + assert updated_custom_field.value_type == :string + # Verify other fields were updated + assert updated_custom_field.name == "updated_name" + assert updated_custom_field.description == "Updated description" + end + + test "rejects value_type change even when other fields are updated", %{actor: actor} do + # Create custom field with value_type :boolean + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :boolean + }) + |> Ash.create(actor: actor) + + original_value_type = custom_field.value_type + assert original_value_type == :boolean + + # Attempt to update both name and value_type + assert {:error, %Ash.Error.Invalid{} = error} = + custom_field + |> Ash.Changeset.for_update(:update, %{ + name: "updated_name", + value_type: :date + }) + |> Ash.update(actor: actor) + + # Verify error message + error_message = Exception.message(error) + assert error_message =~ "cannot be changed" or error_message =~ "value_type" + + # Reload and verify value_type remained unchanged, but name was not updated either + reloaded = Ash.get!(CustomField, custom_field.id, actor: actor) + assert reloaded.value_type == original_value_type + assert reloaded.value_type == :boolean + assert reloaded.name == "test_field" + end + end end diff --git a/test/mv/config_vereinfacht_test.exs b/test/mv/config_vereinfacht_test.exs index 08b8104..d7a3360 100644 --- a/test/mv/config_vereinfacht_test.exs +++ b/test/mv/config_vereinfacht_test.exs @@ -39,11 +39,22 @@ defmodule Mv.ConfigVereinfachtTest do assert Mv.Config.vereinfacht_contact_view_url("123") == nil end - test "returns URL when API URL is set" do + test "returns app contact view URL when API URL is set (derived app URL)" do + clear_vereinfacht_env() + clear_vereinfacht_app_url_from_settings() set_vereinfacht_env("VEREINFACHT_API_URL", "https://api.example.com/api/v1") assert Mv.Config.vereinfacht_contact_view_url("42") == - "https://api.example.com/api/v1/finance-contacts/42" + "https://app.example.com/en/admin/finances/contacts/42" + after + clear_vereinfacht_env() + end + + test "returns app contact view URL when VEREINFACHT_APP_URL is set" do + set_vereinfacht_env("VEREINFACHT_APP_URL", "https://app.verein.visuel.dev") + + assert Mv.Config.vereinfacht_contact_view_url("abc") == + "https://app.verein.visuel.dev/en/admin/finances/contacts/abc" after clear_vereinfacht_env() end @@ -57,5 +68,16 @@ defmodule Mv.ConfigVereinfachtTest do System.delete_env("VEREINFACHT_API_URL") System.delete_env("VEREINFACHT_API_KEY") System.delete_env("VEREINFACHT_CLUB_ID") + System.delete_env("VEREINFACHT_APP_URL") + end + + defp clear_vereinfacht_app_url_from_settings do + case Mv.Membership.get_settings() do + {:ok, settings} -> + Mv.Membership.update_settings(settings, %{vereinfacht_app_url: nil}) + + _ -> + :ok + end end end diff --git a/test/mv_web/components/layouts/sidebar_test.exs b/test/mv_web/components/layouts/sidebar_test.exs index ff81f24..325f19e 100644 --- a/test/mv_web/components/layouts/sidebar_test.exs +++ b/test/mv_web/components/layouts/sidebar_test.exs @@ -132,7 +132,7 @@ defmodule MvWeb.Layouts.SidebarTest do refute html =~ ~s(role="menuitem") # Footer section should not be rendered - refute html =~ "theme-controller" + refute html =~ "data-theme-toggle" refute html =~ "locale-select" end @@ -253,8 +253,8 @@ defmodule MvWeb.Layouts.SidebarTest do # Check for language selector form assert html =~ ~s(action="/set_locale") - # Check for theme toggle - assert has_class?(html, "theme-controller") + # Check for theme toggle (using data attribute instead of class) + assert html =~ "data-theme-toggle" # Check for user menu/avatar assert has_class?(html, "avatar") @@ -536,7 +536,7 @@ defmodule MvWeb.Layouts.SidebarTest do assert html =~ ~s(role="group") # Footer section - assert html =~ "theme-controller" + assert html =~ "data-theme-toggle" assert html =~ ~s(action="/set_locale") # Check that critical navigation exists (at least /members) @@ -694,8 +694,8 @@ defmodule MvWeb.Layouts.SidebarTest do test "renders theme toggle" do html = render_sidebar(authenticated_assigns()) - # Toggle is always visible - assert has_class?(html, "theme-controller") + # Toggle is always visible (using data attribute instead of class) + assert html =~ "data-theme-toggle" assert html =~ "hero-sun" assert html =~ "hero-moon" end diff --git a/test/mv_web/components/sort_header_component_test.exs b/test/mv_web/components/sort_header_component_test.exs index 6d23ab4..bdde4ae 100644 --- a/test/mv_web/components/sort_header_component_test.exs +++ b/test/mv_web/components/sort_header_component_test.exs @@ -223,7 +223,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end describe "component behavior" do - test "clicking sends sort message to parent", %{conn: conn} do + test "clicking triggers sort event on parent LiveView", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") @@ -232,7 +232,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do |> element("button[phx-value-field='first_name']") |> render_click() - # The component should send a message to the parent LiveView + # The component triggers a "sort" event on the parent LiveView # This is tested indirectly through the URL change in integration tests end diff --git a/test/mv_web/controllers/auth_controller_test.exs b/test/mv_web/controllers/auth_controller_test.exs index 444571b..bac46c8 100644 --- a/test/mv_web/controllers/auth_controller_test.exs +++ b/test/mv_web/controllers/auth_controller_test.exs @@ -2,11 +2,15 @@ defmodule MvWeb.AuthControllerTest do use MvWeb.ConnCase, async: true import Phoenix.LiveViewTest import Phoenix.ConnTest + import ExUnit.CaptureLog # Helper to create an unauthenticated conn (preserves sandbox metadata) defp build_unauthenticated_conn(authenticated_conn) do # Create new conn but preserve sandbox metadata for database access - new_conn = build_conn() + new_conn = + build_conn() + |> init_test_session(%{}) + |> fetch_flash() # Copy sandbox metadata from authenticated conn if authenticated_conn.private[:ecto_sandbox] do @@ -248,4 +252,159 @@ defmodule MvWeb.AuthControllerTest do assert to =~ "/auth/user/password/sign_in_with_token" end + + # OIDC/Rauthy error handling tests + describe "handle_rauthy_failure/2" do + test "Assent.ServerUnreachableError redirects to sign-in with error flash", %{ + conn: authenticated_conn + } do + conn = build_unauthenticated_conn(authenticated_conn) + # Create a mock Assent.ServerUnreachableError struct with required fields + error = %Assent.ServerUnreachableError{ + http_adapter: Assent.HTTPAdapter.Finch, + request_url: "https://auth.example.com/callback?token=secret123", + reason: %Mint.TransportError{reason: :econnrefused} + } + + conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error) + + assert redirected_to(conn) == ~p"/sign-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "The authentication server is currently unavailable. Please try again later." + end + + test "Assent.InvalidResponseError redirects to sign-in with error flash", %{ + conn: authenticated_conn + } do + conn = build_unauthenticated_conn(authenticated_conn) + # Create a mock Assent.InvalidResponseError struct with required field + # InvalidResponseError only has :response field (HTTPResponse struct) + error = %Assent.InvalidResponseError{ + response: %Assent.HTTPAdapter.HTTPResponse{ + status: 400, + headers: [], + body: "invalid_request" + } + } + + conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error) + + assert redirected_to(conn) == ~p"/sign-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Authentication configuration error. Please contact the administrator." + end + + test "unknown reason triggers catch-all and redirects to sign-in with error flash", %{ + conn: authenticated_conn + } do + conn = build_unauthenticated_conn(authenticated_conn) + unknown_reason = :oops + + conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, unknown_reason) + + assert redirected_to(conn) == ~p"/sign-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Unable to authenticate with OIDC. Please try again." + end + end + + # Logging security tests - ensure no sensitive data is logged + describe "failure/3 logging security" do + test "does not log full URL with query params for Assent.ServerUnreachableError", %{ + conn: authenticated_conn + } do + conn = build_unauthenticated_conn(authenticated_conn) + + error = %Assent.ServerUnreachableError{ + http_adapter: Assent.HTTPAdapter.Finch, + request_url: "https://auth.example.com/callback?token=secret123&code=abc456", + reason: %Mint.TransportError{reason: :econnrefused} + } + + log = + capture_log(fn -> + MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error) + end) + + # Should log redacted URL (only scheme and host) + assert log =~ "https://auth.example.com" + # Should NOT log query parameters or tokens + refute log =~ "token=secret123" + refute log =~ "code=abc456" + refute log =~ "callback?token" + end + + test "does not log sensitive data for Assent.InvalidResponseError", %{ + conn: authenticated_conn + } do + conn = build_unauthenticated_conn(authenticated_conn) + + error = %Assent.InvalidResponseError{ + response: %Assent.HTTPAdapter.HTTPResponse{ + status: 400, + headers: [], + body: "invalid_request" + } + } + + log = + capture_log(fn -> + MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error) + end) + + # Should log error type but not full error details + assert log =~ "Authentication failure" + assert log =~ "rauthy" + # Should not log full error struct with inspect + refute log =~ "Assent.InvalidResponseError" + end + + test "does not log full reason for unknown rauthy errors", %{ + conn: authenticated_conn + } do + conn = build_unauthenticated_conn(authenticated_conn) + # Simulate an error that might contain sensitive data + error_with_sensitive_data = %{ + token: "secret_token_123", + url: "https://example.com/callback?access_token=abc123", + error: :something_went_wrong + } + + log = + capture_log(fn -> + MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error_with_sensitive_data) + end) + + # Should log error type but not full error details + assert log =~ "Authentication failure" + assert log =~ "rauthy" + # Should NOT log sensitive data + refute log =~ "secret_token_123" + refute log =~ "access_token=abc123" + refute log =~ "callback?access_token" + end + + test "logs full reason for non-rauthy activities (password auth)", %{ + conn: authenticated_conn + } do + conn = build_unauthenticated_conn(authenticated_conn) + + reason = %AshAuthentication.Errors.AuthenticationFailed{ + caused_by: %Ash.Error.Forbidden{errors: []} + } + + log = + capture_log(fn -> + MvWeb.AuthController.failure(conn, {:password, :sign_in}, reason) + end) + + # For non-rauthy activities, full reason is safe to log + assert log =~ "Authentication failure" + assert log =~ "password" + assert log =~ "AuthenticationFailed" + end + end end diff --git a/test/mv_web/live/group_live/form_test.exs b/test/mv_web/live/group_live/form_test.exs index 9169dfe..8934e85 100644 --- a/test/mv_web/live/group_live/form_test.exs +++ b/test/mv_web/live/group_live/form_test.exs @@ -19,6 +19,7 @@ defmodule MvWeb.GroupLive.FormTest do test "form renders with empty fields", %{conn: conn} do {:ok, view, html} = live(conn, "/groups/new") + # OR-chain for i18n (Create Group / Gruppe erstellen) assert html =~ gettext("Create Group") or html =~ "create" or html =~ "Gruppe erstellen" assert has_element?(view, "form") end @@ -65,6 +66,7 @@ defmodule MvWeb.GroupLive.FormTest do |> form("#group-form", group: form_data) |> render_submit() + # OR-chain for i18n (required/erforderlich) and validation message wording assert html =~ gettext("required") or html =~ "name" or html =~ "error" or html =~ "erforderlich" end @@ -80,6 +82,7 @@ defmodule MvWeb.GroupLive.FormTest do |> form("#group-form", group: form_data) |> render_submit() + # OR-chain for i18n (length/Länge) and validation message assert html =~ "100" or html =~ "length" or html =~ "error" or html =~ "Länge" end @@ -98,6 +101,7 @@ defmodule MvWeb.GroupLive.FormTest do |> form("#group-form", group: form_data) |> render_submit() + # OR-chain for i18n (length/Länge) and validation message assert html =~ "500" or html =~ "length" or html =~ "error" or html =~ "Länge" end @@ -116,6 +120,7 @@ defmodule MvWeb.GroupLive.FormTest do |> render_submit() # Check for a validation error on the name field in a robust way + # OR-chain for i18n and validation message (already taken) assert html =~ "name" or html =~ gettext("has already been taken") end @@ -131,6 +136,7 @@ defmodule MvWeb.GroupLive.FormTest do |> form("#group-form", group: form_data) |> render_submit() + # OR-chain for i18n (error/Fehler, invalid/ungültig) assert html =~ "error" or html =~ "invalid" or html =~ "Fehler" or html =~ "ungültig" end end @@ -196,6 +202,7 @@ defmodule MvWeb.GroupLive.FormTest do |> form("#group-form", group: form_data) |> render_submit() + # OR-chain for i18n (already taken / bereits vergeben) and validation wording assert html =~ "already" or html =~ "taken" or html =~ "exists" or html =~ "error" or html =~ "bereits" or html =~ "vergeben" end @@ -205,7 +212,7 @@ defmodule MvWeb.GroupLive.FormTest do {:ok, _view, html} = live(conn, "/groups/#{group.slug}/edit") - # Slug should not be in form (it's immutable) + # Slug should not be in form (it's immutable); regex for input element refute html =~ ~r/slug.*input/i or html =~ ~r/input.*slug/i end end diff --git a/test/mv_web/live/group_live/index_test.exs b/test/mv_web/live/group_live/index_test.exs index b972095..751b5c6 100644 --- a/test/mv_web/live/group_live/index_test.exs +++ b/test/mv_web/live/group_live/index_test.exs @@ -40,13 +40,14 @@ defmodule MvWeb.GroupLive.IndexTest do assert html =~ "Test Group" assert html =~ "Test description" - # Member count should be displayed (0 for empty group) + # OR-chain for i18n (Members/Mitglieder) and alternate copy for count assert html =~ "0" or html =~ gettext("Members") or html =~ "Mitglieder" end test "displays 'Create Group' button for admin users", %{conn: conn} do {:ok, _view, html} = live(conn, "/groups") + # OR-chain for i18n (Create Group / Gruppe erstellen) and alternate wording assert html =~ gettext("Create Group") or html =~ "create" or html =~ "new" or html =~ "Gruppe erstellen" end @@ -54,7 +55,7 @@ defmodule MvWeb.GroupLive.IndexTest do test "displays empty state when no groups exist", %{conn: conn} do {:ok, _view, html} = live(conn, "/groups") - # Should show empty state or empty list message + # OR-chain for i18n (No groups / Keine Gruppen) and alternate empty state copy assert html =~ gettext("No groups") or html =~ "0" or html =~ "empty" or html =~ "Keine Gruppen" end @@ -76,6 +77,7 @@ defmodule MvWeb.GroupLive.IndexTest do {:ok, _view, html} = live(conn, "/groups") + # Long description may be truncated in UI assert html =~ long_description or html =~ String.slice(long_description, 0, 100) end end @@ -109,7 +111,7 @@ defmodule MvWeb.GroupLive.IndexTest do # Should be able to see groups assert html =~ gettext("Groups") - # Should NOT see create button + # Read-only must not see create button (OR for i18n) refute html =~ gettext("Create Group") or html =~ "create" end end @@ -177,7 +179,7 @@ defmodule MvWeb.GroupLive.IndexTest do final_count = Agent.get(query_count_agent, & &1) :telemetry.detach(handler_id) - # Member count should be displayed (should be 2) + # OR-chain for i18n (Members/Mitglieder) and count display assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder" # Verify query count is reasonable (member count should be calculated efficiently) diff --git a/test/mv_web/live/group_live/integration_test.exs b/test/mv_web/live/group_live/integration_test.exs index a98de85..96e9031 100644 --- a/test/mv_web/live/group_live/integration_test.exs +++ b/test/mv_web/live/group_live/integration_test.exs @@ -62,7 +62,7 @@ defmodule MvWeb.GroupLive.IntegrationTest do assert html =~ "Updated Workflow Test Group" assert html =~ "Updated description" - # Slug should remain unchanged + # OR-chain: slug may appear as UUID or normalized slug in copy assert html =~ original_slug or html =~ "workflow-test-group" end @@ -101,7 +101,7 @@ defmodule MvWeb.GroupLive.IntegrationTest do # View group via slug {:ok, _view, html} = live(conn, "/groups/#{group.slug}") - # Member count should be 2 + # OR-chain for i18n (Members/Mitglieder); member names may be first or last assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder" assert html =~ member1.first_name or html =~ member1.last_name assert html =~ member2.first_name or html =~ member2.last_name diff --git a/test/mv_web/live/group_live/show_accessibility_test.exs b/test/mv_web/live/group_live/show_accessibility_test.exs index 97ce469..fc63551 100644 --- a/test/mv_web/live/group_live/show_accessibility_test.exs +++ b/test/mv_web/live/group_live/show_accessibility_test.exs @@ -22,12 +22,13 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do |> element("button", "Add Member") |> render_click() - html = render(view) - - # Search input should have proper ARIA attributes - assert html =~ ~r/aria-label/ || - html =~ ~r/aria-autocomplete/ || - html =~ ~r/role=["']combobox["']/ + # OR-chain: at least one of these ARIA/role attributes must be present + assert has_element?(view, "[data-testid=group-show-member-search-input][aria-label]") or + has_element?( + view, + "[data-testid=group-show-member-search-input][aria-autocomplete]" + ) or + has_element?(view, "[data-testid=group-show-member-search-input][role=combobox]") end test "search input has correct aria-label and aria-autocomplete attributes", %{conn: conn} do @@ -35,16 +36,14 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Open inline input view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() - html = render(view) - - # Search input should have ARIA attributes - assert html =~ ~r/aria-label.*[Ss]earch.*member/ || - html =~ ~r/aria-autocomplete=["']list["']/ + assert has_element?( + view, + "[data-testid=group-show-member-search-input][aria-autocomplete=list]" + ) end test "remove button has aria-label with tooltip text", %{conn: conn} do @@ -67,11 +66,7 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - html = render(view) - - # Remove button should have aria-label - assert html =~ ~r/aria-label.*[Rr]emove/ || - html =~ ~r/aria-label.*member/i + assert has_element?(view, "[data-testid=group-show-remove-member][aria-label]") end test "add button has correct aria-label", %{conn: conn} do @@ -79,16 +74,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Open inline input view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() - html = render(view) - - # Add button should have aria-label - assert html =~ ~r/aria-label.*[Aa]dd/ || - html =~ ~r/button.*[Aa]dd/ + assert has_element?(view, "[data-testid=group-show-add-selected-members-btn][aria-label]") end end @@ -100,16 +90,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Open inline input view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() - html = render(view) - - # Inline add member area should have focusable elements - assert html =~ ~r/input|button/ || - html =~ "#member-search-input" + assert has_element?(view, "[data-testid=group-show-member-search-input]") end test "inline input can be closed", %{conn: conn} do @@ -117,17 +102,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Open inline input view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() - assert has_element?(view, "#member-search-input") - - # Click Add Member button again to close (or add a member to close it) - # For now, we verify the input is visible when opened - html = render(view) - assert html =~ "#member-search-input" || has_element?(view, "#member-search-input") + assert has_element?(view, "[data-testid=group-show-member-search-input]") end test "enter/space activates buttons when focused", %{conn: conn} do @@ -148,17 +127,14 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Open inline input view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() - # Select member view - |> element("#member-search-input") + |> element("[data-testid=group-show-member-search-input]") |> render_focus() - # phx-change is on the form, so we need to trigger it via the form view |> element("form[phx-change='search_members']") |> render_change(%{"member_search" => "Bob"}) @@ -167,14 +143,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do |> element("[data-member-id='#{member.id}']") |> render_click() - # Add button should be enabled and clickable view - |> element("button[phx-click='add_selected_members']") + |> element("[data-testid=group-show-add-selected-members-btn]") |> render_click() - # Should succeed (member should appear in list) - html = render(view) - assert html =~ "Bob" + assert has_element?(view, "[data-testid=group-show-members-table]", "Bob") end test "focus management: focus is set to input when opened", %{conn: conn} do @@ -184,16 +157,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Open inline input view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() - html = render(view) - - # Input should be visible and focusable - assert html =~ "#member-search-input" || - html =~ ~r/autofocus|tabindex/ + assert has_element?(view, "[data-testid=group-show-member-search-input]") end end @@ -203,16 +171,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Open inline input view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() - html = render(view) - - # Input should have aria-label - assert html =~ ~r/aria-label.*[Ss]earch.*member/ || - html =~ ~r/aria-label/ + assert has_element?(view, "[data-testid=group-show-member-search-input][aria-label]") end test "search results are properly announced", %{conn: conn} do @@ -231,27 +194,20 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Open inline input view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() - # Search view - |> element("#member-search-input") + |> element("[data-testid=group-show-member-search-input]") |> render_focus() - # phx-change is on the form, so we need to trigger it via the form view |> element("form[phx-change='search_members']") |> render_change(%{"member_search" => "Charlie"}) - html = render(view) - - # Search results should have proper ARIA attributes - assert html =~ ~r/role=["']listbox["']/ || - html =~ ~r/role=["']option["']/ || - html =~ "Charlie" + assert has_element?(view, "#member-dropdown[role=listbox]") + assert has_element?(view, "#member-dropdown", "Charlie") end test "flash messages are properly announced", %{conn: conn} do @@ -270,16 +226,14 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Add member view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() view - |> element("#member-search-input") + |> element("[data-testid=group-show-member-search-input]") |> render_focus() - # phx-change is on the form, so we need to trigger it via the form view |> element("form[phx-change='search_members']") |> render_change(%{"member_search" => "David"}) @@ -289,13 +243,10 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do |> render_click() view - |> element("button[phx-click='add_selected_members']") + |> element("[data-testid=group-show-add-selected-members-btn]") |> render_click() - html = render(view) - - # Member should appear in list (no flash message) - assert html =~ "David" + assert has_element?(view, "[data-testid=group-show-members-table]", "David") end end end diff --git a/test/mv_web/live/group_live/show_add_member_test.exs b/test/mv_web/live/group_live/show_add_member_test.exs index 783db9d..0e1af32 100644 --- a/test/mv_web/live/group_live/show_add_member_test.exs +++ b/test/mv_web/live/group_live/show_add_member_test.exs @@ -34,9 +34,8 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do select_member(view, member) add_selected(view) - html = render(view) - assert html =~ "Alice" - assert html =~ "Johnson" + assert has_element?(view, "[data-testid=group-show-members-table]", "Alice") + assert has_element?(view, "[data-testid=group-show-members-table]", "Johnson") end test "member is successfully added to group (verified in list)", %{conn: conn} do @@ -55,16 +54,14 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Open inline input and add member view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() view - |> element("#member-search-input") + |> element("[data-testid=group-show-member-search-input]") |> render_focus() - # phx-change is on the form, so we need to trigger it via the form view |> element("form[phx-change='search_members']") |> render_change(%{"member_search" => "Bob"}) @@ -74,14 +71,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do |> render_click() view - |> element("button[phx-click='add_selected_members']") + |> element("[data-testid=group-show-add-selected-members-btn]") |> render_click() - html = render(view) - - # Verify member appears in group list (no success flash message) - assert html =~ "Bob" - assert html =~ "Smith" + assert has_element?(view, "[data-testid=group-show-members-table]", "Bob") + assert has_element?(view, "[data-testid=group-show-members-table]", "Smith") end test "group member list updates automatically after add", %{conn: conn} do @@ -98,21 +92,18 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do actor: system_actor ) - {:ok, view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Initially member should NOT be in list - refute html =~ "Charlie" + refute has_element?(view, "[data-testid=group-show-members-table]", "Charlie") - # Add member view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() view - |> element("#member-search-input") + |> element("[data-testid=group-show-member-search-input]") |> render_focus() - # phx-change is on the form, so we need to trigger it via the form view |> element("form[phx-change='search_members']") |> render_change(%{"member_search" => "Charlie"}) @@ -122,13 +113,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do |> render_click() view - |> element("button[phx-click='add_selected_members']") + |> element("[data-testid=group-show-add-selected-members-btn]") |> render_click() - # Member should now appear in list - html = render(view) - assert html =~ "Charlie" - assert html =~ "Brown" + assert has_element?(view, "[data-testid=group-show-members-table]", "Charlie") + assert has_element?(view, "[data-testid=group-show-members-table]", "Brown") end test "member count updates automatically after add", %{conn: conn} do @@ -152,11 +141,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do # Add member view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() view - |> element("#member-search-input") + |> element("[data-testid=group-show-member-search-input]") |> render_focus() # phx-change is on the form, so we need to trigger it via the form @@ -169,7 +158,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do |> render_click() view - |> element("button[phx-click='add_selected_members']") + |> element("[data-testid=group-show-add-selected-members-btn]") |> render_click() # Count should have increased @@ -196,14 +185,14 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do # Open inline input view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() - assert has_element?(view, "#member-search-input") + assert has_element?(view, "[data-testid=group-show-member-search-input]") # Add member view - |> element("#member-search-input") + |> element("[data-testid=group-show-member-search-input]") |> render_focus() # phx-change is on the form, so we need to trigger it via the form @@ -216,11 +205,10 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do |> render_click() view - |> element("button[phx-click='add_selected_members']") + |> element("[data-testid=group-show-add-selected-members-btn]") |> render_click() - # Inline input should be closed (Add Member button should be visible again) - refute has_element?(view, "#member-search-input") + refute has_element?(view, "[data-testid=group-show-member-search-input]") end test "Cancel button closes inline add member area without adding", %{conn: conn} do @@ -229,7 +217,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") open_add_member(view) - assert has_element?(view, "#member-search-input") + assert has_element?(view, "[data-testid=group-show-member-search-input]") assert has_element?(view, "button[phx-click='hide_add_member_input']") cancel_add_member(view) @@ -263,7 +251,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do # Try to add same member again view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() # Member should not appear in search (filtered out) @@ -281,12 +269,12 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do |> render_click() view - |> element("button", "Add") + |> element("[data-testid=group-show-add-selected-members-btn]") |> render_click() - # Should show error + # OR-chain for i18n and alternate error wording (already in group / duplicate) html = render(view) - assert html =~ gettext("already in group") || html =~ ~r/already.*group|duplicate/i + assert html =~ gettext("already in group") or html =~ ~r/already.*group|duplicate/i end end @@ -300,7 +288,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do # Open inline input view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() # Try to add with invalid member ID (if possible) @@ -331,11 +319,10 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do # Open inline input view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() - # Inline input should be open - assert has_element?(view, "#member-search-input") + assert has_element?(view, "[data-testid=group-show-member-search-input]") # If error occurs, inline input should remain open # (Implementation will handle this) @@ -348,11 +335,10 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do # Open inline input view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() - # Add button should be disabled - assert has_element?(view, "button[phx-click='add_selected_members'][disabled]") + assert has_element?(view, "[data-testid=group-show-add-selected-members-btn][disabled]") end end @@ -375,11 +361,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do # Add member to empty group view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() view - |> element("#member-search-input") + |> element("[data-testid=group-show-member-search-input]") |> render_focus() # phx-change is on the form, so we need to trigger it via the form @@ -392,12 +378,10 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do |> render_click() view - |> element("button[phx-click='add_selected_members']") + |> element("[data-testid=group-show-add-selected-members-btn]") |> render_click() - # Member should be added - html = render(view) - assert html =~ "Henry" + assert has_element?(view, "[data-testid=group-show-members-table]", "Henry") end test "add works when member is already in other groups", %{conn: conn} do @@ -424,11 +408,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do # Add same member to group2 (should work) view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() view - |> element("#member-search-input") + |> element("[data-testid=group-show-member-search-input]") |> render_focus() # phx-change is on the form, so we need to trigger it via the form @@ -441,12 +425,10 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do |> render_click() view - |> element("button[phx-click='add_selected_members']") + |> element("[data-testid=group-show-add-selected-members-btn]") |> render_click() - # Member should be added to group2 - html = render(view) - assert html =~ "Isabel" + assert has_element?(view, "[data-testid=group-show-members-table]", "Isabel") end end diff --git a/test/mv_web/live/group_live/show_add_remove_members_test.exs b/test/mv_web/live/group_live/show_add_remove_members_test.exs index c014372..047205d 100644 --- a/test/mv_web/live/group_live/show_add_remove_members_test.exs +++ b/test/mv_web/live/group_live/show_add_remove_members_test.exs @@ -22,18 +22,18 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do test "Add Member button is visible for users with :update permission", %{conn: conn} do group = Fixtures.group_fixture() - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - assert html =~ gettext("Add Member") or html =~ "Add Member" + assert has_element?(view, "button[phx-click='show_add_member_input']") end @tag role: :read_only test "Add Member button is NOT visible for users without :update permission", %{conn: conn} do group = Fixtures.group_fixture() - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - refute html =~ gettext("Add Member") + refute has_element?(view, "button[phx-click='show_add_member_input']") end test "Add Member button is positioned above member table", %{conn: conn} do @@ -61,11 +61,7 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Remove button should exist (can be icon button with trash icon) - html = render(view) - - assert html =~ "Remove" or html =~ "remove" or html =~ "trash" or - html =~ ~r/hero-trash|hero-x-mark/ + assert has_element?(view, "[data-testid=group-show-remove-member]") end @tag role: :read_only @@ -78,10 +74,9 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do actor: system_actor ) - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Remove button should NOT exist (check for trash icon or remove button specifically) - refute html =~ "hero-trash" or html =~ ~r/]*remove_member/ + refute has_element?(view, "[data-testid=group-show-remove-member]") end end @@ -110,10 +105,7 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do |> element("button", gettext("Add Member")) |> render_click() - html = render(view) - - assert html =~ gettext("Search for a member...") || - html =~ ~r/search.*member/i + assert has_element?(view, "[data-testid=group-show-member-search-input]") end test "Add button (plus icon) is disabled until member selected", %{conn: conn} do @@ -121,15 +113,11 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Open inline input view - |> element("button", gettext("Add Member")) + |> element("button[phx-click='show_add_member_input']") |> render_click() - html = render(view) - # Add button should exist and be disabled initially - assert has_element?(view, "button[phx-click='add_selected_members'][disabled]") || - html =~ ~r/disabled/ + assert has_element?(view, "[data-testid=group-show-add-selected-members-btn][disabled]") end end end diff --git a/test/mv_web/live/group_live/show_authorization_test.exs b/test/mv_web/live/group_live/show_authorization_test.exs index f121b36..4bc2a49 100644 --- a/test/mv_web/live/group_live/show_authorization_test.exs +++ b/test/mv_web/live/group_live/show_authorization_test.exs @@ -52,8 +52,7 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do |> render_click() # Should succeed (admin has :update permission, member should appear in list) - html = render(view) - assert html =~ "Alice" + assert has_element?(view, "[data-testid=group-show-members-table]", "Alice") end @tag role: :read_only @@ -78,9 +77,7 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do # Note: If button is hidden, we can't click it, but we test the event handler # by trying to send the event directly if possible - # For now, we verify that the button is not visible - html = render(view) - refute html =~ "Add Member" + refute has_element?(view, "button[phx-click='show_add_member_input']") end test "remove member event handler checks :update permission", %{conn: conn} do @@ -103,14 +100,11 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Remove member (should succeed for admin) view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") + |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> render_click() - # Should succeed (member should no longer be in list) - html = render(view) - refute html =~ "Charlie" + refute has_element?(view, "[data-testid=group-show-members-table]", "Charlie") end @tag role: :read_only @@ -134,11 +128,7 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Remove button should not be visible - html = render(view) - - # Read-only user should NOT see Remove button (check for trash icon or remove button specifically) - refute html =~ "hero-trash" or html =~ ~r/]*remove_member/ + refute has_element?(view, "[data-testid=group-show-remove-member]") end test "error flash message on unauthorized access", %{conn: conn} do @@ -174,10 +164,10 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do actor: system_actor ) - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Admin should see buttons - assert html =~ "Add Member" || html =~ "Remove" + assert has_element?(view, "button[phx-click='show_add_member_input']") + assert has_element?(view, "[data-testid=group-show-remove-member]") end @tag role: :read_only @@ -185,10 +175,9 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do _system_actor = Mv.Helpers.SystemActor.get_system_actor() group = Fixtures.group_fixture() - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Read-only user should NOT see Add Member button - refute html =~ "Add Member" + refute has_element?(view, "button[phx-click='show_add_member_input']") end @tag role: :read_only @@ -210,21 +199,18 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do actor: system_actor ) - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Read-only user should NOT see Remove button (check for trash icon or remove button specifically) - refute html =~ "hero-trash" or html =~ ~r/]*remove_member/ + refute has_element?(view, "[data-testid=group-show-remove-member]") end @tag role: :read_only test "inline add member area cannot be opened for unauthorized users", %{conn: conn} do group = Fixtures.group_fixture() - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Inline input should not be accessible (button hidden) - refute html =~ "Add Member" - refute html =~ "#member-search-input" + refute has_element?(view, "button[phx-click='show_add_member_input']") end end diff --git a/test/mv_web/live/group_live/show_integration_test.exs b/test/mv_web/live/group_live/show_integration_test.exs index 0a82be8..13f8e5d 100644 --- a/test/mv_web/live/group_live/show_integration_test.exs +++ b/test/mv_web/live/group_live/show_integration_test.exs @@ -305,9 +305,8 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do |> render_click() # Both members should be in list - html = render(view) - assert html =~ "Frank" - assert html =~ "Grace" + assert has_element?(view, "[data-testid=group-show-members-table]", "Frank") + assert has_element?(view, "[data-testid=group-show-members-table]", "Grace") end test "multiple members can be removed sequentially", %{conn: conn} do @@ -343,11 +342,11 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do actor: system_actor ) - {:ok, view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") # Both should be in list initially - assert html =~ "Henry" - assert html =~ "Isabel" + assert has_element?(view, "[data-testid=group-show-members-table]", "Henry") + assert has_element?(view, "[data-testid=group-show-members-table]", "Isabel") # Remove first member view @@ -360,9 +359,8 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do |> render_click() # Both should be removed - html = render(view) - refute html =~ "Henry" - refute html =~ "Isabel" + refute has_element?(view, "[data-testid=group-show-members-table]", "Henry") + refute has_element?(view, "[data-testid=group-show-members-table]", "Isabel") end test "add and remove can be mixed", %{conn: conn} do @@ -424,9 +422,8 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do |> render_click() # Only member2 should remain - html = render(view) - refute html =~ "Jack" - assert html =~ "Kate" + refute has_element?(view, "[data-testid=group-show-members-table]", "Jack") + assert has_element?(view, "[data-testid=group-show-members-table]", "Kate") end end end diff --git a/test/mv_web/live/group_live/show_member_search_test.exs b/test/mv_web/live/group_live/show_member_search_test.exs index 75d1803..ed8a55d 100644 --- a/test/mv_web/live/group_live/show_member_search_test.exs +++ b/test/mv_web/live/group_live/show_member_search_test.exs @@ -34,21 +34,16 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Open inline input view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() - # Type exact name - # phx-change is on the form, so we need to trigger it via the form view |> element("form[phx-change='search_members']") |> render_change(%{"member_search" => "Jonathan"}) - html = render(view) - - assert html =~ "Jonathan" - assert html =~ "Smith" + assert has_element?(view, "#member-dropdown", "Jonathan") + assert has_element?(view, "#member-dropdown", "Smith") end test "search finds member by partial name (fuzzy)", %{conn: conn} do @@ -68,22 +63,16 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Open inline input view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() - # Type partial name - # phx-change is on the form, so we need to trigger it via the form view |> element("form[phx-change='search_members']") |> render_change(%{"member_search" => "Jon"}) - html = render(view) - - # Fuzzy search should find Jonathan - assert html =~ "Jonathan" - assert html =~ "Smith" + assert has_element?(view, "#member-dropdown", "Jonathan") + assert has_element?(view, "#member-dropdown", "Smith") end test "search finds member by email", %{conn: conn} do @@ -103,22 +92,17 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Open inline input view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() - # Search by email - # phx-change is on the form, so we need to trigger it via the form view |> element("form[phx-change='search_members']") |> render_change(%{"member_search" => "alice.johnson"}) - html = render(view) - - assert html =~ "Alice" - assert html =~ "Johnson" - assert html =~ "alice.johnson@example.com" + assert has_element?(view, "#member-dropdown", "Alice") + assert has_element?(view, "#member-dropdown", "Johnson") + assert has_element?(view, "#member-dropdown", "alice.johnson@example.com") end test "dropdown shows member name and email", %{conn: conn} do @@ -153,11 +137,9 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do |> element("form[phx-change='search_members']") |> render_change(%{"member_search" => "Bob"}) - html = render(view) - - assert html =~ "Bob" - assert html =~ "Williams" - assert html =~ "bob@example.com" + assert has_element?(view, "#member-dropdown", "Bob") + assert has_element?(view, "#member-dropdown", "Williams") + assert has_element?(view, "#member-dropdown", "bob@example.com") end test "ComboBox hook works (focus opens dropdown)", %{conn: conn} do @@ -177,20 +159,15 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Open inline input view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() - # Focus input view - |> element("#member-search-input") + |> element("[data-testid=group-show-member-search-input]") |> render_focus() - html = render(view) - - # Dropdown should be visible - assert html =~ ~r/role="listbox"/ || html =~ "listbox" + assert has_element?(view, "#member-dropdown[role=listbox]") end end @@ -228,21 +205,16 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Open inline input view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() - # Search for "David" - # phx-change is on the form, so we need to trigger it via the form view |> element("form[phx-change='search_members']") |> render_change(%{"member_search" => "David"}) - # Assert only on dropdown (available members), not the members table - dropdown_html = view |> element("#member-dropdown") |> render() - assert dropdown_html =~ "Anderson" - refute dropdown_html =~ "Miller" + assert has_element?(view, "#member-dropdown", "Anderson") + refute has_element?(view, "#member-dropdown", "Miller") end test "search filters correctly when group has many members", %{conn: conn} do @@ -280,23 +252,18 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Open inline input view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() - # Search - # phx-change is on the form, so we need to trigger it via the form view |> element("form[phx-change='search_members']") |> render_change(%{"member_search" => "Available"}) - # Assert only on dropdown (available members), not the members table - dropdown_html = view |> element("#member-dropdown") |> render() - assert dropdown_html =~ "Available" - assert dropdown_html =~ "Member" - refute dropdown_html =~ "Member1" - refute dropdown_html =~ "Member2" + assert has_element?(view, "#member-dropdown", "Available") + assert has_element?(view, "#member-dropdown", "Member") + refute has_element?(view, "#member-dropdown", "Member1") + refute has_element?(view, "#member-dropdown", "Member2") end test "search shows no results when all available members are already in group", %{conn: conn} do @@ -321,18 +288,14 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Open inline input view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() - # Search - # phx-change is on the form, so we need to trigger it via the form view |> element("form[phx-change='search_members']") |> render_change(%{"member_search" => "Only"}) - # When no available members, dropdown is not rendered (length(@available_members) == 0) refute has_element?(view, "#member-dropdown") end end diff --git a/test/mv_web/live/group_live/show_remove_member_test.exs b/test/mv_web/live/group_live/show_remove_member_test.exs index d081b50..2b47941 100644 --- a/test/mv_web/live/group_live/show_remove_member_test.exs +++ b/test/mv_web/live/group_live/show_remove_member_test.exs @@ -31,19 +31,15 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do actor: system_actor ) - {:ok, view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Member should be in list initially - assert html =~ "Alice" + assert has_element?(view, "[data-testid=group-show-members-table]", "Alice") - # Click Remove button view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") + |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> render_click() - # Member should no longer be in list (no success flash message) - html = render(view) - refute html =~ "Alice" + refute has_element?(view, "[data-testid=group-show-members-table]", "Alice") end test "member is successfully removed from group (verified in list)", %{conn: conn} do @@ -64,20 +60,15 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do actor: system_actor ) - {:ok, view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Member should be in list initially - assert html =~ "Bob" + assert has_element?(view, "[data-testid=group-show-members-table]", "Bob") - # Remove member view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") + |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> render_click() - html = render(view) - - # Member should no longer be in list (no success flash message) - refute html =~ "Bob" + refute has_element?(view, "[data-testid=group-show-members-table]", "Bob") end test "group member list updates automatically after remove", %{conn: conn} do @@ -98,19 +89,15 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do actor: system_actor ) - {:ok, view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Member should be in list initially - assert html =~ "Charlie" + assert has_element?(view, "[data-testid=group-show-members-table]", "Charlie") - # Remove member view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") + |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> render_click() - # Member should no longer be in list - html = render(view) - refute html =~ "Charlie" + refute has_element?(view, "[data-testid=group-show-members-table]", "Charlie") end test "member count updates automatically after remove", %{conn: conn} do @@ -158,7 +145,7 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do # Extract first member ID from the rendered HTML or use a different approach # Since we have member1 and member2, we can target member1 specifically view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member1.id}']") + |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member1.id}']") |> render_click() # Count should have decreased @@ -187,17 +174,11 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Click Remove - should remove immediately without confirmation view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") + |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> render_click() - # No confirmation dialog should appear (immediate removal) - # This is verified by the member being removed without any dialog - - # Member should be removed - html = render(view) - refute html =~ "Frank" + refute has_element?(view, "[data-testid=group-show-members-table]", "Frank") end end @@ -220,23 +201,17 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do actor: system_actor ) - {:ok, view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Member should be in list - assert html =~ "Grace" + assert has_element?(view, "[data-testid=group-show-members-table]", "Grace") - # Remove last member view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") + |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> render_click() - # Group should show empty state + assert has_element?(view, "[data-testid=group-show-no-members]") + html = render(view) - - assert html =~ gettext("No members in this group") || - html =~ ~r/no.*members/i - - # Count should be 0 count = extract_member_count(html) assert count == 0 end @@ -269,18 +244,14 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do {:ok, view, _html} = live(conn, "/groups/#{group1.slug}") - # Remove from group1 view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") + |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> render_click() - # Member should be removed from group1 - html = render(view) - refute html =~ "Henry" + refute has_element?(view, "[data-testid=group-show-members-table]", "Henry") - # Verify member is still in group2 - {:ok, _view2, html2} = live(conn, "/groups/#{group2.slug}") - assert html2 =~ "Henry" + {:ok, view2, _html2} = live(conn, "/groups/#{group2.slug}") + assert has_element?(view2, "[data-testid=group-show-members-table]", "Henry") end test "remove is idempotent (no error if member already removed)", %{conn: conn} do @@ -303,22 +274,15 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Remove member first time view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") + |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> render_click() - # Try to remove again (should not error, just be idempotent) - # Note: Implementation should handle this gracefully - # If button is still visible somehow, try to click again - html = render(view) - - if html =~ "Isabel" do + if has_element?(view, "[data-testid=group-show-members-table]", "Isabel") do view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") + |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> render_click() - # Should not crash assert render(view) end end diff --git a/test/mv_web/live/group_live/show_test.exs b/test/mv_web/live/group_live/show_test.exs index bef234b..07a0c98 100644 --- a/test/mv_web/live/group_live/show_test.exs +++ b/test/mv_web/live/group_live/show_test.exs @@ -22,34 +22,33 @@ defmodule MvWeb.GroupLive.ShowTest do test "page renders successfully", %{conn: conn} do group = Fixtures.group_fixture() - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - assert html =~ group.name + assert has_element?(view, "h1", group.name) end test "displays group name", %{conn: conn} do group = Fixtures.group_fixture(%{name: "Test Group Name"}) - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - assert html =~ "Test Group Name" + assert has_element?(view, "h1", "Test Group Name") end test "displays group description when present", %{conn: conn} do group = Fixtures.group_fixture(%{description: "This is a test description"}) - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - assert html =~ "This is a test description" + assert has_element?(view, "p", "This is a test description") end test "displays member count", %{conn: conn} do group = Fixtures.group_fixture() - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Member count should be displayed (might be 0 or more) - assert html =~ "0" or html =~ gettext("Members") or html =~ "member" or html =~ "Mitglied" + assert has_element?(view, "[data-testid=group-show-member-count]") end test "displays list of members in group", %{conn: conn} do @@ -67,26 +66,26 @@ defmodule MvWeb.GroupLive.ShowTest do actor: system_actor ) - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - assert html =~ "Alice" or html =~ "Smith" - assert html =~ "Bob" or html =~ "Jones" + assert has_element?(view, "[data-testid=group-show-members-table]", "Alice") + assert has_element?(view, "[data-testid=group-show-members-table]", "Bob") end test "displays edit button for admin users", %{conn: conn} do group = Fixtures.group_fixture() - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - assert html =~ gettext("Edit") or html =~ "edit" or html =~ "Bearbeiten" + assert has_element?(view, "[data-testid=group-show-edit-btn]") end test "displays delete button for admin users", %{conn: conn} do group = Fixtures.group_fixture() - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - assert html =~ gettext("Delete") or html =~ "delete" or html =~ "Löschen" + assert has_element?(view, "[data-testid=group-show-delete-btn]") end end @@ -94,19 +93,17 @@ defmodule MvWeb.GroupLive.ShowTest do test "route /groups/:slug works correctly", %{conn: conn} do group = Fixtures.group_fixture(%{name: "Board Members"}) - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - assert html =~ "Board Members" - # Verify slug is in URL - assert html =~ group.slug or html =~ "board-members" + assert has_element?(view, "h1", "Board Members") end test "group is found by slug via unique_slug identity", %{conn: conn} do group = Fixtures.group_fixture(%{name: "Test Group"}) - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - assert html =~ group.name + assert has_element?(view, "h1", group.name) end test "non-existent slug returns 404", %{conn: conn} do @@ -145,28 +142,26 @@ defmodule MvWeb.GroupLive.ShowTest do test "displays empty group correctly (0 members)", %{conn: conn} do group = Fixtures.group_fixture() - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - assert html =~ "0" or html =~ gettext("No members") or html =~ "empty" or - html =~ "Keine Mitglieder" + assert has_element?(view, "[data-testid=group-show-no-members]") end test "handles group without description correctly", %{conn: conn} do group = Fixtures.group_fixture(%{description: nil}) - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - # Should not crash, description should be optional - assert html =~ group.name + assert has_element?(view, "h1", group.name) end test "handles slug with special characters correctly", %{conn: conn} do # Create group with name that generates slug with hyphens group = Fixtures.group_fixture(%{name: "Test-Group-Name"}) - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - assert html =~ "Test-Group-Name" or html =~ group.name + assert has_element?(view, "h1", group.name) end end @@ -177,11 +172,11 @@ defmodule MvWeb.GroupLive.ShowTest do read_only_user = Fixtures.user_with_role_fixture("read_only") conn = conn_with_password_user(conn, read_only_user) - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - assert html =~ group.name - # Should NOT see edit/delete buttons - refute html =~ gettext("Edit") or html =~ gettext("Delete") + assert has_element?(view, "h1", group.name) + refute has_element?(view, "[data-testid=group-show-edit-btn]") + refute has_element?(view, "[data-testid=group-show-delete-btn]") end @tag role: :unauthenticated @@ -246,14 +241,14 @@ defmodule MvWeb.GroupLive.ShowTest do handler_id = "test-query-counter-#{System.unique_integer([:positive])}" :telemetry.attach(handler_id, [:ash, :query, :start], handler, nil) - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") final_count = Agent.get(query_count_agent, & &1) :telemetry.detach(handler_id) - # All members should be displayed Enum.each(members, fn member -> - assert html =~ member.first_name or html =~ member.last_name + assert has_element?(view, "[data-testid=group-show-members-table]", member.first_name) or + has_element?(view, "[data-testid=group-show-members-table]", member.last_name) end) # Verify query count is reasonable (should avoid N+1 queries) @@ -267,10 +262,9 @@ defmodule MvWeb.GroupLive.ShowTest do test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do group = Fixtures.group_fixture() - # Should use index for fast lookup - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - assert html =~ group.name + assert has_element?(view, "h1", group.name) end end diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 4f36795..53a2815 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -1,5 +1,5 @@ defmodule MvWeb.MemberLive.IndexTest do - use MvWeb.ConnCase, async: true + use MvWeb.ConnCase, async: false import Phoenix.LiveViewTest require Ash.Query diff --git a/test/support/group_live_helpers.ex b/test/support/group_live_helpers.ex index 50e2f9e..d8f87b8 100644 --- a/test/support/group_live_helpers.ex +++ b/test/support/group_live_helpers.ex @@ -13,7 +13,7 @@ defmodule MvWeb.GroupLiveHelpers do """ def open_add_member(view) do view - |> element("button", "Add Member") + |> element("button[phx-click='show_add_member_input']") |> render_click() end @@ -22,7 +22,7 @@ defmodule MvWeb.GroupLiveHelpers do """ def search_member(view, query) do view - |> element("#member-search-input") + |> element("[data-testid=group-show-member-search-input]") |> render_focus() view @@ -44,7 +44,7 @@ defmodule MvWeb.GroupLiveHelpers do """ def add_selected(view) do view - |> element("button[phx-click='add_selected_members']") + |> element("[data-testid=group-show-add-selected-members-btn]") |> render_click() end