From 5aa9c37742eca8b7bd9707fd89ce7d563c3a41cb Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 26 Sep 2025 15:06:08 +0200 Subject: [PATCH 01/14] feat: Add tests for user-member relationship --- .../user_member_relationship_test.exs | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 test/accounts/user_member_relationship_test.exs diff --git a/test/accounts/user_member_relationship_test.exs b/test/accounts/user_member_relationship_test.exs new file mode 100644 index 0000000..19cbe62 --- /dev/null +++ b/test/accounts/user_member_relationship_test.exs @@ -0,0 +1,195 @@ +defmodule Mv.Accounts.UserMemberRelationshipTest do + use Mv.DataCase, async: false + alias Mv.Accounts + alias Mv.Membership + + describe "User-Member Relationship - Basic Tests" do + @valid_user_attrs %{ + email: "test@example.com" + } + + @valid_member_attrs %{ + first_name: "John", + last_name: "Doe", + email: "john@example.com" + } + + test "user can exist without member" do + {:ok, user} = Accounts.create_user(@valid_user_attrs) + assert user.member_id == nil + + # Load the relationship to test it + {:ok, user_with_member} = Ash.get(Mv.Accounts.User, user.id, load: [:member]) + assert user_with_member.member == nil + end + + test "member can exist without user" do + {:ok, member} = Membership.create_member(@valid_member_attrs) + assert member.id != nil + assert member.first_name == "John" + + # Load the relationship to test it + {:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user]) + assert member_with_user.user == nil + end + end + + describe "User-Member Relationship - Linking Tests" do + @valid_user_attrs %{ + email: "test1@example.com" + } + + @valid_member_attrs %{ + first_name: "Alice", + last_name: "Johnson", + email: "alice@example.com" + } + + test "user can be linked to member during user creation" do + {:ok, member} = Membership.create_member(@valid_member_attrs) + + user_attrs = Map.put(@valid_user_attrs, :member, %{id: member.id}) + {:ok, user} = Accounts.create_user(user_attrs) + + # Load the relationship to test it + {:ok, user_with_member} = Ash.get(Mv.Accounts.User, user.id, load: [:member]) + assert user_with_member.member.id == member.id + end + + test "member can be linked to user during member creation using manage_relationship" do + {:ok, user} = Accounts.create_user(@valid_user_attrs) + + member_attrs = Map.put(@valid_member_attrs, :user, %{id: user.id}) + {:ok, member} = Membership.create_member(member_attrs) + + # Load the relationship to test it + {:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user]) + assert member_with_user.user.id == user.id + end + + test "user can be linked to member during update" do + {:ok, user} = Accounts.create_user(@valid_user_attrs) + {:ok, member} = Membership.create_member(@valid_member_attrs) + + {:ok, updated_user} = Accounts.update_user(user, %{member: %{id: member.id}}) + + # Load the relationship to test it + {:ok, user_with_member} = Ash.get(Mv.Accounts.User, updated_user.id, load: [:member]) + assert user_with_member.member.id == member.id + end + + test "member can be linked to user during update using manage_relationship" do + {:ok, user} = Accounts.create_user(@valid_user_attrs) + {:ok, member} = Membership.create_member(@valid_member_attrs) + + {:ok, _updated_member} = Membership.update_member(member, %{user: %{id: user.id}}) + + # Load the relationship to test it + {:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user]) + assert member_with_user.user.id == user.id + end + end + + describe "User-Member Relationship - Inverse Relationship Tests" do + @valid_user_attrs %{ + email: "test2@example.com" + } + + @valid_member_attrs %{ + first_name: "Bob", + last_name: "Smith", + email: "bob@example.com" + } + + test "ash resolves inverse relationship automatically" do + {:ok, member} = Membership.create_member(@valid_member_attrs) + + user_attrs = Map.put(@valid_user_attrs, :member, %{id: member.id}) + {:ok, user} = Accounts.create_user(user_attrs) + + # Load relationships + {:ok, user_with_member} = Ash.get(Mv.Accounts.User, user.id, load: [:member]) + {:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user]) + + assert user_with_member.member.id == member.id + assert member_with_user.user.id == user.id + end + + test "member can find associated user" do + {:ok, member} = Membership.create_member(@valid_member_attrs) + + {:ok, user} = Accounts.create_user(%{email: "test3@example.com", member: %{id: member.id}}) + {:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user]) + assert member_with_user.user.id == user.id + end + end + + describe "User-Member Relationship - Preventing Duplicates" do + @valid_user_attrs %{ + email: "test4@example.com" + } + + @valid_member_attrs %{ + first_name: "Charlie", + last_name: "Brown", + email: "charlie@example.com" + } + + test "prevents overwriting a member of already linked user on update" do + {:ok, existing_member} = Membership.create_member(@valid_member_attrs) + + user_attrs = Map.put(@valid_user_attrs, :member, %{id: existing_member.id}) + {:ok, user} = Accounts.create_user(user_attrs) + + {:ok, member2} = + Membership.create_member(%{ + first_name: "Dave", + last_name: "Wilson", + email: "dave@example.com" + }) + + assert {:error, %Ash.Error.Invalid{}} = + Accounts.update_user(user, %{member: %{id: member2.id}}) + end + + test "prevents linking user to already linked member on update" do + {:ok, existing_user} = Accounts.create_user(@valid_user_attrs) + {:ok, member} = Membership.create_member(@valid_member_attrs) + + {:ok, _updated_user} = Accounts.update_user(existing_user, %{member: %{id: member.id}}) + + {:ok, user2} = Accounts.create_user(%{email: "test5@example.com"}) + + assert {:error, %Ash.Error.Invalid{}} = + Accounts.update_user(user2, %{member: %{id: member.id}}) + end + + test "prevents linking member to already linked user on creation" do + {:ok, existing_member} = Membership.create_member(@valid_member_attrs) + + user_attrs = Map.put(@valid_user_attrs, :member, %{id: existing_member.id}) + {:ok, user} = Accounts.create_user(user_attrs) + + assert {:error, %Ash.Error.Invalid{}} = + Membership.create_member(%{ + first_name: "Dave", + last_name: "Wilson", + email: "dave@example.com", + user: %{id: user.id} + }) + end + + test "prevents linking user to already linked member on creation" do + {:ok, existing_user} = Accounts.create_user(@valid_user_attrs) + {:ok, member} = Membership.create_member(@valid_member_attrs) + + {:ok, _updated_user} = Accounts.update_user(existing_user, %{member: %{id: member.id}}) + + assert {:error, %Ash.Error.Invalid{}} = + Accounts.create_user(%{ + email: "test5@example.com", + member: %{id: member.id} + }) + end + end +end -- 2.47.2 From 72a8415cb3a3bac827bee1403b9beceb0acdfd1b Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 26 Sep 2025 16:09:49 +0200 Subject: [PATCH 02/14] feat: member user relation --- lib/accounts/user.ex | 68 ++++++++- lib/membership/member.ex | 71 +++++++++ .../20250926164519_member_relation.exs | 17 +++ .../repo/users/20250926164519.json | 141 ++++++++++++++++++ 4 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 priv/repo/migrations/20250926164519_member_relation.exs create mode 100644 priv/resource_snapshots/repo/users/20250926164519.json diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index b085407..c65b882 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -60,15 +60,54 @@ defmodule Mv.Accounts.User do end actions do - defaults [:read, :create, :destroy, :update] + defaults [:read, :create, :destroy] + + update :update do + primary? true + require_atomic? false + end create :create_user do + # Only accept email directly - member_id is NOT in accept list + # This prevents direct foreign key manipulation, forcing use of manage_relationship accept [:email] + # Allow member to be passed as argument for relationship management + argument :member, :map, allow_nil?: true upsert? true + + # Manage the member relationship during user creation + change manage_relationship(:member, :member, + # Look up existing member and relate to it + on_lookup: :relate, + # Error if member doesn't exist in database + on_no_match: :error, + # If member already linked to this user, ignore (shouldn't happen in create) + on_match: :ignore, + # If no member provided, that's fine (optional relationship) + on_missing: :ignore + ) end update :update_user do + # Only accept email directly - member_id is NOT in accept list + # This prevents direct foreign key manipulation, forcing use of manage_relationship accept [:email] + # Allow member to be passed as argument for relationship management + argument :member, :map, allow_nil?: true + # Required because custom validation function cannot be done atomically + require_atomic? false + + # Manage the member relationship during user update + change manage_relationship(:member, :member, + # Look up existing member and relate to it + on_lookup: :relate, + # Error if member doesn't exist in database + on_no_match: :error, + # If same member provided, that's fine (allows updates with same member) + on_match: :ignore, + # If no member provided, remove existing relationship (allows member removal) + on_missing: :unrelate + ) end # Admin action for direct password changes in admin panel @@ -76,6 +115,7 @@ defmodule Mv.Accounts.User do update :admin_set_password do accept [:email] argument :password, :string, allow_nil?: false, sensitive?: true + require_atomic? false # Set the strategy context that HashPasswordChange expects change set_context(%{strategy_name: :password}) @@ -125,6 +165,28 @@ defmodule Mv.Accounts.User do validate string_length(:password, min: 8) do where action_is([:register_with_password, :admin_set_password]) end + + # Prevent overwriting existing member relationship + # This validation ensures race condition safety by requiring explicit two-step process: + # 1. Remove existing member (set member to nil) + # 2. Add new member + # This prevents accidental overwrites when multiple admins work simultaneously + validate fn changeset, _context -> + member_arg = Ash.Changeset.get_argument(changeset, :member) + current_member_id = changeset.data.member_id + + # Only trigger if: + # - member argument is provided AND has an ID + # - user currently has a member + # - the new member ID is different from current member ID + if member_arg && member_arg[:id] && current_member_id && + member_arg[:id] != current_member_id do + {:error, + field: :member, message: "User already has a member. Remove existing member first."} + else + :ok + end + end end def validate_oidc_id_present(changeset, _context) do @@ -146,12 +208,16 @@ defmodule Mv.Accounts.User do end relationships do + # 1:1 relationship - User can optionally belong to one Member + # This automatically creates a `member_id` attribute in the User table + # The relationship is optional (allow_nil? true by default) belongs_to :member, Mv.Membership.Member end identities do identity :unique_email, [:email] identity :unique_oidc_id, [:oidc_id] + identity :unique_member, [:member_id] end # You can customize this if you wish, but this is a safe default that diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 7fe69da..0f78750 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -13,7 +13,11 @@ defmodule Mv.Membership.Member do create :create_member do primary? true + # Properties can be created along with member argument :properties, {:array, :map} + # Allow user to be passed as argument for relationship management + # user_id is NOT in accept list to prevent direct foreign key manipulation + argument :user, :map, allow_nil?: true accept [ :first_name, @@ -32,12 +36,29 @@ defmodule Mv.Membership.Member do ] change manage_relationship(:properties, type: :create) + + # Manage the user relationship during member creation + change manage_relationship(:user, :user, + # Look up existing user and relate to it + on_lookup: :relate, + # Error if user doesn't exist in database + on_no_match: :error, + # Error if user is already linked to another member (prevents "stealing") + on_match: :error, + # If no user provided, that's fine (optional relationship) + on_missing: :ignore + ) end update :update_member do primary? true + # Required because custom validation function cannot be done atomically require_atomic? false + # Properties can be updated or created along with member argument :properties, {:array, :map} + # Allow user to be passed as argument for relationship management + # user_id is NOT in accept list to prevent direct foreign key manipulation + argument :user, :map, allow_nil?: true accept [ :first_name, @@ -56,6 +77,18 @@ defmodule Mv.Membership.Member do ] change manage_relationship(:properties, on_match: :update, on_no_match: :create) + + # Manage the user relationship during member update + change manage_relationship(:user, :user, + # Look up existing user and relate to it + on_lookup: :relate, + # Error if user doesn't exist in database + on_no_match: :error, + # Error if user is already linked to another member (prevents "stealing") + on_match: :error, + # If no user provided, remove existing relationship (allows user removal) + on_missing: :unrelate + ) end end @@ -67,6 +100,40 @@ defmodule Mv.Membership.Member do validate present(:last_name) validate present(:email) + # Prevent linking to a user that already has a member + # This validation prevents "stealing" users from other members by checking + # if the target user is already linked to a different member + # This is necessary because manage_relationship's on_match: :error only checks + # if the user is already linked to THIS specific member, not ANY member + validate fn changeset, _context -> + user_arg = Ash.Changeset.get_argument(changeset, :user) + + if user_arg && user_arg[:id] do + user_id = user_arg[:id] + current_member_id = changeset.data.id + + # Check the current state of the user in the database + case Ash.get(Mv.Accounts.User, user_id) do + # User is free to be linked + {:ok, %{member_id: nil}} -> + :ok + + # User already linked to this member (update scenario) + {:ok, %{member_id: ^current_member_id}} -> + :ok + + {:ok, %{member_id: _other_member_id}} -> + # User is linked to a different member - prevent "stealing" + {:error, field: :user, message: "User is already linked to another member"} + + {:error, _} -> + {:error, field: :user, message: "User not found"} + end + else + :ok + end + end + # Birth date not in the future validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0), where: [present(:birth_date)], @@ -175,5 +242,9 @@ defmodule Mv.Membership.Member do relationships do has_many :properties, Mv.Membership.Property + # 1:1 relationship - Member can optionally have one User + # This references the User's member_id attribute + # The relationship is optional (allow_nil? true by default) + has_one :user, Mv.Accounts.User end end diff --git a/priv/repo/migrations/20250926164519_member_relation.exs b/priv/repo/migrations/20250926164519_member_relation.exs new file mode 100644 index 0000000..daaa24c --- /dev/null +++ b/priv/repo/migrations/20250926164519_member_relation.exs @@ -0,0 +1,17 @@ +defmodule Mv.Repo.Migrations.MemberRelation do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create unique_index(:users, [:member_id], name: "users_unique_member_index") + end + + def down do + drop_if_exists unique_index(:users, [:member_id], name: "users_unique_member_index") + end +end diff --git a/priv/resource_snapshots/repo/users/20250926164519.json b/priv/resource_snapshots/repo/users/20250926164519.json new file mode 100644 index 0000000..7eb68f2 --- /dev/null +++ b/priv/resource_snapshots/repo/users/20250926164519.json @@ -0,0 +1,141 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "citext" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "hashed_password", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "oidc_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "users_member_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "members" + }, + "scale": null, + "size": null, + "source": "member_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "FDEBD4840449609DDA8B50D6741C2EEDE9D81DFBC1E26D4BC77DBD9B5A8EA4DC", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "users_unique_email_index", + "keys": [ + { + "type": "atom", + "value": "email" + } + ], + "name": "unique_email", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "users_unique_oidc_id_index", + "keys": [ + { + "type": "atom", + "value": "oidc_id" + } + ], + "name": "unique_oidc_id", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "users_unique_member_index", + "keys": [ + { + "type": "atom", + "value": "member_id" + } + ], + "name": "unique_member", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "users" +} \ No newline at end of file -- 2.47.2 From eeed5370624d3c66c8fccd495ddb7522ecdc851f Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 26 Sep 2025 19:55:06 +0200 Subject: [PATCH 03/14] feat: add member-user link in member view and user view --- lib/mv_web/live/member_live/show.ex | 15 ++++++++++++++- lib/mv_web/live/user_live/show.ex | 17 ++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index fbf5b4a..0b28b2e 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -37,6 +37,19 @@ defmodule MvWeb.MemberLive.Show do <:item title={gettext("Street")}>{@member.street} <:item title={gettext("House Number")}>{@member.house_number} <:item title={gettext("Postal Code")}>{@member.postal_code} + <:item title={gettext("Linked User")}> + <%= if @member.user do %> + <.link + navigate={~p"/users/#{@member.user}"} + class="text-blue-600 hover:text-blue-800 underline" + > + <.icon name="hero-user" class="h-4 w-4 inline mr-1" /> + {@member.user.email} + + <% else %> + {gettext("No user linked")} + <% end %> +

{gettext("Custom Properties")}

@@ -67,7 +80,7 @@ defmodule MvWeb.MemberLive.Show do query = Mv.Membership.Member |> filter(id == ^id) - |> load(properties: [:property_type]) + |> load([:user, properties: [:property_type]]) member = Ash.read_one!(query) diff --git a/lib/mv_web/live/user_live/show.ex b/lib/mv_web/live/user_live/show.ex index 609a07c..5fefb25 100644 --- a/lib/mv_web/live/user_live/show.ex +++ b/lib/mv_web/live/user_live/show.ex @@ -26,6 +26,19 @@ defmodule MvWeb.UserLive.Show do <:item title={gettext("Password Authentication")}> {if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")} + <:item title={gettext("Linked Member")}> + <%= if @user.member do %> + <.link + navigate={~p"/members/#{@user.member}"} + class="text-blue-600 hover:text-blue-800 underline" + > + <.icon name="hero-users" class="h-4 w-4 inline mr-1" /> + {@user.member.first_name} {@user.member.last_name} + + <% else %> + {gettext("No member linked")} + <% end %> + """ @@ -33,9 +46,11 @@ defmodule MvWeb.UserLive.Show do @impl true def mount(%{"id" => id}, _session, socket) do + user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member]) + {:ok, socket |> assign(:page_title, gettext("Show User")) - |> assign(:user, Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts))} + |> assign(:user, user)} end end -- 2.47.2 From 98f4768e00669188f4ac3821f70df548d7fa3ef5 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 26 Sep 2025 19:57:11 +0200 Subject: [PATCH 04/14] feat: seed member user relations --- priv/repo/seeds.exs | 95 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 2 deletions(-) diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index cb38969..d850c7c 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -48,7 +48,7 @@ Accounts.create_user!(%{email: "admin@mv.local"}, upsert?: true, upsert_identity |> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"}) |> Ash.update!() -# Create sample members for testing +# Create sample members for testing - use upsert to prevent duplicates for member_attrs <- [ %{ first_name: "Hans", @@ -90,5 +90,96 @@ for member_attrs <- [ house_number: "8" } ] do - Membership.create_member!(member_attrs) + # Use upsert to prevent duplicates based on email + Membership.create_member!(member_attrs, upsert?: true, upsert_identity: :unique_email) end + +# Create additional users for user-member linking examples +additional_users = [ + %{email: "hans.mueller@example.de"}, + %{email: "greta.schmidt@example.de"}, + %{email: "maria.weber@example.de"}, + %{email: "thomas.klein@example.de"} +] + +created_users = + Enum.map(additional_users, fn user_attrs -> + Accounts.create_user!(user_attrs, upsert?: true, upsert_identity: :unique_email) + |> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"}) + |> Ash.update!() + end) + +# Create members with linked users to demonstrate the 1:1 relationship +# Only create if users don't already have members +linked_members = [ + %{ + first_name: "Maria", + last_name: "Weber", + email: "maria.weber@example.de", + birth_date: ~D[1992-07-14], + join_date: ~D[2023-03-15], + paid: true, + phone_number: "+49301357924", + city: "Frankfurt", + street: "Goetheplatz", + house_number: "5", + postal_code: "60313", + notes: "Linked to user account", + # Link to the third user (maria.weber@example.de) + user: Enum.at(created_users, 2) + }, + %{ + first_name: "Thomas", + last_name: "Klein", + email: "thomas.klein@example.de", + birth_date: ~D[1988-12-03], + join_date: ~D[2023-04-01], + paid: false, + phone_number: "+49302468135", + city: "Köln", + street: "Rheinstraße", + house_number: "23", + postal_code: "50667", + notes: "Linked to user account - needs payment follow-up", + # Link to the fourth user (thomas.klein@example.de) + user: Enum.at(created_users, 3) + } +] + +# Create the linked members - use upsert to prevent duplicates +Enum.each(linked_members, fn member_attrs -> + user = member_attrs.user + member_attrs_without_user = Map.delete(member_attrs, :user) + + # Check if user already has a member + if user.member_id == nil do + # User is free, create member and link - use upsert to prevent duplicates + Membership.create_member!( + Map.put(member_attrs_without_user, :user, %{id: user.id}), + upsert?: true, + upsert_identity: :unique_email + ) + else + # User already has a member, just create the member without linking - use upsert to prevent duplicates + Membership.create_member!(member_attrs_without_user, + upsert?: true, + upsert_identity: :unique_email + ) + end +end) + +IO.puts("✅ Seeds completed successfully!") +IO.puts("📝 Created sample data:") +IO.puts(" - Property types: String, Date, Boolean, Email") +IO.puts(" - Admin user: admin@mv.local (password: testpassword)") +IO.puts(" - Sample members: Hans, Greta, Friedrich") + +IO.puts( + " - Additional users: hans.mueller@example.de, greta.schmidt@example.de, maria.weber@example.de, thomas.klein@example.de" +) + +IO.puts( + " - Linked members: Maria Weber ↔ maria.weber@example.de, Thomas Klein ↔ thomas.klein@example.de" +) + +IO.puts("🔗 Visit the application to see user-member relationships in action!") -- 2.47.2 From d8ec828df06c9d515435b1db88e71058a09f1f4f Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 26 Sep 2025 20:07:47 +0200 Subject: [PATCH 05/14] feat: make member emails unique --- lib/membership/member.ex | 5 + ...0926180341_add_unique_email_to_members.exs | 17 ++ .../repo/members/20250926180341.json | 202 ++++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 priv/repo/migrations/20250926180341_add_unique_email_to_members.exs create mode 100644 priv/resource_snapshots/repo/members/20250926180341.json diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 0f78750..4cec072 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -247,4 +247,9 @@ defmodule Mv.Membership.Member do # The relationship is optional (allow_nil? true by default) has_one :user, Mv.Accounts.User end + + # Define identities for upsert operations + identities do + identity :unique_email, [:email] + end end diff --git a/priv/repo/migrations/20250926180341_add_unique_email_to_members.exs b/priv/repo/migrations/20250926180341_add_unique_email_to_members.exs new file mode 100644 index 0000000..51b874f --- /dev/null +++ b/priv/repo/migrations/20250926180341_add_unique_email_to_members.exs @@ -0,0 +1,17 @@ +defmodule Mv.Repo.Migrations.AddUniqueEmailToMembers do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create unique_index(:members, [:email], name: "members_unique_email_index") + end + + def down do + drop_if_exists unique_index(:members, [:email], name: "members_unique_email_index") + end +end diff --git a/priv/resource_snapshots/repo/members/20250926180341.json b/priv/resource_snapshots/repo/members/20250926180341.json new file mode 100644 index 0000000..3582051 --- /dev/null +++ b/priv/resource_snapshots/repo/members/20250926180341.json @@ -0,0 +1,202 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "first_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "birth_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "paid", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "phone_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "join_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "exit_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "notes", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "city", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "street", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "house_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "postal_code", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "5F070A1E5BEE9883AE864FB5A4A5E81F487A1C57D41576C23BAC8D933005D565", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "members_unique_email_index", + "keys": [ + { + "type": "atom", + "value": "email" + } + ], + "name": "unique_email", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "members" +} \ No newline at end of file -- 2.47.2 From 515cd76ceee475dd4bc90dfb8130269daea673b6 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 26 Sep 2025 20:18:32 +0200 Subject: [PATCH 06/14] feat: add translation --- priv/gettext/de/LC_MESSAGES/auth.po | 3 -- priv/gettext/de/LC_MESSAGES/default.po | 41 +++++++++++----- priv/gettext/de/LC_MESSAGES/errors.po | 66 +++++++++++++------------- priv/gettext/default.pot | 27 ++++++++--- priv/gettext/en/LC_MESSAGES/auth.po | 3 -- priv/gettext/en/LC_MESSAGES/default.po | 29 ++++++++--- 6 files changed, 104 insertions(+), 65 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/auth.po b/priv/gettext/de/LC_MESSAGES/auth.po index 0f2202d..b794f37 100644 --- a/priv/gettext/de/LC_MESSAGES/auth.po +++ b/priv/gettext/de/LC_MESSAGES/auth.po @@ -61,6 +61,3 @@ msgstr "Anmelden..." msgid "Your password has successfully been reset" msgstr "Das Passwort wurde erfolgreich zurückgesetzt" - -msgid "Sign in with Rauthy" -msgstr "Anmelden mit der Vereinscloud" diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 1a7cf7e..073a047 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -48,7 +48,7 @@ msgid "Edit" msgstr "Bearbeite" #: lib/mv_web/live/member_live/show.ex:18 -#: lib/mv_web/live/member_live/show.ex:81 +#: lib/mv_web/live/member_live/show.ex:94 #, elixir-autogen, elixir-format msgid "Edit Member" msgstr "Mitglied bearbeiten" @@ -115,7 +115,7 @@ msgid "Birth Date" msgstr "Geburtsdatum" #: lib/mv_web/live/member_live/form.ex:30 -#: lib/mv_web/live/member_live/show.ex:42 +#: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Custom Properties" msgstr "Eigene Eigenschaften" @@ -194,7 +194,7 @@ msgstr "ID" msgid "No" msgstr "Nein" -#: lib/mv_web/live/member_live/show.ex:80 +#: lib/mv_web/live/member_live/show.ex:93 #, elixir-autogen, elixir-format, fuzzy msgid "Show Member" msgstr "Mitglied anzeigen" @@ -373,9 +373,9 @@ msgid "Profil" msgstr "Profil" #: lib/mv_web/live/property_live/form.ex:207 -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Property %{action} successfully" -msgstr "Mitglied %{action} erfolgreich" +msgstr "Eigenschaft %{action} erfolgreich" #: lib/mv_web/live/property_live/form.ex:18 #, elixir-autogen, elixir-format @@ -422,7 +422,7 @@ msgstr "Einstellungen" msgid "Save User" msgstr "Benutzer speichern" -#: lib/mv_web/live/user_live/show.ex:38 +#: lib/mv_web/live/user_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Show User" msgstr "Benutzer anzeigen" @@ -438,14 +438,14 @@ msgid "Unsupported value type: %{type}" msgstr "Nicht unterstützter Wertetyp: %{type}" #: lib/mv_web/live/property_live/form.ex:10 -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Use this form to manage property records in your database." -msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenschaften." +msgstr "Dieses Formular dient zur Verwaltung von Eigenschaften in der Datenbank." #: lib/mv_web/live/property_type_live/form.ex:11 -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Use this form to manage property_type records in your database." -msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenschaften." +msgstr "Dieses Formular dient zur Verwaltung von Eigenschaftstypen in der Datenbank." #: lib/mv_web/live/user_live/form.ex:10 #, elixir-autogen, elixir-format @@ -553,7 +553,22 @@ msgstr "Passwort setzen" msgid "User will be created without a password. Check 'Set Password' to add one." msgstr "Benutzer wird ohne Passwort erstellt. Aktivieren Sie 'Passwort setzen', um eines hinzuzufügen." -#: lib/mv_web/auth_overrides.ex:30 +#: lib/mv_web/live/user_live/show.ex:29 #, elixir-autogen, elixir-format -msgid "or" -msgstr "oder" +msgid "Linked Member" +msgstr "Verknüpftes Mitglied" + +#: lib/mv_web/live/member_live/show.ex:40 +#, elixir-autogen, elixir-format +msgid "Linked User" +msgstr "Verknüpfter Benutzer" + +#: lib/mv_web/live/user_live/show.ex:39 +#, elixir-autogen, elixir-format +msgid "No member linked" +msgstr "Kein Mitglied verknüpft" + +#: lib/mv_web/live/member_live/show.ex:50 +#, elixir-autogen, elixir-format +msgid "No user linked" +msgstr "Kein Benutzer verknüpft" diff --git a/priv/gettext/de/LC_MESSAGES/errors.po b/priv/gettext/de/LC_MESSAGES/errors.po index 844c4f5..b9332ab 100644 --- a/priv/gettext/de/LC_MESSAGES/errors.po +++ b/priv/gettext/de/LC_MESSAGES/errors.po @@ -12,101 +12,101 @@ msgstr "" ## From Ecto.Changeset.cast/4 msgid "can't be blank" -msgstr "" +msgstr "darf nicht leer sein" ## From Ecto.Changeset.unique_constraint/3 msgid "has already been taken" -msgstr "" +msgstr "ist bereits vergeben" ## From Ecto.Changeset.put_change/3 msgid "is invalid" -msgstr "" +msgstr "ist ungültig" ## From Ecto.Changeset.validate_acceptance/3 msgid "must be accepted" -msgstr "" +msgstr "muss akzeptiert werden" ## From Ecto.Changeset.validate_format/3 msgid "has invalid format" -msgstr "" +msgstr "hat ein ungültiges Format" ## From Ecto.Changeset.validate_subset/3 msgid "has an invalid entry" -msgstr "" +msgstr "hat einen ungültigen Eintrag" ## From Ecto.Changeset.validate_exclusion/3 msgid "is reserved" -msgstr "" +msgstr "ist reserviert" ## From Ecto.Changeset.validate_confirmation/3 msgid "does not match confirmation" -msgstr "" +msgstr "stimmt nicht mit der Bestätigung überein" ## From Ecto.Changeset.no_assoc_constraint/3 msgid "is still associated with this entry" -msgstr "" +msgstr "ist noch mit diesem Eintrag verknüpft" msgid "are still associated with this entry" -msgstr "" +msgstr "sind noch mit diesem Eintrag verknüpft" ## From Ecto.Changeset.validate_length/3 msgid "should have %{count} item(s)" msgid_plural "should have %{count} item(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "sollte %{count} Element haben" +msgstr[1] "sollte %{count} Elemente haben" msgid "should be %{count} character(s)" msgid_plural "should be %{count} character(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "sollte %{count} Zeichen haben" +msgstr[1] "sollte %{count} Zeichen haben" msgid "should be %{count} byte(s)" msgid_plural "should be %{count} byte(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "sollte %{count} Byte haben" +msgstr[1] "sollte %{count} Bytes haben" msgid "should have at least %{count} item(s)" msgid_plural "should have at least %{count} item(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "sollte mindestens %{count} Element haben" +msgstr[1] "sollte mindestens %{count} Elemente haben" msgid "should be at least %{count} character(s)" msgid_plural "should be at least %{count} character(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "sollte mindestens %{count} Zeichen haben" +msgstr[1] "sollte mindestens %{count} Zeichen haben" msgid "should be at least %{count} byte(s)" msgid_plural "should be at least %{count} byte(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "sollte mindestens %{count} Byte haben" +msgstr[1] "sollte mindestens %{count} Bytes haben" msgid "should have at most %{count} item(s)" msgid_plural "should have at most %{count} item(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "sollte höchstens %{count} Element haben" +msgstr[1] "sollte höchstens %{count} Elemente haben" msgid "should be at most %{count} character(s)" msgid_plural "should be at most %{count} character(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "sollte höchstens %{count} Zeichen haben" +msgstr[1] "sollte höchstens %{count} Zeichen haben" msgid "should be at most %{count} byte(s)" msgid_plural "should be at most %{count} byte(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "sollte höchstens %{count} Byte haben" +msgstr[1] "sollte höchstens %{count} Bytes haben" ## From Ecto.Changeset.validate_number/3 msgid "must be less than %{number}" -msgstr "" +msgstr "muss kleiner als %{number} sein" msgid "must be greater than %{number}" -msgstr "" +msgstr "muss größer als %{number} sein" msgid "must be less than or equal to %{number}" -msgstr "" +msgstr "muss kleiner oder gleich %{number} sein" msgid "must be greater than or equal to %{number}" -msgstr "" +msgstr "muss größer oder gleich %{number} sein" msgid "must be equal to %{number}" -msgstr "" +msgstr "muss gleich %{number} sein" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index a9bfb08..42e85d8 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -49,7 +49,7 @@ msgid "Edit" msgstr "" #: lib/mv_web/live/member_live/show.ex:18 -#: lib/mv_web/live/member_live/show.ex:81 +#: lib/mv_web/live/member_live/show.ex:94 #, elixir-autogen, elixir-format msgid "Edit Member" msgstr "" @@ -116,7 +116,7 @@ msgid "Birth Date" msgstr "" #: lib/mv_web/live/member_live/form.ex:30 -#: lib/mv_web/live/member_live/show.ex:42 +#: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Custom Properties" msgstr "" @@ -195,7 +195,7 @@ msgstr "" msgid "No" msgstr "" -#: lib/mv_web/live/member_live/show.ex:80 +#: lib/mv_web/live/member_live/show.ex:93 #, elixir-autogen, elixir-format msgid "Show Member" msgstr "" @@ -423,7 +423,7 @@ msgstr "" msgid "Save User" msgstr "" -#: lib/mv_web/live/user_live/show.ex:38 +#: lib/mv_web/live/user_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Show User" msgstr "" @@ -554,7 +554,22 @@ msgstr "" msgid "User will be created without a password. Check 'Set Password' to add one." msgstr "" -#: lib/mv_web/auth_overrides.ex:30 +#: lib/mv_web/live/user_live/show.ex:29 #, elixir-autogen, elixir-format -msgid "or" +msgid "Linked Member" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex:40 +#, elixir-autogen, elixir-format +msgid "Linked User" +msgstr "" + +#: lib/mv_web/live/user_live/show.ex:39 +#, elixir-autogen, elixir-format +msgid "No member linked" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex:50 +#, elixir-autogen, elixir-format +msgid "No user linked" msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/auth.po b/priv/gettext/en/LC_MESSAGES/auth.po index 1e4e801..21bb4a4 100644 --- a/priv/gettext/en/LC_MESSAGES/auth.po +++ b/priv/gettext/en/LC_MESSAGES/auth.po @@ -58,6 +58,3 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" - -msgid "Sign in with Rauthy" -msgstr "Sign in with Vereinscloud" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 2f09378..e8adaaf 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -49,7 +49,7 @@ msgid "Edit" msgstr "" #: lib/mv_web/live/member_live/show.ex:18 -#: lib/mv_web/live/member_live/show.ex:81 +#: lib/mv_web/live/member_live/show.ex:94 #, elixir-autogen, elixir-format msgid "Edit Member" msgstr "" @@ -116,7 +116,7 @@ msgid "Birth Date" msgstr "" #: lib/mv_web/live/member_live/form.ex:30 -#: lib/mv_web/live/member_live/show.ex:42 +#: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Custom Properties" msgstr "" @@ -195,7 +195,7 @@ msgstr "" msgid "No" msgstr "" -#: lib/mv_web/live/member_live/show.ex:80 +#: lib/mv_web/live/member_live/show.ex:93 #, elixir-autogen, elixir-format, fuzzy msgid "Show Member" msgstr "" @@ -423,7 +423,7 @@ msgstr "" msgid "Save User" msgstr "" -#: lib/mv_web/live/user_live/show.ex:38 +#: lib/mv_web/live/user_live/show.ex:53 #, elixir-autogen, elixir-format, fuzzy msgid "Show User" msgstr "" @@ -554,7 +554,22 @@ msgstr "Set Password" msgid "User will be created without a password. Check 'Set Password' to add one." msgstr "User will be created without a password. Check 'Set Password' to add one." -#: lib/mv_web/auth_overrides.ex:30 -#, elixir-autogen, elixir-format -msgid "or" +#: lib/mv_web/live/user_live/show.ex:29 +#, elixir-autogen, elixir-format, fuzzy +msgid "Linked Member" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex:40 +#, elixir-autogen, elixir-format +msgid "Linked User" +msgstr "" + +#: lib/mv_web/live/user_live/show.ex:39 +#, elixir-autogen, elixir-format +msgid "No member linked" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex:50 +#, elixir-autogen, elixir-format +msgid "No user linked" msgstr "" -- 2.47.2 From 23d1ca8a328a479dcfa3c1dbb2481e422f1b4474 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 26 Sep 2025 20:33:13 +0200 Subject: [PATCH 07/14] fix: axe-core critical and major issues --- lib/mv_web/components/layouts/navbar.ex | 20 +++++- lib/mv_web/live/member_live/show.ex | 3 +- lib/mv_web/live/user_live/show.ex | 3 +- priv/gettext/de/LC_MESSAGES/default.po | 96 +++++++++++++++---------- priv/gettext/default.pot | 96 +++++++++++++++---------- priv/gettext/en/LC_MESSAGES/default.po | 96 +++++++++++++++---------- 6 files changed, 201 insertions(+), 113 deletions(-) diff --git a/lib/mv_web/components/layouts/navbar.ex b/lib/mv_web/components/layouts/navbar.ex index 9009329..a8d5901 100644 --- a/lib/mv_web/components/layouts/navbar.ex +++ b/lib/mv_web/components/layouts/navbar.ex @@ -23,13 +23,20 @@ defmodule MvWeb.Layouts.Navbar do
-
-
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 92d254a..0d7f129 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -15,7 +15,7 @@ msgstr "" msgid "Actions" msgstr "Aktionen" -#: lib/mv_web/live/member_live/index.html.heex:77 +#: lib/mv_web/live/member_live/index.html.heex:84 #: lib/mv_web/live/user_live/index.html.heex:65 #, elixir-autogen, elixir-format msgid "Are you sure?" @@ -28,19 +28,19 @@ msgid "Attempting to reconnect" msgstr "Verbindung wird wiederhergestellt" #: lib/mv_web/live/member_live/form.ex:25 -#: lib/mv_web/live/member_live/index.html.heex:62 +#: lib/mv_web/live/member_live/index.html.heex:69 #: lib/mv_web/live/member_live/show.ex:37 #, elixir-autogen, elixir-format msgid "City" msgstr "Stadt" -#: lib/mv_web/live/member_live/index.html.heex:79 +#: lib/mv_web/live/member_live/index.html.heex:86 #: lib/mv_web/live/user_live/index.html.heex:67 #, elixir-autogen, elixir-format msgid "Delete" msgstr "Löschen" -#: lib/mv_web/live/member_live/index.html.heex:71 +#: lib/mv_web/live/member_live/index.html.heex:78 #: lib/mv_web/live/user_live/form.ex:109 #: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format @@ -54,7 +54,7 @@ msgid "Edit Member" msgstr "Mitglied bearbeiten" #: lib/mv_web/live/member_live/form.ex:18 -#: lib/mv_web/live/member_live/index.html.heex:58 +#: lib/mv_web/live/member_live/index.html.heex:65 #: lib/mv_web/live/member_live/show.ex:28 #: lib/mv_web/live/user_live/form.ex:14 #: lib/mv_web/live/user_live/index.html.heex:44 @@ -70,7 +70,7 @@ msgid "First Name" msgstr "Vorname" #: lib/mv_web/live/member_live/form.ex:22 -#: lib/mv_web/live/member_live/index.html.heex:64 +#: lib/mv_web/live/member_live/index.html.heex:71 #: lib/mv_web/live/member_live/show.ex:34 #, elixir-autogen, elixir-format msgid "Join Date" @@ -87,7 +87,7 @@ msgstr "Nachname" msgid "New Member" msgstr "Neues Mitglied" -#: lib/mv_web/live/member_live/index.html.heex:68 +#: lib/mv_web/live/member_live/index.html.heex:75 #: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" @@ -127,7 +127,7 @@ msgid "Exit Date" msgstr "Austrittsdatum" #: lib/mv_web/live/member_live/form.ex:27 -#: lib/mv_web/live/member_live/index.html.heex:60 +#: lib/mv_web/live/member_live/index.html.heex:67 #: lib/mv_web/live/member_live/show.ex:39 #, elixir-autogen, elixir-format msgid "House Number" @@ -146,14 +146,14 @@ msgid "Paid" msgstr "Bezahlt" #: lib/mv_web/live/member_live/form.ex:21 -#: lib/mv_web/live/member_live/index.html.heex:63 +#: lib/mv_web/live/member_live/index.html.heex:70 #: lib/mv_web/live/member_live/show.ex:33 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "Telefonnummer" #: lib/mv_web/live/member_live/form.ex:28 -#: lib/mv_web/live/member_live/index.html.heex:61 +#: lib/mv_web/live/member_live/index.html.heex:68 #: lib/mv_web/live/member_live/show.ex:40 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -173,7 +173,7 @@ msgid "Saving..." msgstr "Speichern..." #: lib/mv_web/live/member_live/form.ex:26 -#: lib/mv_web/live/member_live/index.html.heex:59 +#: lib/mv_web/live/member_live/index.html.heex:66 #: lib/mv_web/live/member_live/show.ex:38 #, elixir-autogen, elixir-format msgid "Street" @@ -301,7 +301,7 @@ msgstr "ID" msgid "Immutable" msgstr "Unveränderlich" -#: lib/mv_web/components/layouts/navbar.ex:87 +#: lib/mv_web/components/layouts/navbar.ex:88 #, elixir-autogen, elixir-format msgid "Logout" msgstr "Abmelden" @@ -318,13 +318,13 @@ msgid "Member" msgstr "Mitglied" #: lib/mv_web/components/layouts/navbar.ex:14 -#: lib/mv_web/live/member_live/index.ex:12 +#: lib/mv_web/live/member_live/index.ex:14 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "Mitglieder" -#: lib/mv_web/live/member_live/index.html.heex:50 +#: lib/mv_web/live/member_live/index.html.heex:57 #: lib/mv_web/live/property_type_live/form.ex:16 #, elixir-autogen, elixir-format msgid "Name" @@ -367,7 +367,7 @@ msgstr "Passwort-Authentifizierung" msgid "Please select a property type first" msgstr "Bitte wählen Sie zuerst einen Eigenschaftstyp" -#: lib/mv_web/components/layouts/navbar.ex:83 +#: lib/mv_web/components/layouts/navbar.ex:84 #, elixir-autogen, elixir-format msgid "Profil" msgstr "Profil" @@ -402,17 +402,17 @@ msgstr "Eigenschaft speichern" msgid "Save Property type" msgstr "Eigenschaftstyp speichern" -#: lib/mv_web/live/member_live/index.html.heex:27 +#: lib/mv_web/live/member_live/index.html.heex:34 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "Alle Mitglieder auswählen" -#: lib/mv_web/live/member_live/index.html.heex:41 +#: lib/mv_web/live/member_live/index.html.heex:48 #, elixir-autogen, elixir-format msgid "Select member" msgstr "Mitglied auswählen" -#: lib/mv_web/components/layouts/navbar.ex:86 +#: lib/mv_web/components/layouts/navbar.ex:87 #, elixir-autogen, elixir-format msgid "Settings" msgstr "Einstellungen" @@ -585,14 +585,25 @@ msgstr "Zurück zur Mitgliederliste" msgid "Back to users list" msgstr "Zurück zur Benutzerliste" -#: lib/mv_web/components/layouts/navbar.ex:21 -#: lib/mv_web/components/layouts/navbar.ex:27 +#: lib/mv_web/components/layouts/navbar.ex:22 +#: lib/mv_web/components/layouts/navbar.ex:28 #, elixir-autogen, elixir-format msgid "Select language" msgstr "Sprache auswählen" -#: lib/mv_web/components/layouts/navbar.ex:34 -#: lib/mv_web/components/layouts/navbar.ex:54 +#: lib/mv_web/components/layouts/navbar.ex:35 +#: lib/mv_web/components/layouts/navbar.ex:55 #, elixir-autogen, elixir-format msgid "Toggle dark mode" msgstr "Dunklen Modus umschalten" + +#: lib/mv_web/live/components/search_bar_component.ex:15 +#: lib/mv_web/live/member_live/index.html.heex:15 +#, elixir-autogen, elixir-format +msgid "Search..." +msgstr "" + +#: lib/mv_web/components/layouts/navbar.ex:15 +#, elixir-autogen, elixir-format, fuzzy +msgid "Users" +msgstr "Benutzer" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 1262c00..e9da120 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -16,7 +16,7 @@ msgstr "" msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:77 +#: lib/mv_web/live/member_live/index.html.heex:84 #: lib/mv_web/live/user_live/index.html.heex:65 #, elixir-autogen, elixir-format msgid "Are you sure?" @@ -29,19 +29,19 @@ msgid "Attempting to reconnect" msgstr "" #: lib/mv_web/live/member_live/form.ex:25 -#: lib/mv_web/live/member_live/index.html.heex:62 +#: lib/mv_web/live/member_live/index.html.heex:69 #: lib/mv_web/live/member_live/show.ex:37 #, elixir-autogen, elixir-format msgid "City" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:79 +#: lib/mv_web/live/member_live/index.html.heex:86 #: lib/mv_web/live/user_live/index.html.heex:67 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:71 +#: lib/mv_web/live/member_live/index.html.heex:78 #: lib/mv_web/live/user_live/form.ex:109 #: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format @@ -55,7 +55,7 @@ msgid "Edit Member" msgstr "" #: lib/mv_web/live/member_live/form.ex:18 -#: lib/mv_web/live/member_live/index.html.heex:58 +#: lib/mv_web/live/member_live/index.html.heex:65 #: lib/mv_web/live/member_live/show.ex:28 #: lib/mv_web/live/user_live/form.ex:14 #: lib/mv_web/live/user_live/index.html.heex:44 @@ -71,7 +71,7 @@ msgid "First Name" msgstr "" #: lib/mv_web/live/member_live/form.ex:22 -#: lib/mv_web/live/member_live/index.html.heex:64 +#: lib/mv_web/live/member_live/index.html.heex:71 #: lib/mv_web/live/member_live/show.ex:34 #, elixir-autogen, elixir-format msgid "Join Date" @@ -88,7 +88,7 @@ msgstr "" msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:68 +#: lib/mv_web/live/member_live/index.html.heex:75 #: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" @@ -128,7 +128,7 @@ msgid "Exit Date" msgstr "" #: lib/mv_web/live/member_live/form.ex:27 -#: lib/mv_web/live/member_live/index.html.heex:60 +#: lib/mv_web/live/member_live/index.html.heex:67 #: lib/mv_web/live/member_live/show.ex:39 #, elixir-autogen, elixir-format msgid "House Number" @@ -147,14 +147,14 @@ msgid "Paid" msgstr "" #: lib/mv_web/live/member_live/form.ex:21 -#: lib/mv_web/live/member_live/index.html.heex:63 +#: lib/mv_web/live/member_live/index.html.heex:70 #: lib/mv_web/live/member_live/show.ex:33 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "" #: lib/mv_web/live/member_live/form.ex:28 -#: lib/mv_web/live/member_live/index.html.heex:61 +#: lib/mv_web/live/member_live/index.html.heex:68 #: lib/mv_web/live/member_live/show.ex:40 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -174,7 +174,7 @@ msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:26 -#: lib/mv_web/live/member_live/index.html.heex:59 +#: lib/mv_web/live/member_live/index.html.heex:66 #: lib/mv_web/live/member_live/show.ex:38 #, elixir-autogen, elixir-format msgid "Street" @@ -302,7 +302,7 @@ msgstr "" msgid "Immutable" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:87 +#: lib/mv_web/components/layouts/navbar.ex:88 #, elixir-autogen, elixir-format msgid "Logout" msgstr "" @@ -319,13 +319,13 @@ msgid "Member" msgstr "" #: lib/mv_web/components/layouts/navbar.ex:14 -#: lib/mv_web/live/member_live/index.ex:12 +#: lib/mv_web/live/member_live/index.ex:14 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:50 +#: lib/mv_web/live/member_live/index.html.heex:57 #: lib/mv_web/live/property_type_live/form.ex:16 #, elixir-autogen, elixir-format msgid "Name" @@ -368,7 +368,7 @@ msgstr "" msgid "Please select a property type first" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:83 +#: lib/mv_web/components/layouts/navbar.ex:84 #, elixir-autogen, elixir-format msgid "Profil" msgstr "" @@ -403,17 +403,17 @@ msgstr "" msgid "Save Property type" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:27 +#: lib/mv_web/live/member_live/index.html.heex:34 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:41 +#: lib/mv_web/live/member_live/index.html.heex:48 #, elixir-autogen, elixir-format msgid "Select member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:86 +#: lib/mv_web/components/layouts/navbar.ex:87 #, elixir-autogen, elixir-format msgid "Settings" msgstr "" @@ -586,14 +586,25 @@ msgstr "" msgid "Back to users list" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:21 -#: lib/mv_web/components/layouts/navbar.ex:27 +#: lib/mv_web/components/layouts/navbar.ex:22 +#: lib/mv_web/components/layouts/navbar.ex:28 #, elixir-autogen, elixir-format msgid "Select language" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:34 -#: lib/mv_web/components/layouts/navbar.ex:54 +#: lib/mv_web/components/layouts/navbar.ex:35 +#: lib/mv_web/components/layouts/navbar.ex:55 #, elixir-autogen, elixir-format msgid "Toggle dark mode" msgstr "" + +#: lib/mv_web/live/components/search_bar_component.ex:15 +#: lib/mv_web/live/member_live/index.html.heex:15 +#, elixir-autogen, elixir-format +msgid "Search..." +msgstr "" + +#: lib/mv_web/components/layouts/navbar.ex:15 +#, elixir-autogen, elixir-format +msgid "Users" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 933d6b8..4119061 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -16,7 +16,7 @@ msgstr "" msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:77 +#: lib/mv_web/live/member_live/index.html.heex:84 #: lib/mv_web/live/user_live/index.html.heex:65 #, elixir-autogen, elixir-format msgid "Are you sure?" @@ -29,19 +29,19 @@ msgid "Attempting to reconnect" msgstr "" #: lib/mv_web/live/member_live/form.ex:25 -#: lib/mv_web/live/member_live/index.html.heex:62 +#: lib/mv_web/live/member_live/index.html.heex:69 #: lib/mv_web/live/member_live/show.ex:37 #, elixir-autogen, elixir-format msgid "City" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:79 +#: lib/mv_web/live/member_live/index.html.heex:86 #: lib/mv_web/live/user_live/index.html.heex:67 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:71 +#: lib/mv_web/live/member_live/index.html.heex:78 #: lib/mv_web/live/user_live/form.ex:109 #: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format @@ -55,7 +55,7 @@ msgid "Edit Member" msgstr "" #: lib/mv_web/live/member_live/form.ex:18 -#: lib/mv_web/live/member_live/index.html.heex:58 +#: lib/mv_web/live/member_live/index.html.heex:65 #: lib/mv_web/live/member_live/show.ex:28 #: lib/mv_web/live/user_live/form.ex:14 #: lib/mv_web/live/user_live/index.html.heex:44 @@ -71,7 +71,7 @@ msgid "First Name" msgstr "" #: lib/mv_web/live/member_live/form.ex:22 -#: lib/mv_web/live/member_live/index.html.heex:64 +#: lib/mv_web/live/member_live/index.html.heex:71 #: lib/mv_web/live/member_live/show.ex:34 #, elixir-autogen, elixir-format msgid "Join Date" @@ -88,7 +88,7 @@ msgstr "" msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:68 +#: lib/mv_web/live/member_live/index.html.heex:75 #: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" @@ -128,7 +128,7 @@ msgid "Exit Date" msgstr "" #: lib/mv_web/live/member_live/form.ex:27 -#: lib/mv_web/live/member_live/index.html.heex:60 +#: lib/mv_web/live/member_live/index.html.heex:67 #: lib/mv_web/live/member_live/show.ex:39 #, elixir-autogen, elixir-format msgid "House Number" @@ -147,14 +147,14 @@ msgid "Paid" msgstr "" #: lib/mv_web/live/member_live/form.ex:21 -#: lib/mv_web/live/member_live/index.html.heex:63 +#: lib/mv_web/live/member_live/index.html.heex:70 #: lib/mv_web/live/member_live/show.ex:33 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "" #: lib/mv_web/live/member_live/form.ex:28 -#: lib/mv_web/live/member_live/index.html.heex:61 +#: lib/mv_web/live/member_live/index.html.heex:68 #: lib/mv_web/live/member_live/show.ex:40 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -174,7 +174,7 @@ msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:26 -#: lib/mv_web/live/member_live/index.html.heex:59 +#: lib/mv_web/live/member_live/index.html.heex:66 #: lib/mv_web/live/member_live/show.ex:38 #, elixir-autogen, elixir-format msgid "Street" @@ -302,7 +302,7 @@ msgstr "" msgid "Immutable" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:87 +#: lib/mv_web/components/layouts/navbar.ex:88 #, elixir-autogen, elixir-format msgid "Logout" msgstr "" @@ -319,13 +319,13 @@ msgid "Member" msgstr "" #: lib/mv_web/components/layouts/navbar.ex:14 -#: lib/mv_web/live/member_live/index.ex:12 +#: lib/mv_web/live/member_live/index.ex:14 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:50 +#: lib/mv_web/live/member_live/index.html.heex:57 #: lib/mv_web/live/property_type_live/form.ex:16 #, elixir-autogen, elixir-format msgid "Name" @@ -368,7 +368,7 @@ msgstr "" msgid "Please select a property type first" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:83 +#: lib/mv_web/components/layouts/navbar.ex:84 #, elixir-autogen, elixir-format msgid "Profil" msgstr "" @@ -403,17 +403,17 @@ msgstr "" msgid "Save Property type" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:27 +#: lib/mv_web/live/member_live/index.html.heex:34 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:41 +#: lib/mv_web/live/member_live/index.html.heex:48 #, elixir-autogen, elixir-format msgid "Select member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:86 +#: lib/mv_web/components/layouts/navbar.ex:87 #, elixir-autogen, elixir-format msgid "Settings" msgstr "" @@ -586,14 +586,25 @@ msgstr "" msgid "Back to users list" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:21 -#: lib/mv_web/components/layouts/navbar.ex:27 +#: lib/mv_web/components/layouts/navbar.ex:22 +#: lib/mv_web/components/layouts/navbar.ex:28 #, elixir-autogen, elixir-format, fuzzy msgid "Select language" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:34 -#: lib/mv_web/components/layouts/navbar.ex:54 +#: lib/mv_web/components/layouts/navbar.ex:35 +#: lib/mv_web/components/layouts/navbar.ex:55 #, elixir-autogen, elixir-format msgid "Toggle dark mode" msgstr "" + +#: lib/mv_web/live/components/search_bar_component.ex:15 +#: lib/mv_web/live/member_live/index.html.heex:15 +#, elixir-autogen, elixir-format +msgid "Search..." +msgstr "" + +#: lib/mv_web/components/layouts/navbar.ex:15 +#, elixir-autogen, elixir-format, fuzzy +msgid "Users" +msgstr "" -- 2.47.2 From cde619543f85c001165984bca7e51c09f8ed4a57 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 16 Oct 2025 13:54:07 +0200 Subject: [PATCH 09/14] translate all error messages --- lib/accounts/user.ex | 6 ++-- priv/gettext/de/LC_MESSAGES/default.po | 26 +++++++-------- priv/gettext/de/LC_MESSAGES/errors.po | 46 ++++++++++++++++++++++++++ priv/gettext/default.pot | 26 +++++++-------- priv/gettext/en/LC_MESSAGES/default.po | 26 +++++++-------- priv/gettext/en/LC_MESSAGES/errors.po | 46 ++++++++++++++++++++++++++ priv/gettext/errors.pot | 46 ++++++++++++++++++++++++++ 7 files changed, 181 insertions(+), 41 deletions(-) diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index c65b882..d50642f 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -162,8 +162,10 @@ defmodule Mv.Accounts.User do # Global validations - applied to all relevant actions validations do # Password strength policy: minimum 8 characters for all password-related actions - validate string_length(:password, min: 8) do - where action_is([:register_with_password, :admin_set_password]) + validate string_length(:password, min: 8), + where: [action_is([:register_with_password, :admin_set_password])], + message: "must have length of at least 8" + end # Prevent overwriting existing member relationship diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 0d7f129..24b3645 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -21,8 +21,8 @@ msgstr "Aktionen" msgid "Are you sure?" msgstr "Bist du sicher?" -#: lib/mv_web/components/layouts.ex:71 -#: lib/mv_web/components/layouts.ex:83 +#: lib/mv_web/components/layouts.ex:80 +#: lib/mv_web/components/layouts.ex:92 #, elixir-autogen, elixir-format msgid "Attempting to reconnect" msgstr "Verbindung wird wiederhergestellt" @@ -93,12 +93,12 @@ msgstr "Neues Mitglied" msgid "Show" msgstr "Anzeigen" -#: lib/mv_web/components/layouts.ex:78 +#: lib/mv_web/components/layouts.ex:87 #, elixir-autogen, elixir-format msgid "Something went wrong!" msgstr "Etwas ist schiefgelaufen!" -#: lib/mv_web/components/layouts.ex:66 +#: lib/mv_web/components/layouts.ex:75 #, elixir-autogen, elixir-format msgid "We can't find the internet" msgstr "Keine Internetverbindung gefunden" @@ -301,7 +301,7 @@ msgstr "ID" msgid "Immutable" msgstr "Unveränderlich" -#: lib/mv_web/components/layouts/navbar.ex:88 +#: lib/mv_web/components/layouts/navbar.ex:94 #, elixir-autogen, elixir-format msgid "Logout" msgstr "Abmelden" @@ -317,7 +317,7 @@ msgstr "Benutzer auflisten" msgid "Member" msgstr "Mitglied" -#: lib/mv_web/components/layouts/navbar.ex:14 +#: lib/mv_web/components/layouts/navbar.ex:19 #: lib/mv_web/live/member_live/index.ex:14 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format @@ -367,7 +367,7 @@ msgstr "Passwort-Authentifizierung" msgid "Please select a property type first" msgstr "Bitte wählen Sie zuerst einen Eigenschaftstyp" -#: lib/mv_web/components/layouts/navbar.ex:84 +#: lib/mv_web/components/layouts/navbar.ex:89 #, elixir-autogen, elixir-format msgid "Profil" msgstr "Profil" @@ -412,7 +412,7 @@ msgstr "Alle Mitglieder auswählen" msgid "Select member" msgstr "Mitglied auswählen" -#: lib/mv_web/components/layouts/navbar.ex:87 +#: lib/mv_web/components/layouts/navbar.ex:92 #, elixir-autogen, elixir-format msgid "Settings" msgstr "Einstellungen" @@ -585,14 +585,14 @@ msgstr "Zurück zur Mitgliederliste" msgid "Back to users list" msgstr "Zurück zur Benutzerliste" -#: lib/mv_web/components/layouts/navbar.ex:22 -#: lib/mv_web/components/layouts/navbar.ex:28 +#: lib/mv_web/components/layouts/navbar.ex:27 +#: lib/mv_web/components/layouts/navbar.ex:33 #, elixir-autogen, elixir-format msgid "Select language" msgstr "Sprache auswählen" -#: lib/mv_web/components/layouts/navbar.ex:35 -#: lib/mv_web/components/layouts/navbar.ex:55 +#: lib/mv_web/components/layouts/navbar.ex:40 +#: lib/mv_web/components/layouts/navbar.ex:60 #, elixir-autogen, elixir-format msgid "Toggle dark mode" msgstr "Dunklen Modus umschalten" @@ -603,7 +603,7 @@ msgstr "Dunklen Modus umschalten" msgid "Search..." msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:15 +#: lib/mv_web/components/layouts/navbar.ex:20 #, elixir-autogen, elixir-format, fuzzy msgid "Users" msgstr "Benutzer" diff --git a/priv/gettext/de/LC_MESSAGES/errors.po b/priv/gettext/de/LC_MESSAGES/errors.po index b9332ab..e7eb139 100644 --- a/priv/gettext/de/LC_MESSAGES/errors.po +++ b/priv/gettext/de/LC_MESSAGES/errors.po @@ -110,3 +110,49 @@ msgstr "muss größer oder gleich %{number} sein" msgid "must be equal to %{number}" msgstr "muss gleich %{number} sein" + +## Ash Framework - Standard constraint messages +msgid "length must be greater than or equal to %{min}" +msgstr "muss mindestens %{min} Zeichen lang sein" + +msgid "length must be less than or equal to %{max}" +msgstr "darf höchstens %{max} Zeichen lang sein" + +msgid "must be present" +msgstr "muss vorhanden sein" + +## Custom validation messages from Mv.Accounts.User +msgid "User already has a member. Remove existing member first." +msgstr "Benutzer hat bereits ein Mitglied. Entfernen Sie zuerst das vorhandene Mitglied." + +msgid "OIDC user_info must contain a non-empty 'sub' or 'id' field" +msgstr "OIDC user_info darf kein leeres 'sub' oder 'id' Feld enthalten" + + +## Custom validation messages from Mv.Membership.Member +msgid "User is already linked to another member" +msgstr "Benutzer ist bereits mit einem anderen Mitglied verknüpft" + +msgid "User not found" +msgstr "Benutzer nicht gefunden" + +msgid "cannot be in the future" +msgstr "darf nicht in der Zukunft liegen" + +msgid "cannot be before join date" +msgstr "darf nicht vor dem Beitrittsdatum liegen" + +msgid "is not a valid phone number" +msgstr "ist keine gültige Telefonnummer" + +msgid "must consist of 5 digits" +msgstr "muss aus 5 Ziffern bestehen" + +msgid "is not a valid email" +msgstr "ist keine gültige E-Mail-Adresse" + +msgid "must have length of at least 8" +msgstr "muss mindestens 8 Zeichen lang sein" + +msgid "is required" +msgstr "ist erforderlich" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index e9da120..93c5d95 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -22,8 +22,8 @@ msgstr "" msgid "Are you sure?" msgstr "" -#: lib/mv_web/components/layouts.ex:71 -#: lib/mv_web/components/layouts.ex:83 +#: lib/mv_web/components/layouts.ex:80 +#: lib/mv_web/components/layouts.ex:92 #, elixir-autogen, elixir-format msgid "Attempting to reconnect" msgstr "" @@ -94,12 +94,12 @@ msgstr "" msgid "Show" msgstr "" -#: lib/mv_web/components/layouts.ex:78 +#: lib/mv_web/components/layouts.ex:87 #, elixir-autogen, elixir-format msgid "Something went wrong!" msgstr "" -#: lib/mv_web/components/layouts.ex:66 +#: lib/mv_web/components/layouts.ex:75 #, elixir-autogen, elixir-format msgid "We can't find the internet" msgstr "" @@ -302,7 +302,7 @@ msgstr "" msgid "Immutable" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:88 +#: lib/mv_web/components/layouts/navbar.ex:94 #, elixir-autogen, elixir-format msgid "Logout" msgstr "" @@ -318,7 +318,7 @@ msgstr "" msgid "Member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:14 +#: lib/mv_web/components/layouts/navbar.ex:19 #: lib/mv_web/live/member_live/index.ex:14 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format @@ -368,7 +368,7 @@ msgstr "" msgid "Please select a property type first" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:84 +#: lib/mv_web/components/layouts/navbar.ex:89 #, elixir-autogen, elixir-format msgid "Profil" msgstr "" @@ -413,7 +413,7 @@ msgstr "" msgid "Select member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:87 +#: lib/mv_web/components/layouts/navbar.ex:92 #, elixir-autogen, elixir-format msgid "Settings" msgstr "" @@ -586,14 +586,14 @@ msgstr "" msgid "Back to users list" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:22 -#: lib/mv_web/components/layouts/navbar.ex:28 +#: lib/mv_web/components/layouts/navbar.ex:27 +#: lib/mv_web/components/layouts/navbar.ex:33 #, elixir-autogen, elixir-format msgid "Select language" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:35 -#: lib/mv_web/components/layouts/navbar.ex:55 +#: lib/mv_web/components/layouts/navbar.ex:40 +#: lib/mv_web/components/layouts/navbar.ex:60 #, elixir-autogen, elixir-format msgid "Toggle dark mode" msgstr "" @@ -604,7 +604,7 @@ msgstr "" msgid "Search..." msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:15 +#: lib/mv_web/components/layouts/navbar.ex:20 #, elixir-autogen, elixir-format msgid "Users" msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 4119061..ac30f5d 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -22,8 +22,8 @@ msgstr "" msgid "Are you sure?" msgstr "" -#: lib/mv_web/components/layouts.ex:71 -#: lib/mv_web/components/layouts.ex:83 +#: lib/mv_web/components/layouts.ex:80 +#: lib/mv_web/components/layouts.ex:92 #, elixir-autogen, elixir-format msgid "Attempting to reconnect" msgstr "" @@ -94,12 +94,12 @@ msgstr "" msgid "Show" msgstr "" -#: lib/mv_web/components/layouts.ex:78 +#: lib/mv_web/components/layouts.ex:87 #, elixir-autogen, elixir-format msgid "Something went wrong!" msgstr "" -#: lib/mv_web/components/layouts.ex:66 +#: lib/mv_web/components/layouts.ex:75 #, elixir-autogen, elixir-format msgid "We can't find the internet" msgstr "" @@ -302,7 +302,7 @@ msgstr "" msgid "Immutable" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:88 +#: lib/mv_web/components/layouts/navbar.ex:94 #, elixir-autogen, elixir-format msgid "Logout" msgstr "" @@ -318,7 +318,7 @@ msgstr "" msgid "Member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:14 +#: lib/mv_web/components/layouts/navbar.ex:19 #: lib/mv_web/live/member_live/index.ex:14 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format @@ -368,7 +368,7 @@ msgstr "" msgid "Please select a property type first" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:84 +#: lib/mv_web/components/layouts/navbar.ex:89 #, elixir-autogen, elixir-format msgid "Profil" msgstr "" @@ -413,7 +413,7 @@ msgstr "" msgid "Select member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:87 +#: lib/mv_web/components/layouts/navbar.ex:92 #, elixir-autogen, elixir-format msgid "Settings" msgstr "" @@ -586,14 +586,14 @@ msgstr "" msgid "Back to users list" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:22 -#: lib/mv_web/components/layouts/navbar.ex:28 +#: lib/mv_web/components/layouts/navbar.ex:27 +#: lib/mv_web/components/layouts/navbar.ex:33 #, elixir-autogen, elixir-format, fuzzy msgid "Select language" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:35 -#: lib/mv_web/components/layouts/navbar.ex:55 +#: lib/mv_web/components/layouts/navbar.ex:40 +#: lib/mv_web/components/layouts/navbar.ex:60 #, elixir-autogen, elixir-format msgid "Toggle dark mode" msgstr "" @@ -604,7 +604,7 @@ msgstr "" msgid "Search..." msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:15 +#: lib/mv_web/components/layouts/navbar.ex:20 #, elixir-autogen, elixir-format, fuzzy msgid "Users" msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po index 844c4f5..37e99e2 100644 --- a/priv/gettext/en/LC_MESSAGES/errors.po +++ b/priv/gettext/en/LC_MESSAGES/errors.po @@ -110,3 +110,49 @@ msgstr "" msgid "must be equal to %{number}" msgstr "" + +## Ash Framework - Standard constraint messages +msgid "length must be greater than or equal to %{min}" +msgstr "" + +msgid "length must be less than or equal to %{max}" +msgstr "" + +msgid "must be present" +msgstr "" + +## Custom validation messages from Mv.Accounts.User +msgid "User already has a member. Remove existing member first." +msgstr "" + +msgid "OIDC user_info must contain a non-empty 'sub' or 'id' field" +msgstr "" + + +## Custom validation messages from Mv.Membership.Member +msgid "User is already linked to another member" +msgstr "" + +msgid "User not found" +msgstr "" + +msgid "cannot be in the future" +msgstr "" + +msgid "cannot be before join date" +msgstr "" + +msgid "is not a valid phone number" +msgstr "" + +msgid "must consist of 5 digits" +msgstr "" + +msgid "is not a valid email" +msgstr "" + +msgid "must have length of at least 8" +msgstr "" + +msgid "is required" +msgstr "" diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot index eef2de2..d60c8df 100644 --- a/priv/gettext/errors.pot +++ b/priv/gettext/errors.pot @@ -107,3 +107,49 @@ msgstr "" msgid "must be equal to %{number}" msgstr "" + +## Ash Framework - Standard constraint messages +msgid "length must be greater than or equal to %{min}" +msgstr "" + +msgid "length must be less than or equal to %{max}" +msgstr "" + +msgid "must be present" +msgstr "" + +## Custom validation messages from Mv.Accounts.User +msgid "User already has a member. Remove existing member first." +msgstr "" + +msgid "OIDC user_info must contain a non-empty 'sub' or 'id' field" +msgstr "" + + +## Custom validation messages from Mv.Membership.Member +msgid "User is already linked to another member" +msgstr "" + +msgid "User not found" +msgstr "" + +msgid "cannot be in the future" +msgstr "" + +msgid "cannot be before join date" +msgstr "" + +msgid "is not a valid phone number" +msgstr "" + +msgid "must consist of 5 digits" +msgstr "" + +msgid "is not a valid email" +msgstr "" + +msgid "must have length of at least 8" +msgstr "" + +msgid "is required" +msgstr "" -- 2.47.2 From 3b0c1da1ab3b4b0f93943e69171b0d84095283be Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 16 Oct 2025 13:54:57 +0200 Subject: [PATCH 10/14] User email validation --- lib/accounts/user.ex | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index d50642f..bc64e39 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -166,6 +166,28 @@ defmodule Mv.Accounts.User do where: [action_is([:register_with_password, :admin_set_password])], message: "must have length of at least 8" + # Email validation with EctoCommons.EmailValidator (same as Member) + # This ensures consistency between User and Member email validation + validate fn changeset, _ -> + # Get email from attribute (Ash.CiString) and convert to string + email = Ash.Changeset.get_attribute(changeset, :email) + email_string = if email, do: to_string(email), else: nil + + # Only validate if email is present + if email_string do + changeset2 = + {%{}, %{email: :string}} + |> Ecto.Changeset.cast(%{email: email_string}, [:email]) + |> EctoCommons.EmailValidator.validate_email(:email, checks: [:html_input, :pow]) + + if changeset2.valid? do + :ok + else + {:error, field: :email, message: "is not a valid email"} + end + else + :ok + end end # Prevent overwriting existing member relationship @@ -204,7 +226,13 @@ defmodule Mv.Accounts.User do attributes do uuid_primary_key :id - attribute :email, :ci_string, allow_nil?: false, public?: true + attribute :email, :ci_string do + allow_nil? false + public? true + # Same constraints as Member email for consistency + constraints min_length: 5, max_length: 254 + end + attribute :hashed_password, :string, sensitive?: true, allow_nil?: true attribute :oidc_id, :string, allow_nil?: true end -- 2.47.2 From b47b0d36b5e7507862ad18b1b8359e67db2dda77 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 16 Oct 2025 14:22:58 +0200 Subject: [PATCH 11/14] gender neutral translation --- priv/gettext/de/LC_MESSAGES/auth.po | 4 +-- priv/gettext/de/LC_MESSAGES/default.po | 38 +++++++++++++------------- priv/gettext/de/LC_MESSAGES/errors.po | 7 ++--- priv/gettext/en/LC_MESSAGES/errors.po | 1 - priv/gettext/errors.pot | 1 - test/mv_web/user_live/form_test.exs | 2 +- test/mv_web/user_live/index_test.exs | 6 ++-- 7 files changed, 28 insertions(+), 31 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/auth.po b/priv/gettext/de/LC_MESSAGES/auth.po index b794f37..f7eef3e 100644 --- a/priv/gettext/de/LC_MESSAGES/auth.po +++ b/priv/gettext/de/LC_MESSAGES/auth.po @@ -27,10 +27,10 @@ msgid "Forgot your password?" msgstr "Passwort vergessen?" msgid "If this user exists in our database you will contacted with a sign-in link shortly." -msgstr "Falls dieser Benutzer bekannt ist, wird jetzt eine Email mit Anmelde-Link versendet." +msgstr "Falls diese*r Benutzer*in bekannt ist, wird jetzt eine Email mit Anmelde-Link versendet." msgid "If this user exists in our system, you will be contacted with reset instructions shortly." -msgstr "Falls dieser Benutzer bekannt ist, wird jetzt eine Email mit einer Anleitung zum Zurücksetzen versendet." +msgstr "Falls diese*r Benutzer*in bekannt ist, wird jetzt eine Email mit einer Anleitung zum Zurücksetzen versendet." msgid "Need an account?" msgstr "Konto anlegen?" diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 24b3645..bd86f61 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -284,7 +284,7 @@ msgstr "Beschreibung" #: lib/mv_web/live/user_live/show.ex:18 #, elixir-autogen, elixir-format msgid "Edit User" -msgstr "Benutzer bearbeiten" +msgstr "Benutzer*in bearbeiten" #: lib/mv_web/live/user_live/show.ex:28 #, elixir-autogen, elixir-format @@ -310,7 +310,7 @@ msgstr "Abmelden" #: lib/mv_web/live/user_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Listing Users" -msgstr "Benutzer auflisten" +msgstr "Benutzer*innen auflisten" #: lib/mv_web/live/property_live/form.ex:27 #, elixir-autogen, elixir-format @@ -333,7 +333,7 @@ msgstr "Name" #: lib/mv_web/live/user_live/index.html.heex:6 #, elixir-autogen, elixir-format msgid "New User" -msgstr "Neuer Benutzer" +msgstr "Neue*r Benutzer*in" #: lib/mv_web/live/user_live/show.ex:28 #, elixir-autogen, elixir-format @@ -420,17 +420,17 @@ msgstr "Einstellungen" #: lib/mv_web/live/user_live/form.ex:93 #, elixir-autogen, elixir-format msgid "Save User" -msgstr "Benutzer speichern" +msgstr "Benutzer*in speichern" #: lib/mv_web/live/user_live/show.ex:54 #, elixir-autogen, elixir-format msgid "Show User" -msgstr "Benutzer anzeigen" +msgstr "Benutzer*in anzeigen" #: lib/mv_web/live/user_live/show.ex:10 #, elixir-autogen, elixir-format msgid "This is a user record from your database." -msgstr "Dies ist ein Benutzer-Datensatz aus Ihrer Datenbank." +msgstr "Dies ist ein Benutzer*innen-Datensatz aus Ihrer Datenbank." #: lib/mv_web/live/property_live/form.ex:95 #, elixir-autogen, elixir-format @@ -450,13 +450,13 @@ msgstr "Dieses Formular dient zur Verwaltung von Eigenschaftstypen in der Datenb #: lib/mv_web/live/user_live/form.ex:10 #, elixir-autogen, elixir-format msgid "Use this form to manage user records in your database." -msgstr "Verwenden Sie dieses Formular, um Benutzer-Datensätze zu verwalten." +msgstr "Verwenden Sie dieses Formular, um Benutzer*innen-Datensätze zu verwalten." #: lib/mv_web/live/user_live/form.ex:110 #: lib/mv_web/live/user_live/show.ex:9 #, elixir-autogen, elixir-format msgid "User" -msgstr "Benutzer" +msgstr "Benutzer*in" #: lib/mv_web/live/property_live/form.ex:59 #, elixir-autogen, elixir-format @@ -481,17 +481,17 @@ msgstr "absteigend" #: lib/mv_web/live/user_live/form.ex:109 #, elixir-autogen, elixir-format msgid "New" -msgstr "Neuer" +msgstr "Neue*r" #: lib/mv_web/live/user_live/form.ex:64 #, elixir-autogen, elixir-format msgid "Admin Note" -msgstr "Administrator-Hinweis" +msgstr "Administrator*innen-Hinweis" #: lib/mv_web/live/user_live/form.ex:64 #, elixir-autogen, elixir-format msgid "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system." -msgstr "Als Administrator können Sie direkt ein neues Passwort für diesen Benutzer setzen, wobei das gleiche sichere Ash Authentication System verwendet wird." +msgstr "Als Administrator*in können Sie direkt ein neues Passwort für diese*n Benutzer*in setzen, wobei das gleiche sichere Ash Authentication System verwendet wird." #: lib/mv_web/live/user_live/form.ex:55 #, elixir-autogen, elixir-format @@ -506,7 +506,7 @@ msgstr "Passwort ändern" #: lib/mv_web/live/user_live/form.ex:75 #, elixir-autogen, elixir-format msgid "Check 'Change Password' above to set a new password for this user." -msgstr "Aktivieren Sie 'Passwort ändern' oben, um ein neues Passwort für diesen Benutzer zu setzen." +msgstr "Aktivieren Sie 'Passwort ändern' oben, um ein neues Passwort für diese*n Benutzer*in zu setzen." #: lib/mv_web/live/user_live/form.ex:45 #, elixir-autogen, elixir-format @@ -536,12 +536,12 @@ msgstr "Passwort-Anforderungen" #: lib/mv_web/live/user_live/index.html.heex:21 #, elixir-autogen, elixir-format msgid "Select all users" -msgstr "Alle Benutzer auswählen" +msgstr "Alle Benutzer*innen auswählen" #: lib/mv_web/live/user_live/index.html.heex:35 #, elixir-autogen, elixir-format msgid "Select user" -msgstr "Benutzer auswählen" +msgstr "Benutzer*in auswählen" #: lib/mv_web/live/user_live/form.ex:27 #, elixir-autogen, elixir-format @@ -551,7 +551,7 @@ msgstr "Passwort setzen" #: lib/mv_web/live/user_live/form.ex:83 #, elixir-autogen, elixir-format msgid "User will be created without a password. Check 'Set Password' to add one." -msgstr "Benutzer wird ohne Passwort erstellt. Aktivieren Sie 'Passwort setzen', um eines hinzuzufügen." +msgstr "Benutzer*in wird ohne Passwort erstellt. Aktivieren Sie 'Passwort setzen', um eines hinzuzufügen." #: lib/mv_web/live/user_live/show.ex:30 #, elixir-autogen, elixir-format @@ -561,7 +561,7 @@ msgstr "Verknüpftes Mitglied" #: lib/mv_web/live/member_live/show.ex:41 #, elixir-autogen, elixir-format msgid "Linked User" -msgstr "Verknüpfter Benutzer" +msgstr "Verknüpfte*r Benutzer*in" #: lib/mv_web/live/user_live/show.ex:40 #, elixir-autogen, elixir-format @@ -571,7 +571,7 @@ msgstr "Kein Mitglied verknüpft" #: lib/mv_web/live/member_live/show.ex:51 #, elixir-autogen, elixir-format msgid "No user linked" -msgstr "Kein Benutzer verknüpft" +msgstr "Keine*r Benutzer*in verknüpft" #: lib/mv_web/live/member_live/show.ex:14 #: lib/mv_web/live/member_live/show.ex:16 @@ -583,7 +583,7 @@ msgstr "Zurück zur Mitgliederliste" #: lib/mv_web/live/user_live/show.ex:15 #, elixir-autogen, elixir-format msgid "Back to users list" -msgstr "Zurück zur Benutzerliste" +msgstr "Zurück zur Benutzer*innen-Liste" #: lib/mv_web/components/layouts/navbar.ex:27 #: lib/mv_web/components/layouts/navbar.ex:33 @@ -606,4 +606,4 @@ msgstr "" #: lib/mv_web/components/layouts/navbar.ex:20 #, elixir-autogen, elixir-format, fuzzy msgid "Users" -msgstr "Benutzer" +msgstr "Benutzer*innen" diff --git a/priv/gettext/de/LC_MESSAGES/errors.po b/priv/gettext/de/LC_MESSAGES/errors.po index e7eb139..e0db8dd 100644 --- a/priv/gettext/de/LC_MESSAGES/errors.po +++ b/priv/gettext/de/LC_MESSAGES/errors.po @@ -123,18 +123,17 @@ msgstr "muss vorhanden sein" ## Custom validation messages from Mv.Accounts.User msgid "User already has a member. Remove existing member first." -msgstr "Benutzer hat bereits ein Mitglied. Entfernen Sie zuerst das vorhandene Mitglied." +msgstr "Benutzer*in hat bereits ein Mitglied. Entfernen Sie zuerst das vorhandene Mitglied." msgid "OIDC user_info must contain a non-empty 'sub' or 'id' field" msgstr "OIDC user_info darf kein leeres 'sub' oder 'id' Feld enthalten" - ## Custom validation messages from Mv.Membership.Member msgid "User is already linked to another member" -msgstr "Benutzer ist bereits mit einem anderen Mitglied verknüpft" +msgstr "Benutzer*in ist bereits mit einem anderen Mitglied verknüpft" msgid "User not found" -msgstr "Benutzer nicht gefunden" +msgstr "Benutzer*in nicht gefunden" msgid "cannot be in the future" msgstr "darf nicht in der Zukunft liegen" diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po index 37e99e2..62df4a7 100644 --- a/priv/gettext/en/LC_MESSAGES/errors.po +++ b/priv/gettext/en/LC_MESSAGES/errors.po @@ -128,7 +128,6 @@ msgstr "" msgid "OIDC user_info must contain a non-empty 'sub' or 'id' field" msgstr "" - ## Custom validation messages from Mv.Membership.Member msgid "User is already linked to another member" msgstr "" diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot index d60c8df..8f522c0 100644 --- a/priv/gettext/errors.pot +++ b/priv/gettext/errors.pot @@ -125,7 +125,6 @@ msgstr "" msgid "OIDC user_info must contain a non-empty 'sub' or 'id' field" msgstr "" - ## Custom validation messages from Mv.Membership.Member msgid "User is already linked to another member" msgstr "" diff --git a/test/mv_web/user_live/form_test.exs b/test/mv_web/user_live/form_test.exs index decc789..111ff42 100644 --- a/test/mv_web/user_live/form_test.exs +++ b/test/mv_web/user_live/form_test.exs @@ -255,7 +255,7 @@ defmodule MvWeb.UserLive.FormTest do conn = Plug.Test.init_test_session(conn, locale: "de") {:ok, _view, html} = live(conn, "/users/new") - assert html =~ "Neuer Benutzer" + assert html =~ "Neue*r Benutzer*in" assert html =~ "E-Mail" assert html =~ "Passwort setzen" end diff --git a/test/mv_web/user_live/index_test.exs b/test/mv_web/user_live/index_test.exs index bb78377..6393e3b 100644 --- a/test/mv_web/user_live/index_test.exs +++ b/test/mv_web/user_live/index_test.exs @@ -7,7 +7,7 @@ defmodule MvWeb.UserLive.IndexTest do conn = conn_with_oidc_user(conn) conn = Plug.Test.init_test_session(conn, locale: "de") {:ok, _view, html} = live(conn, "/users") - assert html =~ "Benutzer auflisten" + assert html =~ "Benutzer*innen auflisten" end test "shows translated title in English", %{conn: conn} do @@ -362,8 +362,8 @@ defmodule MvWeb.UserLive.IndexTest do conn = Plug.Test.init_test_session(conn, locale: "de") {:ok, _view, html} = live(conn, "/users") - assert html =~ "Alle Benutzer auswählen" - assert html =~ "Benutzer auswählen" + assert html =~ "Alle Benutzer*innen auswählen" + assert html =~ "Benutzer*in auswählen" end test "shows English translations for selection", %{conn: conn} do -- 2.47.2 From 59a8067c09f2d9690fe0884316a298fa6d7ca52f Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 16 Oct 2025 14:46:37 +0200 Subject: [PATCH 12/14] add some comments --- lib/accounts/user.ex | 25 ++++++++++++++++++- lib/mv_web/components/layouts/navbar.ex | 5 ++-- .../20250926164519_member_relation.exs | 2 ++ ...0926180341_add_unique_email_to_members.exs | 2 ++ .../user_member_relationship_test.exs | 4 ++- 5 files changed, 33 insertions(+), 5 deletions(-) diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index bc64e39..58cdfde 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -60,14 +60,33 @@ defmodule Mv.Accounts.User do end actions do + # Default actions kept for framework/tooling integration: + # - :create -> Used by AshAdmin's generated "Create" UI and by generic + # AshPhoenix helpers that assume a default create action. + # It does NOT manage the :member relationship. For admin + # flows that may link an existing member, use :create_user. + # - :read -> Standard read used across the app and by admin tooling. + # - :destroy-> Standard delete used by admin tooling and maintenance tasks. defaults [:read, :create, :destroy] + # Primary generic update action: + # - Selected by AshAdmin's generated "Edit" UI and generic AshPhoenix + # helpers that assume a default update action. + # - Intended for simple attribute updates (e.g., :email) and scenarios + # that do NOT need to manage the :member relationship. + # - For linking/unlinking a member (and the related validations), prefer + # the specialized :update_user action below. update :update do primary? true + + # Required because custom validation functions (email validation, member relationship validation) + # cannot be executed atomically. These validations need to query the database and perform + # complex checks that are not supported in atomic operations. require_atomic? false end create :create_user do + description "Creates a new user with optional member relationship. The member relationship is managed through the :member argument." # Only accept email directly - member_id is NOT in accept list # This prevents direct foreign key manipulation, forcing use of manage_relationship accept [:email] @@ -89,12 +108,16 @@ defmodule Mv.Accounts.User do end update :update_user do + description "Updates a user and manages the optional member relationship. To change an existing member link, first remove it (set member to nil), then add the new one." # Only accept email directly - member_id is NOT in accept list # This prevents direct foreign key manipulation, forcing use of manage_relationship accept [:email] # Allow member to be passed as argument for relationship management argument :member, :map, allow_nil?: true - # Required because custom validation function cannot be done atomically + + # Required because custom validation functions (email validation, member relationship validation) + # cannot be executed atomically. These validations need to query the database and perform + # complex checks that are not supported in atomic operations. require_atomic? false # Manage the member relationship during user update diff --git a/lib/mv_web/components/layouts/navbar.ex b/lib/mv_web/components/layouts/navbar.ex index 91caa8e..9fec3f4 100644 --- a/lib/mv_web/components/layouts/navbar.ex +++ b/lib/mv_web/components/layouts/navbar.ex @@ -16,9 +16,8 @@ defmodule MvWeb.Layouts.Navbar do
Mitgliederverwaltung
diff --git a/priv/repo/migrations/20250926164519_member_relation.exs b/priv/repo/migrations/20250926164519_member_relation.exs index daaa24c..1f63f73 100644 --- a/priv/repo/migrations/20250926164519_member_relation.exs +++ b/priv/repo/migrations/20250926164519_member_relation.exs @@ -8,6 +8,8 @@ defmodule Mv.Repo.Migrations.MemberRelation do use Ecto.Migration def up do + # Ensure 1:1 relationship - one user can only be linked to one member + # This prevents multiple users from sharing the same member account create unique_index(:users, [:member_id], name: "users_unique_member_index") end diff --git a/priv/repo/migrations/20250926180341_add_unique_email_to_members.exs b/priv/repo/migrations/20250926180341_add_unique_email_to_members.exs index 51b874f..a33ce2f 100644 --- a/priv/repo/migrations/20250926180341_add_unique_email_to_members.exs +++ b/priv/repo/migrations/20250926180341_add_unique_email_to_members.exs @@ -8,6 +8,8 @@ defmodule Mv.Repo.Migrations.AddUniqueEmailToMembers do use Ecto.Migration def up do + # Ensure email uniqueness across all members + # This supports upsert operations and prevents duplicate member accounts create unique_index(:members, [:email], name: "members_unique_email_index") end diff --git a/test/accounts/user_member_relationship_test.exs b/test/accounts/user_member_relationship_test.exs index 19cbe62..b64f5ec 100644 --- a/test/accounts/user_member_relationship_test.exs +++ b/test/accounts/user_member_relationship_test.exs @@ -1,5 +1,7 @@ defmodule Mv.Accounts.UserMemberRelationshipTest do - use Mv.DataCase, async: false + # Using async: true for faster test execution + # This is safe because all database operations are sandboxed per test + use Mv.DataCase, async: true alias Mv.Accounts alias Mv.Membership -- 2.47.2 From 7c1aeddad4eda8578ffe060beb4e8b0c7fa491be Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 16 Oct 2025 15:12:30 +0200 Subject: [PATCH 13/14] add constraints for member-user and member-property --- lib/accounts/user.ex | 6 + lib/membership/property.ex | 6 + ...nstraints_for_user_member_and_property.exs | 46 ++++ .../repo/members/20251016130855.json | 214 ++++++++++++++++++ .../repo/properties/20251016130855.json | 124 ++++++++++ .../repo/users/20251016130855.json | 141 ++++++++++++ 6 files changed, 537 insertions(+) create mode 100644 priv/repo/migrations/20251016130855_add_constraints_for_user_member_and_property.exs create mode 100644 priv/resource_snapshots/repo/members/20251016130855.json create mode 100644 priv/resource_snapshots/repo/properties/20251016130855.json create mode 100644 priv/resource_snapshots/repo/users/20251016130855.json diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 58cdfde..668ddd4 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -12,6 +12,12 @@ defmodule Mv.Accounts.User do postgres do table "users" repo Mv.Repo + + references do + # When a member is deleted, set the user's member_id to NULL + # This allows users to continue existing even if their linked member is removed + reference :member, on_delete: :nilify + end end @doc """ diff --git a/lib/membership/property.ex b/lib/membership/property.ex index 2c432a8..de096ca 100644 --- a/lib/membership/property.ex +++ b/lib/membership/property.ex @@ -42,4 +42,10 @@ defmodule Mv.Membership.Property do calculations do calculate :value_to_string, :string, expr(value[:value] <> "") end + + # Ensure a member can only have one property per property type + # For example: A member can have only one "email" property, one "phone" property, etc. + identities do + identity :unique_property_per_member, [:member_id, :property_type_id] + end end diff --git a/priv/repo/migrations/20251016130855_add_constraints_for_user_member_and_property.exs b/priv/repo/migrations/20251016130855_add_constraints_for_user_member_and_property.exs new file mode 100644 index 0000000..ee13a71 --- /dev/null +++ b/priv/repo/migrations/20251016130855_add_constraints_for_user_member_and_property.exs @@ -0,0 +1,46 @@ +defmodule Mv.Repo.Migrations.AddConstraintsForUserMemberAndProperty do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + drop constraint(:users, "users_member_id_fkey") + + alter table(:users) do + modify :member_id, + references(:members, + column: :id, + name: "users_member_id_fkey", + type: :uuid, + prefix: "public", + on_delete: :nilify_all + ) + end + + create unique_index(:properties, [:member_id, :property_type_id], + name: "properties_unique_property_per_member_index" + ) + end + + def down do + drop_if_exists unique_index(:properties, [:member_id, :property_type_id], + name: "properties_unique_property_per_member_index" + ) + + drop constraint(:users, "users_member_id_fkey") + + alter table(:users) do + modify :member_id, + references(:members, + column: :id, + name: "users_member_id_fkey", + type: :uuid, + prefix: "public" + ) + end + end +end diff --git a/priv/resource_snapshots/repo/members/20251016130855.json b/priv/resource_snapshots/repo/members/20251016130855.json new file mode 100644 index 0000000..5188002 --- /dev/null +++ b/priv/resource_snapshots/repo/members/20251016130855.json @@ -0,0 +1,214 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "first_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "birth_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "paid", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "phone_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "join_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "exit_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "notes", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "city", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "street", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "house_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "postal_code", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "search_vector", + "type": "tsvector" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "6AAC71BCCA5F112087CEF6877A5BBF74EF8965D5DA4812C44CD6E672F882CC3F", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "members_unique_email_index", + "keys": [ + { + "type": "atom", + "value": "email" + } + ], + "name": "unique_email", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "members" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/properties/20251016130855.json b/priv/resource_snapshots/repo/properties/20251016130855.json new file mode 100644 index 0000000..13958c0 --- /dev/null +++ b/priv/resource_snapshots/repo/properties/20251016130855.json @@ -0,0 +1,124 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "value", + "type": "map" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "properties_member_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "members" + }, + "scale": null, + "size": null, + "source": "member_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "properties_property_type_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "property_types" + }, + "scale": null, + "size": null, + "source": "property_type_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "8F90B1AAD1063CF2BB0BDEBBDFBA86AF0B24D854689FB834BC20DFAB2143A451", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "properties_unique_property_per_member_index", + "keys": [ + { + "type": "atom", + "value": "member_id" + }, + { + "type": "atom", + "value": "property_type_id" + } + ], + "name": "unique_property_per_member", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "properties" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/users/20251016130855.json b/priv/resource_snapshots/repo/users/20251016130855.json new file mode 100644 index 0000000..2698fd5 --- /dev/null +++ b/priv/resource_snapshots/repo/users/20251016130855.json @@ -0,0 +1,141 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "citext" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "hashed_password", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "oidc_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "users_member_id_fkey", + "on_delete": "nilify", + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "members" + }, + "scale": null, + "size": null, + "source": "member_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "1D22936DF847949B543000F3E2E4BDA7D78682AAE6EE0CB9CBD55A4F8F4A7228", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "users_unique_email_index", + "keys": [ + { + "type": "atom", + "value": "email" + } + ], + "name": "unique_email", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "users_unique_member_index", + "keys": [ + { + "type": "atom", + "value": "member_id" + } + ], + "name": "unique_member", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "users_unique_oidc_id_index", + "keys": [ + { + "type": "atom", + "value": "oidc_id" + } + ], + "name": "unique_oidc_id", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "users" +} \ No newline at end of file -- 2.47.2 From 045ae1c3c79f9d509f9217a54261bc2ba643191f Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 16 Oct 2025 15:24:49 +0200 Subject: [PATCH 14/14] add tests for member deletion --- test/accounts/user_member_deletion_test.exs | 88 +++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 test/accounts/user_member_deletion_test.exs diff --git a/test/accounts/user_member_deletion_test.exs b/test/accounts/user_member_deletion_test.exs new file mode 100644 index 0000000..52a3865 --- /dev/null +++ b/test/accounts/user_member_deletion_test.exs @@ -0,0 +1,88 @@ +defmodule Mv.Accounts.UserMemberDeletionTest do + @moduledoc """ + Tests for ON DELETE SET NULL constraint on users.member_id. + When a member is deleted, the linked user should remain but with member_id set to NULL. + """ + use Mv.DataCase, async: true + alias Mv.Accounts + alias Mv.Membership + + describe "User remains when linked Member is deleted (ON DELETE SET NULL)" do + @valid_user_attrs %{ + email: "test@example.com" + } + + @valid_member_attrs %{ + first_name: "John", + last_name: "Doe", + email: "john@example.com" + } + + test "deleting a member sets the user's member_id to NULL" do + # Create a member + {:ok, member} = Membership.create_member(@valid_member_attrs) + + # Create a user linked to the member + {:ok, user} = + Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id})) + + # Verify the relationship is established + {:ok, user_before_delete} = Ash.get(Mv.Accounts.User, user.id, load: [:member]) + assert user_before_delete.member_id == member.id + assert user_before_delete.member.id == member.id + + # Delete the member + :ok = Membership.destroy_member(member) + + # Verify the user still exists but member_id is NULL + {:ok, user_after_delete} = Ash.get(Mv.Accounts.User, user.id, load: [:member]) + assert user_after_delete.id == user.id + assert user_after_delete.member_id == nil + assert user_after_delete.member == nil + end + + test "user can be linked to a new member after old member is deleted" do + # Create first member + {:ok, member1} = Membership.create_member(@valid_member_attrs) + + # Create user linked to first member + {:ok, user} = + Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member1.id})) + + assert user.member_id == member1.id + + # Delete first member + :ok = Membership.destroy_member(member1) + + # Reload user from database to get updated member_id (should be NULL) + {:ok, user_after_delete} = Ash.get(Mv.Accounts.User, user.id) + assert user_after_delete.member_id == nil + + # Create second member + {:ok, member2} = + Membership.create_member(%{ + first_name: "Jane", + last_name: "Smith", + email: "jane@example.com" + }) + + # Link user to second member (use reloaded user) + {:ok, updated_user} = Accounts.update_user(user_after_delete, %{member: %{id: member2.id}}) + + # Verify new relationship + {:ok, final_user} = Ash.get(Mv.Accounts.User, updated_user.id, load: [:member]) + assert final_user.member_id == member2.id + assert final_user.member.id == member2.id + end + + test "member without linked user can be deleted normally" do + {:ok, member} = Membership.create_member(@valid_member_attrs) + + # Delete member (no users linked) + assert :ok = Membership.destroy_member(member) + + # Verify member is deleted + assert {:error, _} = Ash.get(Mv.Membership.Member, member.id) + end + end +end -- 2.47.2