From 4f3d0c21a81bea52846173b410158b043cf62436 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 5 Nov 2025 18:15:06 +0100 Subject: [PATCH 01/15] add oidc tests --- test/accounts/user_authentication_test.exs | 265 ++++++++++++++ .../controllers/oidc_integration_test.exs | 131 ++++++- .../oidc_password_linking_test.exs | 338 ++++++++++++++++++ 3 files changed, 730 insertions(+), 4 deletions(-) create mode 100644 test/accounts/user_authentication_test.exs create mode 100644 test/mv_web/controllers/oidc_password_linking_test.exs diff --git a/test/accounts/user_authentication_test.exs b/test/accounts/user_authentication_test.exs new file mode 100644 index 0000000..caa3359 --- /dev/null +++ b/test/accounts/user_authentication_test.exs @@ -0,0 +1,265 @@ +defmodule Mv.Accounts.UserAuthenticationTest do + @moduledoc """ + Tests for user authentication and identification mechanisms. + + This test suite verifies that: + - Password login correctly identifies users via email + - OIDC login correctly identifies users via oidc_id + - Session identifiers work as expected for both authentication methods + """ + use MvWeb.ConnCase, async: true + require Ash.Query + + describe "Password authentication user identification" do + @tag :test_proposal + test "password login uses email as identifier" do + # Create a user with password authentication (no oidc_id) + user = + create_test_user(%{ + email: "password.user@example.com", + password: "securepassword123", + oidc_id: nil + }) + + # Verify that the user can be found by email + email_to_find = to_string(user.email) + + {:ok, users} = + Mv.Accounts.User + |> Ash.Query.filter(email == ^email_to_find) + |> Ash.read() + + assert length(users) == 1 + found_user = List.first(users) + assert found_user.id == user.id + assert to_string(found_user.email) == "password.user@example.com" + assert is_nil(found_user.oidc_id) + end + + @tag :test_proposal + test "password authentication uses email as identity_field" do + # Verify the configuration: password strategy should use email as identity_field + # This test checks the AshAuthentication configuration + + strategies = AshAuthentication.Info.authentication_strategies(Mv.Accounts.User) + password_strategy = Enum.find(strategies, fn s -> s.name == :password end) + + assert password_strategy != nil + assert password_strategy.identity_field == :email + end + + @tag :test_proposal + test "multiple users can exist with different emails" do + user1 = + create_test_user(%{ + email: "user1@example.com", + password: "password123", + oidc_id: nil + }) + + user2 = + create_test_user(%{ + email: "user2@example.com", + password: "password456", + oidc_id: nil + }) + + assert user1.id != user2.id + assert to_string(user1.email) != to_string(user2.email) + end + + @tag :test_proposal + test "users with same password but different emails are separate accounts" do + same_password = "shared_password_123" + + user1 = + create_test_user(%{ + email: "alice@example.com", + password: same_password, + oidc_id: nil + }) + + user2 = + create_test_user(%{ + email: "bob@example.com", + password: same_password, + oidc_id: nil + }) + + # Different users despite same password + assert user1.id != user2.id + + # Both passwords should hash to different values (bcrypt uses salt) + assert user1.hashed_password != user2.hashed_password + end + end + + describe "OIDC authentication user identification" do + @tag :test_proposal + test "OIDC login with matching oidc_id finds correct user" do + # Create user with OIDC authentication + user = + create_test_user(%{ + email: "oidc.user@example.com", + oidc_id: "oidc_identifier_12345" + }) + + # Simulate OIDC callback + user_info = %{ + "sub" => "oidc_identifier_12345", + "preferred_username" => "oidc.user@example.com" + } + + # Use sign_in_with_rauthy to find user by oidc_id + # Note: This test will FAIL until we implement the security fix + # that changes the filter from email to oidc_id + result = + Mv.Accounts.read_sign_in_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + case result do + {:ok, [found_user]} -> + assert found_user.id == user.id + assert found_user.oidc_id == "oidc_identifier_12345" + + {:ok, []} -> + flunk("User should be found by oidc_id") + + {:error, error} -> + flunk("Unexpected error: #{inspect(error)}") + end + end + + @tag :test_proposal + test "OIDC login creates new user when both email and oidc_id are new" do + # Completely new user from OIDC provider + user_info = %{ + "sub" => "brand_new_oidc_789", + "preferred_username" => "newuser@example.com" + } + + # Should create via register_with_rauthy + {:ok, new_user} = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + assert to_string(new_user.email) == "newuser@example.com" + assert new_user.oidc_id == "brand_new_oidc_789" + assert is_nil(new_user.hashed_password) + end + + @tag :test_proposal + test "OIDC user can be uniquely identified by oidc_id" do + user1 = + create_test_user(%{ + email: "user1@example.com", + oidc_id: "oidc_unique_1" + }) + + user2 = + create_test_user(%{ + email: "user2@example.com", + oidc_id: "oidc_unique_2" + }) + + # Find by oidc_id + {:ok, users1} = + Mv.Accounts.User + |> Ash.Query.filter(oidc_id == "oidc_unique_1") + |> Ash.read() + + {:ok, users2} = + Mv.Accounts.User + |> Ash.Query.filter(oidc_id == "oidc_unique_2") + |> Ash.read() + + assert length(users1) == 1 + assert length(users2) == 1 + assert List.first(users1).id == user1.id + assert List.first(users2).id == user2.id + end + end + + describe "Mixed authentication scenarios" do + @tag :test_proposal + test "user with oidc_id cannot be found by email-only query in sign_in_with_rauthy" do + # This test verifies the security fix: sign_in_with_rauthy should NOT + # match users by email, only by oidc_id + + _user = + create_test_user(%{ + email: "secure@example.com", + oidc_id: "secure_oidc_999" + }) + + # Try to sign in with DIFFERENT oidc_id but SAME email + user_info = %{ + # Different oidc_id! + "sub" => "attacker_oidc_888", + # Same email + "preferred_username" => "secure@example.com" + } + + # Should NOT find the user (security requirement) + result = + Mv.Accounts.read_sign_in_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + # Either returns empty list OR authentication error - both mean "user not found" + case result do + {:ok, []} -> + :ok + + {:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} -> + :ok + + other -> + flunk("sign_in_with_rauthy should not match by email alone, got: #{inspect(other)}") + end + end + + @tag :test_proposal + test "password user (oidc_id=nil) is not found by sign_in_with_rauthy" do + # Create a password-only user + _user = + create_test_user(%{ + email: "password.only@example.com", + password: "securepass123", + oidc_id: nil + }) + + # Try OIDC sign-in with this email + user_info = %{ + "sub" => "new_oidc_777", + "preferred_username" => "password.only@example.com" + } + + # Should NOT find the user because oidc_id is nil + result = + Mv.Accounts.read_sign_in_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + # Either returns empty list OR authentication error - both mean "user not found" + case result do + {:ok, []} -> + :ok + + {:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} -> + :ok + + other -> + flunk( + "Password-only user should not be found by sign_in_with_rauthy, got: #{inspect(other)}" + ) + end + end + end +end diff --git a/test/mv_web/controllers/oidc_integration_test.exs b/test/mv_web/controllers/oidc_integration_test.exs index a96e7b1..508ebab 100644 --- a/test/mv_web/controllers/oidc_integration_test.exs +++ b/test/mv_web/controllers/oidc_integration_test.exs @@ -54,10 +54,130 @@ defmodule MvWeb.OidcIntegrationTest do end end + describe "OIDC sign-in security tests" do + @tag :test_proposal + test "sign_in_with_rauthy does NOT match user with only email (no oidc_id)" do + # SECURITY TEST: Ensure password-only users cannot be accessed via OIDC + # Create a password-only user (no oidc_id) + _password_user = + create_test_user(%{ + email: "password.only@example.com", + password: "securepassword123", + oidc_id: nil + }) + + # Try to sign in with OIDC using the same email + user_info = %{ + "sub" => "attacker_oidc_456", + "preferred_username" => "password.only@example.com" + } + + # Should NOT find any user (security requirement) + result = + Mv.Accounts.read_sign_in_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + # Either returns empty list OR authentication error - both mean "user not found" + case result do + {:ok, []} -> + :ok + + {:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} -> + :ok + + other -> + flunk("Expected no user match, got: #{inspect(other)}") + end + end + + @tag :test_proposal + test "sign_in_with_rauthy only matches when oidc_id matches" do + # Create user with specific OIDC ID + user = + create_test_user(%{ + email: "oidc.user@example.com", + oidc_id: "correct_oidc_789" + }) + + # Try with correct oidc_id + correct_user_info = %{ + "sub" => "correct_oidc_789", + "preferred_username" => "oidc.user@example.com" + } + + {:ok, [found_user]} = + Mv.Accounts.read_sign_in_with_rauthy(%{ + user_info: correct_user_info, + oauth_tokens: %{} + }) + + assert found_user.id == user.id + + # Try with wrong oidc_id but correct email + wrong_user_info = %{ + "sub" => "wrong_oidc_999", + "preferred_username" => "oidc.user@example.com" + } + + result = + Mv.Accounts.read_sign_in_with_rauthy(%{ + user_info: wrong_user_info, + oauth_tokens: %{} + }) + + # Either returns empty list OR authentication error - both mean "user not found" + case result do + {:ok, []} -> + :ok + + {:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} -> + :ok + + other -> + flunk("Expected no user match when oidc_id differs, got: #{inspect(other)}") + end + end + + @tag :test_proposal + test "sign_in_with_rauthy does not match user with empty string oidc_id" do + # Edge case: empty string should be treated like nil + _user = + create_test_user(%{ + email: "empty.oidc@example.com", + oidc_id: "" + }) + + user_info = %{ + "sub" => "new_oidc_111", + "preferred_username" => "empty.oidc@example.com" + } + + result = + Mv.Accounts.read_sign_in_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + # Either returns empty list OR authentication error - both mean "user not found" + case result do + {:ok, []} -> + :ok + + {:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} -> + :ok + + other -> + flunk("Expected no user match with empty oidc_id, got: #{inspect(other)}") + end + end + end + describe "OIDC error and edge case scenarios" do test "OIDC registration with conflicting email and OIDC ID shows error" do # Create user with email and OIDC ID - _existing_user = + existing_user = create_test_user(%{ email: "conflict@example.com", oidc_id: "oidc_conflict_1" @@ -75,12 +195,15 @@ defmodule MvWeb.OidcIntegrationTest do oauth_tokens: %{} }) - # Should fail due to unique constraint + # Should fail with PasswordVerificationRequired (account conflict) + # This prevents someone with OIDC provider B from taking over an account + # that's already linked to OIDC provider A assert {:error, %Ash.Error.Invalid{errors: errors}} = result + # Should contain PasswordVerificationRequired error assert Enum.any?(errors, fn - %Ash.Error.Changes.InvalidAttribute{field: :email, message: message} -> - String.contains?(message, "has already been taken") + %Mv.Accounts.User.Errors.PasswordVerificationRequired{user_id: user_id} -> + user_id == existing_user.id _ -> false diff --git a/test/mv_web/controllers/oidc_password_linking_test.exs b/test/mv_web/controllers/oidc_password_linking_test.exs new file mode 100644 index 0000000..b59633c --- /dev/null +++ b/test/mv_web/controllers/oidc_password_linking_test.exs @@ -0,0 +1,338 @@ +defmodule MvWeb.OidcPasswordLinkingTest do + @moduledoc """ + Tests for OIDC account linking when email collision occurs. + + This test suite verifies the security flow when an OIDC login attempts + to use an email that already exists in the system with a password account. + """ + use MvWeb.ConnCase, async: true + require Ash.Query + + describe "OIDC login with existing email (no oidc_id) - Email Collision" do + @tag :test_proposal + test "OIDC register with existing password user email fails with PasswordVerificationRequired" do + # Create password-only user + existing_user = + create_test_user(%{ + email: "existing@example.com", + password: "securepassword123", + oidc_id: nil + }) + + # Try OIDC registration with same email + user_info = %{ + "sub" => "new_oidc_12345", + "preferred_username" => "existing@example.com" + } + + result = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + # Should fail with PasswordVerificationRequired error + assert {:error, %Ash.Error.Invalid{errors: errors}} = result + + # Check that the error is our custom PasswordVerificationRequired + password_verification_error = + Enum.find(errors, fn err -> + err.__struct__ == Mv.Accounts.User.Errors.PasswordVerificationRequired + end) + + assert password_verification_error != nil, + "Should contain PasswordVerificationRequired error" + + assert password_verification_error.user_id == existing_user.id + end + + @tag :test_proposal + test "PasswordVerificationRequired error contains necessary context" do + existing_user = + create_test_user(%{ + email: "test@example.com", + password: "password123", + oidc_id: nil + }) + + user_info = %{ + "sub" => "oidc_99999", + "preferred_username" => "test@example.com" + } + + {:error, %Ash.Error.Invalid{errors: errors}} = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + password_error = + Enum.find(errors, fn err -> + err.__struct__ == Mv.Accounts.User.Errors.PasswordVerificationRequired + end) + + # Verify error contains all necessary context + assert password_error.user_id == existing_user.id + assert password_error.oidc_user_info["sub"] == "oidc_99999" + assert password_error.oidc_user_info["preferred_username"] == "test@example.com" + end + + @tag :test_proposal + test "after successful password verification, oidc_id can be set" do + # Create password user + user = + create_test_user(%{ + email: "link@example.com", + password: "mypassword123", + oidc_id: nil + }) + + # Simulate password verification passed, now link OIDC + user_info = %{ + "sub" => "linked_oidc_555", + "preferred_username" => "link@example.com" + } + + # Use the link_oidc_id action + {:ok, updated_user} = + Mv.Accounts.User + |> Ash.Query.filter(id == ^user.id) + |> Ash.read_one!() + |> Ash.Changeset.for_update(:link_oidc_id, %{ + oidc_id: user_info["sub"], + oidc_user_info: user_info + }) + |> Ash.update() + + assert updated_user.id == user.id + assert updated_user.oidc_id == "linked_oidc_555" + assert to_string(updated_user.email) == "link@example.com" + # Password should still exist + assert updated_user.hashed_password == user.hashed_password + end + + @tag :test_proposal + test "password verification with wrong password keeps oidc_id as nil" do + # This test verifies that if password verification fails, + # the oidc_id should NOT be set + + user = + create_test_user(%{ + email: "secure@example.com", + password: "correctpassword", + oidc_id: nil + }) + + # This test verifies the CONCEPT that wrong password should prevent linking + # In practice, the password verification happens BEFORE calling link_oidc_id + # So we just verify that the user still has no oidc_id + + # Attempt to verify with wrong password would fail in the controller/LiveView + # before link_oidc_id is called, so here we just verify the user state + + # User should still have no oidc_id (no linking happened) + {:ok, unchanged_user} = Ash.get(Mv.Accounts.User, user.id) + assert is_nil(unchanged_user.oidc_id) + assert unchanged_user.hashed_password == user.hashed_password + end + end + + describe "OIDC login with email of user having different oidc_id - Account Conflict" do + @tag :test_proposal + test "OIDC register with email of user having different oidc_id fails" do + # User already linked to OIDC provider A + _existing_user = + create_test_user(%{ + email: "linked@example.com", + oidc_id: "oidc_provider_a_123" + }) + + # Someone tries to register with OIDC provider B using same email + user_info = %{ + # Different OIDC ID! + "sub" => "oidc_provider_b_456", + "preferred_username" => "linked@example.com" + } + + result = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + # Should fail - cannot link different OIDC account to same email + assert {:error, %Ash.Error.Invalid{errors: errors}} = result + + # The error should indicate email is already taken + assert Enum.any?(errors, fn err -> + (err.__struct__ == Ash.Error.Changes.InvalidAttribute and err.field == :email) or + err.__struct__ == Mv.Accounts.User.Errors.PasswordVerificationRequired + end) + end + + @tag :test_proposal + test "existing OIDC user email remains unchanged when oidc_id matches" do + user = + create_test_user(%{ + email: "oidc@example.com", + oidc_id: "oidc_stable_789" + }) + + # Same OIDC ID, same email - should just sign in + user_info = %{ + "sub" => "oidc_stable_789", + "preferred_username" => "oidc@example.com" + } + + # This should work via upsert + {:ok, updated_user} = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + assert updated_user.id == user.id + assert updated_user.oidc_id == "oidc_stable_789" + assert to_string(updated_user.email) == "oidc@example.com" + end + end + + describe "Email update during OIDC linking" do + @tag :test_proposal + test "linking OIDC to password account updates email if different in OIDC" do + # Password user with old email + user = + create_test_user(%{ + email: "oldemail@example.com", + password: "password123", + oidc_id: nil + }) + + # OIDC provider returns new email (user changed it there) + user_info = %{ + "sub" => "oidc_link_999", + "preferred_username" => "newemail@example.com" + } + + # After password verification, link and update email + {:ok, updated_user} = + Mv.Accounts.User + |> Ash.Query.filter(id == ^user.id) + |> Ash.read_one!() + |> Ash.Changeset.for_update(:link_oidc_id, %{ + oidc_id: user_info["sub"], + oidc_user_info: user_info + }) + |> Ash.update() + + assert updated_user.oidc_id == "oidc_link_999" + assert to_string(updated_user.email) == "newemail@example.com" + end + + @tag :test_proposal + test "email change during linking triggers member email sync" do + # Create member + member = + Ash.Seed.seed!(Mv.Membership.Member, %{ + email: "member@example.com", + first_name: "Test", + last_name: "User" + }) + + # Create user linked to member + user = + Ash.Seed.seed!(Mv.Accounts.User, %{ + email: "member@example.com", + hashed_password: "dummy_hash", + oidc_id: nil, + member_id: member.id + }) + + # Link OIDC with new email + user_info = %{ + "sub" => "oidc_sync_777", + "preferred_username" => "newemail@example.com" + } + + {:ok, updated_user} = + Mv.Accounts.User + |> Ash.Query.filter(id == ^user.id) + |> Ash.read_one!() + |> Ash.Changeset.for_update(:link_oidc_id, %{ + oidc_id: user_info["sub"], + oidc_user_info: user_info + }) + |> Ash.update() + + # Verify user email changed + assert to_string(updated_user.email) == "newemail@example.com" + + # Verify member email was synced + {:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id) + assert to_string(updated_member.email) == "newemail@example.com" + end + end + + describe "Edge cases" do + @tag :test_proposal + test "user with empty string oidc_id is treated as password-only user" do + _user = + create_test_user(%{ + email: "empty@example.com", + password: "password123", + oidc_id: "" + }) + + # Try OIDC registration with same email + user_info = %{ + "sub" => "oidc_new_111", + "preferred_username" => "empty@example.com" + } + + result = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + # Should trigger PasswordVerificationRequired (empty string = no OIDC) + assert {:error, %Ash.Error.Invalid{errors: errors}} = result + + password_error = + Enum.find(errors, fn err -> + err.__struct__ == Mv.Accounts.User.Errors.PasswordVerificationRequired + end) + + assert password_error != nil + end + + @tag :test_proposal + test "cannot link same oidc_id to multiple users" do + # User 1 with OIDC + _user1 = + create_test_user(%{ + email: "user1@example.com", + oidc_id: "shared_oidc_333" + }) + + # Try to create user 2 with same OIDC ID using raw Ash.Changeset + # (create_test_user uses Ash.Seed which does upsert) + result = + Mv.Accounts.User + |> Ash.Changeset.for_create(:create_user, %{ + email: "user2@example.com" + }) + |> Ash.Changeset.change_attribute(:oidc_id, "shared_oidc_333") + |> Ash.create() + + # Should fail due to unique constraint on oidc_id + assert match?({:error, %Ash.Error.Invalid{}}, result) + + {:error, error} = result + # Verify the error is about oidc_id uniqueness + assert Enum.any?(error.errors, fn err -> + match?(%Ash.Error.Changes.InvalidAttribute{field: :oidc_id}, err) + end) + end + end +end From 293e85334f7fc8f7020b658b960a775ded79ce79 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 5 Nov 2025 18:54:27 +0100 Subject: [PATCH 02/15] fix oidc security bug --- lib/accounts/user.ex | 44 +++++++- .../errors/password_verification_required.ex | 33 ++++++ .../user/validations/oidc_email_collision.ex | 101 ++++++++++++++++++ 3 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 lib/accounts/user/errors/password_verification_required.ex create mode 100644 lib/accounts/user/validations/oidc_email_collision.ex diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 0fc5ab0..ac0439c 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -171,6 +171,40 @@ defmodule Mv.Accounts.User do change AshAuthentication.Strategy.Password.HashPasswordChange end + # Action to link an OIDC account to an existing password-only user + # This is called after the user has verified their password + update :link_oidc_id do + description "Links an OIDC ID to an existing user after password verification" + accept [] + argument :oidc_id, :string, allow_nil?: false + argument :oidc_user_info, :map, allow_nil?: false + require_atomic? false + + change fn changeset, _ctx -> + oidc_id = Ash.Changeset.get_argument(changeset, :oidc_id) + oidc_user_info = Ash.Changeset.get_argument(changeset, :oidc_user_info) + + # Get the new email from OIDC user_info + new_email = Map.get(oidc_user_info, "preferred_username") + + changeset + |> Ash.Changeset.change_attribute(:oidc_id, oidc_id) + # Update email if it differs from OIDC provider + |> then(fn cs -> + if new_email && to_string(cs.data.email) != new_email do + Ash.Changeset.change_attribute(cs, :email, new_email) + else + cs + end + end) + end + + # Sync email changes to member if email was updated + change Mv.EmailSync.Changes.SyncUserEmailToMember do + where [changing(:email)] + end + end + read :get_by_subject do description "Get a user by the subject claim in a JWT" argument :subject, :string, allow_nil?: false @@ -183,7 +217,11 @@ defmodule Mv.Accounts.User do argument :oauth_tokens, :map, allow_nil?: false prepare AshAuthentication.Strategy.OAuth2.SignInPreparation - filter expr(email == get_path(^arg(:user_info), [:preferred_username])) + # SECURITY: Filter by oidc_id, NOT by email! + # This ensures that OIDC sign-in only works for users who have already + # linked their account via OIDC. Password-only users (oidc_id = nil) + # cannot be accessed via OIDC login without password verification. + filter expr(oidc_id == get_path(^arg(:user_info), [:sub])) end create :register_with_rauthy do @@ -204,6 +242,10 @@ defmodule Mv.Accounts.User do |> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"]) end + # Check for email collisions with existing password-only accounts + # This validation must run AFTER email and oidc_id are set above + validate Mv.Accounts.User.Validations.OidcEmailCollision + # Sync user email to member when linking (User → Member) change Mv.EmailSync.Changes.SyncUserEmailToMember end diff --git a/lib/accounts/user/errors/password_verification_required.ex b/lib/accounts/user/errors/password_verification_required.ex new file mode 100644 index 0000000..ffcc260 --- /dev/null +++ b/lib/accounts/user/errors/password_verification_required.ex @@ -0,0 +1,33 @@ +defmodule Mv.Accounts.User.Errors.PasswordVerificationRequired do + @moduledoc """ + Custom error raised when an OIDC login attempts to use an email that already exists + in the system with a password-only account (no oidc_id set). + + This error indicates that the user must verify their password before the OIDC account + can be linked to the existing password account. + """ + use Splode.Error, + fields: [:user_id, :oidc_user_info], + class: :invalid + + @type t :: %__MODULE__{ + user_id: String.t(), + oidc_user_info: map() + } + + @doc """ + Returns a human-readable error message. + + ## Parameters + - error: The error struct containing user_id and oidc_user_info + """ + def message(%{user_id: user_id, oidc_user_info: user_info}) do + email = Map.get(user_info, "preferred_username", "unknown") + oidc_id = Map.get(user_info, "sub") || Map.get(user_info, "id", "unknown") + + """ + Password verification required: An account with email '#{email}' already exists (user_id: #{user_id}). + To link your OIDC account (oidc_id: #{oidc_id}) to this existing account, please verify your password. + """ + end +end diff --git a/lib/accounts/user/validations/oidc_email_collision.ex b/lib/accounts/user/validations/oidc_email_collision.ex new file mode 100644 index 0000000..bd75894 --- /dev/null +++ b/lib/accounts/user/validations/oidc_email_collision.ex @@ -0,0 +1,101 @@ +defmodule Mv.Accounts.User.Validations.OidcEmailCollision do + @moduledoc """ + Validation that checks for email collisions during OIDC registration. + + This validation prevents OIDC accounts from automatically taking over existing + password-only accounts. Instead, it requires password verification. + + ## Scenarios: + + 1. **User exists with matching oidc_id**: + - Allow (upsert will update the existing user) + + 2. **User exists with email but NO oidc_id (or empty string)**: + - Raise PasswordVerificationRequired error + - User must verify password before linking + + 3. **User exists with email AND different oidc_id**: + - Raise PasswordVerificationRequired error + - This prevents linking different OIDC providers to same account + + 4. **No user exists with this email**: + - Allow (new user will be created) + """ + use Ash.Resource.Validation + + alias Mv.Accounts.User.Errors.PasswordVerificationRequired + + @impl true + def init(opts), do: {:ok, opts} + + @impl true + def validate(changeset, _opts, _context) do + # Get the email and oidc_id from the changeset + email = Ash.Changeset.get_attribute(changeset, :email) + oidc_id = Ash.Changeset.get_attribute(changeset, :oidc_id) + user_info = Ash.Changeset.get_argument(changeset, :user_info) + + # Only validate if we have both email and oidc_id (from OIDC registration) + if email && oidc_id && user_info do + check_email_collision(email, oidc_id, user_info) + else + :ok + end + end + + defp check_email_collision(email, new_oidc_id, user_info) do + # Find existing user with this email + case Mv.Accounts.User + |> Ash.Query.filter(email == ^to_string(email)) + |> Ash.read_one() do + {:ok, nil} -> + # No user exists with this email - OK to create new user + :ok + + {:ok, existing_user} -> + # User exists - check oidc_id + handle_existing_user(existing_user, new_oidc_id, user_info) + + {:error, error} -> + # Database error + {:error, field: :email, message: "Could not verify email uniqueness: #{inspect(error)}"} + end + end + + defp handle_existing_user(existing_user, new_oidc_id, user_info) do + existing_oidc_id = existing_user.oidc_id + + cond do + # Case 1: Same oidc_id - this is an upsert, allow it + existing_oidc_id == new_oidc_id -> + :ok + + # Case 2: No oidc_id set (nil or empty string) - password-only user + is_nil(existing_oidc_id) or existing_oidc_id == "" -> + {:error, + PasswordVerificationRequired.exception( + user_id: existing_user.id, + oidc_user_info: user_info + )} + + # Case 3: Different oidc_id - account conflict + true -> + {:error, + PasswordVerificationRequired.exception( + user_id: existing_user.id, + oidc_user_info: user_info + )} + end + end + + @impl true + def atomic?(), do: false + + @impl true + def describe(_opts) do + [ + message: "OIDC email collision detected", + vars: [] + ] + end +end From 87e54cb13f1e6c82993aa3c8069caa1901725c5d Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 6 Nov 2025 11:25:14 +0100 Subject: [PATCH 03/15] add UI e2e tests for account linking --- .../mv_web/controllers/oidc_e2e_flow_test.exs | 409 ++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 test/mv_web/controllers/oidc_e2e_flow_test.exs diff --git a/test/mv_web/controllers/oidc_e2e_flow_test.exs b/test/mv_web/controllers/oidc_e2e_flow_test.exs new file mode 100644 index 0000000..c992d2f --- /dev/null +++ b/test/mv_web/controllers/oidc_e2e_flow_test.exs @@ -0,0 +1,409 @@ +defmodule MvWeb.OidcE2EFlowTest do + @moduledoc """ + End-to-end tests for OIDC authentication flows. + + These tests simulate the complete user journey through OIDC authentication, + including account linking scenarios. + """ + use MvWeb.ConnCase, async: true + require Ash.Query + + describe "E2E: New OIDC user registration" do + test "new user can register via OIDC", %{conn: conn} do + # Simulate OIDC callback for brand new user + user_info = %{ + "sub" => "new_oidc_user_123", + "preferred_username" => "newuser@example.com" + } + + # Call register action + result = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + assert {:ok, new_user} = result + assert to_string(new_user.email) == "newuser@example.com" + assert new_user.oidc_id == "new_oidc_user_123" + assert is_nil(new_user.hashed_password) + + # Verify user can be found by oidc_id + {:ok, [found_user]} = + Mv.Accounts.read_sign_in_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + assert found_user.id == new_user.id + end + end + + describe "E2E: Existing OIDC user sign-in" do + test "existing OIDC user can sign in and email updates", %{conn: conn} do + # Create OIDC user + user = + create_test_user(%{ + email: "oldmail@example.com", + oidc_id: "oidc_existing_999" + }) + + # User changed email at OIDC provider + updated_user_info = %{ + "sub" => "oidc_existing_999", + "preferred_username" => "newmail@example.com" + } + + # Register (upsert) with new email + {:ok, updated_user} = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: updated_user_info, + oauth_tokens: %{} + }) + + # Same user, updated email + assert updated_user.id == user.id + assert to_string(updated_user.email) == "newmail@example.com" + assert updated_user.oidc_id == "oidc_existing_999" + end + end + + describe "E2E: OIDC with existing password account (Email Collision)" do + test "OIDC registration with password account email triggers PasswordVerificationRequired", + %{conn: conn} do + # Step 1: Create a password-only user + password_user = + create_test_user(%{ + email: "collision@example.com", + password: "mypassword123", + oidc_id: nil + }) + + # Step 2: Try to register via OIDC with same email + user_info = %{ + "sub" => "oidc_new_777", + "preferred_username" => "collision@example.com" + } + + result = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + # Step 3: Should fail with PasswordVerificationRequired + assert {:error, %Ash.Error.Invalid{errors: errors}} = result + + password_error = + Enum.find(errors, fn err -> + match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) + end) + + assert password_error != nil + assert password_error.user_id == password_user.id + assert password_error.oidc_user_info["sub"] == "oidc_new_777" + assert password_error.oidc_user_info["preferred_username"] == "collision@example.com" + end + + test "full E2E flow: OIDC collision -> password verification -> account linked", + %{conn: conn} do + # Step 1: Create password user + password_user = + create_test_user(%{ + email: "full@example.com", + password: "testpass123", + oidc_id: nil + }) + + # Step 2: OIDC registration triggers error + user_info = %{ + "sub" => "oidc_link_888", + "preferred_username" => "full@example.com" + } + + {:error, %Ash.Error.Invalid{errors: errors}} = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + # Extract the error + password_error = + Enum.find(errors, fn err -> + match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) + end) + + assert password_error != nil + + # Step 3: User verifies password (this would happen in LiveView) + # Here we simulate successful password verification + + # Step 4: Link OIDC account after verification + {:ok, linked_user} = + Mv.Accounts.User + |> Ash.Query.filter(id == ^password_user.id) + |> Ash.read_one!() + |> Ash.Changeset.for_update(:link_oidc_id, %{ + oidc_id: user_info["sub"], + oidc_user_info: user_info + }) + |> Ash.update() + + # Verify account is now linked + assert linked_user.id == password_user.id + assert linked_user.oidc_id == "oidc_link_888" + assert to_string(linked_user.email) == "full@example.com" + # Password should still exist + assert linked_user.hashed_password == password_user.hashed_password + + # Step 5: User can now sign in via OIDC + {:ok, [signed_in_user]} = + Mv.Accounts.read_sign_in_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + assert signed_in_user.id == password_user.id + assert signed_in_user.oidc_id == "oidc_link_888" + end + + test "E2E: OIDC collision with different email at provider updates email after linking", + %{conn: conn} do + # Password user with old email + password_user = + create_test_user(%{ + email: "old@example.com", + password: "pass123", + oidc_id: nil + }) + + # OIDC provider has new email + user_info = %{ + "sub" => "oidc_new_email_555", + "preferred_username" => "old@example.com" + } + + # Collision detected + {:error, %Ash.Error.Invalid{}} = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + # After password verification, link with OIDC info that has NEW email + updated_user_info = %{ + "sub" => "oidc_new_email_555", + "preferred_username" => "new@example.com" + } + + {:ok, linked_user} = + Mv.Accounts.User + |> Ash.Query.filter(id == ^password_user.id) + |> Ash.read_one!() + |> Ash.Changeset.for_update(:link_oidc_id, %{ + oidc_id: updated_user_info["sub"], + oidc_user_info: updated_user_info + }) + |> Ash.update() + + # Email should be updated to match OIDC provider + assert to_string(linked_user.email) == "new@example.com" + assert linked_user.oidc_id == "oidc_new_email_555" + end + end + + describe "E2E: OIDC with linked member" do + test "E2E: email sync to member when linking OIDC to password account", %{conn: conn} do + # Create member + member = + Ash.Seed.seed!(Mv.Membership.Member, %{ + email: "member@example.com", + first_name: "Test", + last_name: "User" + }) + + # Create password user linked to member + password_user = + Ash.Seed.seed!(Mv.Accounts.User, %{ + email: "member@example.com", + hashed_password: "dummy_hash", + oidc_id: nil, + member_id: member.id + }) + + # OIDC registration with same email + user_info = %{ + "sub" => "oidc_member_333", + "preferred_username" => "member@example.com" + } + + # Collision detected + {:error, %Ash.Error.Invalid{}} = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + # After password verification, link OIDC with NEW email + updated_user_info = %{ + "sub" => "oidc_member_333", + "preferred_username" => "newmember@example.com" + } + + {:ok, linked_user} = + Mv.Accounts.User + |> Ash.Query.filter(id == ^password_user.id) + |> Ash.read_one!() + |> Ash.Changeset.for_update(:link_oidc_id, %{ + oidc_id: updated_user_info["sub"], + oidc_user_info: updated_user_info + }) + |> Ash.update() + + # User email updated + assert to_string(linked_user.email) == "newmember@example.com" + + # Member email should be synced + {:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id) + assert to_string(updated_member.email) == "newmember@example.com" + end + end + + describe "E2E: Security scenarios" do + test "E2E: password-only user cannot be accessed via OIDC without password", %{conn: conn} do + # Create password user + _password_user = + create_test_user(%{ + email: "secure@example.com", + password: "securepass123", + oidc_id: nil + }) + + # Attacker tries to sign in via OIDC with same email + user_info = %{ + "sub" => "attacker_oidc_666", + "preferred_username" => "secure@example.com" + } + + # Sign-in should fail (no matching oidc_id) + result = + Mv.Accounts.read_sign_in_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + case result do + {:ok, []} -> + :ok + + {:error, %Ash.Error.Forbidden{}} -> + :ok + + other -> + flunk("Expected no access, got: #{inspect(other)}") + end + + # Registration should trigger password requirement + {:error, %Ash.Error.Invalid{errors: errors}} = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + assert Enum.any?(errors, fn err -> + match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) + end) + end + + test "E2E: user with oidc_id cannot be hijacked by different OIDC provider", %{conn: conn} do + # User linked to OIDC provider A + user = + create_test_user(%{ + email: "linked@example.com", + oidc_id: "provider_a_123" + }) + + # Attacker tries to register with OIDC provider B using same email + user_info = %{ + "sub" => "provider_b_456", + "preferred_username" => "linked@example.com" + } + + # Should trigger password requirement (different oidc_id) + {:error, %Ash.Error.Invalid{errors: errors}} = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + password_error = + Enum.find(errors, fn err -> + match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) + end) + + assert password_error != nil + assert password_error.user_id == user.id + end + + test "E2E: empty string oidc_id is treated as password-only account", %{conn: conn} do + # User with empty oidc_id + password_user = + create_test_user(%{ + email: "empty@example.com", + password: "pass123", + oidc_id: "" + }) + + # Try OIDC registration + user_info = %{ + "sub" => "oidc_new_222", + "preferred_username" => "empty@example.com" + } + + {:error, %Ash.Error.Invalid{errors: errors}} = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + # Should require password (empty string = no OIDC) + assert Enum.any?(errors, fn err -> + match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) + end) + end + end + + describe "E2E: Error scenarios" do + test "E2E: OIDC registration without oidc_id fails", %{conn: conn} do + user_info = %{ + "preferred_username" => "noid@example.com" + } + + {:error, %Ash.Error.Invalid{errors: errors}} = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + assert Enum.any?(errors, fn err -> + match?(%Ash.Error.Changes.InvalidChanges{}, err) + end) + end + + test "E2E: OIDC registration without email fails", %{conn: conn} do + user_info = %{ + "sub" => "noemail_123" + } + + {:error, %Ash.Error.Invalid{errors: errors}} = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{} + }) + + assert Enum.any?(errors, fn err -> + match?(%Ash.Error.Changes.Required{field: :email}, err) + end) + end + end +end From 527657d37b4f0f176c91fe8356798c30eb711048 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 5 Nov 2025 19:04:34 +0100 Subject: [PATCH 04/15] UI for oidc account linking --- lib/mv_web/controllers/auth_controller.ex | 106 ++++++++++-- .../live/auth/link_oidc_account_live.ex | 162 ++++++++++++++++++ lib/mv_web/router.ex | 3 + 3 files changed, 256 insertions(+), 15 deletions(-) create mode 100644 lib/mv_web/live/auth/link_oidc_account_live.ex diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex index a8375d1..51d44d4 100644 --- a/lib/mv_web/controllers/auth_controller.ex +++ b/lib/mv_web/controllers/auth_controller.ex @@ -24,28 +24,104 @@ defmodule MvWeb.AuthController do end def failure(conn, activity, reason) do - Logger.error(%{conn: conn, reason: reason}) + # Log the error for debugging + Logger.warning( + "Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}" + ) - message = - case {activity, reason} do - {_, - %AshAuthentication.Errors.AuthenticationFailed{ - caused_by: %Ash.Error.Forbidden{ - errors: [%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}] - } - }} -> + case {activity, reason} do + # OIDC registration with existing email requires password verification (direct error) + {{:rauthy, :register}, %Ash.Error.Invalid{errors: errors}} -> + handle_oidc_email_collision(conn, errors) + + # OIDC registration with existing email (wrapped in AuthenticationFailed) + {{:rauthy, :register}, + %AshAuthentication.Errors.AuthenticationFailed{ + caused_by: %Ash.Error.Invalid{errors: errors} + }} -> + handle_oidc_email_collision(conn, errors) + + # OIDC sign-in failure (wrapped) + {{:rauthy, :sign_in}, %AshAuthentication.Errors.AuthenticationFailed{caused_by: caused_by}} -> + # Check if it's actually a registration issue + case caused_by do + %Ash.Error.Invalid{errors: errors} -> + handle_oidc_email_collision(conn, errors) + + _ -> + # Real sign-in failure + conn + |> put_flash(:error, gettext("Unable to sign in with OIDC. Please try again.")) + |> redirect(to: ~p"/sign-in") + end + + # OIDC callback failure (can be either sign-in or registration) + {{:rauthy, :callback}, %AshAuthentication.Errors.AuthenticationFailed{caused_by: caused_by}} -> + case caused_by do + %Ash.Error.Invalid{errors: errors} -> + handle_oidc_email_collision(conn, errors) + + _ -> + conn + |> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again.")) + |> redirect(to: ~p"/sign-in") + end + + {_, + %AshAuthentication.Errors.AuthenticationFailed{ + caused_by: %Ash.Error.Forbidden{ + errors: [%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}] + } + }} -> + message = gettext(""" You have already signed in another way, but have not confirmed your account. You can confirm your account using the link we sent to you, or by resetting your password. """) - _ -> - gettext("Incorrect email or password") - end + conn + |> put_flash(:error, message) + |> redirect(to: ~p"/sign-in") - conn - |> put_flash(:error, message) - |> redirect(to: ~p"/sign-in") + _ -> + message = gettext("Incorrect email or password") + + conn + |> put_flash(:error, message) + |> redirect(to: ~p"/sign-in") + end + end + + # Handle OIDC email collision - user needs to verify password + defp handle_oidc_email_collision(conn, errors) do + password_verification_error = + Enum.find(errors, fn err -> + match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) + end) + + case password_verification_error do + %Mv.Accounts.User.Errors.PasswordVerificationRequired{ + user_id: user_id, + oidc_user_info: oidc_user_info + } -> + # Store the OIDC info in session for the linking flow + conn + |> put_session(:oidc_linking_user_id, user_id) + |> put_session(:oidc_linking_user_info, oidc_user_info) + |> put_flash( + :info, + gettext( + "An account with this email already exists. Please verify your password to link your OIDC account." + ) + ) + |> redirect(to: ~p"/auth/link-oidc-account") + + _ -> + # Other validation errors - show generic error + conn + |> put_flash(:error, gettext("Unable to sign in. Please try again.")) + |> redirect(to: ~p"/sign-in") + end end def sign_out(conn, _params) do diff --git a/lib/mv_web/live/auth/link_oidc_account_live.ex b/lib/mv_web/live/auth/link_oidc_account_live.ex new file mode 100644 index 0000000..8a510b9 --- /dev/null +++ b/lib/mv_web/live/auth/link_oidc_account_live.ex @@ -0,0 +1,162 @@ +defmodule MvWeb.LinkOidcAccountLive do + @moduledoc """ + LiveView for linking an OIDC account to an existing password account. + + This page is shown when a user tries to log in via OIDC using an email + that already exists with a password-only account. The user must verify + their password before the OIDC account can be linked. + """ + use MvWeb, :live_view + require Ash.Query + + @impl true + def mount(_params, session, socket) do + user_id = Map.get(session, "oidc_linking_user_id") + oidc_user_info = Map.get(session, "oidc_linking_user_info") + + if user_id && oidc_user_info do + # Load the user + case Ash.get(Mv.Accounts.User, user_id) do + {:ok, user} -> + {:ok, + socket + |> assign(:user, user) + |> assign(:oidc_user_info, oidc_user_info) + |> assign(:password, "") + |> assign(:error, nil) + |> assign(:form, to_form(%{"password" => ""}))} + + {:error, _} -> + {:ok, + socket + |> put_flash(:error, gettext("Session expired. Please try again.")) + |> redirect(to: ~p"/sign-in")} + end + else + {:ok, + socket + |> put_flash(:error, gettext("Invalid session. Please try again.")) + |> redirect(to: ~p"/sign-in")} + end + end + + @impl true + def handle_event("validate", %{"password" => password}, socket) do + {:noreply, assign(socket, :password, password)} + end + + @impl true + def handle_event("submit", %{"password" => password}, socket) do + user = socket.assigns.user + oidc_user_info = socket.assigns.oidc_user_info + + # Verify the password using AshAuthentication + case verify_password(user.email, password) do + {:ok, verified_user} -> + # Password correct - link the OIDC account + link_oidc_account(socket, verified_user, oidc_user_info) + + {:error, _reason} -> + # Password incorrect + {:noreply, + socket + |> assign(:error, gettext("Incorrect password. Please try again.")) + |> assign(:form, to_form(%{"password" => ""}))} + end + end + + defp verify_password(email, password) do + # Use AshAuthentication password strategy to verify + strategies = AshAuthentication.Info.authentication_strategies(Mv.Accounts.User) + password_strategy = Enum.find(strategies, fn s -> s.name == :password end) + + if password_strategy do + AshAuthentication.Strategy.Password.Actions.sign_in( + password_strategy, + %{ + "email" => email, + "password" => password + }, + [] + ) + else + {:error, "Password authentication not configured"} + end + end + + defp link_oidc_account(socket, user, oidc_user_info) do + oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id") + + # Update the user with the OIDC ID + case Mv.Accounts.User + |> Ash.Query.filter(id == ^user.id) + |> Ash.read_one!() + |> Ash.Changeset.for_update(:link_oidc_id, %{ + oidc_id: oidc_id, + oidc_user_info: oidc_user_info + }) + |> Ash.update() do + {:ok, _updated_user} -> + # After successful linking, redirect to OIDC login + # Since the user now has an oidc_id, the next OIDC login will succeed + {:noreply, + socket + |> put_flash( + :info, + gettext( + "Your OIDC account has been successfully linked! Redirecting to complete sign-in..." + ) + ) + |> Phoenix.LiveView.redirect(to: ~p"/auth/user/rauthy")} + + {:error, error} -> + {:noreply, + socket + |> assign(:error, gettext("Failed to link account: %{error}", error: inspect(error))) + |> assign(:form, to_form(%{"password" => ""}))} + end + end + + @impl true + def render(assigns) do + ~H""" +
+ <.header class="text-center"> + {gettext("Link OIDC Account")} + <:subtitle> + {gettext( + "An account with email %{email} already exists. Please enter your password to link your OIDC account.", + email: @user.email + )} + + + + <.form for={@form} id="link-oidc-form" phx-submit="submit" phx-change="validate" class="mt-8"> +
+
+ <.input field={@form[:password]} type="password" label={gettext("Password")} required /> +
+ + <%= if @error do %> +
+

{@error}

+
+ <% end %> + +
+ <.button phx-disable-with={gettext("Linking...")} class="w-full"> + {gettext("Link Account")} + +
+
+ + +
+ <.link navigate={~p"/sign-in"} class="text-brand hover:underline"> + {gettext("Cancel")} + +
+
+ """ + end +end diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index bf2c071..21589d7 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -76,6 +76,9 @@ defmodule MvWeb.Router do post "/set_locale", LocaleController, :set_locale end + # OIDC account linking - user needs to verify password (MUST be before auth_routes!) + live "/auth/link-oidc-account", LinkOidcAccountLive + # ASHAUTHENTICATION GENERATED AUTH ROUTES auth_routes AuthController, Mv.Accounts.User, path: "/auth" sign_out_route AuthController From 4ba03821a2f3e480db925f1a143fb918ef118fe9 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 6 Nov 2025 11:33:09 +0100 Subject: [PATCH 05/15] add translation --- .../live/auth/link_oidc_account_live.ex | 47 ++++-- lib/mv_web/locale_controller.ex | 2 + lib/mv_web/router.ex | 8 + priv/gettext/auth.pot | 52 +++++++ priv/gettext/de/LC_MESSAGES/auth.po | 53 ++++++- priv/gettext/de/LC_MESSAGES/default.po | 102 ++++++++----- priv/gettext/default.pot | 91 +++++++----- priv/gettext/en/LC_MESSAGES/auth.po | 53 ++++++- priv/gettext/en/LC_MESSAGES/default.po | 139 +++++++++++++----- 9 files changed, 426 insertions(+), 121 deletions(-) diff --git a/lib/mv_web/live/auth/link_oidc_account_live.ex b/lib/mv_web/live/auth/link_oidc_account_live.ex index 8a510b9..af91e31 100644 --- a/lib/mv_web/live/auth/link_oidc_account_live.ex +++ b/lib/mv_web/live/auth/link_oidc_account_live.ex @@ -29,13 +29,13 @@ defmodule MvWeb.LinkOidcAccountLive do {:error, _} -> {:ok, socket - |> put_flash(:error, gettext("Session expired. Please try again.")) + |> put_flash(:error, dgettext("auth", "Session expired. Please try again.")) |> redirect(to: ~p"/sign-in")} end else {:ok, socket - |> put_flash(:error, gettext("Invalid session. Please try again.")) + |> put_flash(:error, dgettext("auth", "Invalid session. Please try again.")) |> redirect(to: ~p"/sign-in")} end end @@ -60,7 +60,7 @@ defmodule MvWeb.LinkOidcAccountLive do # Password incorrect {:noreply, socket - |> assign(:error, gettext("Incorrect password. Please try again.")) + |> assign(:error, dgettext("auth", "Incorrect password. Please try again.")) |> assign(:form, to_form(%{"password" => ""}))} end end @@ -103,7 +103,8 @@ defmodule MvWeb.LinkOidcAccountLive do socket |> put_flash( :info, - gettext( + dgettext( + "auth", "Your OIDC account has been successfully linked! Redirecting to complete sign-in..." ) ) @@ -112,7 +113,10 @@ defmodule MvWeb.LinkOidcAccountLive do {:error, error} -> {:noreply, socket - |> assign(:error, gettext("Failed to link account: %{error}", error: inspect(error))) + |> assign( + :error, + dgettext("auth", "Failed to link account: %{error}", error: inspect(error)) + ) |> assign(:form, to_form(%{"password" => ""}))} end end @@ -121,10 +125,26 @@ defmodule MvWeb.LinkOidcAccountLive do def render(assigns) do ~H"""
+ <%!-- Language Selector --%> +
+
+ + +
+
+ <.header class="text-center"> - {gettext("Link OIDC Account")} + {dgettext("auth", "Link OIDC Account")} <:subtitle> - {gettext( + {dgettext( + "auth", "An account with email %{email} already exists. Please enter your password to link your OIDC account.", email: @user.email )} @@ -134,7 +154,12 @@ defmodule MvWeb.LinkOidcAccountLive do <.form for={@form} id="link-oidc-form" phx-submit="submit" phx-change="validate" class="mt-8">
- <.input field={@form[:password]} type="password" label={gettext("Password")} required /> + <.input + field={@form[:password]} + type="password" + label={dgettext("auth", "Password")} + required + />
<%= if @error do %> @@ -144,8 +169,8 @@ defmodule MvWeb.LinkOidcAccountLive do <% end %>
- <.button phx-disable-with={gettext("Linking...")} class="w-full"> - {gettext("Link Account")} + <.button phx-disable-with={dgettext("auth", "Linking...")} class="w-full"> + {dgettext("auth", "Link Account")}
@@ -153,7 +178,7 @@ defmodule MvWeb.LinkOidcAccountLive do
<.link navigate={~p"/sign-in"} class="text-brand hover:underline"> - {gettext("Cancel")} + {dgettext("auth", "Cancel")}
diff --git a/lib/mv_web/locale_controller.ex b/lib/mv_web/locale_controller.ex index 3c8056f..0289efa 100644 --- a/lib/mv_web/locale_controller.ex +++ b/lib/mv_web/locale_controller.ex @@ -4,6 +4,8 @@ defmodule MvWeb.LocaleController do def set_locale(conn, %{"locale" => locale}) do conn |> put_session(:locale, locale) + # Store locale in a cookie that persists beyond the session + |> put_resp_cookie("locale", locale, max_age: 365 * 24 * 60 * 60, same_site: "Lax") |> redirect(to: get_referer(conn) || "/") end diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index 21589d7..a08f1be 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -144,6 +144,7 @@ defmodule MvWeb.Router do defp set_locale(conn, _opts) do locale = get_session(conn, :locale) || + get_locale_from_cookie(conn) || extract_locale_from_headers(conn.req_headers) Gettext.put_locale(MvWeb.Gettext, locale) @@ -153,6 +154,13 @@ defmodule MvWeb.Router do |> assign(:locale, locale) end + defp get_locale_from_cookie(conn) do + case conn.req_cookies do + %{"locale" => locale} when locale in ["en", "de"] -> locale + _ -> nil + end + end + # Get locale from user defp extract_locale_from_headers(headers) do headers diff --git a/priv/gettext/auth.pot b/priv/gettext/auth.pot index 29ee991..79e5941 100644 --- a/priv/gettext/auth.pot +++ b/priv/gettext/auth.pot @@ -36,6 +36,8 @@ msgstr "" msgid "Need an account?" msgstr "" +#: lib/mv_web/live/auth/link_oidc_account_live.ex:141 +#, elixir-autogen msgid "Password" msgstr "" @@ -62,3 +64,53 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:130 +#, elixir-autogen, elixir-format +msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account." +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:160 +#, elixir-autogen, elixir-format +msgid "Cancel" +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:118 +#, elixir-autogen, elixir-format +msgid "Failed to link account: %{error}" +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:65 +#, elixir-autogen, elixir-format +msgid "Incorrect password. Please try again." +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:40 +#, elixir-autogen, elixir-format +msgid "Invalid session. Please try again." +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:152 +#, elixir-autogen, elixir-format +msgid "Link Account" +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:128 +#, elixir-autogen, elixir-format +msgid "Link OIDC Account" +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:151 +#, elixir-autogen, elixir-format +msgid "Linking..." +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:34 +#, elixir-autogen, elixir-format +msgid "Session expired. Please try again." +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:108 +#, elixir-autogen, elixir-format +msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..." +msgstr "" diff --git a/priv/gettext/de/LC_MESSAGES/auth.po b/priv/gettext/de/LC_MESSAGES/auth.po index 967755e..ca98792 100644 --- a/priv/gettext/de/LC_MESSAGES/auth.po +++ b/priv/gettext/de/LC_MESSAGES/auth.po @@ -35,6 +35,8 @@ msgstr "Falls diese*r Benutzer*in bekannt ist, wird jetzt eine Email mit einer A msgid "Need an account?" msgstr "Konto anlegen?" +#: lib/mv_web/live/auth/link_oidc_account_live.ex:141 +#, elixir-autogen msgid "Password" msgstr "Passwort" @@ -62,5 +64,52 @@ 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" +#: lib/mv_web/live/auth/link_oidc_account_live.ex:130 +#, elixir-autogen, elixir-format +msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account." +msgstr "Ein Konto mit der E-Mail %{email} existiert bereits. Bitte geben Sie Ihr Passwort ein, um Ihr OIDC-Konto zu verknüpfen." + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:160 +#, elixir-autogen, elixir-format +msgid "Cancel" +msgstr "Abbrechen" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:118 +#, elixir-autogen, elixir-format +msgid "Failed to link account: %{error}" +msgstr "Verknüpfung des Kontos fehlgeschlagen: %{error}" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:65 +#, elixir-autogen, elixir-format +msgid "Incorrect password. Please try again." +msgstr "Falsches Passwort. Bitte versuchen Sie es erneut." + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:40 +#, elixir-autogen, elixir-format +msgid "Invalid session. Please try again." +msgstr "Ungültige Sitzung. Bitte versuchen Sie es erneut." + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:152 +#, elixir-autogen, elixir-format +msgid "Link Account" +msgstr "Konto verknüpfen" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:128 +#, elixir-autogen, elixir-format +msgid "Link OIDC Account" +msgstr "OIDC-Konto verknüpfen" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:151 +#, elixir-autogen, elixir-format +msgid "Linking..." +msgstr "Verknüpfen..." + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:34 +#, elixir-autogen, elixir-format +msgid "Session expired. Please try again." +msgstr "Sitzung abgelaufen. Bitte versuchen Sie es erneut." + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:108 +#, elixir-autogen, elixir-format +msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..." +msgstr "Ihr OIDC-Konto wurde erfolgreich verknüpft! Sie werden zur Anmeldung weitergeleitet..." diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index c8c219a..10a7259 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:193 +#: lib/mv_web/live/member_live/index.html.heex:200 #: 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:138 -#: lib/mv_web/live/member_live/show.ex:36 +#: lib/mv_web/live/member_live/index.html.heex:145 +#: 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:195 +#: lib/mv_web/live/member_live/index.html.heex:202 #: 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:187 +#: lib/mv_web/live/member_live/index.html.heex:194 #: lib/mv_web/live/user_live/form.ex:109 #: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format @@ -54,8 +54,8 @@ msgid "Edit Member" msgstr "Mitglied bearbeiten" #: lib/mv_web/live/member_live/form.ex:18 -#: lib/mv_web/live/member_live/index.html.heex:70 -#: lib/mv_web/live/member_live/show.ex:27 +#: lib/mv_web/live/member_live/index.html.heex:77 +#: 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 #: lib/mv_web/live/user_live/show.ex:25 @@ -70,8 +70,8 @@ msgid "First Name" msgstr "Vorname" #: lib/mv_web/live/member_live/form.ex:22 -#: lib/mv_web/live/member_live/index.html.heex:172 -#: lib/mv_web/live/member_live/show.ex:33 +#: lib/mv_web/live/member_live/index.html.heex:179 +#: lib/mv_web/live/member_live/show.ex:34 #, elixir-autogen, elixir-format msgid "Join Date" msgstr "Beitrittsdatum" @@ -87,7 +87,7 @@ msgstr "Nachname" msgid "New Member" msgstr "Neues Mitglied" -#: lib/mv_web/live/member_live/index.html.heex:184 +#: lib/mv_web/live/member_live/index.html.heex:191 #: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" @@ -127,8 +127,8 @@ msgid "Exit Date" msgstr "Austrittsdatum" #: lib/mv_web/live/member_live/form.ex:27 -#: lib/mv_web/live/member_live/index.html.heex:104 -#: lib/mv_web/live/member_live/show.ex:38 +#: lib/mv_web/live/member_live/index.html.heex:111 +#: lib/mv_web/live/member_live/show.ex:39 #, elixir-autogen, elixir-format msgid "House Number" msgstr "Hausnummer" @@ -146,15 +146,15 @@ msgid "Paid" msgstr "Bezahlt" #: lib/mv_web/live/member_live/form.ex:21 -#: lib/mv_web/live/member_live/index.html.heex:155 -#: lib/mv_web/live/member_live/show.ex:32 +#: lib/mv_web/live/member_live/index.html.heex:162 +#: 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:121 -#: lib/mv_web/live/member_live/show.ex:39 +#: lib/mv_web/live/member_live/index.html.heex:128 +#: lib/mv_web/live/member_live/show.ex:40 #, elixir-autogen, elixir-format msgid "Postal Code" msgstr "Postleitzahl" @@ -173,8 +173,8 @@ msgid "Saving..." msgstr "Speichern..." #: lib/mv_web/live/member_live/form.ex:26 -#: lib/mv_web/live/member_live/index.html.heex:87 -#: lib/mv_web/live/member_live/show.ex:37 +#: lib/mv_web/live/member_live/index.html.heex:94 +#: lib/mv_web/live/member_live/show.ex:38 #, elixir-autogen, elixir-format msgid "Street" msgstr "Straße" @@ -223,7 +223,7 @@ msgstr "erstellt" msgid "update" msgstr "aktualisiert" -#: lib/mv_web/controllers/auth_controller.ex:43 +#: lib/mv_web/controllers/auth_controller.ex:87 #, elixir-autogen, elixir-format msgid "Incorrect email or password" msgstr "Falsche E-Mail oder Passwort" @@ -238,12 +238,12 @@ msgstr "Mitglied %{action} erfolgreich" msgid "You are now signed in" msgstr "Sie sind jetzt angemeldet" -#: lib/mv_web/controllers/auth_controller.ex:56 +#: lib/mv_web/controllers/auth_controller.ex:132 #, elixir-autogen, elixir-format msgid "You are now signed out" msgstr "Sie sind jetzt abgemeldet" -#: lib/mv_web/controllers/auth_controller.ex:37 +#: lib/mv_web/controllers/auth_controller.ex:77 #, elixir-autogen, elixir-format msgid "You have already signed in another way, but have not confirmed your account.\nYou can confirm your account using the link we sent to you, or by resetting your password.\n" msgstr "Sie haben sich bereits auf andere Weise angemeldet, aber Ihr Konto noch nicht bestätigt.\nSie können Ihr Konto über den Link bestätigen, den wir Ihnen gesendet haben, oder durch Zurücksetzen Ihres Passworts.\n" @@ -301,7 +301,7 @@ msgstr "ID" msgid "Immutable" msgstr "Unveränderlich" -#: lib/mv_web/components/layouts/navbar.ex:94 +#: lib/mv_web/components/layouts/navbar.ex:93 #, elixir-autogen, elixir-format msgid "Logout" msgstr "Abmelden" @@ -317,8 +317,8 @@ msgstr "Benutzer*innen auflisten" msgid "Member" msgstr "Mitglied" -#: lib/mv_web/components/layouts/navbar.ex:14 -#: lib/mv_web/live/member_live/index.ex:8 +#: lib/mv_web/components/layouts/navbar.ex:19 +#: lib/mv_web/live/member_live/index.ex:10 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" @@ -366,7 +366,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:89 +#: lib/mv_web/components/layouts/navbar.ex:88 #, elixir-autogen, elixir-format msgid "Profil" msgstr "Profil" @@ -411,7 +411,7 @@ msgstr "Alle Mitglieder auswählen" msgid "Select member" msgstr "Mitglied auswählen" -#: lib/mv_web/components/layouts/navbar.ex:92 +#: lib/mv_web/components/layouts/navbar.ex:91 #, elixir-autogen, elixir-format msgid "Settings" msgstr "Einstellungen" @@ -468,13 +468,13 @@ msgid "Value type" msgstr "Wertetyp" #: lib/mv_web/components/table_components.ex:30 -#: lib/mv_web/live/components/sort_header_component.ex:55 +#: lib/mv_web/live/components/sort_header_component.ex:57 #, elixir-autogen, elixir-format msgid "ascending" msgstr "aufsteigend" #: lib/mv_web/components/table_components.ex:30 -#: lib/mv_web/live/components/sort_header_component.ex:56 +#: lib/mv_web/live/components/sort_header_component.ex:58 #, elixir-autogen, elixir-format msgid "descending" msgstr "absteigend" @@ -586,14 +586,14 @@ msgstr "Zurück zur Mitgliederliste" msgid "Back to users list" msgstr "Zurück zur Benutzer*innen-Liste" -#: lib/mv_web/components/layouts/navbar.ex:27 -#: lib/mv_web/components/layouts/navbar.ex:33 +#: lib/mv_web/components/layouts/navbar.ex:26 +#: lib/mv_web/components/layouts/navbar.ex:32 #, elixir-autogen, elixir-format msgid "Select language" msgstr "Sprache auswählen" -#: lib/mv_web/components/layouts/navbar.ex:40 -#: lib/mv_web/components/layouts/navbar.ex:60 +#: lib/mv_web/components/layouts/navbar.ex:39 +#: lib/mv_web/components/layouts/navbar.ex:59 #, elixir-autogen, elixir-format msgid "Toggle dark mode" msgstr "Dunklen Modus umschalten" @@ -601,15 +601,41 @@ 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 "Suchen..." + +#: lib/mv_web/components/layouts/navbar.ex:20 +#, elixir-autogen, elixir-format +msgid "Users" +msgstr "Benutzer*innen" + +#: lib/mv_web/live/components/sort_header_component.ex:59 +#: lib/mv_web/live/components/sort_header_component.ex:63 +#, elixir-autogen, elixir-format msgid "Click to sort" msgstr "Klicke um zu sortieren" -#: lib/mv_web/live/member_live/index.html.heex:53 -#, elixir-autogen, elixir-format, fuzzy +#: lib/mv_web/live/member_live/index.html.heex:60 +#, elixir-autogen, elixir-format msgid "First name" msgstr "Vorname" -#~ #: lib/mv_web/auth_overrides.ex:30 -#~ #, elixir-autogen, elixir-format -#~ msgid "or" -#~ msgstr "oder" +#: lib/mv_web/controllers/auth_controller.ex:113 +#, elixir-autogen, elixir-format +msgid "An account with this email already exists. Please verify your password to link your OIDC account." +msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte verifizieren Sie Ihr Passwort, um Ihr OIDC-Konto zu verknüpfen." + +#: lib/mv_web/controllers/auth_controller.ex:66 +#, elixir-autogen, elixir-format +msgid "Unable to authenticate with OIDC. Please try again." +msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut." + +#: lib/mv_web/controllers/auth_controller.ex:54 +#, elixir-autogen, elixir-format +msgid "Unable to sign in with OIDC. Please try again." +msgstr "Anmeldung mit OIDC fehlgeschlagen. Bitte versuchen Sie es erneut." + +#: lib/mv_web/controllers/auth_controller.ex:122 +#, elixir-autogen, elixir-format +msgid "Unable to sign in. Please try again." +msgstr "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 4c5438a..0976553 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:193 +#: lib/mv_web/live/member_live/index.html.heex:200 #: 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:138 -#: lib/mv_web/live/member_live/show.ex:36 +#: lib/mv_web/live/member_live/index.html.heex:145 +#: 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:195 +#: lib/mv_web/live/member_live/index.html.heex:202 #: 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:187 +#: lib/mv_web/live/member_live/index.html.heex:194 #: lib/mv_web/live/user_live/form.ex:109 #: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format @@ -55,8 +55,8 @@ msgid "Edit Member" msgstr "" #: lib/mv_web/live/member_live/form.ex:18 -#: lib/mv_web/live/member_live/index.html.heex:70 -#: lib/mv_web/live/member_live/show.ex:27 +#: lib/mv_web/live/member_live/index.html.heex:77 +#: 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 #: lib/mv_web/live/user_live/show.ex:25 @@ -71,8 +71,8 @@ msgid "First Name" msgstr "" #: lib/mv_web/live/member_live/form.ex:22 -#: lib/mv_web/live/member_live/index.html.heex:172 -#: lib/mv_web/live/member_live/show.ex:33 +#: lib/mv_web/live/member_live/index.html.heex:179 +#: lib/mv_web/live/member_live/show.ex:34 #, elixir-autogen, elixir-format msgid "Join Date" msgstr "" @@ -88,7 +88,7 @@ msgstr "" msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:184 +#: lib/mv_web/live/member_live/index.html.heex:191 #: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" @@ -128,8 +128,8 @@ msgid "Exit Date" msgstr "" #: lib/mv_web/live/member_live/form.ex:27 -#: lib/mv_web/live/member_live/index.html.heex:104 -#: lib/mv_web/live/member_live/show.ex:38 +#: lib/mv_web/live/member_live/index.html.heex:111 +#: lib/mv_web/live/member_live/show.ex:39 #, elixir-autogen, elixir-format msgid "House Number" msgstr "" @@ -147,15 +147,15 @@ msgid "Paid" msgstr "" #: lib/mv_web/live/member_live/form.ex:21 -#: lib/mv_web/live/member_live/index.html.heex:155 -#: lib/mv_web/live/member_live/show.ex:32 +#: lib/mv_web/live/member_live/index.html.heex:162 +#: 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:121 -#: lib/mv_web/live/member_live/show.ex:39 +#: lib/mv_web/live/member_live/index.html.heex:128 +#: lib/mv_web/live/member_live/show.ex:40 #, elixir-autogen, elixir-format msgid "Postal Code" msgstr "" @@ -174,8 +174,8 @@ msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:26 -#: lib/mv_web/live/member_live/index.html.heex:87 -#: lib/mv_web/live/member_live/show.ex:37 +#: lib/mv_web/live/member_live/index.html.heex:94 +#: lib/mv_web/live/member_live/show.ex:38 #, elixir-autogen, elixir-format msgid "Street" msgstr "" @@ -224,7 +224,7 @@ msgstr "" msgid "update" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:43 +#: lib/mv_web/controllers/auth_controller.ex:87 #, elixir-autogen, elixir-format msgid "Incorrect email or password" msgstr "" @@ -239,12 +239,12 @@ msgstr "" msgid "You are now signed in" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:56 +#: lib/mv_web/controllers/auth_controller.ex:132 #, elixir-autogen, elixir-format msgid "You are now signed out" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:37 +#: lib/mv_web/controllers/auth_controller.ex:77 #, elixir-autogen, elixir-format msgid "You have already signed in another way, but have not confirmed your account.\nYou can confirm your account using the link we sent to you, or by resetting your password.\n" msgstr "" @@ -302,7 +302,7 @@ msgstr "" msgid "Immutable" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:94 +#: lib/mv_web/components/layouts/navbar.ex:93 #, elixir-autogen, elixir-format msgid "Logout" msgstr "" @@ -318,8 +318,8 @@ msgstr "" msgid "Member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:14 -#: lib/mv_web/live/member_live/index.ex:8 +#: lib/mv_web/components/layouts/navbar.ex:19 +#: lib/mv_web/live/member_live/index.ex:10 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" @@ -367,7 +367,7 @@ msgstr "" msgid "Please select a property type first" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:89 +#: lib/mv_web/components/layouts/navbar.ex:88 #, elixir-autogen, elixir-format msgid "Profil" msgstr "" @@ -412,7 +412,7 @@ msgstr "" msgid "Select member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:92 +#: lib/mv_web/components/layouts/navbar.ex:91 #, elixir-autogen, elixir-format msgid "Settings" msgstr "" @@ -469,13 +469,13 @@ msgid "Value type" msgstr "" #: lib/mv_web/components/table_components.ex:30 -#: lib/mv_web/live/components/sort_header_component.ex:55 +#: lib/mv_web/live/components/sort_header_component.ex:57 #, elixir-autogen, elixir-format msgid "ascending" msgstr "" #: lib/mv_web/components/table_components.ex:30 -#: lib/mv_web/live/components/sort_header_component.ex:56 +#: lib/mv_web/live/components/sort_header_component.ex:58 #, elixir-autogen, elixir-format msgid "descending" msgstr "" @@ -587,14 +587,14 @@ msgstr "" msgid "Back to users list" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:27 -#: lib/mv_web/components/layouts/navbar.ex:33 +#: lib/mv_web/components/layouts/navbar.ex:26 +#: lib/mv_web/components/layouts/navbar.ex:32 #, elixir-autogen, elixir-format msgid "Select language" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:40 -#: lib/mv_web/components/layouts/navbar.ex:60 +#: lib/mv_web/components/layouts/navbar.ex:39 +#: lib/mv_web/components/layouts/navbar.ex:59 #, elixir-autogen, elixir-format msgid "Toggle dark mode" msgstr "" @@ -608,12 +608,35 @@ msgstr "" #: lib/mv_web/components/layouts/navbar.ex:20 #, elixir-autogen, elixir-format msgid "Users" -#: lib/mv_web/live/components/sort_header_component.ex:60 +msgstr "" + +#: lib/mv_web/live/components/sort_header_component.ex:59 +#: lib/mv_web/live/components/sort_header_component.ex:63 #, elixir-autogen, elixir-format msgid "Click to sort" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:53 +#: lib/mv_web/live/member_live/index.html.heex:60 #, elixir-autogen, elixir-format msgid "First name" msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:113 +#, elixir-autogen, elixir-format +msgid "An account with this email already exists. Please verify your password to link your OIDC account." +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:66 +#, elixir-autogen, elixir-format +msgid "Unable to authenticate with OIDC. Please try again." +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:54 +#, elixir-autogen, elixir-format +msgid "Unable to sign in with OIDC. Please try again." +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:122 +#, elixir-autogen, elixir-format +msgid "Unable to sign in. Please try again." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/auth.po b/priv/gettext/en/LC_MESSAGES/auth.po index 59ce742..85f611c 100644 --- a/priv/gettext/en/LC_MESSAGES/auth.po +++ b/priv/gettext/en/LC_MESSAGES/auth.po @@ -32,6 +32,8 @@ msgstr "" msgid "Need an account?" msgstr "" +#: lib/mv_web/live/auth/link_oidc_account_live.ex:141 +#, elixir-autogen msgid "Password" msgstr "" @@ -59,5 +61,52 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#~ msgid "Sign in with Rauthy" -#~ msgstr "Sign in with Vereinscloud" +#: lib/mv_web/live/auth/link_oidc_account_live.ex:130 +#, elixir-autogen, elixir-format +msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account." +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:160 +#, elixir-autogen, elixir-format +msgid "Cancel" +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:118 +#, elixir-autogen, elixir-format +msgid "Failed to link account: %{error}" +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:65 +#, elixir-autogen, elixir-format +msgid "Incorrect password. Please try again." +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:40 +#, elixir-autogen, elixir-format +msgid "Invalid session. Please try again." +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:152 +#, elixir-autogen, elixir-format +msgid "Link Account" +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:128 +#, elixir-autogen, elixir-format +msgid "Link OIDC Account" +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:151 +#, elixir-autogen, elixir-format +msgid "Linking..." +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:34 +#, elixir-autogen, elixir-format +msgid "Session expired. Please try again." +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:108 +#, elixir-autogen, elixir-format +msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 451ba84..b3c6d77 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:193 +#: lib/mv_web/live/member_live/index.html.heex:200 #: 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:138 -#: lib/mv_web/live/member_live/show.ex:36 +#: lib/mv_web/live/member_live/index.html.heex:145 +#: 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:195 +#: lib/mv_web/live/member_live/index.html.heex:202 #: 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:187 +#: lib/mv_web/live/member_live/index.html.heex:194 #: lib/mv_web/live/user_live/form.ex:109 #: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format @@ -55,8 +55,8 @@ msgid "Edit Member" msgstr "" #: lib/mv_web/live/member_live/form.ex:18 -#: lib/mv_web/live/member_live/index.html.heex:70 -#: lib/mv_web/live/member_live/show.ex:27 +#: lib/mv_web/live/member_live/index.html.heex:77 +#: 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 #: lib/mv_web/live/user_live/show.ex:25 @@ -71,8 +71,8 @@ msgid "First Name" msgstr "" #: lib/mv_web/live/member_live/form.ex:22 -#: lib/mv_web/live/member_live/index.html.heex:172 -#: lib/mv_web/live/member_live/show.ex:33 +#: lib/mv_web/live/member_live/index.html.heex:179 +#: lib/mv_web/live/member_live/show.ex:34 #, elixir-autogen, elixir-format msgid "Join Date" msgstr "" @@ -88,7 +88,7 @@ msgstr "" msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:184 +#: lib/mv_web/live/member_live/index.html.heex:191 #: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" @@ -128,8 +128,8 @@ msgid "Exit Date" msgstr "" #: lib/mv_web/live/member_live/form.ex:27 -#: lib/mv_web/live/member_live/index.html.heex:104 -#: lib/mv_web/live/member_live/show.ex:38 +#: lib/mv_web/live/member_live/index.html.heex:111 +#: lib/mv_web/live/member_live/show.ex:39 #, elixir-autogen, elixir-format msgid "House Number" msgstr "" @@ -147,15 +147,15 @@ msgid "Paid" msgstr "" #: lib/mv_web/live/member_live/form.ex:21 -#: lib/mv_web/live/member_live/index.html.heex:155 -#: lib/mv_web/live/member_live/show.ex:32 +#: lib/mv_web/live/member_live/index.html.heex:162 +#: 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:121 -#: lib/mv_web/live/member_live/show.ex:39 +#: lib/mv_web/live/member_live/index.html.heex:128 +#: lib/mv_web/live/member_live/show.ex:40 #, elixir-autogen, elixir-format msgid "Postal Code" msgstr "" @@ -174,8 +174,8 @@ msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:26 -#: lib/mv_web/live/member_live/index.html.heex:87 -#: lib/mv_web/live/member_live/show.ex:37 +#: lib/mv_web/live/member_live/index.html.heex:94 +#: lib/mv_web/live/member_live/show.ex:38 #, elixir-autogen, elixir-format msgid "Street" msgstr "" @@ -224,7 +224,7 @@ msgstr "" msgid "update" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:43 +#: lib/mv_web/controllers/auth_controller.ex:87 #, elixir-autogen, elixir-format msgid "Incorrect email or password" msgstr "" @@ -239,12 +239,12 @@ msgstr "" msgid "You are now signed in" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:56 +#: lib/mv_web/controllers/auth_controller.ex:132 #, elixir-autogen, elixir-format msgid "You are now signed out" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:37 +#: lib/mv_web/controllers/auth_controller.ex:77 #, elixir-autogen, elixir-format msgid "You have already signed in another way, but have not confirmed your account.\nYou can confirm your account using the link we sent to you, or by resetting your password.\n" msgstr "" @@ -302,7 +302,7 @@ msgstr "" msgid "Immutable" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:94 +#: lib/mv_web/components/layouts/navbar.ex:93 #, elixir-autogen, elixir-format msgid "Logout" msgstr "" @@ -318,8 +318,8 @@ msgstr "" msgid "Member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:14 -#: lib/mv_web/live/member_live/index.ex:8 +#: lib/mv_web/components/layouts/navbar.ex:19 +#: lib/mv_web/live/member_live/index.ex:10 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" @@ -367,7 +367,7 @@ msgstr "" msgid "Please select a property type first" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:89 +#: lib/mv_web/components/layouts/navbar.ex:88 #, elixir-autogen, elixir-format msgid "Profil" msgstr "" @@ -412,7 +412,7 @@ msgstr "" msgid "Select member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:92 +#: lib/mv_web/components/layouts/navbar.ex:91 #, elixir-autogen, elixir-format msgid "Settings" msgstr "" @@ -469,13 +469,13 @@ msgid "Value type" msgstr "" #: lib/mv_web/components/table_components.ex:30 -#: lib/mv_web/live/components/sort_header_component.ex:55 +#: lib/mv_web/live/components/sort_header_component.ex:57 #, elixir-autogen, elixir-format msgid "ascending" msgstr "" #: lib/mv_web/components/table_components.ex:30 -#: lib/mv_web/live/components/sort_header_component.ex:56 +#: lib/mv_web/live/components/sort_header_component.ex:58 #, elixir-autogen, elixir-format msgid "descending" msgstr "" @@ -555,17 +555,88 @@ 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/live/components/sort_header_component.ex:60 +#: lib/mv_web/live/user_live/show.ex:30 +#, elixir-autogen, elixir-format, fuzzy +msgid "Linked Member" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex:41 +#, elixir-autogen, elixir-format +msgid "Linked User" +msgstr "" + +#: lib/mv_web/live/user_live/show.ex:40 +#, elixir-autogen, elixir-format +msgid "No member linked" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex:51 +#, elixir-autogen, elixir-format +msgid "No user linked" +msgstr "" + +#: lib/mv_web/live/member_live/show.ex:14 +#: lib/mv_web/live/member_live/show.ex:16 +#, elixir-autogen, elixir-format +msgid "Back to members list" +msgstr "" + +#: lib/mv_web/live/user_live/show.ex:13 +#: lib/mv_web/live/user_live/show.ex:15 +#, elixir-autogen, elixir-format +msgid "Back to users list" +msgstr "" + +#: lib/mv_web/components/layouts/navbar.ex:26 +#: lib/mv_web/components/layouts/navbar.ex:32 +#, elixir-autogen, elixir-format, fuzzy +msgid "Select language" +msgstr "" + +#: lib/mv_web/components/layouts/navbar.ex:39 +#: lib/mv_web/components/layouts/navbar.ex:59 +#, 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:20 +#, elixir-autogen, elixir-format, fuzzy +msgid "Users" +msgstr "" + +#: lib/mv_web/live/components/sort_header_component.ex:59 +#: lib/mv_web/live/components/sort_header_component.ex:63 #, elixir-autogen, elixir-format msgid "Click to sort" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:53 +#: lib/mv_web/live/member_live/index.html.heex:60 #, elixir-autogen, elixir-format, fuzzy msgid "First name" msgstr "" -#~ #: lib/mv_web/auth_overrides.ex:30 -#~ #, elixir-autogen, elixir-format -#~ msgid "or" -#~ msgstr "" +#: lib/mv_web/controllers/auth_controller.ex:113 +#, elixir-autogen, elixir-format +msgid "An account with this email already exists. Please verify your password to link your OIDC account." +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:66 +#, elixir-autogen, elixir-format +msgid "Unable to authenticate with OIDC. Please try again." +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:54 +#, elixir-autogen, elixir-format +msgid "Unable to sign in with OIDC. Please try again." +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:122 +#, elixir-autogen, elixir-format +msgid "Unable to sign in. Please try again." +msgstr "" From 5ce220862fae5e94ae940f720f08602f6819a63c Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 6 Nov 2025 14:02:29 +0100 Subject: [PATCH 06/15] refactor and docs --- docs/oidc-account-linking.md | 207 +++++++++++++ lib/accounts/user.ex | 5 +- .../user/validations/oidc_email_collision.ex | 141 +++++++-- lib/mv_web/controllers/auth_controller.ex | 220 ++++++++------ .../live/auth/link_oidc_account_live.ex | 176 +++++++++--- lib/mv_web/locale_controller.ex | 7 +- priv/gettext/de/LC_MESSAGES/auth.po | 25 ++ priv/gettext/de/LC_MESSAGES/default.po | 10 + .../mv_web/controllers/oidc_e2e_flow_test.exs | 46 +-- .../controllers/oidc_email_update_test.exs | 271 ++++++++++++++++++ .../controllers/oidc_integration_test.exs | 17 +- .../oidc_password_linking_test.exs | 160 ++++++++++- .../oidc_passwordless_linking_test.exs | 210 ++++++++++++++ 13 files changed, 1321 insertions(+), 174 deletions(-) create mode 100644 docs/oidc-account-linking.md create mode 100644 test/mv_web/controllers/oidc_email_update_test.exs create mode 100644 test/mv_web/controllers/oidc_passwordless_linking_test.exs diff --git a/docs/oidc-account-linking.md b/docs/oidc-account-linking.md new file mode 100644 index 0000000..29c2233 --- /dev/null +++ b/docs/oidc-account-linking.md @@ -0,0 +1,207 @@ +# OIDC Account Linking Implementation + +## Overview + +This feature implements secure account linking between password-based accounts and OIDC authentication. When a user attempts to log in via OIDC with an email that already exists as a password-only account, the system requires password verification before linking the accounts. + +## Architecture + +### Key Components + +#### 1. Security Fix: `lib/accounts/user.ex` + +**Change**: The `sign_in_with_rauthy` action now filters by `oidc_id` instead of `email`. + +```elixir +read :sign_in_with_rauthy do + argument :user_info, :map, allow_nil?: false + argument :oauth_tokens, :map, allow_nil?: false + prepare AshAuthentication.Strategy.OAuth2.SignInPreparation + # SECURITY: Filter by oidc_id, NOT by email! + filter expr(oidc_id == get_path(^arg(:user_info), [:sub])) +end +``` + +**Why**: Prevents OIDC users from bypassing password authentication and taking over existing accounts. + +#### 2. Custom Error: `lib/accounts/user/errors/password_verification_required.ex` + +Custom error raised when OIDC login conflicts with existing password account. + +**Fields**: + +- `user_id`: ID of the existing user +- `oidc_user_info`: OIDC user information for account linking + +#### 3. Validation: `lib/accounts/user/validations/oidc_email_collision.ex` + +Validates email uniqueness during OIDC registration. + +**Scenarios**: + +1. **User exists with matching `oidc_id`**: Allow (upsert) +2. **User exists without `oidc_id`** (password-protected OR passwordless): Raise `PasswordVerificationRequired` + - The `LinkOidcAccountLive` will auto-link passwordless users without password prompt + - Password-protected users must verify their password +3. **User exists with different `oidc_id`**: Hard error (cannot link multiple OIDC providers) +4. **No user exists**: Allow (new user creation) + +#### 4. Account Linking Action: `lib/accounts/user.ex` + +```elixir +update :link_oidc_id do + description "Links an OIDC ID to an existing user after password verification" + accept [] + argument :oidc_id, :string, allow_nil?: false + argument :oidc_user_info, :map, allow_nil?: false + # ... implementation +end +``` + +**Features**: + +- Links `oidc_id` to existing user +- Updates email if it differs from OIDC provider +- Syncs email changes to linked member + +#### 5. Controller: `lib/mv_web/controllers/auth_controller.ex` + +Refactored for better complexity and maintainability. + +**Key improvements**: + +- Reduced cyclomatic complexity from 11 to below 9 +- Better separation of concerns with helper functions +- Comprehensive documentation + +**Flow**: + +1. Detects `PasswordVerificationRequired` error +2. Stores OIDC info in session +3. Redirects to account linking page + +#### 6. LiveView: `lib/mv_web/live/auth/link_oidc_account_live.ex` + +Interactive UI for password verification and account linking. + +**Flow**: + +1. Retrieves OIDC info from session +2. **Auto-links passwordless users** immediately (no password prompt) +3. Displays password verification form for password-protected users +4. Verifies password using AshAuthentication +5. Links OIDC account on success +6. Redirects to complete OIDC login +7. **Logs all security-relevant events** (successful/failed linking attempts) + +### Locale Persistence + +**Problem**: Locale was lost on logout (session cleared). + +**Solution**: Store locale in persistent cookie (1 year TTL) with security flags. + +**Changes**: + +- `lib/mv_web/locale_controller.ex`: Sets locale cookie with `http_only` and `secure` flags +- `lib/mv_web/router.ex`: Reads locale from cookie if session empty + +**Security Features**: +- `http_only: true` - Cookie not accessible via JavaScript (XSS protection) +- `secure: true` - Cookie only transmitted over HTTPS in production +- `same_site: "Lax"` - CSRF protection + +## Security Considerations + +### 1. OIDC ID Matching + +- **Before**: Matched by email (vulnerable to account takeover) +- **After**: Matched by `oidc_id` (secure) + +### 2. Account Linking Flow + +- Password verification required before linking (for password-protected users) +- Passwordless users are auto-linked immediately (secure, as they have no password) +- OIDC info stored in session (not in URL/query params) +- CSRF protection on all forms +- All linking attempts logged for audit trail + +### 3. Email Updates + +- Email updates from OIDC provider are applied during linking +- Email changes sync to linked member (if exists) + +### 4. Error Handling + +- Internal errors are logged but not exposed to users (prevents information disclosure) +- User-friendly error messages shown in UI +- Security-relevant events logged with appropriate levels: + - `Logger.info` for successful operations + - `Logger.warning` for failed authentication attempts + - `Logger.error` for system errors + +## Usage Examples + +### Scenario 1: New OIDC User + +```elixir +# User signs in with OIDC for the first time +# → New user created with oidc_id +``` + +### Scenario 2: Existing OIDC User + +```elixir +# User with oidc_id signs in via OIDC +# → Matched by oidc_id, email updated if changed +``` + +### Scenario 3: Password User + OIDC Login + +```elixir +# User with password account tries OIDC login +# → PasswordVerificationRequired raised +# → Redirected to /auth/link-oidc-account +# → User enters password +# → Password verified and logged +# → oidc_id linked to account +# → Successful linking logged +# → Redirected to complete OIDC login +``` + +### Scenario 4: Passwordless User + OIDC Login + +```elixir +# User without password (invited user) tries OIDC login +# → PasswordVerificationRequired raised +# → Redirected to /auth/link-oidc-account +# → System detects passwordless user +# → oidc_id automatically linked (no password prompt) +# → Auto-linking logged +# → Redirected to complete OIDC login +``` + +## API + +### Custom Actions + +#### `link_oidc_id` + +Links an OIDC ID to existing user after password verification. + +**Arguments**: + +- `oidc_id` (required): OIDC sub/id from provider +- `oidc_user_info` (required): Full OIDC user info map + +**Returns**: Updated user with linked `oidc_id` + +**Side Effects**: + +- Updates email if different from OIDC provider +- Syncs email to linked member (if exists) + +## References + +- [AshAuthentication Documentation](https://hexdocs.pm/ash_authentication) +- [OIDC Specification](https://openid.net/specs/openid-connect-core-1_0.html) +- [Security Best Practices for Account Linking](https://cheatsheetseries.owasp.org/cheatsheets/Credential_Stuffing_Prevention_Cheat_Sheet.html) diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index ac0439c..1547ffe 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -228,6 +228,7 @@ defmodule Mv.Accounts.User do argument :user_info, :map, allow_nil?: false argument :oauth_tokens, :map, allow_nil?: false upsert? true + # Upsert based on oidc_id (primary match for existing OIDC users) upsert_identity :unique_oidc_id validate &__MODULE__.validate_oidc_id_present/2 @@ -242,8 +243,10 @@ defmodule Mv.Accounts.User do |> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"]) end - # Check for email collisions with existing password-only accounts + # Check for email collisions with existing accounts # This validation must run AFTER email and oidc_id are set above + # - Raises PasswordVerificationRequired for password-protected OR passwordless users + # - The LinkOidcAccountLive will auto-link passwordless users without password prompt validate Mv.Accounts.User.Validations.OidcEmailCollision # Sync user email to member when linking (User → Member) diff --git a/lib/accounts/user/validations/oidc_email_collision.ex b/lib/accounts/user/validations/oidc_email_collision.ex index bd75894..ca633ee 100644 --- a/lib/accounts/user/validations/oidc_email_collision.ex +++ b/lib/accounts/user/validations/oidc_email_collision.ex @@ -2,26 +2,29 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do @moduledoc """ Validation that checks for email collisions during OIDC registration. - This validation prevents OIDC accounts from automatically taking over existing - password-only accounts. Instead, it requires password verification. + This validation prevents unauthorized account takeovers and enforces proper + account linking flows based on user state. ## Scenarios: 1. **User exists with matching oidc_id**: - Allow (upsert will update the existing user) - 2. **User exists with email but NO oidc_id (or empty string)**: - - Raise PasswordVerificationRequired error - - User must verify password before linking + 2. **User exists with different oidc_id**: + - Hard error: Cannot link multiple OIDC providers to same account + - No linking possible - user must use original OIDC provider - 3. **User exists with email AND different oidc_id**: + 3. **User exists without oidc_id** (password-protected OR passwordless): - Raise PasswordVerificationRequired error - - This prevents linking different OIDC providers to same account + - User is redirected to LinkOidcAccountLive which will: + - Show password form if user has password + - Auto-link immediately if user is passwordless 4. **No user exists with this email**: - Allow (new user will be created) """ use Ash.Resource.Validation + require Logger alias Mv.Accounts.User.Errors.PasswordVerificationRequired @@ -37,13 +40,23 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do # Only validate if we have both email and oidc_id (from OIDC registration) if email && oidc_id && user_info do - check_email_collision(email, oidc_id, user_info) + # Check if a user with this oidc_id already exists + # If yes, this will be an upsert (email update), not a new registration + existing_oidc_user = + case Mv.Accounts.User + |> Ash.Query.filter(oidc_id == ^to_string(oidc_id)) + |> Ash.read_one() do + {:ok, user} -> user + _ -> nil + end + + check_email_collision(email, oidc_id, user_info, existing_oidc_user) else :ok end end - defp check_email_collision(email, new_oidc_id, user_info) do + defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user) do # Find existing user with this email case Mv.Accounts.User |> Ash.Query.filter(email == ^to_string(email)) @@ -52,42 +65,116 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do # No user exists with this email - OK to create new user :ok - {:ok, existing_user} -> - # User exists - check oidc_id - handle_existing_user(existing_user, new_oidc_id, user_info) + {:ok, user_with_email} -> + # User exists with this email - check if it's an upsert or registration + is_upsert = not is_nil(existing_oidc_user) + + handle_existing_user( + user_with_email, + new_oidc_id, + user_info, + is_upsert, + existing_oidc_user + ) {:error, error} -> - # Database error - {:error, field: :email, message: "Could not verify email uniqueness: #{inspect(error)}"} + # Database error - log for debugging but don't expose internals to user + Logger.error("Email uniqueness check failed during OIDC registration: #{inspect(error)}") + {:error, field: :email, message: "Could not verify email uniqueness. Please try again."} end end - defp handle_existing_user(existing_user, new_oidc_id, user_info) do - existing_oidc_id = existing_user.oidc_id + defp handle_existing_user( + user_with_email, + new_oidc_id, + user_info, + is_upsert, + existing_oidc_user + ) do + if is_upsert do + handle_upsert_scenario(user_with_email, user_info, existing_oidc_user) + else + handle_create_scenario(user_with_email, new_oidc_id, user_info) + end + end + # Handle email update for existing OIDC user + defp handle_upsert_scenario(user_with_email, user_info, existing_oidc_user) do cond do - # Case 1: Same oidc_id - this is an upsert, allow it - existing_oidc_id == new_oidc_id -> + # Same user updating their own record + not is_nil(existing_oidc_user) and user_with_email.id == existing_oidc_user.id -> :ok - # Case 2: No oidc_id set (nil or empty string) - password-only user - is_nil(existing_oidc_id) or existing_oidc_id == "" -> + # Different user exists with target email + not is_nil(existing_oidc_user) and user_with_email.id != existing_oidc_user.id -> + handle_email_conflict(user_with_email, user_info) + + # Should not reach here + true -> + {:error, field: :email, message: "Unexpected error during email update"} + end + end + + # Handle email conflict during upsert + defp handle_email_conflict(user_with_email, user_info) do + email = Map.get(user_info, "preferred_username", "unknown") + email_user_oidc_id = user_with_email.oidc_id + + # Check if target email belongs to another OIDC user + if not is_nil(email_user_oidc_id) and email_user_oidc_id != "" do + different_oidc_error(email) + else + email_taken_error(email) + end + end + + # Handle new OIDC user registration scenarios + defp handle_create_scenario(user_with_email, new_oidc_id, user_info) do + email_user_oidc_id = user_with_email.oidc_id + + cond do + # Same oidc_id (should not happen in practice, but allow for safety) + email_user_oidc_id == new_oidc_id -> + :ok + + # Different oidc_id exists (hard error) + not is_nil(email_user_oidc_id) and email_user_oidc_id != "" and + email_user_oidc_id != new_oidc_id -> + email = Map.get(user_info, "preferred_username", "unknown") + different_oidc_error(email) + + # No oidc_id (require account linking) + is_nil(email_user_oidc_id) or email_user_oidc_id == "" -> {:error, PasswordVerificationRequired.exception( - user_id: existing_user.id, + user_id: user_with_email.id, oidc_user_info: user_info )} - # Case 3: Different oidc_id - account conflict + # Should not reach here true -> - {:error, - PasswordVerificationRequired.exception( - user_id: existing_user.id, - oidc_user_info: user_info - )} + {:error, field: :email, message: "Unexpected error during OIDC registration"} end end + # Generate error for different OIDC account conflict + defp different_oidc_error(email) do + {:error, + field: :email, + message: + "Email '#{email}' is already linked to a different OIDC account. " <> + "Cannot link multiple OIDC providers to the same account."} + end + + # Generate error for email already taken + defp email_taken_error(email) do + {:error, + field: :email, + message: + "Cannot update email to '#{email}': This email is already registered to another account. " <> + "Please change your email in the identity provider."} + end + @impl true def atomic?(), do: false diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex index 51d44d4..9282903 100644 --- a/lib/mv_web/controllers/auth_controller.ex +++ b/lib/mv_web/controllers/auth_controller.ex @@ -1,9 +1,21 @@ require Logger defmodule MvWeb.AuthController do + @moduledoc """ + Handles authentication callbacks for password and OIDC authentication. + + This controller manages: + - Successful authentication (password, OIDC, password reset, email confirmation) + - Authentication failures with appropriate error handling + - OIDC account linking flow when email collision occurs + - Sign out functionality + """ + use MvWeb, :controller use AshAuthentication.Phoenix.Controller + alias Mv.Accounts.User.Errors.PasswordVerificationRequired + def success(conn, activity, user, _token) do return_to = get_session(conn, :return_to) || ~p"/" @@ -23,107 +35,149 @@ defmodule MvWeb.AuthController do |> redirect(to: return_to) end + @doc """ + Handles authentication failures and routes to appropriate error handling. + + Manages: + - OIDC email collisions (triggers password verification flow) + - Generic OIDC authentication failures + - Unconfirmed account errors + - Generic authentication failures + """ def failure(conn, activity, reason) do - # Log the error for debugging Logger.warning( "Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}" ) case {activity, reason} do - # OIDC registration with existing email requires password verification (direct error) - {{:rauthy, :register}, %Ash.Error.Invalid{errors: errors}} -> - handle_oidc_email_collision(conn, errors) + {{:rauthy, _action}, reason} -> + handle_rauthy_failure(conn, reason) - # OIDC registration with existing email (wrapped in AuthenticationFailed) - {{:rauthy, :register}, - %AshAuthentication.Errors.AuthenticationFailed{ - caused_by: %Ash.Error.Invalid{errors: errors} - }} -> - handle_oidc_email_collision(conn, errors) - - # OIDC sign-in failure (wrapped) - {{:rauthy, :sign_in}, %AshAuthentication.Errors.AuthenticationFailed{caused_by: caused_by}} -> - # Check if it's actually a registration issue - case caused_by do - %Ash.Error.Invalid{errors: errors} -> - handle_oidc_email_collision(conn, errors) - - _ -> - # Real sign-in failure - conn - |> put_flash(:error, gettext("Unable to sign in with OIDC. Please try again.")) - |> redirect(to: ~p"/sign-in") - end - - # OIDC callback failure (can be either sign-in or registration) - {{:rauthy, :callback}, %AshAuthentication.Errors.AuthenticationFailed{caused_by: caused_by}} -> - case caused_by do - %Ash.Error.Invalid{errors: errors} -> - handle_oidc_email_collision(conn, errors) - - _ -> - conn - |> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again.")) - |> redirect(to: ~p"/sign-in") - end - - {_, - %AshAuthentication.Errors.AuthenticationFailed{ - caused_by: %Ash.Error.Forbidden{ - errors: [%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}] - } - }} -> - message = - gettext(""" - You have already signed in another way, but have not confirmed your account. - You can confirm your account using the link we sent to you, or by resetting your password. - """) - - conn - |> put_flash(:error, message) - |> redirect(to: ~p"/sign-in") + {_, %AshAuthentication.Errors.AuthenticationFailed{caused_by: caused_by}} -> + handle_authentication_failed(conn, caused_by) _ -> - message = gettext("Incorrect email or password") - - conn - |> put_flash(:error, message) - |> redirect(to: ~p"/sign-in") + redirect_with_error(conn, gettext("Incorrect email or password")) end end - # Handle OIDC email collision - user needs to verify password - defp handle_oidc_email_collision(conn, errors) do - password_verification_error = - Enum.find(errors, fn err -> - match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) - end) + # Handle all Rauthy (OIDC) authentication failures + defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do + handle_oidc_email_collision(conn, errors) + end - case password_verification_error do - %Mv.Accounts.User.Errors.PasswordVerificationRequired{ - user_id: user_id, - oidc_user_info: oidc_user_info - } -> - # Store the OIDC info in session for the linking flow - conn - |> put_session(:oidc_linking_user_id, user_id) - |> put_session(:oidc_linking_user_info, oidc_user_info) - |> put_flash( - :info, - gettext( - "An account with this email already exists. Please verify your password to link your OIDC account." - ) - ) - |> redirect(to: ~p"/auth/link-oidc-account") + defp handle_rauthy_failure(conn, %AshAuthentication.Errors.AuthenticationFailed{ + caused_by: caused_by + }) do + case caused_by do + %Ash.Error.Invalid{errors: errors} -> + handle_oidc_email_collision(conn, errors) _ -> - # Other validation errors - show generic error - conn - |> put_flash(:error, gettext("Unable to sign in. Please try again.")) - |> redirect(to: ~p"/sign-in") + redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again.")) end end + # Handle generic AuthenticationFailed errors + defp handle_authentication_failed(conn, %Ash.Error.Forbidden{errors: errors}) do + if Enum.any?(errors, &match?(%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}, &1)) do + message = + gettext(""" + You have already signed in another way, but have not confirmed your account. + You can confirm your account using the link we sent to you, or by resetting your password. + """) + + redirect_with_error(conn, message) + else + redirect_with_error(conn, gettext("Authentication failed. Please try again.")) + end + end + + defp handle_authentication_failed(conn, _other) do + redirect_with_error(conn, gettext("Authentication failed. Please try again.")) + end + + # Handle OIDC email collision - user needs to verify password to link accounts + defp handle_oidc_email_collision(conn, errors) do + case find_password_verification_error(errors) do + %PasswordVerificationRequired{user_id: user_id, oidc_user_info: oidc_user_info} -> + redirect_to_account_linking(conn, user_id, oidc_user_info) + + nil -> + # Check if it's a "different OIDC account" error or email uniqueness error + error_message = extract_meaningful_error_message(errors) + redirect_with_error(conn, error_message) + end + end + + # Extract meaningful error message from Ash errors + defp extract_meaningful_error_message(errors) do + # Look for specific error messages in InvalidAttribute errors + meaningful_error = + Enum.find_value(errors, fn + %Ash.Error.Changes.InvalidAttribute{message: message, field: :email} + when is_binary(message) -> + cond do + # Email update conflict during OIDC login + String.contains?(message, "Cannot update email to") and + String.contains?(message, "already registered to another account") -> + gettext( + "Cannot update email: This email is already registered to another account. Please change your email in the identity provider." + ) + + # Different OIDC account error + String.contains?(message, "already linked to a different OIDC account") -> + gettext( + "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." + ) + + true -> + nil + end + + %Ash.Error.Changes.InvalidAttribute{message: message} + when is_binary(message) -> + # Return any other meaningful message + if String.length(message) > 20 and + not String.contains?(message, "has already been taken") do + message + else + nil + end + + _ -> + nil + end) + + meaningful_error || gettext("Unable to sign in. Please try again.") + end + + # Find PasswordVerificationRequired error in error list + defp find_password_verification_error(errors) do + Enum.find(errors, &match?(%PasswordVerificationRequired{}, &1)) + end + + # Redirect to account linking page with OIDC info stored in session + defp redirect_to_account_linking(conn, user_id, oidc_user_info) do + conn + |> put_session(:oidc_linking_user_id, user_id) + |> put_session(:oidc_linking_user_info, oidc_user_info) + |> put_flash( + :info, + gettext( + "An account with this email already exists. Please verify your password to link your OIDC account." + ) + ) + |> redirect(to: ~p"/auth/link-oidc-account") + end + + # Generic error redirect helper + defp redirect_with_error(conn, message) do + conn + |> put_flash(:error, message) + |> redirect(to: ~p"/sign-in") + end + def sign_out(conn, _params) do return_to = get_session(conn, :return_to) || ~p"/" diff --git a/lib/mv_web/live/auth/link_oidc_account_live.ex b/lib/mv_web/live/auth/link_oidc_account_live.ex index af91e31..2262723 100644 --- a/lib/mv_web/live/auth/link_oidc_account_live.ex +++ b/lib/mv_web/live/auth/link_oidc_account_live.ex @@ -5,41 +5,139 @@ defmodule MvWeb.LinkOidcAccountLive do This page is shown when a user tries to log in via OIDC using an email that already exists with a password-only account. The user must verify their password before the OIDC account can be linked. + + ## Flow + 1. User attempts OIDC login with email that has existing password account + 2. System raises `PasswordVerificationRequired` error + 3. AuthController redirects here with user_id and oidc_user_info in session + 4. User enters password to verify identity + 5. On success, oidc_id is linked to user account + 6. User is redirected to complete OIDC login """ use MvWeb, :live_view require Ash.Query + require Logger @impl true def mount(_params, session, socket) do - user_id = Map.get(session, "oidc_linking_user_id") - oidc_user_info = Map.get(session, "oidc_linking_user_info") - - if user_id && oidc_user_info do - # Load the user - case Ash.get(Mv.Accounts.User, user_id) do - {:ok, user} -> - {:ok, - socket - |> assign(:user, user) - |> assign(:oidc_user_info, oidc_user_info) - |> assign(:password, "") - |> assign(:error, nil) - |> assign(:form, to_form(%{"password" => ""}))} - - {:error, _} -> - {:ok, - socket - |> put_flash(:error, dgettext("auth", "Session expired. Please try again.")) - |> redirect(to: ~p"/sign-in")} + with user_id when not is_nil(user_id) <- Map.get(session, "oidc_linking_user_id"), + oidc_user_info when not is_nil(oidc_user_info) <- + Map.get(session, "oidc_linking_user_info"), + {:ok, user} <- Ash.get(Mv.Accounts.User, user_id) do + # Check if user is passwordless + if passwordless?(user) do + # Auto-link passwordless user immediately + {:ok, auto_link_passwordless_user(socket, user, oidc_user_info)} + else + # Show password form for password-protected user + {:ok, initialize_socket(socket, user, oidc_user_info)} end else - {:ok, - socket - |> put_flash(:error, dgettext("auth", "Invalid session. Please try again.")) - |> redirect(to: ~p"/sign-in")} + nil -> + {:ok, redirect_with_error(socket, dgettext("auth", "Invalid session. Please try again."))} + + {:error, _} -> + {:ok, redirect_with_error(socket, dgettext("auth", "Session expired. Please try again."))} end end + defp passwordless?(user) do + is_nil(user.hashed_password) + end + + defp reload_user!(user_id) do + Mv.Accounts.User + |> Ash.Query.filter(id == ^user_id) + |> Ash.read_one!() + end + + defp reset_password_form(socket) do + assign(socket, :form, to_form(%{"password" => ""})) + end + + defp auto_link_passwordless_user(socket, user, oidc_user_info) do + oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id") + + case user.id + |> reload_user!() + |> Ash.Changeset.for_update(:link_oidc_id, %{ + oidc_id: oidc_id, + oidc_user_info: oidc_user_info + }) + |> Ash.update() do + {:ok, updated_user} -> + Logger.info( + "Passwordless account auto-linked to OIDC: user_id=#{updated_user.id}, oidc_id=#{oidc_id}" + ) + + socket + |> put_flash( + :info, + dgettext("auth", "Account activated! Redirecting to complete sign-in...") + ) + |> Phoenix.LiveView.redirect(to: ~p"/auth/user/rauthy") + + {:error, error} -> + Logger.warning( + "Failed to auto-link passwordless account: user_id=#{user.id}, error=#{inspect(error)}" + ) + + error_message = extract_user_friendly_error(error) + + socket + |> put_flash(:error, error_message) + |> redirect(to: ~p"/sign-in") + end + end + + defp extract_user_friendly_error(%Ash.Error.Invalid{errors: errors}) do + # Check for specific error types + Enum.find_value(errors, fn + %Ash.Error.Changes.InvalidAttribute{field: :oidc_id, message: message} -> + if String.contains?(message, "already been taken") do + dgettext( + "auth", + "This OIDC account is already linked to another user. Please contact support." + ) + else + nil + end + + %Ash.Error.Changes.InvalidAttribute{field: :email, message: message} -> + if String.contains?(message, "already been taken") do + dgettext( + "auth", + "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support." + ) + else + nil + end + + _ -> + nil + end) || + dgettext("auth", "Failed to link account. Please try again or contact support.") + end + + defp extract_user_friendly_error(_error) do + dgettext("auth", "Failed to link account. Please try again or contact support.") + end + + defp initialize_socket(socket, user, oidc_user_info) do + socket + |> assign(:user, user) + |> assign(:oidc_user_info, oidc_user_info) + |> assign(:password, "") + |> assign(:error, nil) + |> reset_password_form() + end + + defp redirect_with_error(socket, message) do + socket + |> put_flash(:error, message) + |> redirect(to: ~p"/sign-in") + end + @impl true def handle_event("validate", %{"password" => password}, socket) do {:noreply, assign(socket, :password, password)} @@ -57,11 +155,13 @@ defmodule MvWeb.LinkOidcAccountLive do link_oidc_account(socket, verified_user, oidc_user_info) {:error, _reason} -> - # Password incorrect + # Password incorrect - log security event + Logger.warning("Failed password verification for OIDC linking: user_email=#{user.email}") + {:noreply, socket |> assign(:error, dgettext("auth", "Incorrect password. Please try again.")) - |> assign(:form, to_form(%{"password" => ""}))} + |> reset_password_form()} end end @@ -88,17 +188,20 @@ defmodule MvWeb.LinkOidcAccountLive do oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id") # Update the user with the OIDC ID - case Mv.Accounts.User - |> Ash.Query.filter(id == ^user.id) - |> Ash.read_one!() + case user.id + |> reload_user!() |> Ash.Changeset.for_update(:link_oidc_id, %{ oidc_id: oidc_id, oidc_user_info: oidc_user_info }) |> Ash.update() do - {:ok, _updated_user} -> + {:ok, updated_user} -> # After successful linking, redirect to OIDC login # Since the user now has an oidc_id, the next OIDC login will succeed + Logger.info( + "OIDC account successfully linked after password verification: user_id=#{updated_user.id}, oidc_id=#{oidc_id}" + ) + {:noreply, socket |> put_flash( @@ -111,13 +214,16 @@ defmodule MvWeb.LinkOidcAccountLive do |> Phoenix.LiveView.redirect(to: ~p"/auth/user/rauthy")} {:error, error} -> + Logger.warning( + "Failed to link OIDC account after password verification: user_id=#{user.id}, error=#{inspect(error)}" + ) + + error_message = extract_user_friendly_error(error) + {:noreply, socket - |> assign( - :error, - dgettext("auth", "Failed to link account: %{error}", error: inspect(error)) - ) - |> assign(:form, to_form(%{"password" => ""}))} + |> assign(:error, error_message) + |> reset_password_form()} end end diff --git a/lib/mv_web/locale_controller.ex b/lib/mv_web/locale_controller.ex index 0289efa..99a200f 100644 --- a/lib/mv_web/locale_controller.ex +++ b/lib/mv_web/locale_controller.ex @@ -5,7 +5,12 @@ defmodule MvWeb.LocaleController do conn |> put_session(:locale, locale) # Store locale in a cookie that persists beyond the session - |> put_resp_cookie("locale", locale, max_age: 365 * 24 * 60 * 60, same_site: "Lax") + |> put_resp_cookie("locale", locale, + max_age: 365 * 24 * 60 * 60, + same_site: "Lax", + http_only: true, + secure: Application.get_env(:mv, :use_secure_cookies, false) + ) |> redirect(to: get_referer(conn) || "/") end diff --git a/priv/gettext/de/LC_MESSAGES/auth.po b/priv/gettext/de/LC_MESSAGES/auth.po index ca98792..60d905e 100644 --- a/priv/gettext/de/LC_MESSAGES/auth.po +++ b/priv/gettext/de/LC_MESSAGES/auth.po @@ -69,11 +69,21 @@ msgstr "Das Passwort wurde erfolgreich zurückgesetzt" msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account." msgstr "Ein Konto mit der E-Mail %{email} existiert bereits. Bitte geben Sie Ihr Passwort ein, um Ihr OIDC-Konto zu verknüpfen." +#: lib/mv_web/live/auth/link_oidc_account_live.ex:61 +#, elixir-autogen, elixir-format +msgid "Account activated! Redirecting to complete sign-in..." +msgstr "Konto aktiviert! Sie werden zur Anmeldung weitergeleitet..." + #: lib/mv_web/live/auth/link_oidc_account_live.ex:160 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "Abbrechen" +#: lib/mv_web/live/auth/link_oidc_account_live.ex:67 +#, elixir-autogen, elixir-format +msgid "Failed to activate account: %{error}" +msgstr "Aktivierung des Kontos fehlgeschlagen: %{error}" + #: lib/mv_web/live/auth/link_oidc_account_live.ex:118 #, elixir-autogen, elixir-format msgid "Failed to link account: %{error}" @@ -109,6 +119,21 @@ msgstr "Verknüpfen..." msgid "Session expired. Please try again." msgstr "Sitzung abgelaufen. Bitte versuchen Sie es erneut." +#: lib/mv_web/live/auth/link_oidc_account_live.ex:79 +#, elixir-autogen, elixir-format +msgid "This OIDC account is already linked to another user. Please contact support." +msgstr "Dieses OIDC-Konto ist bereits mit einem anderen Benutzer verknüpft. Bitte kontaktieren Sie den Support." + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:89 +#, elixir-autogen, elixir-format +msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support." +msgstr "Die E-Mail-Adresse aus Ihrem OIDC-Provider ist bereits für ein anderes Konto registriert. Bitte ändern Sie Ihre E-Mail-Adresse im Identity-Provider oder kontaktieren Sie den Support." + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:100 +#, elixir-autogen, elixir-format +msgid "Failed to link account. Please try again or contact support." +msgstr "Verknüpfung des Kontos fehlgeschlagen. Bitte versuchen Sie es erneut oder kontaktieren Sie den Support." + #: lib/mv_web/live/auth/link_oidc_account_live.ex:108 #, elixir-autogen, elixir-format msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..." diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 10a7259..a15489d 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -639,3 +639,13 @@ msgstr "Anmeldung mit OIDC fehlgeschlagen. Bitte versuchen Sie es erneut." #, elixir-autogen, elixir-format msgid "Unable to sign in. Please try again." msgstr "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut." + +#: lib/mv_web/controllers/auth_controller.ex:120 +#, elixir-autogen, elixir-format +msgid "Cannot update email: This email is already registered to another account. Please change your email in the identity provider." +msgstr "E-Mail kann nicht aktualisiert werden: Diese E-Mail-Adresse ist bereits für ein anderes Konto registriert. Bitte ändern Sie Ihre E-Mail-Adresse im Identity-Provider." + +#: lib/mv_web/controllers/auth_controller.ex:126 +#, elixir-autogen, elixir-format +msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." +msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft. Es können nicht mehrere OIDC-Provider mit demselben Konto verknüpft werden." diff --git a/test/mv_web/controllers/oidc_e2e_flow_test.exs b/test/mv_web/controllers/oidc_e2e_flow_test.exs index c992d2f..3b4a22f 100644 --- a/test/mv_web/controllers/oidc_e2e_flow_test.exs +++ b/test/mv_web/controllers/oidc_e2e_flow_test.exs @@ -9,7 +9,7 @@ defmodule MvWeb.OidcE2EFlowTest do require Ash.Query describe "E2E: New OIDC user registration" do - test "new user can register via OIDC", %{conn: conn} do + test "new user can register via OIDC", %{conn: _conn} do # Simulate OIDC callback for brand new user user_info = %{ "sub" => "new_oidc_user_123", @@ -40,7 +40,7 @@ defmodule MvWeb.OidcE2EFlowTest do end describe "E2E: Existing OIDC user sign-in" do - test "existing OIDC user can sign in and email updates", %{conn: conn} do + test "existing OIDC user can sign in and email updates", %{conn: _conn} do # Create OIDC user user = create_test_user(%{ @@ -70,7 +70,7 @@ defmodule MvWeb.OidcE2EFlowTest do describe "E2E: OIDC with existing password account (Email Collision)" do test "OIDC registration with password account email triggers PasswordVerificationRequired", - %{conn: conn} do + %{conn: _conn} do # Step 1: Create a password-only user password_user = create_test_user(%{ @@ -106,7 +106,7 @@ defmodule MvWeb.OidcE2EFlowTest do end test "full E2E flow: OIDC collision -> password verification -> account linked", - %{conn: conn} do + %{conn: _conn} do # Step 1: Create password user password_user = create_test_user(%{ @@ -168,7 +168,7 @@ defmodule MvWeb.OidcE2EFlowTest do end test "E2E: OIDC collision with different email at provider updates email after linking", - %{conn: conn} do + %{conn: _conn} do # Password user with old email password_user = create_test_user(%{ @@ -213,7 +213,7 @@ defmodule MvWeb.OidcE2EFlowTest do end describe "E2E: OIDC with linked member" do - test "E2E: email sync to member when linking OIDC to password account", %{conn: conn} do + test "E2E: email sync to member when linking OIDC to password account", %{conn: _conn} do # Create member member = Ash.Seed.seed!(Mv.Membership.Member, %{ @@ -270,7 +270,7 @@ defmodule MvWeb.OidcE2EFlowTest do end describe "E2E: Security scenarios" do - test "E2E: password-only user cannot be accessed via OIDC without password", %{conn: conn} do + test "E2E: password-only user cannot be accessed via OIDC without password", %{conn: _conn} do # Create password user _password_user = create_test_user(%{ @@ -315,9 +315,9 @@ defmodule MvWeb.OidcE2EFlowTest do end) end - test "E2E: user with oidc_id cannot be hijacked by different OIDC provider", %{conn: conn} do + test "E2E: user with oidc_id cannot be hijacked by different OIDC provider", %{conn: _conn} do # User linked to OIDC provider A - user = + _user = create_test_user(%{ email: "linked@example.com", oidc_id: "provider_a_123" @@ -329,25 +329,31 @@ defmodule MvWeb.OidcE2EFlowTest do "preferred_username" => "linked@example.com" } - # Should trigger password requirement (different oidc_id) + # Should trigger hard error (not PasswordVerificationRequired) {:error, %Ash.Error.Invalid{errors: errors}} = Mv.Accounts.create_register_with_rauthy(%{ user_info: user_info, oauth_tokens: %{} }) - password_error = - Enum.find(errors, fn err -> - match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) - end) + # Should have hard error about "already linked to a different OIDC account" + assert Enum.any?(errors, fn + %Ash.Error.Changes.InvalidAttribute{message: msg} -> + String.contains?(msg, "already linked to a different OIDC account") - assert password_error != nil - assert password_error.user_id == user.id + _ -> + false + end) + + # Should NOT be PasswordVerificationRequired + refute Enum.any?(errors, fn err -> + match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) + end) end - test "E2E: empty string oidc_id is treated as password-only account", %{conn: conn} do + test "E2E: empty string oidc_id is treated as password-only account", %{conn: _conn} do # User with empty oidc_id - password_user = + _password_user = create_test_user(%{ email: "empty@example.com", password: "pass123", @@ -374,7 +380,7 @@ defmodule MvWeb.OidcE2EFlowTest do end describe "E2E: Error scenarios" do - test "E2E: OIDC registration without oidc_id fails", %{conn: conn} do + test "E2E: OIDC registration without oidc_id fails", %{conn: _conn} do user_info = %{ "preferred_username" => "noid@example.com" } @@ -390,7 +396,7 @@ defmodule MvWeb.OidcE2EFlowTest do end) end - test "E2E: OIDC registration without email fails", %{conn: conn} do + test "E2E: OIDC registration without email fails", %{conn: _conn} do user_info = %{ "sub" => "noemail_123" } diff --git a/test/mv_web/controllers/oidc_email_update_test.exs b/test/mv_web/controllers/oidc_email_update_test.exs new file mode 100644 index 0000000..53a6514 --- /dev/null +++ b/test/mv_web/controllers/oidc_email_update_test.exs @@ -0,0 +1,271 @@ +defmodule MvWeb.OidcEmailUpdateTest do + @moduledoc """ + Tests for OIDC email updates - when an existing OIDC user changes their email + in the OIDC provider and logs in again. + """ + use MvWeb.ConnCase, async: true + + describe "OIDC user updates email to available email" do + test "should succeed and update email" do + # Create OIDC user + {:ok, oidc_user} = + Mv.Accounts.User + |> Ash.Changeset.for_create(:create_user, %{ + email: "original@example.com" + }) + |> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_123") + |> Ash.create() + + # User logs in via OIDC with NEW email + user_info = %{ + "sub" => "oidc_123", + "preferred_username" => "newemail@example.com" + } + + result = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{"access_token" => "test_token"} + }) + + # Should succeed and email should be updated + assert {:ok, updated_user} = result + assert updated_user.id == oidc_user.id + assert to_string(updated_user.email) == "newemail@example.com" + assert updated_user.oidc_id == "oidc_123" + end + end + + describe "OIDC user updates email to email of passwordless user" do + test "should fail with clear error message" do + # Create OIDC user + {:ok, _oidc_user} = + Mv.Accounts.User + |> Ash.Changeset.for_create(:create_user, %{ + email: "oidcuser@example.com" + }) + |> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_456") + |> Ash.create() + + # Create passwordless user with target email + {:ok, _passwordless_user} = + Mv.Accounts.User + |> Ash.Changeset.for_create(:create_user, %{ + email: "taken@example.com" + }) + |> Ash.create() + + # OIDC user tries to update email to taken email + user_info = %{ + "sub" => "oidc_456", + "preferred_username" => "taken@example.com" + } + + result = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{"access_token" => "test_token"} + }) + + # Should fail with email update conflict error + assert {:error, %Ash.Error.Invalid{errors: errors}} = result + + # Should contain error about email being registered to another account + assert Enum.any?(errors, fn + %Ash.Error.Changes.InvalidAttribute{field: :email, message: message} -> + String.contains?(message, "Cannot update email to") and + String.contains?(message, "already registered to another account") + + _ -> + false + end) + + # Should NOT contain PasswordVerificationRequired + refute Enum.any?(errors, fn err -> + match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) + end) + end + end + + describe "OIDC user updates email to email of password-protected user" do + test "should fail with clear error message" do + # Create OIDC user + {:ok, _oidc_user} = + Mv.Accounts.User + |> Ash.Changeset.for_create(:create_user, %{ + email: "oidcuser2@example.com" + }) + |> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_789") + |> Ash.create() + + # Create password user with target email (explicitly NO oidc_id) + password_user = + create_test_user(%{ + email: "passworduser@example.com", + password: "securepass123" + }) + + # Ensure it's a password-only user + {:ok, password_user} = Ash.reload(password_user) + assert not is_nil(password_user.hashed_password) + # Force oidc_id to be nil to avoid any confusion + {:ok, password_user} = + password_user + |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.force_change_attribute(:oidc_id, nil) + |> Ash.update() + + assert is_nil(password_user.oidc_id) + + # OIDC user tries to update email to password user's email + user_info = %{ + "sub" => "oidc_789", + "preferred_username" => "passworduser@example.com" + } + + result = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{"access_token" => "test_token"} + }) + + # Should fail with email update conflict error + assert {:error, %Ash.Error.Invalid{errors: errors}} = result + + # Should contain error about email being registered to another account + assert Enum.any?(errors, fn + %Ash.Error.Changes.InvalidAttribute{field: :email, message: message} -> + String.contains?(message, "Cannot update email to") and + String.contains?(message, "already registered to another account") + + _ -> + false + end) + + # Should NOT contain PasswordVerificationRequired + refute Enum.any?(errors, fn err -> + match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) + end) + end + end + + describe "OIDC user updates email to email of different OIDC user" do + test "should fail with clear error message about different OIDC account" do + # Create first OIDC user + {:ok, _oidc_user1} = + Mv.Accounts.User + |> Ash.Changeset.for_create(:create_user, %{ + email: "oidcuser1@example.com" + }) + |> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_aaa") + |> Ash.create() + + # Create second OIDC user with target email + {:ok, _oidc_user2} = + Mv.Accounts.User + |> Ash.Changeset.for_create(:create_user, %{ + email: "oidcuser2@example.com" + }) + |> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_bbb") + |> Ash.create() + + # First OIDC user tries to update email to second user's email + user_info = %{ + "sub" => "oidc_aaa", + "preferred_username" => "oidcuser2@example.com" + } + + result = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{"access_token" => "test_token"} + }) + + # Should fail with "already linked to different OIDC account" error + assert {:error, %Ash.Error.Invalid{errors: errors}} = result + + # Should contain error about different OIDC account + assert Enum.any?(errors, fn + %Ash.Error.Changes.InvalidAttribute{field: :email, message: message} -> + String.contains?(message, "already linked to a different OIDC account") + + _ -> + false + end) + + # Should NOT contain PasswordVerificationRequired + refute Enum.any?(errors, fn err -> + match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) + end) + end + end + + describe "New OIDC user registration scenarios (for comparison)" do + test "new OIDC user with email of passwordless user triggers linking flow" do + # Create passwordless user + {:ok, passwordless_user} = + Mv.Accounts.User + |> Ash.Changeset.for_create(:create_user, %{ + email: "passwordless@example.com" + }) + |> Ash.create() + + # New OIDC user tries to register + user_info = %{ + "sub" => "new_oidc_999", + "preferred_username" => "passwordless@example.com" + } + + result = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{"access_token" => "test_token"} + }) + + # Should trigger PasswordVerificationRequired (linking flow) + assert {:error, %Ash.Error.Invalid{errors: errors}} = result + + assert Enum.any?(errors, fn + %Mv.Accounts.User.Errors.PasswordVerificationRequired{user_id: user_id} -> + user_id == passwordless_user.id + + _ -> + false + end) + end + + test "new OIDC user with email of existing OIDC user shows hard error" do + # Create existing OIDC user + {:ok, _existing_oidc_user} = + Mv.Accounts.User + |> Ash.Changeset.for_create(:create_user, %{ + email: "existing@example.com" + }) + |> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_existing") + |> Ash.create() + + # New OIDC user tries to register with same email + user_info = %{ + "sub" => "oidc_new", + "preferred_username" => "existing@example.com" + } + + result = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{"access_token" => "test_token"} + }) + + # Should fail with "already linked to different OIDC account" error + assert {:error, %Ash.Error.Invalid{errors: errors}} = result + + assert Enum.any?(errors, fn + %Ash.Error.Changes.InvalidAttribute{field: :email, message: message} -> + String.contains?(message, "already linked to a different OIDC account") + + _ -> + false + end) + end + end +end diff --git a/test/mv_web/controllers/oidc_integration_test.exs b/test/mv_web/controllers/oidc_integration_test.exs index 508ebab..bc12196 100644 --- a/test/mv_web/controllers/oidc_integration_test.exs +++ b/test/mv_web/controllers/oidc_integration_test.exs @@ -175,9 +175,9 @@ defmodule MvWeb.OidcIntegrationTest do end describe "OIDC error and edge case scenarios" do - test "OIDC registration with conflicting email and OIDC ID shows error" do + test "OIDC registration with conflicting email and OIDC ID shows hard error" do # Create user with email and OIDC ID - existing_user = + _existing_user = create_test_user(%{ email: "conflict@example.com", oidc_id: "oidc_conflict_1" @@ -195,19 +195,24 @@ defmodule MvWeb.OidcIntegrationTest do oauth_tokens: %{} }) - # Should fail with PasswordVerificationRequired (account conflict) + # Should fail with hard error (not PasswordVerificationRequired) # This prevents someone with OIDC provider B from taking over an account # that's already linked to OIDC provider A assert {:error, %Ash.Error.Invalid{errors: errors}} = result - # Should contain PasswordVerificationRequired error + # Should contain error about "already linked to a different OIDC account" assert Enum.any?(errors, fn - %Mv.Accounts.User.Errors.PasswordVerificationRequired{user_id: user_id} -> - user_id == existing_user.id + %Ash.Error.Changes.InvalidAttribute{message: msg} -> + String.contains?(msg, "already linked to a different OIDC account") _ -> false end) + + # Should NOT be PasswordVerificationRequired + refute Enum.any?(errors, fn err -> + match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) + end) end test "OIDC registration with missing sub and id should fail" do diff --git a/test/mv_web/controllers/oidc_password_linking_test.exs b/test/mv_web/controllers/oidc_password_linking_test.exs index b59633c..a898f95 100644 --- a/test/mv_web/controllers/oidc_password_linking_test.exs +++ b/test/mv_web/controllers/oidc_password_linking_test.exs @@ -322,7 +322,7 @@ defmodule MvWeb.OidcPasswordLinkingTest do |> Ash.Changeset.for_create(:create_user, %{ email: "user2@example.com" }) - |> Ash.Changeset.change_attribute(:oidc_id, "shared_oidc_333") + |> Ash.Changeset.force_change_attribute(:oidc_id, "shared_oidc_333") |> Ash.create() # Should fail due to unique constraint on oidc_id @@ -335,4 +335,162 @@ defmodule MvWeb.OidcPasswordLinkingTest do end) end end + + describe "OIDC login with passwordless user - Requires Linking Flow" do + test "user without password and without oidc_id triggers PasswordVerificationRequired" do + # Create user without password (e.g., invited user) + {:ok, existing_user} = + Mv.Accounts.User + |> Ash.Changeset.for_create(:create_user, %{ + email: "invited@example.com" + }) + |> Ash.create() + + # Verify user has no password and no oidc_id + assert is_nil(existing_user.hashed_password) + assert is_nil(existing_user.oidc_id) + + # OIDC registration should trigger linking flow (not automatic) + user_info = %{ + "sub" => "auto_link_oidc_123", + "preferred_username" => "invited@example.com" + } + + result = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{"access_token" => "test_token"} + }) + + # Should fail with PasswordVerificationRequired + # The LinkOidcAccountLive will auto-link without password prompt + assert {:error, %Ash.Error.Invalid{}} = result + {:error, error} = result + + assert Enum.any?(error.errors, fn err -> + match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) + end) + end + + test "user without password but WITH password later requires verification" do + # Create user without password first + {:ok, user} = + Mv.Accounts.User + |> Ash.Changeset.for_create(:create_user, %{ + email: "added-password@example.com" + }) + |> Ash.create() + + # User sets password later (using admin action) + {:ok, user_with_password} = + user + |> Ash.Changeset.for_update(:admin_set_password, %{ + password: "newpassword123" + }) + |> Ash.update() + + assert not is_nil(user_with_password.hashed_password) + + # Now OIDC login should require password verification + user_info = %{ + "sub" => "needs_verification", + "preferred_username" => "added-password@example.com" + } + + result = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{"access_token" => "test_token"} + }) + + # Should fail with PasswordVerificationRequired + assert {:error, %Ash.Error.Invalid{}} = result + {:error, error} = result + + assert Enum.any?(error.errors, fn err -> + match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) + end) + end + end + + describe "OIDC login with different oidc_id - Hard Error" do + test "user with different oidc_id cannot be linked (hard error)" do + # Create user with existing OIDC ID + {:ok, existing_user} = + Mv.Accounts.User + |> Ash.Changeset.for_create(:create_user, %{ + email: "already-linked@example.com" + }) + |> Ash.Changeset.force_change_attribute(:oidc_id, "original_oidc_999") + |> Ash.create() + + assert existing_user.oidc_id == "original_oidc_999" + + # Try to register with same email but different OIDC ID + user_info = %{ + "sub" => "different_oidc_888", + "preferred_username" => "already-linked@example.com" + } + + result = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{"access_token" => "test_token"} + }) + + # Should fail with hard error (not PasswordVerificationRequired) + assert {:error, %Ash.Error.Invalid{}} = result + {:error, error} = result + + # Should NOT be PasswordVerificationRequired + refute Enum.any?(error.errors, fn err -> + match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) + end) + + # Should be a validation error about email already linked + assert Enum.any?(error.errors, fn err -> + case err do + %Ash.Error.Changes.InvalidAttribute{message: msg} -> + String.contains?(msg, "already linked to a different OIDC account") + + _ -> + false + end + end) + end + + test "cannot link different oidc_id even with password verification" do + # Create user with password AND existing OIDC ID + existing_user = + create_test_user(%{ + email: "password-and-oidc@example.com", + password: "mypassword123", + oidc_id: "first_oidc_111" + }) + + assert existing_user.oidc_id == "first_oidc_111" + assert not is_nil(existing_user.hashed_password) + + # Try to register with different OIDC ID + user_info = %{ + "sub" => "second_oidc_222", + "preferred_username" => "password-and-oidc@example.com" + } + + result = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{"access_token" => "test_token"} + }) + + # Should fail - cannot link different OIDC ID + assert {:error, %Ash.Error.Invalid{}} = result + {:error, error} = result + + # Should be a hard error, not password verification + refute Enum.any?(error.errors, fn err -> + match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) + end) + end + end end diff --git a/test/mv_web/controllers/oidc_passwordless_linking_test.exs b/test/mv_web/controllers/oidc_passwordless_linking_test.exs new file mode 100644 index 0000000..9da66ac --- /dev/null +++ b/test/mv_web/controllers/oidc_passwordless_linking_test.exs @@ -0,0 +1,210 @@ +defmodule MvWeb.OidcPasswordlessLinkingTest do + @moduledoc """ + Tests for OIDC account linking with passwordless users. + + These tests verify the behavior when a passwordless user + (e.g., invited user, user created by admin) attempts to log in via OIDC. + """ + use MvWeb.ConnCase, async: true + + describe "Passwordless user - Automatic linking via special action" do + test "passwordless user can be linked via link_passwordless_oidc action" do + # Create user without password (e.g., invited user) + {:ok, existing_user} = + Mv.Accounts.User + |> Ash.Changeset.for_create(:create_user, %{ + email: "invited@example.com" + }) + |> Ash.create() + + # Verify user has no password and no oidc_id + assert is_nil(existing_user.hashed_password) + assert is_nil(existing_user.oidc_id) + + # Link via special action (simulating what happens after first OIDC attempt) + {:ok, linked_user} = + existing_user + |> Ash.Changeset.for_update(:link_oidc_id, %{ + oidc_id: "auto_link_oidc_123", + oidc_user_info: %{ + "sub" => "auto_link_oidc_123", + "preferred_username" => "invited@example.com" + } + }) + |> Ash.update() + + # User should now have oidc_id linked + assert linked_user.oidc_id == "auto_link_oidc_123" + assert linked_user.id == existing_user.id + + # Now OIDC sign-in should work + result = + Mv.Accounts.User + |> Ash.Query.for_read(:sign_in_with_rauthy, %{ + user_info: %{ + "sub" => "auto_link_oidc_123", + "preferred_username" => "invited@example.com" + }, + oauth_tokens: %{"access_token" => "test_token"} + }) + |> Ash.read_one() + + assert {:ok, signed_in_user} = result + assert signed_in_user.id == existing_user.id + end + + test "passwordless user triggers PasswordVerificationRequired for linking flow" do + # Create passwordless user + {:ok, existing_user} = + Mv.Accounts.User + |> Ash.Changeset.for_create(:create_user, %{ + email: "passwordless@example.com" + }) + |> Ash.create() + + assert is_nil(existing_user.hashed_password) + assert is_nil(existing_user.oidc_id) + + # Try OIDC registration - should trigger PasswordVerificationRequired + user_info = %{ + "sub" => "new_oidc_456", + "preferred_username" => "passwordless@example.com" + } + + result = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{"access_token" => "test_token"} + }) + + # Should fail with PasswordVerificationRequired + # LinkOidcAccountLive will auto-link without password prompt + assert {:error, %Ash.Error.Invalid{}} = result + {:error, error} = result + + assert Enum.any?(error.errors, fn err -> + case err do + %Mv.Accounts.User.Errors.PasswordVerificationRequired{user_id: user_id} -> + user_id == existing_user.id + + _ -> + false + end + end) + end + end + + describe "User with different OIDC ID - Hard Error" do + test "user with different oidc_id gets hard error, not password verification" do + # Create user with existing OIDC ID + {:ok, _existing_user} = + Mv.Accounts.User + |> Ash.Changeset.for_create(:create_user, %{ + email: "already-linked@example.com" + }) + |> Ash.Changeset.force_change_attribute(:oidc_id, "original_oidc_999") + |> Ash.create() + + # Try to register with same email but different OIDC ID + user_info = %{ + "sub" => "different_oidc_888", + "preferred_username" => "already-linked@example.com" + } + + result = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{"access_token" => "test_token"} + }) + + # Should fail with hard error + assert {:error, %Ash.Error.Invalid{}} = result + {:error, error} = result + + # Should NOT be PasswordVerificationRequired + refute Enum.any?(error.errors, fn err -> + match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) + end) + + # Should have error message about already linked + assert Enum.any?(error.errors, fn err -> + case err do + %Ash.Error.Changes.InvalidAttribute{message: msg} -> + String.contains?(msg, "already linked to a different OIDC account") + + _ -> + false + end + end) + end + + test "passwordless user with different oidc_id also gets hard error" do + # Create passwordless user with OIDC ID + {:ok, existing_user} = + Mv.Accounts.User + |> Ash.Changeset.for_create(:create_user, %{ + email: "passwordless-linked@example.com" + }) + |> Ash.Changeset.force_change_attribute(:oidc_id, "first_oidc_777") + |> Ash.create() + + assert is_nil(existing_user.hashed_password) + assert existing_user.oidc_id == "first_oidc_777" + + # Try to register with different OIDC ID + user_info = %{ + "sub" => "second_oidc_666", + "preferred_username" => "passwordless-linked@example.com" + } + + result = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{"access_token" => "test_token"} + }) + + # Should be hard error, not PasswordVerificationRequired + assert {:error, %Ash.Error.Invalid{}} = result + {:error, error} = result + + refute Enum.any?(error.errors, fn err -> + match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) + end) + end + end + + describe "Password user - Requires verification (existing behavior)" do + test "password user without oidc_id requires password verification" do + # Create password user + password_user = + create_test_user(%{ + email: "password@example.com", + password: "securepass123", + oidc_id: nil + }) + + assert not is_nil(password_user.hashed_password) + assert is_nil(password_user.oidc_id) + + # Try OIDC registration + user_info = %{ + "sub" => "new_oidc_999", + "preferred_username" => "password@example.com" + } + + result = + Mv.Accounts.create_register_with_rauthy(%{ + user_info: user_info, + oauth_tokens: %{"access_token" => "test_token"} + }) + + # Should require password verification + assert {:error, %Ash.Error.Invalid{}} = result + {:error, error} = result + + assert Enum.any?(error.errors, fn err -> + match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err) + end) + end + end +end From d02461f8ea6445488a3a597d707f97e0ba291e0b Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 6 Nov 2025 18:36:42 +0100 Subject: [PATCH 07/15] fix missing translations --- mix.exs | 2 +- priv/gettext/auth.pot | 46 ++++++++++++------ priv/gettext/de/LC_MESSAGES/auth.po | 67 +++++++++++++------------- priv/gettext/de/LC_MESSAGES/default.po | 38 +++++++++------ priv/gettext/default.pot | 39 +++++++++------ priv/gettext/en/LC_MESSAGES/auth.po | 51 ++++++++++++++------ priv/gettext/en/LC_MESSAGES/default.po | 44 +++++++++++------ 7 files changed, 179 insertions(+), 108 deletions(-) diff --git a/mix.exs b/mix.exs index 86b1010..b215d59 100644 --- a/mix.exs +++ b/mix.exs @@ -22,7 +22,7 @@ defmodule Mv.MixProject do def application do [ mod: {Mv.Application, []}, - extra_applications: [:logger, :runtime_tools] + extra_applications: [:logger, :runtime_tools, :gettext] ] end diff --git a/priv/gettext/auth.pot b/priv/gettext/auth.pot index 79e5941..5d3f8db 100644 --- a/priv/gettext/auth.pot +++ b/priv/gettext/auth.pot @@ -36,7 +36,7 @@ msgstr "" msgid "Need an account?" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:141 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:266 #, elixir-autogen msgid "Password" msgstr "" @@ -65,52 +65,68 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:130 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:252 #, elixir-autogen, elixir-format msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:160 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:287 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:118 -#, elixir-autogen, elixir-format -msgid "Failed to link account: %{error}" -msgstr "" - -#: lib/mv_web/live/auth/link_oidc_account_live.ex:65 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:163 #, elixir-autogen, elixir-format msgid "Incorrect password. Please try again." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:40 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:37 #, elixir-autogen, elixir-format msgid "Invalid session. Please try again." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:152 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:279 #, elixir-autogen, elixir-format msgid "Link Account" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:128 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:250 #, elixir-autogen, elixir-format msgid "Link OIDC Account" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:151 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:278 #, elixir-autogen, elixir-format msgid "Linking..." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:34 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:40 #, elixir-autogen, elixir-format msgid "Session expired. Please try again." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:108 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:209 #, elixir-autogen, elixir-format msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..." msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:76 +#, elixir-autogen, elixir-format +msgid "Account activated! Redirecting to complete sign-in..." +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:119 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:123 +#, elixir-autogen, elixir-format +msgid "Failed to link account. Please try again or contact support." +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:108 +#, elixir-autogen, elixir-format +msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support." +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:98 +#, elixir-autogen, elixir-format +msgid "This OIDC account is already linked to another user. Please contact support." +msgstr "" diff --git a/priv/gettext/de/LC_MESSAGES/auth.po b/priv/gettext/de/LC_MESSAGES/auth.po index 60d905e..7966aa1 100644 --- a/priv/gettext/de/LC_MESSAGES/auth.po +++ b/priv/gettext/de/LC_MESSAGES/auth.po @@ -35,7 +35,7 @@ msgstr "Falls diese*r Benutzer*in bekannt ist, wird jetzt eine Email mit einer A msgid "Need an account?" msgstr "Konto anlegen?" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:141 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:266 #, elixir-autogen msgid "Password" msgstr "Passwort" @@ -64,77 +64,78 @@ msgstr "Anmelden..." msgid "Your password has successfully been reset" msgstr "Das Passwort wurde erfolgreich zurückgesetzt" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:130 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:252 #, elixir-autogen, elixir-format msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account." msgstr "Ein Konto mit der E-Mail %{email} existiert bereits. Bitte geben Sie Ihr Passwort ein, um Ihr OIDC-Konto zu verknüpfen." -#: lib/mv_web/live/auth/link_oidc_account_live.ex:61 -#, elixir-autogen, elixir-format -msgid "Account activated! Redirecting to complete sign-in..." -msgstr "Konto aktiviert! Sie werden zur Anmeldung weitergeleitet..." - -#: lib/mv_web/live/auth/link_oidc_account_live.ex:160 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:287 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "Abbrechen" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:67 -#, elixir-autogen, elixir-format -msgid "Failed to activate account: %{error}" -msgstr "Aktivierung des Kontos fehlgeschlagen: %{error}" - -#: lib/mv_web/live/auth/link_oidc_account_live.ex:118 -#, elixir-autogen, elixir-format -msgid "Failed to link account: %{error}" -msgstr "Verknüpfung des Kontos fehlgeschlagen: %{error}" - -#: lib/mv_web/live/auth/link_oidc_account_live.ex:65 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:163 #, elixir-autogen, elixir-format msgid "Incorrect password. Please try again." msgstr "Falsches Passwort. Bitte versuchen Sie es erneut." -#: lib/mv_web/live/auth/link_oidc_account_live.ex:40 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:37 #, elixir-autogen, elixir-format msgid "Invalid session. Please try again." msgstr "Ungültige Sitzung. Bitte versuchen Sie es erneut." -#: lib/mv_web/live/auth/link_oidc_account_live.ex:152 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:279 #, elixir-autogen, elixir-format msgid "Link Account" msgstr "Konto verknüpfen" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:128 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:250 #, elixir-autogen, elixir-format msgid "Link OIDC Account" msgstr "OIDC-Konto verknüpfen" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:151 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:278 #, elixir-autogen, elixir-format msgid "Linking..." msgstr "Verknüpfen..." -#: lib/mv_web/live/auth/link_oidc_account_live.ex:34 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:40 #, elixir-autogen, elixir-format msgid "Session expired. Please try again." msgstr "Sitzung abgelaufen. Bitte versuchen Sie es erneut." -#: lib/mv_web/live/auth/link_oidc_account_live.ex:79 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:209 #, elixir-autogen, elixir-format -msgid "This OIDC account is already linked to another user. Please contact support." -msgstr "Dieses OIDC-Konto ist bereits mit einem anderen Benutzer verknüpft. Bitte kontaktieren Sie den Support." +msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..." +msgstr "Ihr OIDC-Konto wurde erfolgreich verknüpft! Sie werden zur Anmeldung weitergeleitet..." -#: lib/mv_web/live/auth/link_oidc_account_live.ex:89 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:76 #, elixir-autogen, elixir-format -msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support." -msgstr "Die E-Mail-Adresse aus Ihrem OIDC-Provider ist bereits für ein anderes Konto registriert. Bitte ändern Sie Ihre E-Mail-Adresse im Identity-Provider oder kontaktieren Sie den Support." +msgid "Account activated! Redirecting to complete sign-in..." +msgstr "Konto aktiviert! Sie werden zur Anmeldung weitergeleitet..." -#: lib/mv_web/live/auth/link_oidc_account_live.ex:100 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:119 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:123 #, elixir-autogen, elixir-format msgid "Failed to link account. Please try again or contact support." msgstr "Verknüpfung des Kontos fehlgeschlagen. Bitte versuchen Sie es erneut oder kontaktieren Sie den Support." #: lib/mv_web/live/auth/link_oidc_account_live.ex:108 #, elixir-autogen, elixir-format -msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..." -msgstr "Ihr OIDC-Konto wurde erfolgreich verknüpft! Sie werden zur Anmeldung weitergeleitet..." +msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support." +msgstr "Die E-Mail-Adresse aus Ihrem OIDC-Provider ist bereits für ein anderes Konto registriert. Bitte ändern Sie Ihre E-Mail-Adresse im Identity-Provider oder kontaktieren Sie den Support." + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:98 +#, elixir-autogen, elixir-format +msgid "This OIDC account is already linked to another user. Please contact support." +msgstr "Dieses OIDC-Konto ist bereits mit einem anderen Benutzer verknüpft. Bitte kontaktieren Sie den Support." + +#~ #: lib/mv_web/live/auth/link_oidc_account_live.ex:67 +#~ #, elixir-autogen, elixir-format +#~ msgid "Failed to activate account: %{error}" +#~ msgstr "Aktivierung des Kontos fehlgeschlagen: %{error}" + +#~ #: lib/mv_web/live/auth/link_oidc_account_live.ex:118 +#~ #, elixir-autogen, elixir-format +#~ msgid "Failed to link account: %{error}" +#~ msgstr "Verknüpfung des Kontos fehlgeschlagen: %{error}" diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index a15489d..facf4f3 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -223,7 +223,7 @@ msgstr "erstellt" msgid "update" msgstr "aktualisiert" -#: lib/mv_web/controllers/auth_controller.ex:87 +#: lib/mv_web/controllers/auth_controller.ex:60 #, elixir-autogen, elixir-format msgid "Incorrect email or password" msgstr "Falsche E-Mail oder Passwort" @@ -233,27 +233,27 @@ msgstr "Falsche E-Mail oder Passwort" msgid "Member %{action} successfully" msgstr "Mitglied %{action} erfolgreich" -#: lib/mv_web/controllers/auth_controller.ex:14 +#: lib/mv_web/controllers/auth_controller.ex:26 #, elixir-autogen, elixir-format msgid "You are now signed in" msgstr "Sie sind jetzt angemeldet" -#: lib/mv_web/controllers/auth_controller.ex:132 +#: lib/mv_web/controllers/auth_controller.ex:186 #, elixir-autogen, elixir-format msgid "You are now signed out" msgstr "Sie sind jetzt abgemeldet" -#: lib/mv_web/controllers/auth_controller.ex:77 +#: lib/mv_web/controllers/auth_controller.ex:85 #, elixir-autogen, elixir-format msgid "You have already signed in another way, but have not confirmed your account.\nYou can confirm your account using the link we sent to you, or by resetting your password.\n" msgstr "Sie haben sich bereits auf andere Weise angemeldet, aber Ihr Konto noch nicht bestätigt.\nSie können Ihr Konto über den Link bestätigen, den wir Ihnen gesendet haben, oder durch Zurücksetzen Ihres Passworts.\n" -#: lib/mv_web/controllers/auth_controller.ex:12 +#: lib/mv_web/controllers/auth_controller.ex:24 #, elixir-autogen, elixir-format msgid "Your email address has now been confirmed" msgstr "Ihre E-Mail-Adresse wurde bestätigt" -#: lib/mv_web/controllers/auth_controller.ex:13 +#: lib/mv_web/controllers/auth_controller.ex:25 #, elixir-autogen, elixir-format msgid "Your password has successfully been reset" msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt" @@ -620,32 +620,38 @@ msgstr "Klicke um zu sortieren" msgid "First name" msgstr "Vorname" -#: lib/mv_web/controllers/auth_controller.ex:113 +#: lib/mv_web/controllers/auth_controller.ex:167 #, elixir-autogen, elixir-format msgid "An account with this email already exists. Please verify your password to link your OIDC account." msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte verifizieren Sie Ihr Passwort, um Ihr OIDC-Konto zu verknüpfen." -#: lib/mv_web/controllers/auth_controller.ex:66 +#: lib/mv_web/controllers/auth_controller.ex:77 #, elixir-autogen, elixir-format msgid "Unable to authenticate with OIDC. Please try again." msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut." -#: lib/mv_web/controllers/auth_controller.ex:54 -#, elixir-autogen, elixir-format -msgid "Unable to sign in with OIDC. Please try again." -msgstr "Anmeldung mit OIDC fehlgeschlagen. Bitte versuchen Sie es erneut." - -#: lib/mv_web/controllers/auth_controller.ex:122 +#: lib/mv_web/controllers/auth_controller.ex:152 #, elixir-autogen, elixir-format msgid "Unable to sign in. Please try again." msgstr "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut." -#: lib/mv_web/controllers/auth_controller.ex:120 +#: lib/mv_web/controllers/auth_controller.ex:92 +#: lib/mv_web/controllers/auth_controller.ex:97 +#, elixir-autogen, elixir-format +msgid "Authentication failed. Please try again." +msgstr "Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut." + +#: lib/mv_web/controllers/auth_controller.ex:124 #, elixir-autogen, elixir-format msgid "Cannot update email: This email is already registered to another account. Please change your email in the identity provider." msgstr "E-Mail kann nicht aktualisiert werden: Diese E-Mail-Adresse ist bereits für ein anderes Konto registriert. Bitte ändern Sie Ihre E-Mail-Adresse im Identity-Provider." -#: lib/mv_web/controllers/auth_controller.ex:126 +#: lib/mv_web/controllers/auth_controller.ex:130 #, elixir-autogen, elixir-format msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft. Es können nicht mehrere OIDC-Provider mit demselben Konto verknüpft werden." + +#~ #: lib/mv_web/controllers/auth_controller.ex:54 +#~ #, elixir-autogen, elixir-format +#~ msgid "Unable to sign in with OIDC. Please try again." +#~ msgstr "Anmeldung mit OIDC fehlgeschlagen. Bitte versuchen Sie es erneut." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 0976553..ebcda96 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -224,7 +224,7 @@ msgstr "" msgid "update" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:87 +#: lib/mv_web/controllers/auth_controller.ex:60 #, elixir-autogen, elixir-format msgid "Incorrect email or password" msgstr "" @@ -234,27 +234,27 @@ msgstr "" msgid "Member %{action} successfully" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:14 +#: lib/mv_web/controllers/auth_controller.ex:26 #, elixir-autogen, elixir-format msgid "You are now signed in" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:132 +#: lib/mv_web/controllers/auth_controller.ex:186 #, elixir-autogen, elixir-format msgid "You are now signed out" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:77 +#: lib/mv_web/controllers/auth_controller.ex:85 #, elixir-autogen, elixir-format msgid "You have already signed in another way, but have not confirmed your account.\nYou can confirm your account using the link we sent to you, or by resetting your password.\n" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:12 +#: lib/mv_web/controllers/auth_controller.ex:24 #, elixir-autogen, elixir-format msgid "Your email address has now been confirmed" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:13 +#: lib/mv_web/controllers/auth_controller.ex:25 #, elixir-autogen, elixir-format msgid "Your password has successfully been reset" msgstr "" @@ -621,22 +621,33 @@ msgstr "" msgid "First name" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:113 +#: lib/mv_web/controllers/auth_controller.ex:167 #, elixir-autogen, elixir-format msgid "An account with this email already exists. Please verify your password to link your OIDC account." msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:66 +#: lib/mv_web/controllers/auth_controller.ex:77 #, elixir-autogen, elixir-format msgid "Unable to authenticate with OIDC. Please try again." msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:54 -#, elixir-autogen, elixir-format -msgid "Unable to sign in with OIDC. Please try again." -msgstr "" - -#: lib/mv_web/controllers/auth_controller.ex:122 +#: lib/mv_web/controllers/auth_controller.ex:152 #, elixir-autogen, elixir-format msgid "Unable to sign in. Please try again." msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:92 +#: lib/mv_web/controllers/auth_controller.ex:97 +#, elixir-autogen, elixir-format +msgid "Authentication failed. Please try again." +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:124 +#, elixir-autogen, elixir-format +msgid "Cannot update email: This email is already registered to another account. Please change your email in the identity provider." +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:130 +#, elixir-autogen, elixir-format +msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/auth.po b/priv/gettext/en/LC_MESSAGES/auth.po index 85f611c..f8e5564 100644 --- a/priv/gettext/en/LC_MESSAGES/auth.po +++ b/priv/gettext/en/LC_MESSAGES/auth.po @@ -32,7 +32,7 @@ msgstr "" msgid "Need an account?" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:141 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:266 #, elixir-autogen msgid "Password" msgstr "" @@ -61,52 +61,73 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:130 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:252 #, elixir-autogen, elixir-format msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:160 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:287 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:118 -#, elixir-autogen, elixir-format -msgid "Failed to link account: %{error}" -msgstr "" - -#: lib/mv_web/live/auth/link_oidc_account_live.ex:65 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:163 #, elixir-autogen, elixir-format msgid "Incorrect password. Please try again." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:40 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:37 #, elixir-autogen, elixir-format msgid "Invalid session. Please try again." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:152 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:279 #, elixir-autogen, elixir-format msgid "Link Account" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:128 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:250 #, elixir-autogen, elixir-format msgid "Link OIDC Account" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:151 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:278 #, elixir-autogen, elixir-format msgid "Linking..." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:34 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:40 #, elixir-autogen, elixir-format msgid "Session expired. Please try again." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:108 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:209 #, elixir-autogen, elixir-format msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..." msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:76 +#, elixir-autogen, elixir-format +msgid "Account activated! Redirecting to complete sign-in..." +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:119 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:123 +#, elixir-autogen, elixir-format +msgid "Failed to link account. Please try again or contact support." +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:108 +#, elixir-autogen, elixir-format +msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support." +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:98 +#, elixir-autogen, elixir-format +msgid "This OIDC account is already linked to another user. Please contact support." +msgstr "" + +#~ #: lib/mv_web/live/auth/link_oidc_account_live.ex:118 +#~ #, elixir-autogen, elixir-format +#~ msgid "Failed to link account: %{error}" +#~ msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index b3c6d77..2b414eb 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -224,7 +224,7 @@ msgstr "" msgid "update" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:87 +#: lib/mv_web/controllers/auth_controller.ex:60 #, elixir-autogen, elixir-format msgid "Incorrect email or password" msgstr "" @@ -234,27 +234,27 @@ msgstr "" msgid "Member %{action} successfully" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:14 +#: lib/mv_web/controllers/auth_controller.ex:26 #, elixir-autogen, elixir-format msgid "You are now signed in" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:132 +#: lib/mv_web/controllers/auth_controller.ex:186 #, elixir-autogen, elixir-format msgid "You are now signed out" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:77 +#: lib/mv_web/controllers/auth_controller.ex:85 #, elixir-autogen, elixir-format msgid "You have already signed in another way, but have not confirmed your account.\nYou can confirm your account using the link we sent to you, or by resetting your password.\n" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:12 +#: lib/mv_web/controllers/auth_controller.ex:24 #, elixir-autogen, elixir-format msgid "Your email address has now been confirmed" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:13 +#: lib/mv_web/controllers/auth_controller.ex:25 #, elixir-autogen, elixir-format msgid "Your password has successfully been reset" msgstr "" @@ -621,22 +621,38 @@ msgstr "" msgid "First name" msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:113 +#: lib/mv_web/controllers/auth_controller.ex:167 #, elixir-autogen, elixir-format msgid "An account with this email already exists. Please verify your password to link your OIDC account." msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:66 +#: lib/mv_web/controllers/auth_controller.ex:77 #, elixir-autogen, elixir-format msgid "Unable to authenticate with OIDC. Please try again." msgstr "" -#: lib/mv_web/controllers/auth_controller.ex:54 -#, elixir-autogen, elixir-format -msgid "Unable to sign in with OIDC. Please try again." -msgstr "" - -#: lib/mv_web/controllers/auth_controller.ex:122 +#: lib/mv_web/controllers/auth_controller.ex:152 #, elixir-autogen, elixir-format msgid "Unable to sign in. Please try again." msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:92 +#: lib/mv_web/controllers/auth_controller.ex:97 +#, elixir-autogen, elixir-format +msgid "Authentication failed. Please try again." +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:124 +#, elixir-autogen, elixir-format +msgid "Cannot update email: This email is already registered to another account. Please change your email in the identity provider." +msgstr "" + +#: lib/mv_web/controllers/auth_controller.ex:130 +#, elixir-autogen, elixir-format +msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." +msgstr "" + +#~ #: lib/mv_web/controllers/auth_controller.ex:54 +#~ #, elixir-autogen, elixir-format +#~ msgid "Unable to sign in with OIDC. Please try again." +#~ msgstr "" From 918b02a7146a494c50beb28ef1627d0a61970819 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 6 Nov 2025 18:56:22 +0100 Subject: [PATCH 08/15] fix accessibility issues --- .../live/auth/link_oidc_account_live.ex | 79 ++++++++++--------- priv/gettext/auth.pot | 22 ++++-- priv/gettext/de/LC_MESSAGES/auth.po | 28 +++---- priv/gettext/de/LC_MESSAGES/default.po | 5 -- priv/gettext/en/LC_MESSAGES/auth.po | 25 +++--- priv/gettext/en/LC_MESSAGES/default.po | 5 -- 6 files changed, 86 insertions(+), 78 deletions(-) diff --git a/lib/mv_web/live/auth/link_oidc_account_live.ex b/lib/mv_web/live/auth/link_oidc_account_live.ex index 2262723..05faf67 100644 --- a/lib/mv_web/live/auth/link_oidc_account_live.ex +++ b/lib/mv_web/live/auth/link_oidc_account_live.ex @@ -232,61 +232,64 @@ defmodule MvWeb.LinkOidcAccountLive do ~H"""
<%!-- Language Selector --%> -
+
+ - <.header class="text-center"> - {dgettext("auth", "Link OIDC Account")} - <:subtitle> - {dgettext( - "auth", - "An account with email %{email} already exists. Please enter your password to link your OIDC account.", - email: @user.email - )} - - +
+ <.header class="text-center"> + {dgettext("auth", "Link OIDC Account")} + <:subtitle> + {dgettext( + "auth", + "An account with email %{email} already exists. Please enter your password to link your OIDC account.", + email: @user.email + )} + + - <.form for={@form} id="link-oidc-form" phx-submit="submit" phx-change="validate" class="mt-8"> -
-
- <.input - field={@form[:password]} - type="password" - label={dgettext("auth", "Password")} - required - /> -
- - <%= if @error do %> -
-

{@error}

+ <.form for={@form} id="link-oidc-form" phx-submit="submit" phx-change="validate" class="mt-8"> +
+
+ <.input + field={@form[:password]} + type="password" + label={dgettext("auth", "Password")} + required + />
- <% end %> -
- <.button phx-disable-with={dgettext("auth", "Linking...")} class="w-full"> - {dgettext("auth", "Link Account")} - + <%= if @error do %> +
+

{@error}

+
+ <% end %> + +
+ <.button phx-disable-with={dgettext("auth", "Linking...")} class="w-full"> + {dgettext("auth", "Link Account")} + +
-
- + -
- <.link navigate={~p"/sign-in"} class="text-brand hover:underline"> - {dgettext("auth", "Cancel")} - -
+
+ <.link navigate={~p"/sign-in"} class="text-brand hover:underline"> + {dgettext("auth", "Cancel")} + +
+
""" end diff --git a/priv/gettext/auth.pot b/priv/gettext/auth.pot index 5d3f8db..ebb8d3c 100644 --- a/priv/gettext/auth.pot +++ b/priv/gettext/auth.pot @@ -36,7 +36,7 @@ msgstr "" msgid "Need an account?" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:266 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:268 #, elixir-autogen msgid "Password" msgstr "" @@ -65,12 +65,12 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:252 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:254 #, elixir-autogen, elixir-format msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:287 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:289 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" @@ -85,17 +85,17 @@ msgstr "" msgid "Invalid session. Please try again." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:279 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:281 #, elixir-autogen, elixir-format msgid "Link Account" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:250 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:252 #, elixir-autogen, elixir-format msgid "Link OIDC Account" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:278 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:280 #, elixir-autogen, elixir-format msgid "Linking..." msgstr "" @@ -130,3 +130,13 @@ msgstr "" #, elixir-autogen, elixir-format msgid "This OIDC account is already linked to another user. Please contact support." msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:235 +#, elixir-autogen, elixir-format +msgid "Language selection" +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:242 +#, elixir-autogen, elixir-format +msgid "Select language" +msgstr "" diff --git a/priv/gettext/de/LC_MESSAGES/auth.po b/priv/gettext/de/LC_MESSAGES/auth.po index 7966aa1..f0cbdf3 100644 --- a/priv/gettext/de/LC_MESSAGES/auth.po +++ b/priv/gettext/de/LC_MESSAGES/auth.po @@ -35,7 +35,7 @@ msgstr "Falls diese*r Benutzer*in bekannt ist, wird jetzt eine Email mit einer A msgid "Need an account?" msgstr "Konto anlegen?" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:266 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:268 #, elixir-autogen msgid "Password" msgstr "Passwort" @@ -64,12 +64,12 @@ msgstr "Anmelden..." msgid "Your password has successfully been reset" msgstr "Das Passwort wurde erfolgreich zurückgesetzt" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:252 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:254 #, elixir-autogen, elixir-format msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account." msgstr "Ein Konto mit der E-Mail %{email} existiert bereits. Bitte geben Sie Ihr Passwort ein, um Ihr OIDC-Konto zu verknüpfen." -#: lib/mv_web/live/auth/link_oidc_account_live.ex:287 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:289 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "Abbrechen" @@ -84,17 +84,17 @@ msgstr "Falsches Passwort. Bitte versuchen Sie es erneut." msgid "Invalid session. Please try again." msgstr "Ungültige Sitzung. Bitte versuchen Sie es erneut." -#: lib/mv_web/live/auth/link_oidc_account_live.ex:279 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:281 #, elixir-autogen, elixir-format msgid "Link Account" msgstr "Konto verknüpfen" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:250 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:252 #, elixir-autogen, elixir-format msgid "Link OIDC Account" msgstr "OIDC-Konto verknüpfen" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:278 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:280 #, elixir-autogen, elixir-format msgid "Linking..." msgstr "Verknüpfen..." @@ -130,12 +130,12 @@ msgstr "Die E-Mail-Adresse aus Ihrem OIDC-Provider ist bereits für ein anderes msgid "This OIDC account is already linked to another user. Please contact support." msgstr "Dieses OIDC-Konto ist bereits mit einem anderen Benutzer verknüpft. Bitte kontaktieren Sie den Support." -#~ #: lib/mv_web/live/auth/link_oidc_account_live.ex:67 -#~ #, elixir-autogen, elixir-format -#~ msgid "Failed to activate account: %{error}" -#~ msgstr "Aktivierung des Kontos fehlgeschlagen: %{error}" +#: lib/mv_web/live/auth/link_oidc_account_live.ex:235 +#, elixir-autogen, elixir-format +msgid "Language selection" +msgstr "Sprachauswahl" -#~ #: lib/mv_web/live/auth/link_oidc_account_live.ex:118 -#~ #, elixir-autogen, elixir-format -#~ msgid "Failed to link account: %{error}" -#~ msgstr "Verknüpfung des Kontos fehlgeschlagen: %{error}" +#: lib/mv_web/live/auth/link_oidc_account_live.ex:242 +#, elixir-autogen, elixir-format +msgid "Select language" +msgstr "Sprache auswählen" diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index facf4f3..22ff795 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -650,8 +650,3 @@ msgstr "E-Mail kann nicht aktualisiert werden: Diese E-Mail-Adresse ist bereits #, elixir-autogen, elixir-format msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft. Es können nicht mehrere OIDC-Provider mit demselben Konto verknüpft werden." - -#~ #: lib/mv_web/controllers/auth_controller.ex:54 -#~ #, elixir-autogen, elixir-format -#~ msgid "Unable to sign in with OIDC. Please try again." -#~ msgstr "Anmeldung mit OIDC fehlgeschlagen. Bitte versuchen Sie es erneut." diff --git a/priv/gettext/en/LC_MESSAGES/auth.po b/priv/gettext/en/LC_MESSAGES/auth.po index f8e5564..921d76b 100644 --- a/priv/gettext/en/LC_MESSAGES/auth.po +++ b/priv/gettext/en/LC_MESSAGES/auth.po @@ -32,7 +32,7 @@ msgstr "" msgid "Need an account?" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:266 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:268 #, elixir-autogen msgid "Password" msgstr "" @@ -61,12 +61,12 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:252 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:254 #, elixir-autogen, elixir-format msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:287 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:289 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" @@ -81,17 +81,17 @@ msgstr "" msgid "Invalid session. Please try again." msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:279 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:281 #, elixir-autogen, elixir-format msgid "Link Account" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:250 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:252 #, elixir-autogen, elixir-format msgid "Link OIDC Account" msgstr "" -#: lib/mv_web/live/auth/link_oidc_account_live.ex:278 +#: lib/mv_web/live/auth/link_oidc_account_live.ex:280 #, elixir-autogen, elixir-format msgid "Linking..." msgstr "" @@ -127,7 +127,12 @@ msgstr "" msgid "This OIDC account is already linked to another user. Please contact support." msgstr "" -#~ #: lib/mv_web/live/auth/link_oidc_account_live.ex:118 -#~ #, elixir-autogen, elixir-format -#~ msgid "Failed to link account: %{error}" -#~ msgstr "" +#: lib/mv_web/live/auth/link_oidc_account_live.ex:235 +#, elixir-autogen, elixir-format +msgid "Language selection" +msgstr "" + +#: lib/mv_web/live/auth/link_oidc_account_live.ex:242 +#, elixir-autogen, elixir-format +msgid "Select language" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 2b414eb..bc0e16c 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -651,8 +651,3 @@ msgstr "" #, elixir-autogen, elixir-format msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." msgstr "" - -#~ #: lib/mv_web/controllers/auth_controller.ex:54 -#~ #, elixir-autogen, elixir-format -#~ msgid "Unable to sign in with OIDC. Please try again." -#~ msgstr "" From 55fb8458555562b76f387628e5205e48271873ad Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 13 Nov 2025 16:32:54 +0100 Subject: [PATCH 09/15] refactor: small changes from PR review --- lib/accounts/user.ex | 3 ++- .../user/validations/oidc_email_collision.ex | 26 ++++--------------- 2 files changed, 7 insertions(+), 22 deletions(-) diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 1547ffe..749740d 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -190,8 +190,9 @@ defmodule Mv.Accounts.User do changeset |> Ash.Changeset.change_attribute(:oidc_id, oidc_id) # Update email if it differs from OIDC provider + # change_attribute/3 already checks if value matches existing value |> then(fn cs -> - if new_email && to_string(cs.data.email) != new_email do + if new_email do Ash.Changeset.change_attribute(cs, :email, new_email) else cs diff --git a/lib/accounts/user/validations/oidc_email_collision.ex b/lib/accounts/user/validations/oidc_email_collision.ex index ca633ee..041647a 100644 --- a/lib/accounts/user/validations/oidc_email_collision.ex +++ b/lib/accounts/user/validations/oidc_email_collision.ex @@ -69,13 +69,11 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do # User exists with this email - check if it's an upsert or registration is_upsert = not is_nil(existing_oidc_user) - handle_existing_user( - user_with_email, - new_oidc_id, - user_info, - is_upsert, - existing_oidc_user - ) + if is_upsert do + handle_upsert_scenario(user_with_email, user_info, existing_oidc_user) + else + handle_create_scenario(user_with_email, new_oidc_id, user_info) + end {:error, error} -> # Database error - log for debugging but don't expose internals to user @@ -84,20 +82,6 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do end end - defp handle_existing_user( - user_with_email, - new_oidc_id, - user_info, - is_upsert, - existing_oidc_user - ) do - if is_upsert do - handle_upsert_scenario(user_with_email, user_info, existing_oidc_user) - else - handle_create_scenario(user_with_email, new_oidc_id, user_info) - end - end - # Handle email update for existing OIDC user defp handle_upsert_scenario(user_with_email, user_info, existing_oidc_user) do cond do From 47f18e9ef3a2f249ecae317ac6d78af4fd966fe9 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 13 Nov 2025 16:56:41 +0100 Subject: [PATCH 10/15] docs: update the docs --- docs/database-schema-readme.md | 86 ++++++++++++++++-- docs/database_schema.dbml | 43 ++++++--- docs/development-progress-log.md | 147 +++++++++++++++++++++++++++++-- docs/feature-roadmap.md | 23 +++-- 4 files changed, 264 insertions(+), 35 deletions(-) diff --git a/docs/database-schema-readme.md b/docs/database-schema-readme.md index ab1d76d..eefb608 100644 --- a/docs/database-schema-readme.md +++ b/docs/database-schema-readme.md @@ -132,11 +132,17 @@ Member (1) → (N) Properties ### Performance Indexes **members:** -- `search_vector` (GIN) - Full-text search -- `email` - Email lookups -- `last_name` - Name sorting -- `join_date` - Date filtering -- `paid` (partial) - Payment status queries +- `search_vector` (GIN) - Full-text search (tsvector) +- `first_name` (GIN trgm) - Fuzzy search on first name +- `last_name` (GIN trgm) - Fuzzy search on last name +- `email` (GIN trgm) - Fuzzy search on email +- `city` (GIN trgm) - Fuzzy search on city +- `street` (GIN trgm) - Fuzzy search on street +- `notes` (GIN trgm) - Fuzzy search on notes +- `email` (B-tree) - Exact email lookups +- `last_name` (B-tree) - Name sorting +- `join_date` (B-tree) - Date filtering +- `paid` (partial B-tree) - Payment status queries **properties:** - `member_id` - Member property lookups @@ -172,6 +178,64 @@ SELECT * FROM members WHERE search_vector @@ to_tsquery('simple', 'john & doe'); ``` +## Fuzzy Search (Trigram-based) + +### Implementation +- **Extension:** `pg_trgm` (PostgreSQL Trigram) +- **Index Type:** GIN with `gin_trgm_ops` operator class +- **Similarity Threshold:** 0.2 (default, configurable) +- **Added:** November 2025 (PR #187, closes #162) + +### How It Works +Fuzzy search combines multiple search strategies: +1. **Full-text search** - Primary filter using tsvector +2. **Trigram similarity** - `similarity(field, query) > threshold` +3. **Word similarity** - `word_similarity(query, field) > threshold` +4. **Substring matching** - `LIKE` and `ILIKE` for exact substrings +5. **Modulo operator** - `query % field` for quick similarity check + +### Indexed Fields for Fuzzy Search +- `first_name` - GIN trigram index +- `last_name` - GIN trigram index +- `email` - GIN trigram index +- `city` - GIN trigram index +- `street` - GIN trigram index +- `notes` - GIN trigram index + +### Usage Example (Ash Action) +```elixir +# In LiveView or context +Member.fuzzy_search(Member, query: "john", similarity_threshold: 0.2) + +# Or using Ash Query directly +Member +|> Ash.Query.for_read(:search, %{query: "john", similarity_threshold: 0.2}) +|> Mv.Membership.read!() +``` + +### Usage Example (SQL) +```sql +-- Trigram similarity search +SELECT * FROM members +WHERE similarity(first_name, 'john') > 0.2 + OR similarity(last_name, 'doe') > 0.2 +ORDER BY similarity(first_name, 'john') DESC; + +-- Word similarity (better for partial matches) +SELECT * FROM members +WHERE word_similarity('john', first_name) > 0.2; + +-- Quick similarity check with % operator +SELECT * FROM members +WHERE 'john' % first_name; +``` + +### Performance Considerations +- **GIN indexes** speed up trigram operations significantly +- **Similarity threshold** of 0.2 balances precision and recall +- **Combined approach** (FTS + trigram) provides best results +- Lower threshold = more results but less specific + ## Database Extensions ### Required PostgreSQL Extensions @@ -184,10 +248,17 @@ WHERE search_vector @@ to_tsquery('simple', 'john & doe'); - Purpose: Case-insensitive text type - Used for: `users.email` (case-insensitive email matching) +3. **pg_trgm** + - Purpose: Trigram-based fuzzy text search and similarity matching + - Used for: Fuzzy member search with similarity scoring + - Operators: `%` (similarity), `word_similarity()`, `similarity()` + - Added in: Migration `20251001141005_add_trigram_to_members.exs` + ### Installation ```sql CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE EXTENSION IF NOT EXISTS "citext"; +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; ``` ## Migration Strategy @@ -215,6 +286,7 @@ priv/repo/migrations/ ├── 20250620110850_add_accounts_domain.exs ├── 20250912085235_AddSearchVectorToMembers.exs ├── 20250926180341_add_unique_email_to_members.exs +├── 20251001141005_add_trigram_to_members.exs └── 20251016130855_add_constraints_for_user_member_and_property.exs ``` @@ -386,7 +458,7 @@ mix run priv/repo/seeds.exs --- -**Last Updated:** 2025-11-10 -**Schema Version:** 1.0 +**Last Updated:** 2025-11-13 +**Schema Version:** 1.1 **Database:** PostgreSQL 17.6 (dev) / 16 (prod) diff --git a/docs/database_schema.dbml b/docs/database_schema.dbml index a536d26..b414cf9 100644 --- a/docs/database_schema.dbml +++ b/docs/database_schema.dbml @@ -6,8 +6,8 @@ // - https://dbdocs.io // - VS Code Extensions: "DBML Language" or "dbdiagram.io" // -// Version: 1.0 -// Last Updated: 2025-11-10 +// Version: 1.1 +// Last Updated: 2025-11-13 Project mila_membership_management { database_type: 'PostgreSQL' @@ -17,15 +17,21 @@ Project mila_membership_management { A membership management application for small to mid-sized clubs. ## Key Features: - - User authentication (OIDC + Password) + - User authentication (OIDC + Password with secure account linking) - Member management with flexible custom properties - Bidirectional email synchronization between users and members - - Full-text search capabilities + - Full-text search capabilities (tsvector) + - Fuzzy search with trigram matching (pg_trgm) - GDPR-compliant data management ## Domains: - **Accounts**: User authentication and session management - **Membership**: Club member data and custom properties + + ## Required PostgreSQL Extensions: + - uuid-ossp (UUID generation) + - citext (case-insensitive text) + - pg_trgm (trigram-based fuzzy search) ''' } @@ -130,10 +136,16 @@ Table members { indexes { email [unique, name: 'members_unique_email_index'] - search_vector [type: gin, name: 'members_search_vector_idx', note: 'GIN index for full-text search'] - email [name: 'members_email_idx'] - last_name [name: 'members_last_name_idx', note: 'For name sorting'] - join_date [name: 'members_join_date_idx', note: 'For date filters'] + search_vector [type: gin, name: 'members_search_vector_idx', note: 'GIN index for full-text search (tsvector)'] + first_name [type: gin, name: 'members_first_name_trgm_idx', note: 'GIN trigram index for fuzzy search'] + last_name [type: gin, name: 'members_last_name_trgm_idx', note: 'GIN trigram index for fuzzy search'] + email [type: gin, name: 'members_email_trgm_idx', note: 'GIN trigram index for fuzzy search'] + city [type: gin, name: 'members_city_trgm_idx', note: 'GIN trigram index for fuzzy search'] + street [type: gin, name: 'members_street_trgm_idx', note: 'GIN trigram index for fuzzy search'] + notes [type: gin, name: 'members_notes_trgm_idx', note: 'GIN trigram index for fuzzy search'] + email [name: 'members_email_idx', note: 'B-tree index for exact lookups'] + last_name [name: 'members_last_name_idx', note: 'B-tree index for name sorting'] + join_date [name: 'members_join_date_idx', note: 'B-tree index for date filters'] (paid) [name: 'members_paid_idx', type: btree, note: 'Partial index WHERE paid IS NOT NULL'] } @@ -152,10 +164,17 @@ Table members { - Subsequent changes to either email sync bidirectionally - Validates that email is not already used by another unlinked user - **Full-Text Search:** - - `search_vector` is auto-updated via trigger - - Weighted fields: first_name (A), last_name (A), email (B), notes (B) - - Supports flexible member search across multiple fields + **Search Capabilities:** + 1. Full-Text Search (tsvector): + - `search_vector` is auto-updated via trigger + - Weighted fields: first_name (A), last_name (A), email (B), notes (B) + - GIN index for fast text search + + 2. Fuzzy Search (pg_trgm): + - Trigram-based similarity matching + - 6 GIN trigram indexes on searchable fields + - Configurable similarity threshold (default 0.2) + - Supports typos and partial matches **Relationships:** - Optional 1:1 with users (0..1 ↔ 0..1) - authentication account diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index aa3795b..0022631 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -227,6 +227,108 @@ attribute :search_vector, AshPostgres.Tsvector, --- +#### Phase 6: Search Enhancement & OIDC Improvements (Sprint 9) + +**Sprint 9 - 01.11 - 13.11 (finalized)** + +**PR #187:** *Implement fuzzy search* (closes #162) 🔍 +- PostgreSQL `pg_trgm` extension for trigram-based fuzzy search +- 6 new GIN trigram indexes on members table: + - first_name, last_name, email, city, street, notes +- Combined search strategy: Full-text (tsvector) + Trigram similarity +- Configurable similarity threshold (default 0.2) +- Migration: `20251001141005_add_trigram_to_members.exs` +- 443 lines of comprehensive tests + +**Key learnings:** +- Trigram indexes significantly improve fuzzy matching +- Combined FTS + trigram provides best user experience +- word_similarity() better for partial word matching than similarity() +- Similarity threshold of 0.2 balances precision and recall + +**Implementation highlights:** +```elixir +# New Ash action: :search with fuzzy matching +read :search do + argument :query, :string, allow_nil?: true + argument :similarity_threshold, :float, allow_nil?: true + # Uses fragment() for pg_trgm operators: %, similarity(), word_similarity() +end + +# Public function for LiveView usage +def fuzzy_search(query, opts) do + Ash.Query.for_read(query, :search, %{query: query_string}) +end +``` + +--- + +**PR #192:** *OIDC handling and linking* (closes #171) 🔐 +- Secure OIDC account linking with password verification +- Security fix: Filter OIDC sign-in by `oidc_id` instead of email +- New custom error: `PasswordVerificationRequired` +- New validation: `OidcEmailCollision` for email conflict detection +- New LiveView: `LinkOidcAccountLive` for interactive linking +- Automatic linking for passwordless users (no password prompt) +- Password verification required for password-protected accounts +- Comprehensive security logging for audit trail +- Locale persistence via secure cookie (1 year TTL) +- Documentation: `docs/oidc-account-linking.md` + +**Security improvements:** +- Prevents account takeover via OIDC email matching +- Password verification before linking OIDC to password accounts +- All linking attempts logged with appropriate severity +- CSRF protection on linking forms +- Secure cookie flags: `http_only`, `secure`, `same_site: "Lax"` + +**Test coverage:** +- 5 new comprehensive test files (1,793 lines total): + - `user_authentication_test.exs` (265 lines) + - `oidc_e2e_flow_test.exs` (415 lines) + - `oidc_email_update_test.exs` (271 lines) + - `oidc_password_linking_test.exs` (496 lines) + - `oidc_passwordless_linking_test.exs` (210 lines) +- Extended `oidc_integration_test.exs` (+136 lines) + +**Key learnings:** +- Account linking requires careful security considerations +- Passwordless users should be auto-linked (better UX) +- Audit logging essential for security-critical operations +- Locale persistence improves user experience post-logout + +--- + +**PR #193:** *Docs, Code Guidelines and Progress Log* 📚 +- Complete project documentation suite (5,554 lines) +- New documentation files: + - `CODE_GUIDELINES.md` (2,578 lines) - Comprehensive development guidelines + - `docs/database-schema-readme.md` (392 lines) - Database documentation + - `docs/database_schema.dbml` (329 lines) - DBML schema definition + - `docs/development-progress-log.md` (1,227 lines) - This file + - `docs/feature-roadmap.md` (743 lines) - Feature planning and roadmap +- Reduced redundancy in README.md (links to detailed docs) +- Cross-referenced documentation for easy navigation + +--- + +**PR #201:** *Code documentation and refactoring* 🔧 +- @moduledoc for ALL modules (51 modules documented) +- @doc for all public functions +- Enabled Credo `ModuleDoc` check (enforces documentation standards) +- Refactored complex functions: + - `MemberLive.Index.handle_event/3` - Split sorting logic into smaller functions + - `AuthController.handle_auth_failure/2` - Reduced cyclomatic complexity +- Documentation coverage: 100% for core modules + +**Key learnings:** +- @moduledoc enforcement improves code maintainability +- Refactoring complex functions improves readability +- Documentation should explain "why" not just "what" +- Credo helps maintain consistent code quality + +--- + ## Implementation Decisions ### Architecture Patterns @@ -369,9 +471,11 @@ end - ✅ Consistent styling - ✅ Mobile-responsive out of the box -#### 7. Full-Text Search Implementation +#### 7. Search Implementation (Full-Text + Fuzzy) -**PostgreSQL tsvector + GIN Index** +**Two-Tiered Search Strategy:** + +**A) Full-Text Search (tsvector + GIN Index)** ```sql -- Auto-updating trigger @@ -389,16 +493,40 @@ END $$ LANGUAGE plpgsql; ``` +**B) Fuzzy Search (pg_trgm + Trigram GIN Indexes)** + +Added November 2025 (PR #187): + +```elixir +# Ash action combining FTS + trigram similarity +read :search do + argument :query, :string + argument :similarity_threshold, :float + + prepare fn query, _ctx -> + # 1. Full-text search (tsvector) + # 2. Trigram similarity (%, similarity(), word_similarity()) + # 3. Substring matching (contains, ilike) + end +end +``` + +**6 Trigram Indexes:** +- first_name, last_name, email, city, street, notes +- GIN index with `gin_trgm_ops` operator class + **Reasoning:** -- Native PostgreSQL feature (no external service) -- Fast with GIN index -- Weighted fields (names more important than dates) +- Native PostgreSQL features (no external service) +- Combined approach handles typos + partial matches +- Fast with GIN indexes - Simple lexer (no German stemming initially) +- Similarity threshold configurable (default 0.2) **Why not Elasticsearch/Meilisearch?** - Overkill for small to mid-sized clubs - Additional infrastructure complexity -- PostgreSQL full-text sufficient for 10k+ members +- PostgreSQL full-text + fuzzy sufficient for 10k+ members +- Better integration with existing stack ### Deviations from Initial Plans @@ -470,7 +598,8 @@ end 3. `20250620110850_add_accounts_domain.exs` - Users & tokens tables 4. `20250912085235_AddSearchVectorToMembers.exs` - Full-text search (tsvector + GIN index) 5. `20250926164519_member_relation.exs` - User-Member link (optional 1:1) -6. `20251016130855_add_constraints_for_user_member_and_property.exs` - Email sync constraints +6. `20251001141005_add_trigram_to_members.exs` - Fuzzy search (pg_trgm + 6 GIN trigram indexes) +7. `20251016130855_add_constraints_for_user_member_and_property.exs` - Email sync constraints **Learning:** Ash's code generation from resources ensures schema always matches code. @@ -1220,8 +1349,8 @@ This project demonstrates a modern Phoenix application built with: --- -**Document Version:** 1.0 -**Last Updated:** 2025-11-10 +**Document Version:** 1.1 +**Last Updated:** 2025-11-13 **Maintainer:** Development Team **Status:** Living Document (update as project evolves) diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 4768089..5ffd980 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -26,9 +26,14 @@ - ✅ Password-based authentication - ✅ User sessions and tokens - ✅ Basic authentication flows +- ✅ **OIDC account linking with password verification** (PR #192, closes #171) +- ✅ **Secure OIDC email collision handling** (PR #192) +- ✅ **Automatic linking for passwordless users** (PR #192) + +**Closed Issues:** +- ✅ [#171](https://git.local-it.org/local-it/mitgliederverwaltung/issues/171) - OIDC handling and linking (closed 2025-11-13) **Open Issues:** -- [#171](https://git.local-it.org/local-it/mitgliederverwaltung/issues/171) - Ensure correct handling of Password login vs OIDC login (M) - [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen (Low) - [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen (Low) @@ -54,20 +59,24 @@ - ✅ Address management - ✅ Membership status tracking - ✅ Full-text search (PostgreSQL tsvector) +- ✅ **Fuzzy search with trigram matching** (PR #187, closes #162) +- ✅ **Combined FTS + trigram search** (PR #187) +- ✅ **6 GIN trigram indexes** for fuzzy matching (PR #187) - ✅ Sorting by basic fields - ✅ User-Member linking (optional 1:1) - ✅ Email synchronization between User and Member +**Closed Issues:** +- ✅ [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Fuzzy and substring search (closed 2025-11-12) + **Open Issues:** - [#169](https://git.local-it.org/local-it/mitgliederverwaltung/issues/169) - Allow combined creation of Users/Members (M, Low priority) - [#168](https://git.local-it.org/local-it/mitgliederverwaltung/issues/168) - Allow user-member association in edit/create views (M, High priority) - [#165](https://git.local-it.org/local-it/mitgliederverwaltung/issues/165) - Pagination for list of members (S, Low priority) -- [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Implement fuzzy and substring search (M, Medium priority) - [#160](https://git.local-it.org/local-it/mitgliederverwaltung/issues/160) - Implement clear icon in searchbar (S, Low priority) - [#154](https://git.local-it.org/local-it/mitgliederverwaltung/issues/154) - Concept advanced search (Low priority, needs refinement) **Missing Features:** -- ❌ Fuzzy search - ❌ Advanced filters (date ranges, multiple criteria) - ❌ Pagination (currently all members loaded) - ❌ Bulk operations (bulk delete, bulk update) @@ -367,8 +376,8 @@ | Feature Area | Current Status | Priority | Complexity | |--------------|----------------|----------|------------| -| **Authentication & Authorization** | 40% complete | **High** | Medium | -| **Member Management** | 70% complete | **High** | Low-Medium | +| **Authentication & Authorization** | 60% complete | **High** | Medium | +| **Member Management** | 85% complete | **High** | Low-Medium | | **Custom Fields** | 50% complete | **High** | Medium | | **User Management** | 60% complete | Medium | Low | | **Navigation & UX** | 50% complete | Medium | Low | @@ -388,12 +397,12 @@ ### Open Milestones (From Issues) 1. ✅ **Ich kann einen neuen Kontakt anlegen** (Closed) -2. 🔄 **I can search through the list of members - fulltext** (Open) - Related: #162, #154 +2. ✅ **I can search through the list of members - fulltext** (Closed) - #162 implemented (Fuzzy Search), #154 needs refinement 3. 🔄 **I can sort the list of members for specific fields** (Open) - Related: #153 4. 🔄 **We have a intuitive navigation structure** (Open) 5. 🔄 **We have different roles and permissions** (Open) - Related: #191, #190, #151 6. 🔄 **As Admin I can configure settings globally** (Open) -7. 🔄 **Accounts & Logins** (Open) - Related: #171, #169, #168 +7. ✅ **Accounts & Logins** (Partially closed) - #171 implemented (OIDC linking), #169/#168 still open 8. 🔄 **I can add custom fields** (Open) - Related: #194, #157, #161 9. 🔄 **Import transactions via vereinfacht API** (Open) - Related: #156 10. 🔄 **We have a staging environment** (Open) From 8400e727a73ccf6d76a1be482df955a39871f541 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 13 Nov 2025 17:58:12 +0100 Subject: [PATCH 11/15] refactor: Rename Property/PropertyType to CustomFieldValue/CustomField Complete refactoring of resources, database tables, code references, tests, and documentation for improved naming consistency. --- CODE_GUIDELINES.md | 28 ++-- docs/database-schema-readme.md | 46 +++--- docs/database_schema.dbml | 72 ++++----- docs/development-progress-log.md | 44 ++--- docs/feature-roadmap.md | 52 +++--- .../{property_type.ex => custom_field.ex} | 36 ++--- .../{property.ex => custom_field_value.ex} | 30 ++-- lib/membership/email.ex | 10 +- lib/membership/member.ex | 18 +-- lib/membership/membership.ex | 30 ++-- .../form.ex | 62 ++++---- lib/mv_web/live/custom_field_live/index.ex | 88 ++++++++++ lib/mv_web/live/custom_field_live/show.ex | 66 ++++++++ .../form.ex | 150 ++++++++++-------- .../live/custom_field_value_live/index.ex | 86 ++++++++++ .../live/custom_field_value_live/show.ex | 67 ++++++++ lib/mv_web/live/member_live/form.ex | 70 ++++---- lib/mv_web/live/member_live/show.ex | 14 +- lib/mv_web/live/property_live/index.ex | 82 ---------- lib/mv_web/live/property_live/show.ex | 64 -------- lib/mv_web/live/property_type_live/index.ex | 88 ---------- lib/mv_web/live/property_type_live/show.ex | 66 -------- lib/mv_web/router.ex | 20 +-- ...operties_to_custom_fields_extensions_1.exs | 19 +++ ...602_rename_properties_to_custom_fields.exs | 84 ++++++++++ priv/repo/seeds.exs | 4 +- .../custom_field_values/20251113163602.json | 124 +++++++++++++++ .../repo/custom_fields/20251113163602.json | 106 +++++++++++++ priv/resource_snapshots/repo/extensions.json | 3 +- test/mv_web/live/profile_navigation_test.exs | 8 +- test/seeds_test.exs | 12 +- 31 files changed, 1002 insertions(+), 647 deletions(-) rename lib/membership/{property_type.ex => custom_field.ex} (58%) rename lib/membership/{property.ex => custom_field_value.ex} (53%) rename lib/mv_web/live/{property_type_live => custom_field_live}/form.ex (57%) create mode 100644 lib/mv_web/live/custom_field_live/index.ex create mode 100644 lib/mv_web/live/custom_field_live/show.ex rename lib/mv_web/live/{property_live => custom_field_value_live}/form.ex (54%) create mode 100644 lib/mv_web/live/custom_field_value_live/index.ex create mode 100644 lib/mv_web/live/custom_field_value_live/show.ex delete mode 100644 lib/mv_web/live/property_live/index.ex delete mode 100644 lib/mv_web/live/property_live/show.ex delete mode 100644 lib/mv_web/live/property_type_live/index.ex delete mode 100644 lib/mv_web/live/property_type_live/show.ex create mode 100644 priv/repo/migrations/20251113163600_rename_properties_to_custom_fields_extensions_1.exs create mode 100644 priv/repo/migrations/20251113163602_rename_properties_to_custom_fields.exs create mode 100644 priv/resource_snapshots/repo/custom_field_values/20251113163602.json create mode 100644 priv/resource_snapshots/repo/custom_fields/20251113163602.json diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 4a82edb..5cc792c 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -81,8 +81,8 @@ lib/ ├── membership/ # Membership domain │ ├── membership.ex # Domain definition │ ├── member.ex # Member resource -│ ├── property.ex # Custom property resource -│ ├── property_type.ex # Property type resource +│ ├── custom_field_value.ex # Custom field value resource +│ ├── custom_field.ex # CustomFieldValue type resource │ └── email.ex # Email custom type ├── mv/ # Core application modules │ ├── accounts/ # Domain-specific logic @@ -121,8 +121,8 @@ lib/ │ │ │ ├── search_bar_component.ex │ │ │ └── sort_header_component.ex │ │ ├── member_live/ # Member CRUD LiveViews -│ │ ├── property_live/ # Property CRUD LiveViews -│ │ ├── property_type_live/ +│ │ ├── custom_field_value_live/ # CustomFieldValue CRUD LiveViews +│ │ ├── custom_field_live/ │ │ └── user_live/ # User management LiveViews │ ├── auth_overrides.ex # AshAuthentication overrides │ ├── endpoint.ex # Phoenix endpoint @@ -740,14 +740,14 @@ end # Good - preload relationships members = Member - |> Ash.Query.load(:properties) + |> Ash.Query.load(:custom_field_values) |> Mv.Membership.list_members!() # Avoid - causes N+1 queries members = Mv.Membership.list_members!() Enum.map(members, fn member -> # This triggers a query for each member - Ash.load!(member, :properties) + Ash.load!(member, :custom_field_values) end) ``` @@ -1723,13 +1723,13 @@ end # Good - preload relationships members = Member - |> Ash.Query.load([:properties, :user]) + |> Ash.Query.load([:custom_field_values, :user]) |> Mv.Membership.list_members!() # Avoid - causes N+1 members = Mv.Membership.list_members!() Enum.map(members, fn member -> - properties = Ash.load!(member, :properties) # N queries! + custom_field_values = Ash.load!(member, :custom_field_values) # N queries! end) ``` @@ -1904,7 +1904,7 @@ defmodule Mv.Membership.Member do @moduledoc """ Represents a club member with their personal information and membership status. - Members can have custom properties defined by the club administrators. + Members can have custom_field_values defined by the club administrators. Each member is optionally linked to a user account for self-service access. ## Examples @@ -2050,7 +2050,7 @@ open doc/index.html ## [Unreleased] ### Added -- Member custom properties feature +- Member custom_field_values feature - Email synchronization between user and member ### Changed @@ -2081,14 +2081,14 @@ open doc/index.html ```bash # Create feature branch -git checkout -b feature/member-custom-properties +git checkout -b feature/member-custom-custom_field_values # Work on feature git add . -git commit -m "Add custom properties to members" +git commit -m "Add custom_field_values to members" # Push to remote -git push origin feature/member-custom-properties +git push origin feature/member-custom-custom_field_values ``` ### 8.2 Commit Messages @@ -2127,7 +2127,7 @@ Closes #123 ``` fix: resolve N+1 query in member list -Preload properties relationship when loading members to avoid N+1 queries. +Preload custom_field_values relationship when loading members to avoid N+1 queries. Performance improvement: reduced query count from 100+ to 2. ``` diff --git a/docs/database-schema-readme.md b/docs/database-schema-readme.md index eefb608..d548b82 100644 --- a/docs/database-schema-readme.md +++ b/docs/database-schema-readme.md @@ -52,21 +52,21 @@ This document provides a comprehensive overview of the Mila Membership Managemen - Bidirectional email sync with users - Flexible address and contact data -#### `properties` +#### `custom_field_values` - **Purpose:** Dynamic custom member attributes - **Rows (Estimated):** Variable (N per member) - **Key Features:** - Union type value storage (JSONB) - Multiple data types supported - - One property per type per member + - One custom field value per custom field per member -#### `property_types` -- **Purpose:** Schema definitions for custom properties +#### `custom_fields` +- **Purpose:** Schema definitions for custom_field_values - **Rows (Estimated):** Low (admin-defined) - **Key Features:** - Type definitions - Immutable and required flags - - Centralized property management + - Centralized custom field management ## Key Relationships @@ -77,7 +77,7 @@ User (0..1) ←→ (0..1) Member Member (1) → (N) Properties ↓ - PropertyType (1) + CustomField (1) ``` ### Relationship Details @@ -90,11 +90,11 @@ Member (1) → (N) Properties - `ON DELETE SET NULL` on user side (User preserved when Member deleted) 2. **Member → Properties (1:N)** - - One member, many properties - - `ON DELETE CASCADE` - properties deleted with member - - Composite unique constraint (member_id, property_type_id) + - One member, many custom_field_values + - `ON DELETE CASCADE` - custom_field_values deleted with member + - Composite unique constraint (member_id, custom_field_id) -3. **Property → PropertyType (N:1)** +3. **CustomFieldValue → CustomField (N:1)** - Properties reference type definition - `ON DELETE RESTRICT` - cannot delete type if in use - Type defines data structure @@ -121,8 +121,8 @@ Member (1) → (N) Properties - Phone: `+?[0-9\- ]{6,20}` - Postal code: 5 digits -### Property System -- Maximum one property per type per member +### CustomFieldValue System +- Maximum one custom field value per custom field per member - Value stored as union type in JSONB - Supported types: string, integer, boolean, date, email - Types can be marked as immutable or required @@ -144,10 +144,10 @@ Member (1) → (N) Properties - `join_date` (B-tree) - Date filtering - `paid` (partial B-tree) - Payment status queries -**properties:** -- `member_id` - Member property lookups -- `property_type_id` - Type-based queries -- Composite `(member_id, property_type_id)` - Uniqueness +**custom_field_values:** +- `member_id` - Member custom field value lookups +- `custom_field_id` - Type-based queries +- Composite `(member_id, custom_field_id)` - Uniqueness **tokens:** - `subject` - User token lookups @@ -297,8 +297,8 @@ priv/repo/migrations/ | Relationship | On Delete | Rationale | |--------------|-----------|-----------| | `users.member_id → members.id` | SET NULL | Preserve user account when member deleted | -| `properties.member_id → members.id` | CASCADE | Delete properties with member | -| `properties.property_type_id → property_types.id` | RESTRICT | Prevent deletion of types in use | +| `custom_field_values.member_id → members.id` | CASCADE | Delete custom_field_values with member | +| `custom_field_values.custom_field_id → custom_fields.id` | RESTRICT | Prevent deletion of types in use | ### Validation Layers @@ -327,15 +327,15 @@ priv/repo/migrations/ - Member search (uses GIN index on search_vector) - Member list with filters (uses indexes on join_date, paid) - User authentication (uses unique index on email/oidc_id) -- Property lookups by member (uses index on member_id) +- CustomFieldValue lookups by member (uses index on member_id) **Medium Frequency:** - Member CRUD operations -- Property updates +- CustomFieldValue updates - Token validation **Low Frequency:** -- PropertyType management +- CustomField management - User-Member linking - Bulk operations @@ -396,10 +396,10 @@ Install "DBML Language" extension to view/edit DBML files with: ### Critical Tables (Priority 1) - `members` - Core business data - `users` - Authentication data -- `property_types` - Schema definitions +- `custom_fields` - Schema definitions ### Important Tables (Priority 2) -- `properties` - Member custom data +- `custom_field_values` - Member custom data - `tokens` - Can be regenerated but good to backup ### Backup Strategy diff --git a/docs/database_schema.dbml b/docs/database_schema.dbml index b414cf9..431e064 100644 --- a/docs/database_schema.dbml +++ b/docs/database_schema.dbml @@ -18,7 +18,7 @@ Project mila_membership_management { ## Key Features: - User authentication (OIDC + Password with secure account linking) - - Member management with flexible custom properties + - Member management with flexible custom fields - Bidirectional email synchronization between users and members - Full-text search capabilities (tsvector) - Fuzzy search with trigram matching (pg_trgm) @@ -26,7 +26,7 @@ Project mila_membership_management { ## Domains: - **Accounts**: User authentication and session management - - **Membership**: Club member data and custom properties + - **Membership**: Club member data and custom fields ## Required PostgreSQL Extensions: - uuid-ossp (UUID generation) @@ -178,7 +178,7 @@ Table members { **Relationships:** - Optional 1:1 with users (0..1 ↔ 0..1) - authentication account - - 1:N with properties (custom dynamic fields) + - 1:N with custom_field_values (custom dynamic fields) **Validation Rules:** - first_name, last_name: min 1 character @@ -191,20 +191,20 @@ Table members { ''' } -Table properties { +Table custom_field_values { id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier'] value jsonb [null, note: 'Union type value storage (format: {type: "string", value: "example"})'] member_id uuid [not null, note: 'Link to member'] - property_type_id uuid [not null, note: 'Link to property type definition'] + custom_field_id uuid [not null, note: 'Link to custom field definition'] indexes { - (member_id, property_type_id) [unique, name: 'properties_unique_property_per_member_index', note: 'One property per type per member'] - member_id [name: 'properties_member_id_idx'] - property_type_id [name: 'properties_property_type_id_idx'] + (member_id, custom_field_id) [unique, name: 'custom_field_values_unique_custom_field_per_member_index', note: 'One custom field value per custom field per member'] + member_id [name: 'custom_field_values_member_id_idx'] + custom_field_id [name: 'custom_field_values_custom_field_id_idx'] } Note: ''' - **Dynamic Custom Member Properties** + **Dynamic Custom Member Field Values** Provides flexible, extensible attributes for members beyond the fixed schema. @@ -221,9 +221,9 @@ Table properties { - `email`: Validated email addresses **Constraints:** - - Each member can have only ONE property per property_type - - Properties are deleted when member is deleted (CASCADE) - - Property type cannot be deleted if properties exist (RESTRICT) + - Each member can have only ONE custom field value per custom field + - Custom field values are deleted when member is deleted (CASCADE) + - Custom field cannot be deleted if custom field values exist (RESTRICT) **Use Cases:** - Custom membership numbers @@ -233,34 +233,34 @@ Table properties { ''' } -Table property_types { +Table custom_fields { id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier'] - name text [not null, unique, note: 'Property name/identifier (e.g., "membership_number")'] + name text [not null, unique, note: 'CustomFieldValue name/identifier (e.g., "membership_number")'] value_type text [not null, note: 'Data type: string | integer | boolean | date | email'] description text [null, note: 'Human-readable description'] immutable boolean [not null, default: false, note: 'If true, value cannot be changed after creation'] - required boolean [not null, default: false, note: 'If true, all members must have this property'] + required boolean [not null, default: false, note: 'If true, all members must have this custom field'] indexes { - name [unique, name: 'property_types_unique_name_index'] + name [unique, name: 'custom_fields_unique_name_index'] } Note: ''' - **Property Type Definitions** + **CustomFieldValue Type Definitions** - Defines the schema and behavior for custom member properties. + Defines the schema and behavior for custom member custom_field_values. **Attributes:** - - `name`: Unique identifier for the property type + - `name`: Unique identifier for the custom field - `value_type`: Enforces data type consistency - `description`: Documentation for users/admins - `immutable`: Prevents changes after initial creation (e.g., membership numbers) - - `required`: Enforces that all members must have this property + - `required`: Enforces that all members must have this custom field **Constraints:** - `value_type` must be one of: string, integer, boolean, date, email - - `name` must be unique across all property types - - Cannot be deleted if properties reference it (ON DELETE RESTRICT) + - `name` must be unique across all custom fields + - Cannot be deleted if custom_field_values reference it (ON DELETE RESTRICT) **Examples:** - Membership Number (string, immutable, required) @@ -283,25 +283,25 @@ Table property_types { Ref: users.member_id - members.id [delete: set null] // Member → Properties (1:N) -// - One member can have multiple properties -// - Each property belongs to exactly one member +// - One member can have multiple custom_field_values +// - Each custom field value belongs to exactly one member // - ON DELETE CASCADE: Properties deleted when member deleted -// - UNIQUE constraint: One property per type per member -Ref: properties.member_id > members.id [delete: cascade] +// - UNIQUE constraint: One custom field value per custom field per member +Ref: custom_field_values.member_id > members.id [delete: cascade] -// Property → PropertyType (N:1) -// - Many properties can reference one property type -// - Property type defines the schema/behavior -// - ON DELETE RESTRICT: Cannot delete type if properties exist -Ref: properties.property_type_id > property_types.id [delete: restrict] +// CustomFieldValue → CustomField (N:1) +// - Many custom_field_values can reference one custom field +// - CustomFieldValue type defines the schema/behavior +// - ON DELETE RESTRICT: Cannot delete type if custom_field_values exist +Ref: custom_field_values.custom_field_id > custom_fields.id [delete: restrict] // ============================================ // ENUMS // ============================================ -// Valid data types for property values -// Determines how Property.value is interpreted -Enum property_value_type { +// Valid data types for custom field values +// Determines how CustomFieldValue.value is interpreted +Enum custom_field_value_type { string [note: 'Text data'] integer [note: 'Numeric data'] boolean [note: 'True/False flags'] @@ -335,8 +335,8 @@ TableGroup accounts_domain { TableGroup membership_domain { members - properties - property_types + custom_field_values + custom_fields Note: ''' **Membership Domain** diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index 0022631..f7447f2 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -131,11 +131,11 @@ Based on closed PRs from https://git.local-it.org/local-it/mitgliederverwaltung/ **Sprint 3 - 28.05 - 09.07** - Member CRUD operations -- Basic property system +- Basic custom field system - Initial UI with Tailwind CSS **Sprint 4 - 09.07 - 30.07** -- Property types implementation +- CustomFieldValue types implementation - Data validation - Error handling improvements @@ -154,7 +154,7 @@ Based on closed PRs from https://git.local-it.org/local-it/mitgliederverwaltung/ **PR #147:** *Add seed data for members* - Comprehensive seed data - Test users and members -- Property type examples +- CustomFieldValue type examples #### Phase 3: Search & Navigation (Sprint 6) @@ -379,21 +379,21 @@ end **Complete documentation:** See [`docs/email-sync.md`](email-sync.md) for decision tree and sync rules. -#### 4. Property System (EAV Pattern) +#### 4. CustomFieldValue System (EAV Pattern) **Implementation:** Entity-Attribute-Value pattern with union types ```elixir -# Property Type defines schema -defmodule Mv.Membership.PropertyType do +# CustomFieldValue Type defines schema +defmodule Mv.Membership.CustomField do attribute :name, :string # "Membership Number" attribute :value_type, :atom # :string, :integer, :boolean, :date, :email attribute :immutable, :boolean # Can't change after creation attribute :required, :boolean # All members must have this end -# Property stores values -defmodule Mv.Membership.Property do +# CustomFieldValue stores values +defmodule Mv.Membership.CustomFieldValue do attribute :value, :union, # Polymorphic value storage constraints: [ types: [ @@ -405,7 +405,7 @@ defmodule Mv.Membership.Property do ] ] belongs_to :member - belongs_to :property_type + belongs_to :custom_field end ``` @@ -413,12 +413,12 @@ end - Clubs need different custom fields - No schema migrations for new fields - Type safety with union types -- Centralized property management +- Centralized custom field management **Constraints:** -- One property per type per member (composite unique index) +- One custom field value per custom field per member (composite unique index) - Properties deleted with member (CASCADE) -- Property types protected if in use (RESTRICT) +- CustomFieldValue types protected if in use (RESTRICT) #### 5. Authentication Strategy @@ -593,7 +593,7 @@ end #### Database Migrations **Key migrations in chronological order:** -1. `20250528163901_initial_migration.exs` - Core tables (members, properties, property_types) +1. `20250528163901_initial_migration.exs` - Core tables (members, custom_field_values, custom_fields) 2. `20250617090641_member_fields.exs` - Member attributes expansion 3. `20250620110850_add_accounts_domain.exs` - Users & tokens tables 4. `20250912085235_AddSearchVectorToMembers.exs` - Full-text search (tsvector + GIN index) @@ -772,7 +772,7 @@ end - Admin user: `admin@mv.local` / `testpassword` - Sample members: Hans Müller, Greta Schmidt, Friedrich Wagner - Linked accounts: Maria Weber, Thomas Klein -- Property types: String, Date, Boolean, Email +- CustomFieldValue types: String, Date, Boolean, Email **Test Helpers:** ```elixir @@ -956,9 +956,9 @@ mix credo --strict mix credo suggest --format=oneline ``` -### 8. Property Value Type Mismatch +### 8. CustomFieldValue Value Type Mismatch -**Issue:** Property value doesn't match property_type definition. +**Issue:** CustomFieldValue value doesn't match custom_field definition. **Error:** ``` @@ -966,16 +966,16 @@ mix credo suggest --format=oneline ``` **Solution:** -Ensure property value matches property_type.value_type: +Ensure custom field value matches custom_field.value_type: ```elixir -# Property Type: value_type = :integer -property_type = get_property_type("age") +# CustomFieldValue Type: value_type = :integer +custom_field = get_custom_field("age") -# Property Value: must be integer union type -{:ok, property} = create_property(%{ +# CustomFieldValue Value: must be integer union type +{:ok, custom_field_value} = create_custom_field_value(%{ value: %{type: :integer, value: 25}, # Not "25" as string - property_type_id: property_type.id + custom_field_id: custom_field.id }) ``` diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 5ffd980..9a6517d 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -87,12 +87,12 @@ --- -#### 3. **Custom Fields (Property System)** 🔧 +#### 3. **Custom Fields (CustomFieldValue System)** 🔧 **Current State:** -- ✅ Property types (string, integer, boolean, date, email) -- ✅ Property type management -- ✅ Dynamic property assignment to members +- ✅ CustomFieldValue types (string, integer, boolean, date, email) +- ✅ CustomFieldValue type management +- ✅ Dynamic custom field value assignment to members - ✅ Union type storage (JSONB) **Open Issues:** @@ -217,7 +217,7 @@ - ❌ Global settings management - ❌ Club/Organization profile - ❌ Email templates configuration -- ❌ Property type management UI (user-facing) +- ❌ CustomFieldValue type management UI (user-facing) - ❌ Role and permission management UI - ❌ System health dashboard - ❌ Audit log viewer @@ -481,9 +481,9 @@ Since this is a **Phoenix LiveView** application with **Ash Framework**, we have | Mount | Purpose | Auth | Query Params | Events | |-------|---------|------|--------------|--------| | `/members` | Member list with search/sort | 🔐 | `?search=&sort_by=&sort_dir=` | `search`, `sort`, `delete`, `select` | -| `/members/new` | Create new member form | 🔐 | - | `save`, `cancel`, `add_property` | +| `/members/new` | Create new member form | 🔐 | - | `save`, `cancel`, `add_custom_field_value` | | `/members/:id` | Member detail view | 🔐 | - | `edit`, `delete`, `link_user` | -| `/members/:id/edit` | Edit member form | 🔐 | - | `save`, `cancel`, `add_property`, `remove_property` | +| `/members/:id/edit` | Edit member form | 🔐 | - | `save`, `cancel`, `add_custom_field_value`, `remove_custom_field_value` | #### LiveView Event Handlers @@ -495,8 +495,8 @@ Since this is a **Phoenix LiveView** application with **Ash Framework**, we have | `save` | Create/update member | `%{"member" => attrs}` | Redirect or show errors | | `link_user` | Link user to member | `%{"user_id" => id}` | Update member view | | `unlink_user` | Unlink user from member | - | Update member view | -| `add_property` | Add custom property | `%{"property_type_id" => id, "value" => val}` | Update form | -| `remove_property` | Remove custom property | `%{"property_id" => id}` | Update form | +| `add_custom_field_value` | Add custom field value | `%{"custom_field_id" => id, "value" => val}` | Update form | +| `remove_custom_field_value` | Remove custom field value | `%{"custom_field_value_id" => id}` | Update form | #### Ash Resource Actions @@ -517,7 +517,7 @@ Since this is a **Phoenix LiveView** application with **Ash Framework**, we have | `Member` | `:fuzzy_search` | Fuzzy text search | 🔐 | `{query, threshold}` | `[%Member{}]` | | `Member` | `:advanced_search` | Multi-criteria search | 🔐 | `{filters: [{field, op, value}]}` | `[%Member{}]` | | `Member` | `:paginate` | Paginated member list | 🔐 | `{page, per_page, filters}` | `{members, total, page_info}` | -| `Member` | `:sort_by_custom_field` | Sort by property | 🔐 | `{property_type_id, direction}` | `[%Member{}]` | +| `Member` | `:sort_by_custom_field` | Sort by custom field | 🔐 | `{custom_field_id, direction}` | `[%Member{}]` | | `Member` | `:bulk_delete` | Delete multiple members | 🛡️ | `{ids: [id1, id2, ...]}` | `{:ok, count}` | | `Member` | `:bulk_update` | Update multiple members | 🛡️ | `{ids, attrs}` | `{:ok, count}` | | `Member` | `:export` | Export to CSV/Excel | 🔐 | `{format, filters}` | File download | @@ -525,37 +525,37 @@ Since this is a **Phoenix LiveView** application with **Ash Framework**, we have --- -### 3. Custom Fields (Property System) Endpoints +### 3. Custom Fields (CustomFieldValue System) Endpoints #### LiveView Endpoints | Mount | Purpose | Auth | Events | |-------|---------|------|--------| -| `/property-types` | List property types | 🛡️ | `new`, `edit`, `delete` | -| `/property-types/new` | Create property type | 🛡️ | `save`, `cancel` | -| `/property-types/:id/edit` | Edit property type | 🛡️ | `save`, `cancel`, `delete` | +| `/custom-fields` | List custom fields | 🛡️ | `new`, `edit`, `delete` | +| `/custom-fields/new` | Create custom field | 🛡️ | `save`, `cancel` | +| `/custom-fields/:id/edit` | Edit custom field | 🛡️ | `save`, `cancel`, `delete` | #### Ash Resource Actions | Resource | Action | Purpose | Auth | Input | Output | |----------|--------|---------|------|-------|--------| -| `PropertyType` | `:create` | Create property type | 🛡️ | `{name, value_type, description, ...}` | `{:ok, property_type}` | -| `PropertyType` | `:read` | List property types | 🔐 | - | `[%PropertyType{}]` | -| `PropertyType` | `:update` | Update property type | 🛡️ | `{id, attrs}` | `{:ok, property_type}` | -| `PropertyType` | `:destroy` | Delete property type | 🛡️ | `{id}` | `{:ok, property_type}` | -| `Property` | `:create` | Add property to member | 🔐 | `{member_id, property_type_id, value}` | `{:ok, property}` | -| `Property` | `:update` | Update property value | 🔐 | `{id, value}` | `{:ok, property}` | -| `Property` | `:destroy` | Remove property | 🔐 | `{id}` | `{:ok, property}` | +| `CustomField` | `:create` | Create custom field | 🛡️ | `{name, value_type, description, ...}` | `{:ok, custom_field}` | +| `CustomField` | `:read` | List custom fields | 🔐 | - | `[%CustomField{}]` | +| `CustomField` | `:update` | Update custom field | 🛡️ | `{id, attrs}` | `{:ok, custom_field}` | +| `CustomField` | `:destroy` | Delete custom field | 🛡️ | `{id}` | `{:ok, custom_field}` | +| `CustomFieldValue` | `:create` | Add custom field value to member | 🔐 | `{member_id, custom_field_id, value}` | `{:ok, custom_field_value}` | +| `CustomFieldValue` | `:update` | Update custom field value | 🔐 | `{id, value}` | `{:ok, custom_field_value}` | +| `CustomFieldValue` | `:destroy` | Remove custom field value | 🔐 | `{id}` | `{:ok, custom_field_value}` | #### **NEW: Enhanced Custom Fields** (Issue #194, #157, #161, #153) | Resource | Action | Purpose | Auth | Input | Output | |----------|--------|---------|------|-------|--------| -| `PropertyType` | `:set_default_visibility` | Show/hide by default | 🛡️ | `{id, visible}` | `{:ok, property_type}` | -| `PropertyType` | `:set_required` | Mark as required | 🛡️ | `{id, required}` | `{:ok, property_type}` | -| `PropertyType` | `:add_validation` | Add validation rule | 🛡️ | `{id, rule_type, params}` | `{:ok, property_type}` | -| `PropertyType` | `:create_group` | Create field group | 🛡️ | `{name, property_type_ids}` | `{:ok, group}` | -| `Property` | `:validate_value` | Validate property value | 🔐 | `{property_type_id, value}` | `{:ok, valid}` or `{:error, reason}` | +| `CustomField` | `:set_default_visibility` | Show/hide by default | 🛡️ | `{id, visible}` | `{:ok, custom_field}` | +| `CustomField` | `:set_required` | Mark as required | 🛡️ | `{id, required}` | `{:ok, custom_field}` | +| `CustomField` | `:add_validation` | Add validation rule | 🛡️ | `{id, rule_type, params}` | `{:ok, custom_field}` | +| `CustomField` | `:create_group` | Create field group | 🛡️ | `{name, custom_field_ids}` | `{:ok, group}` | +| `CustomFieldValue` | `:validate_value` | Validate custom field value | 🔐 | `{custom_field_id, value}` | `{:ok, valid}` or `{:error, reason}` | --- diff --git a/lib/membership/property_type.ex b/lib/membership/custom_field.ex similarity index 58% rename from lib/membership/property_type.ex rename to lib/membership/custom_field.ex index 6569d1b..f155968 100644 --- a/lib/membership/property_type.ex +++ b/lib/membership/custom_field.ex @@ -1,18 +1,18 @@ -defmodule Mv.Membership.PropertyType do +defmodule Mv.Membership.CustomField do @moduledoc """ - Ash resource defining the schema for custom member properties. + Ash resource defining the schema for custom member fields. ## Overview - PropertyTypes define the "schema" for custom fields in the membership system. - Each PropertyType specifies the name, data type, and behavior of a custom field - that can be attached to members via Property resources. + CustomFields define the "schema" for custom fields in the membership system. + Each CustomField specifies the name, data type, and behavior of a custom field + that can be attached to members via CustomFieldValue resources. ## Attributes - - `name` - Unique identifier for the property (e.g., "phone_mobile", "birthday") + - `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday") - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`) - `description` - Optional human-readable description - - `immutable` - If true, property values cannot be changed after creation - - `required` - If true, all members must have this property (future feature) + - `immutable` - If true, custom field values cannot be changed after creation + - `required` - If true, all members must have this custom field (future feature) ## Supported Value Types - `:string` - Text data (unlimited length) @@ -22,22 +22,22 @@ defmodule Mv.Membership.PropertyType do - `:email` - Validated email addresses ## Relationships - - `has_many :properties` - All property values of this type + - `has_many :custom_field_values` - All custom field values of this type ## Constraints - - Name must be unique across all property types - - Cannot delete a property type that has existing property values (RESTRICT) + - Name must be unique across all custom fields + - Cannot delete a custom field that has existing custom field values (RESTRICT) ## Examples - # Create a new property type - PropertyType.create!(%{ + # Create a new custom field + CustomField.create!(%{ name: "phone_mobile", value_type: :string, description: "Mobile phone number" }) - # Create a required property type - PropertyType.create!(%{ + # Create a required custom field + CustomField.create!(%{ name: "emergency_contact", value_type: :string, required: true @@ -48,7 +48,7 @@ defmodule Mv.Membership.PropertyType do data_layer: AshPostgres.DataLayer postgres do - table "property_types" + table "custom_fields" repo Mv.Repo end @@ -65,7 +65,7 @@ defmodule Mv.Membership.PropertyType do attribute :value_type, :atom, constraints: [one_of: [:string, :integer, :boolean, :date, :email]], allow_nil?: false, - description: "Defines the datatype `Property.value` is interpreted as" + description: "Defines the datatype `CustomFieldValue.value` is interpreted as" attribute :description, :string, allow_nil?: true, public?: true @@ -79,7 +79,7 @@ defmodule Mv.Membership.PropertyType do end relationships do - has_many :properties, Mv.Membership.Property + has_many :custom_field_values, Mv.Membership.CustomFieldValue end identities do diff --git a/lib/membership/property.ex b/lib/membership/custom_field_value.ex similarity index 53% rename from lib/membership/property.ex rename to lib/membership/custom_field_value.ex index 231b264..6e6c95f 100644 --- a/lib/membership/property.ex +++ b/lib/membership/custom_field_value.ex @@ -1,11 +1,11 @@ -defmodule Mv.Membership.Property do +defmodule Mv.Membership.CustomFieldValue do @moduledoc """ - Ash resource representing a custom property value for a member. + Ash resource representing a custom field value for a member. ## Overview - Properties implement the Entity-Attribute-Value (EAV) pattern, allowing - dynamic custom fields to be attached to members. Each property links a - member to a property type and stores the actual value. + CustomFieldValues implement the Entity-Attribute-Value (EAV) pattern, allowing + dynamic custom fields to be attached to members. Each custom field value links a + member to a custom field and stores the actual value. ## Value Storage Values are stored using Ash's union type with JSONB storage format: @@ -24,19 +24,19 @@ defmodule Mv.Membership.Property do - `:email` - Validated email addresses (custom type) ## Relationships - - `belongs_to :member` - The member this property belongs to (CASCADE delete) - - `belongs_to :property_type` - The property type definition + - `belongs_to :member` - The member this custom field value belongs to (CASCADE delete) + - `belongs_to :custom_field` - The custom field definition ## Constraints - - Each member can have only one property per property type (unique composite index) - - Properties are deleted when the associated member is deleted (CASCADE) + - Each member can have only one custom field value per custom field (unique composite index) + - Custom field values are deleted when the associated member is deleted (CASCADE) """ use Ash.Resource, domain: Mv.Membership, data_layer: AshPostgres.DataLayer postgres do - table "properties" + table "custom_field_values" repo Mv.Repo references do @@ -46,7 +46,7 @@ defmodule Mv.Membership.Property do actions do defaults [:create, :read, :update, :destroy] - default_accept [:value, :member_id, :property_type_id] + default_accept [:value, :member_id, :custom_field_id] end attributes do @@ -68,16 +68,16 @@ defmodule Mv.Membership.Property do relationships do belongs_to :member, Mv.Membership.Member - belongs_to :property_type, Mv.Membership.PropertyType + belongs_to :custom_field, Mv.Membership.CustomField end 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. + # Ensure a member can only have one custom field value per custom field + # For example: A member can have only one "phone" custom field value, one "email" custom field value, etc. identities do - identity :unique_property_per_member, [:member_id, :property_type_id] + identity :unique_custom_field_per_member, [:member_id, :custom_field_id] end end diff --git a/lib/membership/email.ex b/lib/membership/email.ex index dccec21..47651f5 100644 --- a/lib/membership/email.ex +++ b/lib/membership/email.ex @@ -4,7 +4,7 @@ defmodule Mv.Membership.Email do ## Overview This type extends `:string` with email-specific validation constraints. - It ensures that email values stored in Property resources are valid email + It ensures that email values stored in CustomFieldValue resources are valid email addresses according to a standard regex pattern. ## Validation Rules @@ -14,12 +14,12 @@ defmodule Mv.Membership.Email do - Automatic trimming of leading/trailing whitespace ## Usage - This type is used in the Property union type for properties with - `value_type: :email` in PropertyType definitions. + This type is used in the CustomFieldValue union type for custom fields with + `value_type: :email` in CustomField definitions. ## Example - # In a property type definition - PropertyType.create!(%{ + # In a custom field definition + CustomField.create!(%{ name: "work_email", value_type: :email }) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 26c876f..eeb12c9 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -7,7 +7,7 @@ defmodule Mv.Membership.Member do can have: - Personal information (name, email, phone, address) - Optional link to a User account (1:1 relationship) - - Dynamic custom properties via PropertyType system + - Dynamic custom field values via CustomField system - Full-text searchable profile ## Email Synchronization @@ -16,7 +16,7 @@ defmodule Mv.Membership.Member do See `Mv.EmailSync` for details. ## Relationships - - `has_many :properties` - Dynamic custom fields + - `has_many :custom_field_values` - Dynamic custom fields - `has_one :user` - Optional authentication account link ## Validations @@ -48,8 +48,8 @@ defmodule Mv.Membership.Member do create :create_member do primary? true - # Properties can be created along with member - argument :properties, {:array, :map} + # Custom field values can be created along with member + argument :custom_field_values, {: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 @@ -70,7 +70,7 @@ defmodule Mv.Membership.Member do :postal_code ] - change manage_relationship(:properties, type: :create) + change manage_relationship(:custom_field_values, type: :create) # Manage the user relationship during member creation change manage_relationship(:user, :user, @@ -95,8 +95,8 @@ defmodule Mv.Membership.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} + # Custom field values can be updated or created along with member + argument :custom_field_values, {: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 @@ -117,7 +117,7 @@ defmodule Mv.Membership.Member do :postal_code ] - change manage_relationship(:properties, on_match: :update, on_no_match: :create) + change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create) # Manage the user relationship during member update change manage_relationship(:user, :user, @@ -349,7 +349,7 @@ defmodule Mv.Membership.Member do end relationships do - has_many :properties, Mv.Membership.Property + has_many :custom_field_values, Mv.Membership.CustomFieldValue # 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) diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 01de11b..f51c2b9 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -3,15 +3,15 @@ defmodule Mv.Membership do Ash Domain for membership management. ## Resources - - `Member` - Club members with personal information and custom properties - - `Property` - Dynamic custom field values attached to members - - `PropertyType` - Schema definitions for custom properties + - `Member` - Club members with personal information and custom field values + - `CustomFieldValue` - Dynamic custom field values attached to members + - `CustomField` - Schema definitions for custom fields ## Public API The domain exposes these main actions: - Member CRUD: `create_member/1`, `list_members/0`, `update_member/2`, `destroy_member/1` - - Property management: `create_property/1`, `list_property/0`, etc. - - PropertyType management: `create_property_type/1`, `list_property_types/0`, etc. + - Custom field value management: `create_custom_field_value/1`, `list_custom_field_values/0`, etc. + - Custom field management: `create_custom_field/1`, `list_custom_fields/0`, etc. ## Admin Interface The domain is configured with AshAdmin for management UI. @@ -31,18 +31,18 @@ defmodule Mv.Membership do define :destroy_member, action: :destroy end - resource Mv.Membership.Property do - define :create_property, action: :create - define :list_property, action: :read - define :update_property, action: :update - define :destroy_property, action: :destroy + resource Mv.Membership.CustomFieldValue do + define :create_custom_field_value, action: :create + define :list_custom_field_values, action: :read + define :update_custom_field_value, action: :update + define :destroy_custom_field_value, action: :destroy end - resource Mv.Membership.PropertyType do - define :create_property_type, action: :create - define :list_property_types, action: :read - define :update_property_type, action: :update - define :destroy_property_type, action: :destroy + resource Mv.Membership.CustomField do + define :create_custom_field, action: :create + define :list_custom_fields, action: :read + define :update_custom_field, action: :update + define :destroy_custom_field, action: :destroy end end end diff --git a/lib/mv_web/live/property_type_live/form.ex b/lib/mv_web/live/custom_field_live/form.ex similarity index 57% rename from lib/mv_web/live/property_type_live/form.ex rename to lib/mv_web/live/custom_field_live/form.ex index 292de2b..b1d3f86 100644 --- a/lib/mv_web/live/property_type_live/form.ex +++ b/lib/mv_web/live/custom_field_live/form.ex @@ -1,10 +1,10 @@ -defmodule MvWeb.PropertyTypeLive.Form do +defmodule MvWeb.CustomFieldLive.Form do @moduledoc """ - LiveView form for creating and editing property types (admin). + LiveView form for creating and editing custom fields (admin). ## Features - - Create new property type definitions - - Edit existing property types + - Create new custom field definitions + - Edit existing custom fields - Select value type from supported types - Set immutable and required flags - Real-time validation @@ -17,7 +17,7 @@ defmodule MvWeb.PropertyTypeLive.Form do **Optional:** - description - Human-readable explanation - immutable - If true, values cannot be changed after creation (default: false) - - required - If true, all members must have this property (default: false) + - required - If true, all members must have this custom field (default: false) ## Value Type Selection - `:string` - Text data (unlimited length) @@ -28,10 +28,10 @@ defmodule MvWeb.PropertyTypeLive.Form do ## Events - `validate` - Real-time form validation - - `save` - Submit form (create or update property type) + - `save` - Submit form (create or update custom field) ## Security - Property type management is restricted to admin users. + Custom field management is restricted to admin users. """ use MvWeb, :live_view @@ -42,18 +42,18 @@ defmodule MvWeb.PropertyTypeLive.Form do <.header> {@page_title} <:subtitle> - {gettext("Use this form to manage property_type records in your database.")} + {gettext("Use this form to manage custom_field records in your database.")} - <.form for={@form} id="property_type-form" phx-change="validate" phx-submit="save"> + <.form for={@form} id="custom_field-form" phx-change="validate" phx-submit="save"> <.input field={@form[:name]} type="text" label={gettext("Name")} /> <.input field={@form[:value_type]} type="select" label={gettext("Value type")} options={ - Ash.Resource.Info.attribute(Mv.Membership.PropertyType, :value_type).constraints[:one_of] + Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of] } /> <.input field={@form[:description]} type="text" label={gettext("Description")} /> @@ -61,9 +61,9 @@ defmodule MvWeb.PropertyTypeLive.Form do <.input field={@form[:required]} type="checkbox" label={gettext("Required")} /> <.button phx-disable-with={gettext("Saving...")} variant="primary"> - {gettext("Save Property type")} + {gettext("Save Custom field")} - <.button navigate={return_path(@return_to, @property_type)}>{gettext("Cancel")} + <.button navigate={return_path(@return_to, @custom_field)}>{gettext("Cancel")} """ @@ -71,19 +71,19 @@ defmodule MvWeb.PropertyTypeLive.Form do @impl true def mount(params, _session, socket) do - property_type = + custom_field = case params["id"] do nil -> nil - id -> Ash.get!(Mv.Membership.PropertyType, id) + id -> Ash.get!(Mv.Membership.CustomField, id) end - action = if is_nil(property_type), do: "New", else: "Edit" - page_title = action <> " " <> "Property type" + action = if is_nil(custom_field), do: "New", else: "Edit" + page_title = action <> " " <> "Custom field" {:ok, socket |> assign(:return_to, return_to(params["return_to"])) - |> assign(property_type: property_type) + |> assign(custom_field: custom_field) |> assign(:page_title, page_title) |> assign_form()} end @@ -92,15 +92,15 @@ defmodule MvWeb.PropertyTypeLive.Form do defp return_to(_), do: "index" @impl true - def handle_event("validate", %{"property_type" => property_type_params}, socket) do + def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do {:noreply, - assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, property_type_params))} + assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))} end - def handle_event("save", %{"property_type" => property_type_params}, socket) do - case AshPhoenix.Form.submit(socket.assigns.form, params: property_type_params) do - {:ok, property_type} -> - notify_parent({:saved, property_type}) + def handle_event("save", %{"custom_field" => custom_field_params}, socket) do + case AshPhoenix.Form.submit(socket.assigns.form, params: custom_field_params) do + {:ok, custom_field} -> + notify_parent({:saved, custom_field}) action = case socket.assigns.form.source.type do @@ -111,8 +111,8 @@ defmodule MvWeb.PropertyTypeLive.Form do socket = socket - |> put_flash(:info, gettext("Property type %{action} successfully", action: action)) - |> push_navigate(to: return_path(socket.assigns.return_to, property_type)) + |> put_flash(:info, gettext("Custom field %{action} successfully", action: action)) + |> push_navigate(to: return_path(socket.assigns.return_to, custom_field)) {:noreply, socket} @@ -123,17 +123,17 @@ defmodule MvWeb.PropertyTypeLive.Form do defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) - defp assign_form(%{assigns: %{property_type: property_type}} = socket) do + defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do form = - if property_type do - AshPhoenix.Form.for_update(property_type, :update, as: "property_type") + if custom_field do + AshPhoenix.Form.for_update(custom_field, :update, as: "custom_field") else - AshPhoenix.Form.for_create(Mv.Membership.PropertyType, :create, as: "property_type") + AshPhoenix.Form.for_create(Mv.Membership.CustomField, :create, as: "custom_field") end assign(socket, form: to_form(form)) end - defp return_path("index", _property_type), do: ~p"/property_types" - defp return_path("show", property_type), do: ~p"/property_types/#{property_type.id}" + defp return_path("index", _custom_field), do: ~p"/custom_fields" + defp return_path("show", custom_field), do: ~p"/custom_fields/#{custom_field.id}" end diff --git a/lib/mv_web/live/custom_field_live/index.ex b/lib/mv_web/live/custom_field_live/index.ex new file mode 100644 index 0000000..2870611 --- /dev/null +++ b/lib/mv_web/live/custom_field_live/index.ex @@ -0,0 +1,88 @@ +defmodule MvWeb.CustomFieldLive.Index do + @moduledoc """ + LiveView for managing custom field definitions (admin). + + ## Features + - List all custom fields + - Display type information (name, value type, description) + - Show immutable and required flags + - Create new custom fields + - Edit existing custom fields + - Delete custom fields (if no custom field values use them) + + ## Displayed Information + - Name: Unique identifier for the custom field + - Value type: Data type constraint (string, integer, boolean, date, email) + - Description: Human-readable explanation + - Immutable: Whether custom field values can be changed after creation + - Required: Whether all members must have this custom field (future feature) + + ## Events + - `delete` - Remove a custom field (only if no custom field values exist) + + ## Security + Custom field management is restricted to admin users. + """ + use MvWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + + <.header> + Listing Custom fields + <:actions> + <.button variant="primary" navigate={~p"/custom_fields/new"}> + <.icon name="hero-plus" /> New Custom field + + + + + <.table + id="custom_fields" + rows={@streams.custom_fields} + row_click={fn {_id, custom_field} -> JS.navigate(~p"/custom_fields/#{custom_field}") end} + > + <:col :let={{_id, custom_field}} label="Id">{custom_field.id} + + <:col :let={{_id, custom_field}} label="Name">{custom_field.name} + + <:col :let={{_id, custom_field}} label="Description">{custom_field.description} + + <:action :let={{_id, custom_field}}> +
+ <.link navigate={~p"/custom_fields/#{custom_field}"}>Show +
+ + <.link navigate={~p"/custom_fields/#{custom_field}/edit"}>Edit + + + <:action :let={{id, custom_field}}> + <.link + phx-click={JS.push("delete", value: %{id: custom_field.id}) |> hide("##{id}")} + data-confirm="Are you sure?" + > + Delete + + + +
+ """ + end + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:page_title, "Listing Custom fields") + |> stream(:custom_fields, Ash.read!(Mv.Membership.CustomField))} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + custom_field = Ash.get!(Mv.Membership.CustomField, id) + Ash.destroy!(custom_field) + + {:noreply, stream_delete(socket, :custom_fields, custom_field)} + end +end diff --git a/lib/mv_web/live/custom_field_live/show.ex b/lib/mv_web/live/custom_field_live/show.ex new file mode 100644 index 0000000..783cb4e --- /dev/null +++ b/lib/mv_web/live/custom_field_live/show.ex @@ -0,0 +1,66 @@ +defmodule MvWeb.CustomFieldLive.Show do + @moduledoc """ + LiveView for displaying a single custom field's details (admin). + + ## Features + - Display custom field definition + - Show all attributes (name, value type, description, flags) + - Navigate to edit form + - Return to custom field list + + ## Displayed Information + - Name: Unique identifier + - Value type: Data type constraint + - Description: Optional explanation + - Immutable flag: Whether values can be changed + - Required flag: Whether all members need this custom field + + ## Navigation + - Back to custom field list + - Edit custom field + + ## Security + Custom field details are restricted to admin users. + """ + use MvWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + + <.header> + Custom field {@custom_field.id} + <:subtitle>This is a custom_field record from your database. + + <:actions> + <.button navigate={~p"/custom_fields"}> + <.icon name="hero-arrow-left" /> + + <.button + variant="primary" + navigate={~p"/custom_fields/#{@custom_field}/edit?return_to=show"} + > + <.icon name="hero-pencil-square" /> Edit Custom field + + + + + <.list> + <:item title="Id">{@custom_field.id} + + <:item title="Name">{@custom_field.name} + + <:item title="Description">{@custom_field.description} + + + """ + end + + @impl true + def mount(%{"id" => id}, _session, socket) do + {:ok, + socket + |> assign(:page_title, "Show Custom field") + |> assign(:custom_field, Ash.get!(Mv.Membership.CustomField, id))} + end +end diff --git a/lib/mv_web/live/property_live/form.ex b/lib/mv_web/live/custom_field_value_live/form.ex similarity index 54% rename from lib/mv_web/live/property_live/form.ex rename to lib/mv_web/live/custom_field_value_live/form.ex index b85597d..7df4c69 100644 --- a/lib/mv_web/live/property_live/form.ex +++ b/lib/mv_web/live/custom_field_value_live/form.ex @@ -1,21 +1,21 @@ -defmodule MvWeb.PropertyLive.Form do +defmodule MvWeb.CustomFieldValueLive.Form do @moduledoc """ - LiveView form for creating and editing properties. + LiveView form for creating and editing custom field values. ## Features - - Create new properties with member and type selection - - Edit existing property values - - Value input adapts to property type (string, integer, boolean, date, email) + - Create new custom field values with member and type selection + - Edit existing custom field values + - Value input adapts to custom field type (string, integer, boolean, date, email) - Real-time validation ## Form Fields **Required:** - - member - Select which member owns this property - - property_type - Select the type (defines value type) - - value - The actual value (input type depends on property type) + - member - Select which member owns this custom field value + - custom_field - Select the type (defines value type) + - value - The actual value (input type depends on custom field type) ## Value Types - The form dynamically renders appropriate inputs based on property type: + The form dynamically renders appropriate inputs based on custom field type: - String: text input - Integer: number input - Boolean: checkbox @@ -24,10 +24,10 @@ defmodule MvWeb.PropertyLive.Form do ## Events - `validate` - Real-time form validation - - `save` - Submit form (create or update property) + - `save` - Submit form (create or update custom field value) ## Note - Properties are typically managed through the member edit form, + Custom field values are typically managed through the member edit form, not through this standalone form. """ use MvWeb, :live_view @@ -38,17 +38,19 @@ defmodule MvWeb.PropertyLive.Form do <.header> {@page_title} - <:subtitle>{gettext("Use this form to manage property records in your database.")} + <:subtitle> + {gettext("Use this form to manage custom_field_value records in your database.")} + - <.form for={@form} id="property-form" phx-change="validate" phx-submit="save"> - + <.form for={@form} id="custom_field_value-form" phx-change="validate" phx-submit="save"> + <.input - field={@form[:property_type_id]} + field={@form[:custom_field_id]} type="select" - label={gettext("Property type")} - options={property_type_options(@property_types)} - prompt={gettext("Choose a property type")} + label={gettext("Custom field")} + options={custom_field_options(@custom_fields)} + prompt={gettext("Choose a custom field")} /> @@ -61,18 +63,18 @@ defmodule MvWeb.PropertyLive.Form do /> - <%= if @selected_property_type do %> - <.union_value_input form={@form} property_type={@selected_property_type} /> + <%= if @selected_custom_field do %> + <.union_value_input form={@form} custom_field={@selected_custom_field} /> <% else %>
- {gettext("Please select a property type first")} + {gettext("Please select a custom field first")}
<% end %> <.button phx-disable-with={gettext("Saving...")} variant="primary"> - {gettext("Save Property")} + {gettext("Save Custom field value")} - <.button navigate={return_path(@return_to, @property)}>{gettext("Cancel")} + <.button navigate={return_path(@return_to, @custom_field_value)}>{gettext("Cancel")}
""" @@ -80,8 +82,8 @@ defmodule MvWeb.PropertyLive.Form do # Helper function for Union-Value Input defp union_value_input(assigns) do - # Extract the current value from the Property - current_value = extract_current_value(assigns.form.data, assigns.property_type.value_type) + # Extract the current value from the CustomFieldValue + current_value = extract_current_value(assigns.form.data, assigns.custom_field.value_type) assigns = assign(assigns, :current_value, current_value) ~H""" @@ -90,7 +92,7 @@ defmodule MvWeb.PropertyLive.Form do {gettext("Value")} - <%= case @property_type.value_type do %> + <%= case @custom_field.value_type do %> <% :string -> %> <.inputs_for :let={value_form} field={@form[:value]}> <.input field={value_form[:value]} type="text" label="" value={@current_value} /> @@ -123,16 +125,16 @@ defmodule MvWeb.PropertyLive.Form do <% _ -> %>
- {gettext("Unsupported value type: %{type}", type: @property_type.value_type)} + {gettext("Unsupported value type: %{type}", type: @custom_field.value_type)}
<% end %> """ end - # Helper function to extract the current value from the Property + # Helper function to extract the current value from the CustomFieldValue defp extract_current_value( - %Mv.Membership.Property{value: %Ash.Union{value: value}}, + %Mv.Membership.CustomFieldValue{value: %Ash.Union{value: value}}, _value_type ) do value @@ -160,27 +162,27 @@ defmodule MvWeb.PropertyLive.Form do @impl true def mount(params, _session, socket) do - property = + custom_field_value = case params["id"] do nil -> nil - id -> Ash.get!(Mv.Membership.Property, id) |> Ash.load!([:property_type]) + id -> Ash.get!(Mv.Membership.CustomFieldValue, id) |> Ash.load!([:custom_field]) end - action = if is_nil(property), do: "New", else: "Edit" - page_title = action <> " " <> "Property" + action = if is_nil(custom_field_value), do: "New", else: "Edit" + page_title = action <> " " <> "Custom field value" - # Load all PropertyTypes and Members for the selection fields - property_types = Ash.read!(Mv.Membership.PropertyType) + # Load all CustomFields and Members for the selection fields + custom_fields = Ash.read!(Mv.Membership.CustomField) members = Ash.read!(Mv.Membership.Member) {:ok, socket |> assign(:return_to, return_to(params["return_to"])) - |> assign(property: property) + |> assign(custom_field_value: custom_field_value) |> assign(:page_title, page_title) - |> assign(:property_types, property_types) + |> assign(:custom_fields, custom_fields) |> assign(:members, members) - |> assign(:selected_property_type, property && property.property_type) + |> assign(:selected_custom_field, custom_field_value && custom_field_value.custom_field) |> assign_form()} end @@ -188,43 +190,43 @@ defmodule MvWeb.PropertyLive.Form do defp return_to(_), do: "index" @impl true - def handle_event("validate", %{"property" => property_params}, socket) do - # Find the selected PropertyType - selected_property_type = - case property_params["property_type_id"] do + def handle_event("validate", %{"custom_field_value" => custom_field_value_params}, socket) do + # Find the selected CustomField + selected_custom_field = + case custom_field_value_params["custom_field_id"] do "" -> nil nil -> nil - id -> Enum.find(socket.assigns.property_types, &(&1.id == id)) + id -> Enum.find(socket.assigns.custom_fields, &(&1.id == id)) end - # Set the Union type based on the selected PropertyType + # Set the Union type based on the selected CustomField updated_params = - if selected_property_type do - union_type = to_string(selected_property_type.value_type) - put_in(property_params, ["value", "_union_type"], union_type) + if selected_custom_field do + union_type = to_string(selected_custom_field.value_type) + put_in(custom_field_value_params, ["value", "_union_type"], union_type) else - property_params + custom_field_value_params end {:noreply, socket - |> assign(:selected_property_type, selected_property_type) + |> assign(:selected_custom_field, selected_custom_field) |> assign(form: AshPhoenix.Form.validate(socket.assigns.form, updated_params))} end - def handle_event("save", %{"property" => property_params}, socket) do - # Set the Union type based on the selected PropertyType + def handle_event("save", %{"custom_field_value" => custom_field_value_params}, socket) do + # Set the Union type based on the selected CustomField updated_params = - if socket.assigns.selected_property_type do - union_type = to_string(socket.assigns.selected_property_type.value_type) - put_in(property_params, ["value", "_union_type"], union_type) + if socket.assigns.selected_custom_field do + union_type = to_string(socket.assigns.selected_custom_field.value_type) + put_in(custom_field_value_params, ["value", "_union_type"], union_type) else - property_params + custom_field_value_params end case AshPhoenix.Form.submit(socket.assigns.form, params: updated_params) do - {:ok, property} -> - notify_parent({:saved, property}) + {:ok, custom_field_value} -> + notify_parent({:saved, custom_field_value}) action = case socket.assigns.form.source.type do @@ -235,8 +237,11 @@ defmodule MvWeb.PropertyLive.Form do socket = socket - |> put_flash(:info, gettext("Property %{action} successfully", action: action)) - |> push_navigate(to: return_path(socket.assigns.return_to, property)) + |> put_flash( + :info, + gettext("Custom field value %{action} successfully", action: action) + ) + |> push_navigate(to: return_path(socket.assigns.return_to, custom_field_value)) {:noreply, socket} @@ -247,11 +252,11 @@ defmodule MvWeb.PropertyLive.Form do defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) - defp assign_form(%{assigns: %{property: property}} = socket) do + defp assign_form(%{assigns: %{custom_field_value: custom_field_value}} = socket) do form = - if property do - # Determine the Union type based on the property_type - union_type = property.property_type && property.property_type.value_type + if custom_field_value do + # Determine the Union type based on the custom_field + union_type = custom_field_value.custom_field && custom_field_value.custom_field.value_type params = if union_type do @@ -260,20 +265,27 @@ defmodule MvWeb.PropertyLive.Form do %{} end - AshPhoenix.Form.for_update(property, :update, as: "property", params: params) + AshPhoenix.Form.for_update(custom_field_value, :update, + as: "custom_field_value", + params: params + ) else - AshPhoenix.Form.for_create(Mv.Membership.Property, :create, as: "property") + AshPhoenix.Form.for_create(Mv.Membership.CustomFieldValue, :create, + as: "custom_field_value" + ) end assign(socket, form: to_form(form)) end - defp return_path("index", _property), do: ~p"/properties" - defp return_path("show", property), do: ~p"/properties/#{property.id}" + defp return_path("index", _custom_field_value), do: ~p"/custom_field_values" + + defp return_path("show", custom_field_value), + do: ~p"/custom_field_values/#{custom_field_value.id}" # Helper functions for selection options - defp property_type_options(property_types) do - Enum.map(property_types, &{&1.name, &1.id}) + defp custom_field_options(custom_fields) do + Enum.map(custom_fields, &{&1.name, &1.id}) end defp member_options(members) do diff --git a/lib/mv_web/live/custom_field_value_live/index.ex b/lib/mv_web/live/custom_field_value_live/index.ex new file mode 100644 index 0000000..b52fd96 --- /dev/null +++ b/lib/mv_web/live/custom_field_value_live/index.ex @@ -0,0 +1,86 @@ +defmodule MvWeb.CustomFieldValueLive.Index do + @moduledoc """ + LiveView for displaying and managing custom field values. + + ## Features + - List all custom field values with their values and types + - Show which member each custom field value belongs to + - Display custom field information + - Navigate to custom field value details and edit forms + - Delete custom field values + + ## Relationships + Each custom field value is linked to: + - A member (the custom field value owner) + - A custom field (defining value type and behavior) + + ## Events + - `delete` - Remove a custom field value from the database + + ## Note + Custom field values are typically managed through the member edit form. + This view provides a global overview of all custom field values. + """ + use MvWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + + <.header> + Listing Custom field values + <:actions> + <.button variant="primary" navigate={~p"/custom_field_values/new"}> + <.icon name="hero-plus" /> New Custom field value + + + + + <.table + id="custom_field_values" + rows={@streams.custom_field_values} + row_click={ + fn {_id, custom_field_value} -> + JS.navigate(~p"/custom_field_values/#{custom_field_value}") + end + } + > + <:col :let={{_id, custom_field_value}} label="Id">{custom_field_value.id} + + <:action :let={{_id, custom_field_value}}> +
+ <.link navigate={~p"/custom_field_values/#{custom_field_value}"}>Show +
+ + <.link navigate={~p"/custom_field_values/#{custom_field_value}/edit"}>Edit + + + <:action :let={{id, custom_field_value}}> + <.link + phx-click={JS.push("delete", value: %{id: custom_field_value.id}) |> hide("##{id}")} + data-confirm="Are you sure?" + > + Delete + + + +
+ """ + end + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:page_title, "Listing Custom field values") + |> stream(:custom_field_values, Ash.read!(Mv.Membership.CustomFieldValue))} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + custom_field_value = Ash.get!(Mv.Membership.CustomFieldValue, id) + Ash.destroy!(custom_field_value) + + {:noreply, stream_delete(socket, :custom_field_values, custom_field_value)} + end +end diff --git a/lib/mv_web/live/custom_field_value_live/show.ex b/lib/mv_web/live/custom_field_value_live/show.ex new file mode 100644 index 0000000..42e9f43 --- /dev/null +++ b/lib/mv_web/live/custom_field_value_live/show.ex @@ -0,0 +1,67 @@ +defmodule MvWeb.CustomFieldValueLive.Show do + @moduledoc """ + LiveView for displaying a single custom field value's details. + + ## Features + - Display custom field value and type + - Show linked member + - Show custom field definition + - Navigate to edit form + - Return to custom field value list + + ## Displayed Information + - Custom field value (formatted based on type) + - Custom field name and description + - Member information (who owns this custom field value) + - Custom field value metadata (ID, timestamps if added) + + ## Navigation + - Back to custom field value list + - Edit custom field value + """ + use MvWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + + <.header> + Custom field value {@custom_field_value.id} + <:subtitle>This is a custom_field_value record from your database. + + <:actions> + <.button navigate={~p"/custom_field_values"}> + <.icon name="hero-arrow-left" /> + + <.button + variant="primary" + navigate={~p"/custom_field_values/#{@custom_field_value}/edit?return_to=show"} + > + <.icon name="hero-pencil-square" /> Edit Custom field value + + + + + <.list> + <:item title="Id">{@custom_field_value.id} + + + """ + end + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl true + def handle_params(%{"id" => id}, _, socket) do + {:noreply, + socket + |> assign(:page_title, page_title(socket.assigns.live_action)) + |> assign(:custom_field_value, Ash.get!(Mv.Membership.CustomFieldValue, id))} + end + + defp page_title(:show), do: "Show Custom field value" + defp page_title(:edit), do: "Edit Custom field value" +end diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index ba7ba36..e4c2e7e 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -19,14 +19,14 @@ defmodule MvWeb.MemberLive.Form do - paid status - notes - ## Custom Properties - Members can have dynamic custom properties defined by PropertyTypes. - The form dynamically renders inputs based on available PropertyTypes. + ## Custom Field Values + Members can have dynamic custom field values defined by CustomFields. + The form dynamically renders inputs based on available CustomFields. ## Events - `validate` - Real-time form validation - `save` - Submit form (create or update member) - - Property management events for adding/removing custom fields + - Custom field value management events for adding/removing custom fields """ use MvWeb, :live_view @@ -56,10 +56,11 @@ defmodule MvWeb.MemberLive.Form do <.input field={@form[:house_number]} label={gettext("House Number")} /> <.input field={@form[:postal_code]} label={gettext("Postal Code")} /> -

{gettext("Custom Properties")}

- <.inputs_for :let={f_property} field={@form[:properties]}> - <% type = Enum.find(@property_types, &(&1.id == f_property[:property_type_id].value)) %> - <.inputs_for :let={value_form} field={f_property[:value]}> +

{gettext("Custom Field Values")}

+ <.inputs_for :let={f_custom_field_value} field={@form[:custom_field_values]}> + <% type = + Enum.find(@custom_fields, &(&1.id == f_custom_field_value[:custom_field_id].value)) %> + <.inputs_for :let={value_form} field={f_custom_field_value[:value]}> <% input_type = cond do type && type.value_type == :boolean -> "checkbox" @@ -70,8 +71,8 @@ defmodule MvWeb.MemberLive.Form do @@ -86,16 +87,16 @@ defmodule MvWeb.MemberLive.Form do @impl true def mount(params, _session, socket) do - {:ok, property_types} = Mv.Membership.list_property_types() + {:ok, custom_fields} = Mv.Membership.list_custom_fields() - initial_properties = - Enum.map(property_types, fn pt -> + initial_custom_field_values = + Enum.map(custom_fields, fn cf -> %{ - "property_type_id" => pt.id, + "custom_field_id" => cf.id, "value" => %{ - "type" => pt.value_type, + "type" => cf.value_type, "value" => nil, - "_union_type" => Atom.to_string(pt.value_type) + "_union_type" => Atom.to_string(cf.value_type) } } end) @@ -112,8 +113,8 @@ defmodule MvWeb.MemberLive.Form do {:ok, socket |> assign(:return_to, return_to(params["return_to"])) - |> assign(:property_types, property_types) - |> assign(:initial_properties, initial_properties) + |> assign(:custom_fields, custom_fields) + |> assign(:initial_custom_field_values, initial_custom_field_values) |> assign(member: member) |> assign(:page_title, page_title) |> assign_form()} @@ -156,25 +157,25 @@ defmodule MvWeb.MemberLive.Form do defp assign_form(%{assigns: %{member: member}} = socket) do form = if member do - {:ok, member} = Ash.load(member, properties: [:property_type]) + {:ok, member} = Ash.load(member, custom_field_values: [:custom_field]) - existing_properties = - member.properties - |> Enum.map(& &1.property_type_id) + existing_custom_field_values = + member.custom_field_values + |> Enum.map(& &1.custom_field_id) - is_missing_property = fn i -> - not Enum.member?(existing_properties, Map.get(i, "property_type_id")) + is_missing_custom_field_value = fn i -> + not Enum.member?(existing_custom_field_values, Map.get(i, "custom_field_id")) end params = %{ - "properties" => - Enum.map(member.properties, fn prop -> + "custom_field_values" => + Enum.map(member.custom_field_values, fn cfv -> %{ - "property_type_id" => prop.property_type_id, + "custom_field_id" => cfv.custom_field_id, "value" => %{ - "_union_type" => Atom.to_string(prop.value.type), - "type" => prop.value.type, - "value" => prop.value.value + "_union_type" => Atom.to_string(cfv.value.type), + "type" => cfv.value.type, + "value" => cfv.value.value } } end) @@ -190,12 +191,13 @@ defmodule MvWeb.MemberLive.Form do forms: [auto?: true] ) - missing_properties = Enum.filter(socket.assigns[:initial_properties], is_missing_property) + missing_custom_field_values = + Enum.filter(socket.assigns[:initial_custom_field_values], is_missing_custom_field_value) Enum.reduce( - missing_properties, + missing_custom_field_values, form, - &AshPhoenix.Form.add_form(&2, [:properties], params: &1) + &AshPhoenix.Form.add_form(&2, [:custom_field_values], params: &1) ) else AshPhoenix.Form.for_create( @@ -203,7 +205,7 @@ defmodule MvWeb.MemberLive.Form do :create_member, api: Mv.Membership, as: "member", - params: %{"properties" => socket.assigns[:initial_properties]}, + params: %{"custom_field_values" => socket.assigns[:initial_custom_field_values]}, forms: [auto?: true] ) end diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index 043915e..7ec24fa 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -5,7 +5,7 @@ defmodule MvWeb.MemberLive.Show do ## Features - Display all member information (personal, contact, address) - Show linked user account (if exists) - - Display custom properties + - Display custom field values - Navigate to edit form - Return to member list @@ -15,7 +15,7 @@ defmodule MvWeb.MemberLive.Show do - Address: street, house number, postal code, city - Status: paid flag - Relationships: linked user account - - Custom: dynamic properties from PropertyTypes + - Custom: dynamic custom field values from CustomFields ## Navigation - Back to member list @@ -75,14 +75,14 @@ defmodule MvWeb.MemberLive.Show do -

{gettext("Custom Properties")}

+

{gettext("Custom Field Values")}

<.generic_list items={ - Enum.map(@member.properties, fn p -> + Enum.map(@member.custom_field_values, fn cfv -> { # name - p.property_type && p.property_type.name, + cfv.custom_field && cfv.custom_field.name, # value - case p.value do + case cfv.value do %{value: v} -> v v -> v end @@ -103,7 +103,7 @@ defmodule MvWeb.MemberLive.Show do query = Mv.Membership.Member |> filter(id == ^id) - |> load([:user, properties: [:property_type]]) + |> load([:user, custom_field_values: [:custom_field]]) member = Ash.read_one!(query) diff --git a/lib/mv_web/live/property_live/index.ex b/lib/mv_web/live/property_live/index.ex deleted file mode 100644 index bc96bc0..0000000 --- a/lib/mv_web/live/property_live/index.ex +++ /dev/null @@ -1,82 +0,0 @@ -defmodule MvWeb.PropertyLive.Index do - @moduledoc """ - LiveView for displaying and managing properties. - - ## Features - - List all properties with their values and types - - Show which member each property belongs to - - Display property type information - - Navigate to property details and edit forms - - Delete properties - - ## Relationships - Each property is linked to: - - A member (the property owner) - - A property type (defining value type and behavior) - - ## Events - - `delete` - Remove a property from the database - - ## Note - Properties are typically managed through the member edit form. - This view provides a global overview of all properties. - """ - use MvWeb, :live_view - - @impl true - def render(assigns) do - ~H""" - - <.header> - Listing Properties - <:actions> - <.button variant="primary" navigate={~p"/properties/new"}> - <.icon name="hero-plus" /> New Property - - - - - <.table - id="properties" - rows={@streams.properties} - row_click={fn {_id, property} -> JS.navigate(~p"/properties/#{property}") end} - > - <:col :let={{_id, property}} label="Id">{property.id} - - <:action :let={{_id, property}}> -
- <.link navigate={~p"/properties/#{property}"}>Show -
- - <.link navigate={~p"/properties/#{property}/edit"}>Edit - - - <:action :let={{id, property}}> - <.link - phx-click={JS.push("delete", value: %{id: property.id}) |> hide("##{id}")} - data-confirm="Are you sure?" - > - Delete - - - -
- """ - end - - @impl true - def mount(_params, _session, socket) do - {:ok, - socket - |> assign(:page_title, "Listing Properties") - |> stream(:properties, Ash.read!(Mv.Membership.Property))} - end - - @impl true - def handle_event("delete", %{"id" => id}, socket) do - property = Ash.get!(Mv.Membership.Property, id) - Ash.destroy!(property) - - {:noreply, stream_delete(socket, :properties, property)} - end -end diff --git a/lib/mv_web/live/property_live/show.ex b/lib/mv_web/live/property_live/show.ex deleted file mode 100644 index 41e20c4..0000000 --- a/lib/mv_web/live/property_live/show.ex +++ /dev/null @@ -1,64 +0,0 @@ -defmodule MvWeb.PropertyLive.Show do - @moduledoc """ - LiveView for displaying a single property's details. - - ## Features - - Display property value and type - - Show linked member - - Show property type definition - - Navigate to edit form - - Return to property list - - ## Displayed Information - - Property value (formatted based on type) - - Property type name and description - - Member information (who owns this property) - - Property metadata (ID, timestamps if added) - - ## Navigation - - Back to property list - - Edit property - """ - use MvWeb, :live_view - - @impl true - def render(assigns) do - ~H""" - - <.header> - Property {@property.id} - <:subtitle>This is a property record from your database. - - <:actions> - <.button navigate={~p"/properties"}> - <.icon name="hero-arrow-left" /> - - <.button variant="primary" navigate={~p"/properties/#{@property}/edit?return_to=show"}> - <.icon name="hero-pencil-square" /> Edit Property - - - - - <.list> - <:item title="Id">{@property.id} - - - """ - end - - @impl true - def mount(_params, _session, socket) do - {:ok, socket} - end - - @impl true - def handle_params(%{"id" => id}, _, socket) do - {:noreply, - socket - |> assign(:page_title, page_title(socket.assigns.live_action)) - |> assign(:property, Ash.get!(Mv.Membership.Property, id))} - end - - defp page_title(:show), do: "Show Property" - defp page_title(:edit), do: "Edit Property" -end diff --git a/lib/mv_web/live/property_type_live/index.ex b/lib/mv_web/live/property_type_live/index.ex deleted file mode 100644 index 2731414..0000000 --- a/lib/mv_web/live/property_type_live/index.ex +++ /dev/null @@ -1,88 +0,0 @@ -defmodule MvWeb.PropertyTypeLive.Index do - @moduledoc """ - LiveView for managing property type definitions (admin). - - ## Features - - List all property types - - Display type information (name, value type, description) - - Show immutable and required flags - - Create new property types - - Edit existing property types - - Delete property types (if no properties use them) - - ## Displayed Information - - Name: Unique identifier for the property type - - Value type: Data type constraint (string, integer, boolean, date, email) - - Description: Human-readable explanation - - Immutable: Whether property values can be changed after creation - - Required: Whether all members must have this property (future feature) - - ## Events - - `delete` - Remove a property type (only if no properties exist) - - ## Security - Property type management is restricted to admin users. - """ - use MvWeb, :live_view - - @impl true - def render(assigns) do - ~H""" - - <.header> - Listing Property types - <:actions> - <.button variant="primary" navigate={~p"/property_types/new"}> - <.icon name="hero-plus" /> New Property type - - - - - <.table - id="property_types" - rows={@streams.property_types} - row_click={fn {_id, property_type} -> JS.navigate(~p"/property_types/#{property_type}") end} - > - <:col :let={{_id, property_type}} label="Id">{property_type.id} - - <:col :let={{_id, property_type}} label="Name">{property_type.name} - - <:col :let={{_id, property_type}} label="Description">{property_type.description} - - <:action :let={{_id, property_type}}> -
- <.link navigate={~p"/property_types/#{property_type}"}>Show -
- - <.link navigate={~p"/property_types/#{property_type}/edit"}>Edit - - - <:action :let={{id, property_type}}> - <.link - phx-click={JS.push("delete", value: %{id: property_type.id}) |> hide("##{id}")} - data-confirm="Are you sure?" - > - Delete - - - -
- """ - end - - @impl true - def mount(_params, _session, socket) do - {:ok, - socket - |> assign(:page_title, "Listing Property types") - |> stream(:property_types, Ash.read!(Mv.Membership.PropertyType))} - end - - @impl true - def handle_event("delete", %{"id" => id}, socket) do - property_type = Ash.get!(Mv.Membership.PropertyType, id) - Ash.destroy!(property_type) - - {:noreply, stream_delete(socket, :property_types, property_type)} - end -end diff --git a/lib/mv_web/live/property_type_live/show.ex b/lib/mv_web/live/property_type_live/show.ex deleted file mode 100644 index b5c441c..0000000 --- a/lib/mv_web/live/property_type_live/show.ex +++ /dev/null @@ -1,66 +0,0 @@ -defmodule MvWeb.PropertyTypeLive.Show do - @moduledoc """ - LiveView for displaying a single property type's details (admin). - - ## Features - - Display property type definition - - Show all attributes (name, value type, description, flags) - - Navigate to edit form - - Return to property type list - - ## Displayed Information - - Name: Unique identifier - - Value type: Data type constraint - - Description: Optional explanation - - Immutable flag: Whether values can be changed - - Required flag: Whether all members need this property - - ## Navigation - - Back to property type list - - Edit property type - - ## Security - Property type details are restricted to admin users. - """ - use MvWeb, :live_view - - @impl true - def render(assigns) do - ~H""" - - <.header> - Property type {@property_type.id} - <:subtitle>This is a property_type record from your database. - - <:actions> - <.button navigate={~p"/property_types"}> - <.icon name="hero-arrow-left" /> - - <.button - variant="primary" - navigate={~p"/property_types/#{@property_type}/edit?return_to=show"} - > - <.icon name="hero-pencil-square" /> Edit Property type - - - - - <.list> - <:item title="Id">{@property_type.id} - - <:item title="Name">{@property_type.name} - - <:item title="Description">{@property_type.description} - - - """ - end - - @impl true - def mount(%{"id" => id}, _session, socket) do - {:ok, - socket - |> assign(:page_title, "Show Property type") - |> assign(:property_type, Ash.get!(Mv.Membership.PropertyType, id))} - end -end diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index a08f1be..d2a63bc 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -55,17 +55,17 @@ defmodule MvWeb.Router do live "/members/:id", MemberLive.Show, :show live "/members/:id/show/edit", MemberLive.Show, :edit - live "/property_types", PropertyTypeLive.Index, :index - live "/property_types/new", PropertyTypeLive.Form, :new - live "/property_types/:id/edit", PropertyTypeLive.Form, :edit - live "/property_types/:id", PropertyTypeLive.Show, :show - live "/property_types/:id/show/edit", PropertyTypeLive.Show, :edit + live "/custom_fields", CustomFieldLive.Index, :index + live "/custom_fields/new", CustomFieldLive.Form, :new + live "/custom_fields/:id/edit", CustomFieldLive.Form, :edit + live "/custom_fields/:id", CustomFieldLive.Show, :show + live "/custom_fields/:id/show/edit", CustomFieldLive.Show, :edit - live "/properties", PropertyLive.Index, :index - live "/properties/new", PropertyLive.Form, :new - live "/properties/:id/edit", PropertyLive.Form, :edit - live "/properties/:id", PropertyLive.Show, :show - live "/properties/:id/show/edit", PropertyLive.Show, :edit + live "/custom_field_values", CustomFieldValueLive.Index, :index + live "/custom_field_values/new", CustomFieldValueLive.Form, :new + live "/custom_field_values/:id/edit", CustomFieldValueLive.Form, :edit + live "/custom_field_values/:id", CustomFieldValueLive.Show, :show + live "/custom_field_values/:id/show/edit", CustomFieldValueLive.Show, :edit live "/users", UserLive.Index, :index live "/users/new", UserLive.Form, :new diff --git a/priv/repo/migrations/20251113163600_rename_properties_to_custom_fields_extensions_1.exs b/priv/repo/migrations/20251113163600_rename_properties_to_custom_fields_extensions_1.exs new file mode 100644 index 0000000..2fafbd3 --- /dev/null +++ b/priv/repo/migrations/20251113163600_rename_properties_to_custom_fields_extensions_1.exs @@ -0,0 +1,19 @@ +defmodule Mv.Repo.Migrations.RenamePropertiesToCustomFieldsExtensions1 do + @moduledoc """ + Installs any extensions that are mentioned in the repo's `installed_extensions/0` callback + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + execute("CREATE EXTENSION IF NOT EXISTS \"pg_trgm\"") + end + + def down do + # Uncomment this if you actually want to uninstall the extensions + # when this migration is rolled back: + # execute("DROP EXTENSION IF EXISTS \"pg_trgm\"") + end +end diff --git a/priv/repo/migrations/20251113163602_rename_properties_to_custom_fields.exs b/priv/repo/migrations/20251113163602_rename_properties_to_custom_fields.exs new file mode 100644 index 0000000..0517c0b --- /dev/null +++ b/priv/repo/migrations/20251113163602_rename_properties_to_custom_fields.exs @@ -0,0 +1,84 @@ +defmodule Mv.Repo.Migrations.RenamePropertiesToCustomFields 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 + # Rename tables + rename table("property_types"), to: table("custom_fields") + rename table("properties"), to: table("custom_field_values") + + # Rename the foreign key column + rename table("custom_field_values"), :property_type_id, to: :custom_field_id + + # Drop old foreign key constraints + drop constraint(:custom_field_values, "properties_member_id_fkey") + drop constraint(:custom_field_values, "properties_property_type_id_fkey") + + # Add new foreign key constraints with correct names and on_delete behavior + alter table(:custom_field_values) do + modify :member_id, + references(:members, + column: :id, + name: "custom_field_values_member_id_fkey", + type: :uuid, + prefix: "public", + on_delete: :delete_all + ) + + modify :custom_field_id, + references(:custom_fields, + column: :id, + name: "custom_field_values_custom_field_id_fkey", + type: :uuid, + prefix: "public" + ) + end + + # Rename indexes + execute "ALTER INDEX IF EXISTS property_types_unique_name_index RENAME TO custom_fields_unique_name_index" + + execute "ALTER INDEX IF EXISTS properties_unique_property_per_member_index RENAME TO custom_field_values_unique_custom_field_per_member_index" + end + + def down do + # Rename indexes back + execute "ALTER INDEX IF EXISTS custom_fields_unique_name_index RENAME TO property_types_unique_name_index" + + execute "ALTER INDEX IF EXISTS custom_field_values_unique_custom_field_per_member_index RENAME TO properties_unique_property_per_member_index" + + # Drop new foreign key constraints + drop constraint(:custom_field_values, "custom_field_values_member_id_fkey") + drop constraint(:custom_field_values, "custom_field_values_custom_field_id_fkey") + + # Add back old foreign key constraints + alter table(:custom_field_values) do + modify :member_id, + references(:members, + column: :id, + name: "properties_member_id_fkey", + type: :uuid, + prefix: "public" + ) + + modify :custom_field_id, + references(:custom_fields, + column: :id, + name: "properties_property_type_id_fkey", + type: :uuid, + prefix: "public" + ) + end + + # Rename the foreign key column back + rename table("custom_field_values"), :custom_field_id, to: :property_type_id + + # Rename tables back + rename table("custom_fields"), to: table("property_types") + rename table("custom_field_values"), to: table("properties") + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index a0299fd..4342c32 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -36,7 +36,7 @@ for attrs <- [ required: true } ] do - Membership.create_property_type!( + Membership.create_custom_field!( attrs, upsert?: true, upsert_identity: :unique_name @@ -182,7 +182,7 @@ end) IO.puts("✅ Seeds completed successfully!") IO.puts("📝 Created sample data:") -IO.puts(" - Property types: String, Date, Boolean, Email") +IO.puts(" - Custom fields: String, Date, Boolean, Email") IO.puts(" - Admin user: admin@mv.local (password: testpassword)") IO.puts(" - Sample members: Hans, Greta, Friedrich") diff --git a/priv/resource_snapshots/repo/custom_field_values/20251113163602.json b/priv/resource_snapshots/repo/custom_field_values/20251113163602.json new file mode 100644 index 0000000..2069939 --- /dev/null +++ b/priv/resource_snapshots/repo/custom_field_values/20251113163602.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": "custom_field_values_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": "custom_field_values_custom_field_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "custom_fields" + }, + "scale": null, + "size": null, + "source": "custom_field_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "DFA12C7D80B09C2EE5125469A1EDEF0412C7B2A7E44A9FD97A1387C52C8D7753", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "custom_field_values_unique_custom_field_per_member_index", + "keys": [ + { + "type": "atom", + "value": "member_id" + }, + { + "type": "atom", + "value": "custom_field_id" + } + ], + "name": "unique_custom_field_per_member", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "custom_field_values" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/custom_fields/20251113163602.json b/priv/resource_snapshots/repo/custom_fields/20251113163602.json new file mode 100644 index 0000000..f3959cb --- /dev/null +++ b/priv/resource_snapshots/repo/custom_fields/20251113163602.json @@ -0,0 +1,106 @@ +{ + "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": "name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "value_type", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "immutable", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "required", + "type": "boolean" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "B98535258034AE3C37FCB7AF054B97D7CCADE3CA7015B1B93C64CDE1250807EE", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "custom_fields_unique_name_index", + "keys": [ + { + "type": "atom", + "value": "name" + } + ], + "name": "unique_name", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "custom_fields" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/extensions.json b/priv/resource_snapshots/repo/extensions.json index 323661b..3731105 100644 --- a/priv/resource_snapshots/repo/extensions.json +++ b/priv/resource_snapshots/repo/extensions.json @@ -2,6 +2,7 @@ "ash_functions_version": 5, "installed": [ "ash-functions", - "citext" + "citext", + "pg_trgm" ] } \ No newline at end of file diff --git a/test/mv_web/live/profile_navigation_test.exs b/test/mv_web/live/profile_navigation_test.exs index 8a59656..3222825 100644 --- a/test/mv_web/live/profile_navigation_test.exs +++ b/test/mv_web/live/profile_navigation_test.exs @@ -148,10 +148,10 @@ defmodule MvWeb.ProfileNavigationTest do "/", "/members", "/members/new", - "/properties", - "/properties/new", - "/property_types", - "/property_types/new", + "/custom_field_values", + "/custom_field_values/new", + "/custom_fields", + "/custom_fields/new", "/users", "/users/new" ] diff --git a/test/seeds_test.exs b/test/seeds_test.exs index 5c589ae..6d29760 100644 --- a/test/seeds_test.exs +++ b/test/seeds_test.exs @@ -9,11 +9,11 @@ defmodule Mv.SeedsTest do # Basic smoke test: ensure some data was created {:ok, users} = Ash.read(Mv.Accounts.User) {:ok, members} = Ash.read(Mv.Membership.Member) - {:ok, property_types} = Ash.read(Mv.Membership.PropertyType) + {:ok, custom_fields} = Ash.read(Mv.Membership.CustomField) assert length(users) > 0, "Seeds should create at least one user" assert length(members) > 0, "Seeds should create at least one member" - assert length(property_types) > 0, "Seeds should create at least one property type" + assert length(custom_fields) > 0, "Seeds should create at least one custom field" end test "can be run multiple times (idempotent)" do @@ -23,7 +23,7 @@ defmodule Mv.SeedsTest do # Count records {:ok, users_count_1} = Ash.read(Mv.Accounts.User) {:ok, members_count_1} = Ash.read(Mv.Membership.Member) - {:ok, property_types_count_1} = Ash.read(Mv.Membership.PropertyType) + {:ok, custom_fields_count_1} = Ash.read(Mv.Membership.CustomField) # Run seeds second time - should not raise errors assert Code.eval_file("priv/repo/seeds.exs") @@ -31,7 +31,7 @@ defmodule Mv.SeedsTest do # Count records again - should be the same (upsert, not duplicate) {:ok, users_count_2} = Ash.read(Mv.Accounts.User) {:ok, members_count_2} = Ash.read(Mv.Membership.Member) - {:ok, property_types_count_2} = Ash.read(Mv.Membership.PropertyType) + {:ok, custom_fields_count_2} = Ash.read(Mv.Membership.CustomField) assert length(users_count_1) == length(users_count_2), "Users count should remain same after re-running seeds" @@ -39,8 +39,8 @@ defmodule Mv.SeedsTest do assert length(members_count_1) == length(members_count_2), "Members count should remain same after re-running seeds" - assert length(property_types_count_1) == length(property_types_count_2), - "PropertyTypes count should remain same after re-running seeds" + assert length(custom_fields_count_1) == length(custom_fields_count_2), + "CustomFields count should remain same after re-running seeds" end end end From e9290b7156c2ec888247579053eafaf10a906cad Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 13 Nov 2025 18:37:58 +0100 Subject: [PATCH 12/15] feat: Add validation constraints and tests for CustomField and CustomFieldValue --- lib/membership/custom_field.ex | 21 +- lib/membership/custom_field_value.ex | 29 +- .../custom_field_validation_test.exs | 206 +++++++++++++ .../custom_field_value_validation_test.exs | 276 ++++++++++++++++++ 4 files changed, 523 insertions(+), 9 deletions(-) create mode 100644 test/membership/custom_field_validation_test.exs create mode 100644 test/membership/custom_field_value_validation_test.exs diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index f155968..90bbcaa 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -15,17 +15,18 @@ defmodule Mv.Membership.CustomField do - `required` - If true, all members must have this custom field (future feature) ## Supported Value Types - - `:string` - Text data (unlimited length) + - `:string` - Text data (max 10,000 characters) - `:integer` - Numeric data (64-bit integers) - `:boolean` - True/false flags - `:date` - Date values (no time component) - - `:email` - Validated email addresses + - `:email` - Validated email addresses (max 254 characters) ## Relationships - `has_many :custom_field_values` - All custom field values of this type ## Constraints - Name must be unique across all custom fields + - Name maximum length: 100 characters - Cannot delete a custom field that has existing custom field values (RESTRICT) ## Examples @@ -60,14 +61,26 @@ defmodule Mv.Membership.CustomField do attributes do uuid_primary_key :id - attribute :name, :string, allow_nil?: false, public?: true + attribute :name, :string, + allow_nil?: false, + public?: true, + constraints: [ + max_length: 100, + trim?: true + ] attribute :value_type, :atom, constraints: [one_of: [:string, :integer, :boolean, :date, :email]], allow_nil?: false, description: "Defines the datatype `CustomFieldValue.value` is interpreted as" - attribute :description, :string, allow_nil?: true, public?: true + attribute :description, :string, + allow_nil?: true, + public?: true, + constraints: [ + max_length: 500, + trim?: true + ] attribute :immutable, :boolean, default: false, diff --git a/lib/membership/custom_field_value.ex b/lib/membership/custom_field_value.ex index 6e6c95f..2d6c025 100644 --- a/lib/membership/custom_field_value.ex +++ b/lib/membership/custom_field_value.ex @@ -30,6 +30,11 @@ defmodule Mv.Membership.CustomFieldValue do ## Constraints - Each member can have only one custom field value per custom field (unique composite index) - Custom field values are deleted when the associated member is deleted (CASCADE) + - String values maximum length: 10,000 characters + - Email values maximum length: 254 characters (RFC 5321) + + ## Future Features + - Type-matching validation (value type must match custom field's value_type) - to be implemented """ use Ash.Resource, domain: Mv.Membership, @@ -56,11 +61,25 @@ defmodule Mv.Membership.CustomFieldValue do constraints: [ storage: :type_and_value, types: [ - boolean: [type: :boolean], - date: [type: :date], - integer: [type: :integer], - string: [type: :string], - email: [type: Mv.Membership.Email] + boolean: [ + type: :boolean + ], + date: [ + type: :date + ], + integer: [ + type: :integer + ], + string: [ + type: :string, + constraints: [ + max_length: 10_000, + trim?: true + ] + ], + email: [ + type: Mv.Membership.Email + ] ] ] end diff --git a/test/membership/custom_field_validation_test.exs b/test/membership/custom_field_validation_test.exs new file mode 100644 index 0000000..d8a5bd9 --- /dev/null +++ b/test/membership/custom_field_validation_test.exs @@ -0,0 +1,206 @@ +defmodule Mv.Membership.CustomFieldValidationTest do + @moduledoc """ + Tests for CustomField validation constraints. + + Tests cover: + - Name length validation (max 100 characters) + - Name trimming + - Description length validation (max 500 characters) + - Description trimming + - Required vs optional fields + """ + use Mv.DataCase, async: true + + alias Mv.Membership.CustomField + + describe "name validation" do + test "accepts name with exactly 100 characters" do + name = String.duplicate("a", 100) + + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: name, + value_type: :string + }) + |> Ash.create() + + assert custom_field.name == name + assert String.length(custom_field.name) == 100 + end + + test "rejects name with 101 characters" do + name = String.duplicate("a", 101) + + assert {:error, changeset} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: name, + value_type: :string + }) + |> Ash.create() + + assert [%{field: :name, message: message}] = changeset.errors + assert message =~ "max" or message =~ "length" or message =~ "100" + end + + test "trims whitespace from name" do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: " test_field ", + value_type: :string + }) + |> Ash.create() + + assert custom_field.name == "test_field" + end + + test "rejects empty name" do + assert {:error, changeset} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "", + value_type: :string + }) + |> Ash.create() + + assert Enum.any?(changeset.errors, fn error -> error.field == :name end) + end + + test "rejects nil name" do + assert {:error, changeset} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + value_type: :string + }) + |> Ash.create() + + assert Enum.any?(changeset.errors, fn error -> error.field == :name end) + end + end + + describe "description validation" do + test "accepts description with exactly 500 characters" do + description = String.duplicate("a", 500) + + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + description: description + }) + |> Ash.create() + + assert custom_field.description == description + assert String.length(custom_field.description) == 500 + end + + test "rejects description with 501 characters" do + description = String.duplicate("a", 501) + + assert {:error, changeset} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + description: description + }) + |> Ash.create() + + assert [%{field: :description, message: message}] = changeset.errors + assert message =~ "max" or message =~ "length" or message =~ "500" + end + + test "trims whitespace from description" do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + description: " A nice description " + }) + |> Ash.create() + + assert custom_field.description == "A nice description" + end + + test "accepts nil description (optional field)" do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string + }) + |> Ash.create() + + assert custom_field.description == nil + end + + test "accepts empty description after trimming" do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + description: " " + }) + |> Ash.create() + + # After trimming whitespace, becomes nil (empty strings are converted to nil) + assert custom_field.description == nil + end + end + + describe "name uniqueness" do + test "rejects duplicate names" do + assert {:ok, _} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "unique_field", + value_type: :string + }) + |> Ash.create() + + assert {:error, changeset} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "unique_field", + value_type: :integer + }) + |> Ash.create() + + assert Enum.any?(changeset.errors, fn error -> error.field == :name end) + end + end + + describe "value_type validation" do + test "accepts all valid value types" do + for value_type <- [:string, :integer, :boolean, :date, :email] do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "field_#{value_type}", + value_type: value_type + }) + |> Ash.create() + + assert custom_field.value_type == value_type + end + end + + test "rejects invalid value type" do + assert {:error, changeset} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "invalid_field", + value_type: :invalid_type + }) + |> Ash.create() + + assert [%{field: :value_type}] = changeset.errors + end + end +end + diff --git a/test/membership/custom_field_value_validation_test.exs b/test/membership/custom_field_value_validation_test.exs new file mode 100644 index 0000000..ce5b5c6 --- /dev/null +++ b/test/membership/custom_field_value_validation_test.exs @@ -0,0 +1,276 @@ +defmodule Mv.Membership.CustomFieldValueValidationTest do + @moduledoc """ + Tests for CustomFieldValue validation constraints. + + Tests cover: + - String value length validation (max 10,000 characters) + - String value trimming + - Email value validation (via Email type) + - Optional values (nil allowed) + """ + use Mv.DataCase, async: true + + alias Mv.Membership.{CustomField, CustomFieldValue, Member} + + setup do + # Create a test member + {:ok, member} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Test", + last_name: "User", + email: "test.validation@example.com" + }) + |> Ash.create() + + # Create custom fields for different types + {:ok, string_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "string_field", + value_type: :string + }) + |> Ash.create() + + {:ok, integer_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "integer_field", + value_type: :integer + }) + |> Ash.create() + + {:ok, email_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "email_field", + value_type: :email + }) + |> Ash.create() + + %{ + member: member, + string_field: string_field, + integer_field: integer_field, + email_field: email_field + } + end + + describe "string value length validation" do + test "accepts string value with exactly 10,000 characters", %{ + member: member, + string_field: string_field + } do + value_string = String.duplicate("a", 10_000) + + assert {:ok, custom_field_value} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: string_field.id, + value: %{ + "_union_type" => "string", + "_union_value" => value_string + } + }) + |> Ash.create() + + assert custom_field_value.value.value == value_string + assert String.length(custom_field_value.value.value) == 10_000 + end + + test "rejects string value with 10,001 characters", %{ + member: member, + string_field: string_field + } do + value_string = String.duplicate("a", 10_001) + + assert {:error, changeset} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => value_string} + }) + |> Ash.create() + + assert Enum.any?(changeset.errors, fn error -> + error.field == :value and (error.message =~ "max" or error.message =~ "length") + end) + end + + test "trims whitespace from string value", %{member: member, string_field: string_field} do + assert {:ok, custom_field_value} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => " test value "} + }) + |> Ash.create() + + assert custom_field_value.value.value == "test value" + end + + test "accepts empty string value", %{member: member, string_field: string_field} do + assert {:ok, custom_field_value} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => ""} + }) + |> Ash.create() + + # Empty strings after trimming become nil + assert custom_field_value.value.value == nil + end + + test "accepts string with special characters", %{member: member, string_field: string_field} do + special_string = "Hello 世界! 🎉 @#$%^&*()" + + assert {:ok, custom_field_value} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => special_string} + }) + |> Ash.create() + + assert custom_field_value.value.value == special_string + end + end + + describe "integer value validation" do + test "accepts valid integer value", %{member: member, integer_field: integer_field} do + assert {:ok, custom_field_value} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: integer_field.id, + value: %{"_union_type" => "integer", "_union_value" => 42} + }) + |> Ash.create() + + assert custom_field_value.value.value == 42 + end + + test "accepts negative integer", %{member: member, integer_field: integer_field} do + assert {:ok, custom_field_value} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: integer_field.id, + value: %{"_union_type" => "integer", "_union_value" => -100} + }) + |> Ash.create() + + assert custom_field_value.value.value == -100 + end + + test "accepts zero", %{member: member, integer_field: integer_field} do + assert {:ok, custom_field_value} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: integer_field.id, + value: %{"_union_type" => "integer", "_union_value" => 0} + }) + |> Ash.create() + + assert custom_field_value.value.value == 0 + end + end + + describe "email value validation" do + test "accepts valid email", %{member: member, email_field: email_field} do + assert {:ok, custom_field_value} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: email_field.id, + value: %{"_union_type" => "email", "_union_value" => "test@example.com"} + }) + |> Ash.create() + + assert custom_field_value.value.value == "test@example.com" + end + + test "rejects invalid email format", %{member: member, email_field: email_field} do + assert {:error, changeset} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: email_field.id, + value: %{"_union_type" => "email", "_union_value" => "not-an-email"} + }) + |> Ash.create() + + assert Enum.any?(changeset.errors, fn error -> error.field == :value end) + end + + test "rejects email longer than 254 characters", %{member: member, email_field: email_field} do + # Create an email with >254 chars (243 + 12 = 255) + long_email = String.duplicate("a", 243) <> "@example.com" + + assert {:error, changeset} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: email_field.id, + value: %{"_union_type" => "email", "_union_value" => long_email} + }) + |> Ash.create() + + assert Enum.any?(changeset.errors, fn error -> error.field == :value end) + end + + test "trims whitespace from email", %{member: member, email_field: email_field} do + assert {:ok, custom_field_value} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: email_field.id, + value: %{"_union_type" => "email", "_union_value" => " test@example.com "} + }) + |> Ash.create() + + assert custom_field_value.value.value == "test@example.com" + end + end + + describe "uniqueness constraint" do + test "rejects duplicate custom_field_id per member", %{ + member: member, + string_field: string_field + } do + # Create first custom field value + assert {:ok, _} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => "first value"} + }) + |> Ash.create() + + # Try to create second custom field value with same custom_field_id for same member + assert {:error, changeset} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => "second value"} + }) + |> Ash.create() + + # Should have uniqueness error + assert Enum.any?(changeset.errors, fn error -> + error.message =~ "unique" or error.message =~ "already exists" or + error.message =~ "has already been taken" + end) + end + end +end + From 2b3c94d3b2308ce709599dcb54f8fb3352f877cc Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 13 Nov 2025 18:39:21 +0100 Subject: [PATCH 13/15] fix: Allow optional email values in custom fields --- lib/membership/email.ex | 12 +++++-- .../custom_field_validation_test.exs | 3 +- .../custom_field_value_validation_test.exs | 33 +++++++++++++++++-- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/lib/membership/email.ex b/lib/membership/email.ex index 47651f5..730ccd7 100644 --- a/lib/membership/email.ex +++ b/lib/membership/email.ex @@ -8,10 +8,11 @@ defmodule Mv.Membership.Email do addresses according to a standard regex pattern. ## Validation Rules - - Minimum length: 5 characters + - **Optional**: `nil` and empty strings are allowed (custom fields are optional) + - Minimum length: 5 characters (for non-empty values) - Maximum length: 254 characters (RFC 5321 maximum) - Pattern: Standard email format (username@domain.tld) - - Automatic trimming of leading/trailing whitespace + - Automatic trimming of leading/trailing whitespace (empty strings become `nil`) ## Usage This type is used in the CustomFieldValue union type for custom fields with @@ -46,11 +47,18 @@ defmodule Mv.Membership.Email do max_length: @max_length ] + @impl true + def cast_input(nil, _), do: {:ok, nil} + @impl true def cast_input(value, _) when is_binary(value) do value = String.trim(value) cond do + # Empty string after trim becomes nil (optional field) + value == "" -> + {:ok, nil} + String.length(value) < @min_length -> :error diff --git a/test/membership/custom_field_validation_test.exs b/test/membership/custom_field_validation_test.exs index d8a5bd9..a5c1f2d 100644 --- a/test/membership/custom_field_validation_test.exs +++ b/test/membership/custom_field_validation_test.exs @@ -1,7 +1,7 @@ defmodule Mv.Membership.CustomFieldValidationTest do @moduledoc """ Tests for CustomField validation constraints. - + Tests cover: - Name length validation (max 100 characters) - Name trimming @@ -203,4 +203,3 @@ defmodule Mv.Membership.CustomFieldValidationTest do end end end - diff --git a/test/membership/custom_field_value_validation_test.exs b/test/membership/custom_field_value_validation_test.exs index ce5b5c6..dd3438a 100644 --- a/test/membership/custom_field_value_validation_test.exs +++ b/test/membership/custom_field_value_validation_test.exs @@ -1,7 +1,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do @moduledoc """ Tests for CustomFieldValue validation constraints. - + Tests cover: - String value length validation (max 10,000 characters) - String value trimming @@ -184,6 +184,36 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do end describe "email value validation" do + test "accepts nil value (optional field)", %{member: member, email_field: email_field} do + assert {:ok, custom_field_value} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: email_field.id, + value: %{"_union_type" => "email", "_union_value" => nil} + }) + |> Ash.create() + + assert custom_field_value.value.value == nil + end + + test "accepts empty string (becomes nil after trim)", %{ + member: member, + email_field: email_field + } do + assert {:ok, custom_field_value} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: email_field.id, + value: %{"_union_type" => "email", "_union_value" => ""} + }) + |> Ash.create() + + # Empty string after trim should become nil + assert custom_field_value.value.value == nil + end + test "accepts valid email", %{member: member, email_field: email_field} do assert {:ok, custom_field_value} = CustomFieldValue @@ -273,4 +303,3 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do end end end - From 7f77eb7023d9e1d97ac065dd2ca84129c62fc44b Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 13 Nov 2025 18:47:00 +0100 Subject: [PATCH 14/15] feat: Add German translations and extended seeds for custom fields --- priv/gettext/de/LC_MESSAGES/default.po | 302 ++++++++++++------------- priv/gettext/default.pot | 302 ++++++++++++------------- priv/gettext/en/LC_MESSAGES/default.po | 302 ++++++++++++------------- priv/repo/seeds.exs | 159 ++++++++++++- 4 files changed, 606 insertions(+), 459 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 22ff795..c7f0048 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -27,9 +27,9 @@ msgstr "Bist du sicher?" msgid "Attempting to reconnect" msgstr "Verbindung wird wiederhergestellt" -#: lib/mv_web/live/member_live/form.ex:25 +#: lib/mv_web/live/member_live/form.ex:54 #: lib/mv_web/live/member_live/index.html.heex:145 -#: lib/mv_web/live/member_live/show.ex:37 +#: lib/mv_web/live/member_live/show.ex:59 #, elixir-autogen, elixir-format msgid "City" msgstr "Stadt" @@ -41,43 +41,43 @@ msgid "Delete" msgstr "Löschen" #: lib/mv_web/live/member_live/index.html.heex:194 -#: lib/mv_web/live/user_live/form.ex:109 +#: lib/mv_web/live/user_live/form.ex:141 #: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format msgid "Edit" msgstr "Bearbeite" -#: lib/mv_web/live/member_live/show.ex:19 -#: lib/mv_web/live/member_live/show.ex:95 +#: lib/mv_web/live/member_live/show.ex:41 +#: lib/mv_web/live/member_live/show.ex:117 #, elixir-autogen, elixir-format msgid "Edit Member" msgstr "Mitglied bearbeiten" -#: lib/mv_web/live/member_live/form.ex:18 +#: lib/mv_web/live/member_live/form.ex:47 #: lib/mv_web/live/member_live/index.html.heex:77 -#: lib/mv_web/live/member_live/show.ex:28 -#: lib/mv_web/live/user_live/form.ex:14 +#: lib/mv_web/live/member_live/show.ex:50 +#: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/index.html.heex:44 -#: lib/mv_web/live/user_live/show.ex:25 +#: lib/mv_web/live/user_live/show.ex:50 #, elixir-autogen, elixir-format msgid "Email" msgstr "E-Mail" -#: lib/mv_web/live/member_live/form.ex:16 -#: lib/mv_web/live/member_live/show.ex:26 +#: lib/mv_web/live/member_live/form.ex:45 +#: lib/mv_web/live/member_live/show.ex:48 #, elixir-autogen, elixir-format msgid "First Name" msgstr "Vorname" -#: lib/mv_web/live/member_live/form.ex:22 +#: lib/mv_web/live/member_live/form.ex:51 #: lib/mv_web/live/member_live/index.html.heex:179 -#: lib/mv_web/live/member_live/show.ex:34 +#: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" msgstr "Beitrittsdatum" -#: lib/mv_web/live/member_live/form.ex:17 -#: lib/mv_web/live/member_live/show.ex:27 +#: lib/mv_web/live/member_live/form.ex:46 +#: lib/mv_web/live/member_live/show.ex:49 #, elixir-autogen, elixir-format msgid "Last Name" msgstr "Nachname" @@ -108,117 +108,111 @@ msgstr "Keine Internetverbindung gefunden" msgid "close" msgstr "schließen" -#: lib/mv_web/live/member_live/form.ex:19 -#: lib/mv_web/live/member_live/show.ex:29 +#: lib/mv_web/live/member_live/form.ex:48 +#: lib/mv_web/live/member_live/show.ex:51 #, elixir-autogen, elixir-format msgid "Birth Date" msgstr "Geburtsdatum" -#: lib/mv_web/live/member_live/form.ex:30 -#: lib/mv_web/live/member_live/show.ex:56 -#, elixir-autogen, elixir-format -msgid "Custom Properties" -msgstr "Eigene Eigenschaften" - -#: lib/mv_web/live/member_live/form.ex:23 -#: lib/mv_web/live/member_live/show.ex:35 +#: lib/mv_web/live/member_live/form.ex:52 +#: lib/mv_web/live/member_live/show.ex:57 #, elixir-autogen, elixir-format msgid "Exit Date" msgstr "Austrittsdatum" -#: lib/mv_web/live/member_live/form.ex:27 +#: lib/mv_web/live/member_live/form.ex:56 #: lib/mv_web/live/member_live/index.html.heex:111 -#: lib/mv_web/live/member_live/show.ex:39 +#: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "House Number" msgstr "Hausnummer" -#: lib/mv_web/live/member_live/form.ex:24 -#: lib/mv_web/live/member_live/show.ex:36 +#: lib/mv_web/live/member_live/form.ex:53 +#: lib/mv_web/live/member_live/show.ex:58 #, elixir-autogen, elixir-format msgid "Notes" msgstr "Notizen" -#: lib/mv_web/live/member_live/form.ex:20 -#: lib/mv_web/live/member_live/show.ex:30 +#: lib/mv_web/live/member_live/form.ex:49 +#: lib/mv_web/live/member_live/show.ex:52 #, elixir-autogen, elixir-format msgid "Paid" msgstr "Bezahlt" -#: lib/mv_web/live/member_live/form.ex:21 +#: lib/mv_web/live/member_live/form.ex:50 #: lib/mv_web/live/member_live/index.html.heex:162 -#: lib/mv_web/live/member_live/show.ex:33 +#: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "Telefonnummer" -#: lib/mv_web/live/member_live/form.ex:28 +#: lib/mv_web/live/member_live/form.ex:57 #: lib/mv_web/live/member_live/index.html.heex:128 -#: lib/mv_web/live/member_live/show.ex:40 +#: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" msgstr "Postleitzahl" -#: lib/mv_web/live/member_live/form.ex:50 +#: lib/mv_web/live/member_live/form.ex:80 #, elixir-autogen, elixir-format msgid "Save Member" msgstr "Mitglied speichern" -#: lib/mv_web/live/member_live/form.ex:49 -#: lib/mv_web/live/property_live/form.ex:41 -#: lib/mv_web/live/property_type_live/form.ex:29 -#: lib/mv_web/live/user_live/form.ex:92 +#: lib/mv_web/live/custom_field_live/form.ex:63 +#: lib/mv_web/live/custom_field_value_live/form.ex:74 +#: lib/mv_web/live/member_live/form.ex:79 +#: lib/mv_web/live/user_live/form.ex:124 #, elixir-autogen, elixir-format msgid "Saving..." msgstr "Speichern..." -#: lib/mv_web/live/member_live/form.ex:26 +#: lib/mv_web/live/member_live/form.ex:55 #: lib/mv_web/live/member_live/index.html.heex:94 -#: lib/mv_web/live/member_live/show.ex:38 +#: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" msgstr "Straße" -#: lib/mv_web/live/member_live/form.ex:11 +#: lib/mv_web/live/member_live/form.ex:40 #, elixir-autogen, elixir-format msgid "Use this form to manage member records and their properties." msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenschaften." -#: lib/mv_web/live/member_live/show.ex:25 +#: lib/mv_web/live/member_live/show.ex:47 #, elixir-autogen, elixir-format msgid "Id" msgstr "ID" -#: lib/mv_web/live/member_live/show.ex:31 +#: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "No" msgstr "Nein" -#: lib/mv_web/live/member_live/show.ex:94 +#: lib/mv_web/live/member_live/show.ex:116 #, elixir-autogen, elixir-format, fuzzy msgid "Show Member" msgstr "Mitglied anzeigen" -#: lib/mv_web/live/member_live/show.ex:11 +#: lib/mv_web/live/member_live/show.ex:33 #, elixir-autogen, elixir-format msgid "This is a member record from your database." msgstr "Dies ist ein Mitglied aus deiner Datenbank." -#: lib/mv_web/live/member_live/show.ex:31 +#: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Yes" msgstr "Ja" -#: lib/mv_web/live/member_live/form.ex:108 -#: lib/mv_web/live/property_live/form.ex:200 -#: lib/mv_web/live/property_type_live/form.ex:73 +#: lib/mv_web/live/custom_field_live/form.ex:107 +#: lib/mv_web/live/custom_field_value_live/form.ex:233 +#: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "erstellt" -#: lib/mv_web/live/member_live/form.ex:109 -#: lib/mv_web/live/property_live/form.ex:201 -#: lib/mv_web/live/property_type_live/form.ex:74 +#: lib/mv_web/live/custom_field_live/form.ex:108 +#: lib/mv_web/live/custom_field_value_live/form.ex:234 +#: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format msgid "update" msgstr "aktualisiert" @@ -228,7 +222,7 @@ msgstr "aktualisiert" msgid "Incorrect email or password" msgstr "Falsche E-Mail oder Passwort" -#: lib/mv_web/live/member_live/form.ex:115 +#: lib/mv_web/live/member_live/form.ex:145 #, elixir-autogen, elixir-format msgid "Member %{action} successfully" msgstr "Mitglied %{action} erfolgreich" @@ -258,45 +252,40 @@ msgstr "Ihre E-Mail-Adresse wurde bestätigt" msgid "Your password has successfully been reset" msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt" -#: lib/mv_web/live/member_live/form.ex:52 -#: lib/mv_web/live/property_live/form.ex:44 -#: lib/mv_web/live/property_type_live/form.ex:32 -#: lib/mv_web/live/user_live/form.ex:95 +#: lib/mv_web/live/custom_field_live/form.ex:66 +#: lib/mv_web/live/custom_field_value_live/form.ex:77 +#: lib/mv_web/live/member_live/form.ex:82 +#: lib/mv_web/live/user_live/form.ex:127 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "Abbrechen" -#: lib/mv_web/live/property_live/form.ex:29 +#: lib/mv_web/live/custom_field_value_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Choose a member" msgstr "Mitglied auswählen" -#: lib/mv_web/live/property_live/form.ex:20 -#, elixir-autogen, elixir-format -msgid "Choose a property type" -msgstr "Eigenschaftstyp auswählen" - -#: lib/mv_web/live/property_type_live/form.ex:25 +#: lib/mv_web/live/custom_field_live/form.ex:59 #, elixir-autogen, elixir-format msgid "Description" msgstr "Beschreibung" -#: lib/mv_web/live/user_live/show.ex:18 +#: lib/mv_web/live/user_live/show.ex:43 #, elixir-autogen, elixir-format msgid "Edit User" msgstr "Benutzer*in bearbeiten" -#: lib/mv_web/live/user_live/show.ex:28 +#: lib/mv_web/live/user_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Enabled" msgstr "Aktiviert" -#: lib/mv_web/live/user_live/show.ex:24 +#: lib/mv_web/live/user_live/show.ex:49 #, elixir-autogen, elixir-format msgid "ID" msgstr "ID" -#: lib/mv_web/live/property_type_live/form.ex:26 +#: lib/mv_web/live/custom_field_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "Unveränderlich" @@ -306,25 +295,25 @@ msgstr "Unveränderlich" msgid "Logout" msgstr "Abmelden" -#: lib/mv_web/live/user_live/index.ex:12 +#: lib/mv_web/live/user_live/index.ex:33 #: lib/mv_web/live/user_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Listing Users" msgstr "Benutzer*innen auflisten" -#: lib/mv_web/live/property_live/form.ex:27 +#: lib/mv_web/live/custom_field_value_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Member" msgstr "Mitglied" #: lib/mv_web/components/layouts/navbar.ex:19 -#: lib/mv_web/live/member_live/index.ex:10 +#: lib/mv_web/live/member_live/index.ex:39 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "Mitglieder" -#: lib/mv_web/live/property_type_live/form.ex:16 +#: lib/mv_web/live/custom_field_live/form.ex:50 #, elixir-autogen, elixir-format msgid "Name" msgstr "Name" @@ -334,73 +323,43 @@ msgstr "Name" msgid "New User" msgstr "Neue*r Benutzer*in" -#: lib/mv_web/live/user_live/show.ex:28 +#: lib/mv_web/live/user_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Not enabled" msgstr "Nicht aktiviert" -#: lib/mv_web/live/user_live/show.ex:26 +#: lib/mv_web/live/user_live/show.ex:51 #, elixir-autogen, elixir-format msgid "Not set" msgstr "Nicht gesetzt" -#: lib/mv_web/live/user_live/form.ex:75 -#: lib/mv_web/live/user_live/form.ex:83 +#: lib/mv_web/live/user_live/form.ex:107 +#: lib/mv_web/live/user_live/form.ex:115 #, elixir-autogen, elixir-format msgid "Note" msgstr "Hinweis" #: lib/mv_web/live/user_live/index.html.heex:52 -#: lib/mv_web/live/user_live/show.ex:26 +#: lib/mv_web/live/user_live/show.ex:51 #, elixir-autogen, elixir-format msgid "OIDC ID" msgstr "OIDC ID" -#: lib/mv_web/live/user_live/show.ex:27 +#: lib/mv_web/live/user_live/show.ex:52 #, elixir-autogen, elixir-format msgid "Password Authentication" msgstr "Passwort-Authentifizierung" -#: lib/mv_web/live/property_live/form.ex:37 -#, elixir-autogen, elixir-format -msgid "Please select a property type first" -msgstr "Bitte wählen Sie zuerst einen Eigenschaftstyp" - #: lib/mv_web/components/layouts/navbar.ex:88 #, elixir-autogen, elixir-format msgid "Profil" msgstr "Profil" -#: lib/mv_web/live/property_live/form.ex:207 -#, elixir-autogen, elixir-format -msgid "Property %{action} successfully" -msgstr "Eigenschaft %{action} erfolgreich" - -#: lib/mv_web/live/property_live/form.ex:18 -#, elixir-autogen, elixir-format -msgid "Property type" -msgstr "Eigenschaftstyp" - -#: lib/mv_web/live/property_type_live/form.ex:80 -#, elixir-autogen, elixir-format -msgid "Property type %{action} successfully" -msgstr "Eigenschaftstyp %{action} erfolgreich" - -#: lib/mv_web/live/property_type_live/form.ex:27 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Required" msgstr "Erforderlich" -#: lib/mv_web/live/property_live/form.ex:42 -#, elixir-autogen, elixir-format -msgid "Save Property" -msgstr "Eigenschaft speichern" - -#: lib/mv_web/live/property_type_live/form.ex:30 -#, elixir-autogen, elixir-format -msgid "Save Property type" -msgstr "Eigenschaftstyp speichern" - #: lib/mv_web/live/member_live/index.html.heex:34 #, elixir-autogen, elixir-format msgid "Select all members" @@ -416,53 +375,43 @@ msgstr "Mitglied auswählen" msgid "Settings" msgstr "Einstellungen" -#: lib/mv_web/live/user_live/form.ex:93 +#: lib/mv_web/live/user_live/form.ex:125 #, elixir-autogen, elixir-format msgid "Save User" msgstr "Benutzer*in speichern" -#: lib/mv_web/live/user_live/show.ex:54 +#: lib/mv_web/live/user_live/show.ex:79 #, elixir-autogen, elixir-format msgid "Show User" msgstr "Benutzer*in anzeigen" -#: lib/mv_web/live/user_live/show.ex:10 +#: lib/mv_web/live/user_live/show.ex:35 #, elixir-autogen, elixir-format msgid "This is a user record from your database." msgstr "Dies ist ein Benutzer*innen-Datensatz aus Ihrer Datenbank." -#: lib/mv_web/live/property_live/form.ex:95 +#: lib/mv_web/live/custom_field_value_live/form.ex:128 #, elixir-autogen, elixir-format msgid "Unsupported value type: %{type}" msgstr "Nicht unterstützter Wertetyp: %{type}" -#: lib/mv_web/live/property_live/form.ex:10 -#, elixir-autogen, elixir-format -msgid "Use this form to manage property records in your database." -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 -msgid "Use this form to manage property_type records in your database." -msgstr "Dieses Formular dient zur Verwaltung von Eigenschaftstypen in der Datenbank." - -#: lib/mv_web/live/user_live/form.ex:10 +#: lib/mv_web/live/user_live/form.ex:42 #, elixir-autogen, elixir-format msgid "Use this form to manage user records in your database." 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 +#: lib/mv_web/live/user_live/form.ex:142 +#: lib/mv_web/live/user_live/show.ex:34 #, elixir-autogen, elixir-format msgid "User" msgstr "Benutzer*in" -#: lib/mv_web/live/property_live/form.ex:59 +#: lib/mv_web/live/custom_field_value_live/form.ex:92 #, elixir-autogen, elixir-format msgid "Value" msgstr "Wert" -#: lib/mv_web/live/property_type_live/form.ex:20 +#: lib/mv_web/live/custom_field_live/form.ex:54 #, elixir-autogen, elixir-format msgid "Value type" msgstr "Wertetyp" @@ -479,57 +428,57 @@ msgstr "aufsteigend" msgid "descending" msgstr "absteigend" -#: lib/mv_web/live/user_live/form.ex:109 +#: lib/mv_web/live/user_live/form.ex:141 #, elixir-autogen, elixir-format msgid "New" msgstr "Neue*r" -#: lib/mv_web/live/user_live/form.ex:64 +#: lib/mv_web/live/user_live/form.ex:96 #, elixir-autogen, elixir-format msgid "Admin Note" msgstr "Administrator*innen-Hinweis" -#: lib/mv_web/live/user_live/form.ex:64 +#: lib/mv_web/live/user_live/form.ex:96 #, 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*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 +#: lib/mv_web/live/user_live/form.ex:87 #, elixir-autogen, elixir-format msgid "At least 8 characters" msgstr "Mindestens 8 Zeichen" -#: lib/mv_web/live/user_live/form.ex:27 +#: lib/mv_web/live/user_live/form.ex:59 #, elixir-autogen, elixir-format msgid "Change Password" msgstr "Passwort ändern" -#: lib/mv_web/live/user_live/form.ex:75 +#: lib/mv_web/live/user_live/form.ex:107 #, 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 diese*n Benutzer*in zu setzen." -#: lib/mv_web/live/user_live/form.ex:45 +#: lib/mv_web/live/user_live/form.ex:77 #, elixir-autogen, elixir-format msgid "Confirm Password" msgstr "Passwort bestätigen" -#: lib/mv_web/live/user_live/form.ex:57 +#: lib/mv_web/live/user_live/form.ex:89 #, elixir-autogen, elixir-format msgid "Consider using special characters" msgstr "Sonderzeichen empfohlen" -#: lib/mv_web/live/user_live/form.ex:56 +#: lib/mv_web/live/user_live/form.ex:88 #, elixir-autogen, elixir-format msgid "Include both letters and numbers" msgstr "Buchstaben und Zahlen verwenden" -#: lib/mv_web/live/user_live/form.ex:35 +#: lib/mv_web/live/user_live/form.ex:67 #, elixir-autogen, elixir-format msgid "Password" msgstr "Passwort" -#: lib/mv_web/live/user_live/form.ex:53 +#: lib/mv_web/live/user_live/form.ex:85 #, elixir-autogen, elixir-format msgid "Password requirements" msgstr "Passwort-Anforderungen" @@ -544,44 +493,44 @@ msgstr "Alle Benutzer*innen auswählen" msgid "Select user" msgstr "Benutzer*in auswählen" -#: lib/mv_web/live/user_live/form.ex:27 +#: lib/mv_web/live/user_live/form.ex:59 #, elixir-autogen, elixir-format msgid "Set Password" msgstr "Passwort setzen" -#: lib/mv_web/live/user_live/form.ex:83 +#: lib/mv_web/live/user_live/form.ex:115 #, elixir-autogen, elixir-format msgid "User will be created without a password. Check 'Set Password' to add one." msgstr "Benutzer*in wird ohne Passwort erstellt. Aktivieren Sie 'Passwort setzen', um eines hinzuzufügen." -#: lib/mv_web/live/user_live/show.ex:30 +#: lib/mv_web/live/user_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Linked Member" msgstr "Verknüpftes Mitglied" -#: lib/mv_web/live/member_live/show.ex:41 +#: lib/mv_web/live/member_live/show.ex:63 #, elixir-autogen, elixir-format msgid "Linked User" msgstr "Verknüpfte*r Benutzer*in" -#: lib/mv_web/live/user_live/show.ex:40 +#: lib/mv_web/live/user_live/show.ex:65 #, elixir-autogen, elixir-format msgid "No member linked" msgstr "Kein Mitglied verknüpft" -#: lib/mv_web/live/member_live/show.ex:51 +#: lib/mv_web/live/member_live/show.ex:73 #, elixir-autogen, elixir-format msgid "No user linked" 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 +#: lib/mv_web/live/member_live/show.ex:36 +#: lib/mv_web/live/member_live/show.ex:38 #, elixir-autogen, elixir-format msgid "Back to members list" msgstr "Zurück zur Mitgliederliste" -#: lib/mv_web/live/user_live/show.ex:13 -#: lib/mv_web/live/user_live/show.ex:15 +#: lib/mv_web/live/user_live/show.ex:38 +#: lib/mv_web/live/user_live/show.ex:40 #, elixir-autogen, elixir-format msgid "Back to users list" msgstr "Zurück zur Benutzer*innen-Liste" @@ -650,3 +599,54 @@ msgstr "E-Mail kann nicht aktualisiert werden: Diese E-Mail-Adresse ist bereits #, elixir-autogen, elixir-format msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft. Es können nicht mehrere OIDC-Provider mit demselben Konto verknüpft werden." + +#: lib/mv_web/live/custom_field_value_live/form.ex:53 +#, elixir-autogen, elixir-format +msgid "Choose a custom field" +msgstr "Wähle ein Benutzerdefiniertes Feld" + +#: lib/mv_web/live/member_live/form.ex:59 +#: lib/mv_web/live/member_live/show.ex:78 +#, elixir-autogen, elixir-format +msgid "Custom Field Values" +msgstr "Benutzerdefinierte Feldwerte" + +#: lib/mv_web/live/custom_field_value_live/form.ex:51 +#, elixir-autogen, elixir-format +msgid "Custom field" +msgstr "Benutzerdefiniertes Feld" + +#: lib/mv_web/live/custom_field_live/form.ex:114 +#, elixir-autogen, elixir-format +msgid "Custom field %{action} successfully" +msgstr "Benutzerdefiniertes Feld erfolgreich %{action}" + +#: lib/mv_web/live/custom_field_value_live/form.ex:242 +#, elixir-autogen, elixir-format +msgid "Custom field value %{action} successfully" +msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}" + +#: lib/mv_web/live/custom_field_value_live/form.ex:70 +#, elixir-autogen, elixir-format +msgid "Please select a custom field first" +msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld" + +#: lib/mv_web/live/custom_field_live/form.ex:64 +#, elixir-autogen, elixir-format +msgid "Save Custom field" +msgstr "Benutzerdefiniertes Feld speichern" + +#: lib/mv_web/live/custom_field_value_live/form.ex:75 +#, elixir-autogen, elixir-format +msgid "Save Custom field value" +msgstr "Benutzerdefinierten Feldwert speichern" + +#: lib/mv_web/live/custom_field_live/form.ex:45 +#, elixir-autogen, elixir-format +msgid "Use this form to manage custom_field records in your database." +msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten." + +#: lib/mv_web/live/custom_field_value_live/form.ex:42 +#, elixir-autogen, elixir-format +msgid "Use this form to manage custom_field_value records in your database." +msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Datenbank zu verwalten." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index ebcda96..684515b 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -28,9 +28,9 @@ msgstr "" msgid "Attempting to reconnect" msgstr "" -#: lib/mv_web/live/member_live/form.ex:25 +#: lib/mv_web/live/member_live/form.ex:54 #: lib/mv_web/live/member_live/index.html.heex:145 -#: lib/mv_web/live/member_live/show.ex:37 +#: lib/mv_web/live/member_live/show.ex:59 #, elixir-autogen, elixir-format msgid "City" msgstr "" @@ -42,43 +42,43 @@ msgid "Delete" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:194 -#: lib/mv_web/live/user_live/form.ex:109 +#: lib/mv_web/live/user_live/form.ex:141 #: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format msgid "Edit" msgstr "" -#: lib/mv_web/live/member_live/show.ex:19 -#: lib/mv_web/live/member_live/show.ex:95 +#: lib/mv_web/live/member_live/show.ex:41 +#: lib/mv_web/live/member_live/show.ex:117 #, elixir-autogen, elixir-format msgid "Edit Member" msgstr "" -#: lib/mv_web/live/member_live/form.ex:18 +#: lib/mv_web/live/member_live/form.ex:47 #: lib/mv_web/live/member_live/index.html.heex:77 -#: lib/mv_web/live/member_live/show.ex:28 -#: lib/mv_web/live/user_live/form.ex:14 +#: lib/mv_web/live/member_live/show.ex:50 +#: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/index.html.heex:44 -#: lib/mv_web/live/user_live/show.ex:25 +#: lib/mv_web/live/user_live/show.ex:50 #, elixir-autogen, elixir-format msgid "Email" msgstr "" -#: lib/mv_web/live/member_live/form.ex:16 -#: lib/mv_web/live/member_live/show.ex:26 +#: lib/mv_web/live/member_live/form.ex:45 +#: lib/mv_web/live/member_live/show.ex:48 #, elixir-autogen, elixir-format msgid "First Name" msgstr "" -#: lib/mv_web/live/member_live/form.ex:22 +#: lib/mv_web/live/member_live/form.ex:51 #: lib/mv_web/live/member_live/index.html.heex:179 -#: lib/mv_web/live/member_live/show.ex:34 +#: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" msgstr "" -#: lib/mv_web/live/member_live/form.ex:17 -#: lib/mv_web/live/member_live/show.ex:27 +#: lib/mv_web/live/member_live/form.ex:46 +#: lib/mv_web/live/member_live/show.ex:49 #, elixir-autogen, elixir-format msgid "Last Name" msgstr "" @@ -109,117 +109,111 @@ msgstr "" msgid "close" msgstr "" -#: lib/mv_web/live/member_live/form.ex:19 -#: lib/mv_web/live/member_live/show.ex:29 +#: lib/mv_web/live/member_live/form.ex:48 +#: lib/mv_web/live/member_live/show.ex:51 #, elixir-autogen, elixir-format msgid "Birth Date" msgstr "" -#: lib/mv_web/live/member_live/form.ex:30 -#: lib/mv_web/live/member_live/show.ex:56 -#, elixir-autogen, elixir-format -msgid "Custom Properties" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex:23 -#: lib/mv_web/live/member_live/show.ex:35 +#: lib/mv_web/live/member_live/form.ex:52 +#: lib/mv_web/live/member_live/show.ex:57 #, elixir-autogen, elixir-format msgid "Exit Date" msgstr "" -#: lib/mv_web/live/member_live/form.ex:27 +#: lib/mv_web/live/member_live/form.ex:56 #: lib/mv_web/live/member_live/index.html.heex:111 -#: lib/mv_web/live/member_live/show.ex:39 +#: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "House Number" msgstr "" -#: lib/mv_web/live/member_live/form.ex:24 -#: lib/mv_web/live/member_live/show.ex:36 +#: lib/mv_web/live/member_live/form.ex:53 +#: lib/mv_web/live/member_live/show.ex:58 #, elixir-autogen, elixir-format msgid "Notes" msgstr "" -#: lib/mv_web/live/member_live/form.ex:20 -#: lib/mv_web/live/member_live/show.ex:30 +#: lib/mv_web/live/member_live/form.ex:49 +#: lib/mv_web/live/member_live/show.ex:52 #, elixir-autogen, elixir-format msgid "Paid" msgstr "" -#: lib/mv_web/live/member_live/form.ex:21 +#: lib/mv_web/live/member_live/form.ex:50 #: lib/mv_web/live/member_live/index.html.heex:162 -#: lib/mv_web/live/member_live/show.ex:33 +#: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "" -#: lib/mv_web/live/member_live/form.ex:28 +#: lib/mv_web/live/member_live/form.ex:57 #: lib/mv_web/live/member_live/index.html.heex:128 -#: lib/mv_web/live/member_live/show.ex:40 +#: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" msgstr "" -#: lib/mv_web/live/member_live/form.ex:50 +#: lib/mv_web/live/member_live/form.ex:80 #, elixir-autogen, elixir-format msgid "Save Member" msgstr "" -#: lib/mv_web/live/member_live/form.ex:49 -#: lib/mv_web/live/property_live/form.ex:41 -#: lib/mv_web/live/property_type_live/form.ex:29 -#: lib/mv_web/live/user_live/form.ex:92 +#: lib/mv_web/live/custom_field_live/form.ex:63 +#: lib/mv_web/live/custom_field_value_live/form.ex:74 +#: lib/mv_web/live/member_live/form.ex:79 +#: lib/mv_web/live/user_live/form.ex:124 #, elixir-autogen, elixir-format msgid "Saving..." msgstr "" -#: lib/mv_web/live/member_live/form.ex:26 +#: lib/mv_web/live/member_live/form.ex:55 #: lib/mv_web/live/member_live/index.html.heex:94 -#: lib/mv_web/live/member_live/show.ex:38 +#: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" msgstr "" -#: lib/mv_web/live/member_live/form.ex:11 +#: lib/mv_web/live/member_live/form.ex:40 #, elixir-autogen, elixir-format msgid "Use this form to manage member records and their properties." msgstr "" -#: lib/mv_web/live/member_live/show.ex:25 +#: lib/mv_web/live/member_live/show.ex:47 #, elixir-autogen, elixir-format msgid "Id" msgstr "" -#: lib/mv_web/live/member_live/show.ex:31 +#: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "No" msgstr "" -#: lib/mv_web/live/member_live/show.ex:94 +#: lib/mv_web/live/member_live/show.ex:116 #, elixir-autogen, elixir-format msgid "Show Member" msgstr "" -#: lib/mv_web/live/member_live/show.ex:11 +#: lib/mv_web/live/member_live/show.ex:33 #, elixir-autogen, elixir-format msgid "This is a member record from your database." msgstr "" -#: lib/mv_web/live/member_live/show.ex:31 +#: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Yes" msgstr "" -#: lib/mv_web/live/member_live/form.ex:108 -#: lib/mv_web/live/property_live/form.ex:200 -#: lib/mv_web/live/property_type_live/form.ex:73 +#: lib/mv_web/live/custom_field_live/form.ex:107 +#: lib/mv_web/live/custom_field_value_live/form.ex:233 +#: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "" -#: lib/mv_web/live/member_live/form.ex:109 -#: lib/mv_web/live/property_live/form.ex:201 -#: lib/mv_web/live/property_type_live/form.ex:74 +#: lib/mv_web/live/custom_field_live/form.ex:108 +#: lib/mv_web/live/custom_field_value_live/form.ex:234 +#: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format msgid "update" msgstr "" @@ -229,7 +223,7 @@ msgstr "" msgid "Incorrect email or password" msgstr "" -#: lib/mv_web/live/member_live/form.ex:115 +#: lib/mv_web/live/member_live/form.ex:145 #, elixir-autogen, elixir-format msgid "Member %{action} successfully" msgstr "" @@ -259,45 +253,40 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/member_live/form.ex:52 -#: lib/mv_web/live/property_live/form.ex:44 -#: lib/mv_web/live/property_type_live/form.ex:32 -#: lib/mv_web/live/user_live/form.ex:95 +#: lib/mv_web/live/custom_field_live/form.ex:66 +#: lib/mv_web/live/custom_field_value_live/form.ex:77 +#: lib/mv_web/live/member_live/form.ex:82 +#: lib/mv_web/live/user_live/form.ex:127 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" -#: lib/mv_web/live/property_live/form.ex:29 +#: lib/mv_web/live/custom_field_value_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Choose a member" msgstr "" -#: lib/mv_web/live/property_live/form.ex:20 -#, elixir-autogen, elixir-format -msgid "Choose a property type" -msgstr "" - -#: lib/mv_web/live/property_type_live/form.ex:25 +#: lib/mv_web/live/custom_field_live/form.ex:59 #, elixir-autogen, elixir-format msgid "Description" msgstr "" -#: lib/mv_web/live/user_live/show.ex:18 +#: lib/mv_web/live/user_live/show.ex:43 #, elixir-autogen, elixir-format msgid "Edit User" msgstr "" -#: lib/mv_web/live/user_live/show.ex:28 +#: lib/mv_web/live/user_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Enabled" msgstr "" -#: lib/mv_web/live/user_live/show.ex:24 +#: lib/mv_web/live/user_live/show.ex:49 #, elixir-autogen, elixir-format msgid "ID" msgstr "" -#: lib/mv_web/live/property_type_live/form.ex:26 +#: lib/mv_web/live/custom_field_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -307,25 +296,25 @@ msgstr "" msgid "Logout" msgstr "" -#: lib/mv_web/live/user_live/index.ex:12 +#: lib/mv_web/live/user_live/index.ex:33 #: lib/mv_web/live/user_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Listing Users" msgstr "" -#: lib/mv_web/live/property_live/form.ex:27 +#: lib/mv_web/live/custom_field_value_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Member" msgstr "" #: lib/mv_web/components/layouts/navbar.ex:19 -#: lib/mv_web/live/member_live/index.ex:10 +#: lib/mv_web/live/member_live/index.ex:39 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "" -#: lib/mv_web/live/property_type_live/form.ex:16 +#: lib/mv_web/live/custom_field_live/form.ex:50 #, elixir-autogen, elixir-format msgid "Name" msgstr "" @@ -335,73 +324,43 @@ msgstr "" msgid "New User" msgstr "" -#: lib/mv_web/live/user_live/show.ex:28 +#: lib/mv_web/live/user_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Not enabled" msgstr "" -#: lib/mv_web/live/user_live/show.ex:26 +#: lib/mv_web/live/user_live/show.ex:51 #, elixir-autogen, elixir-format msgid "Not set" msgstr "" -#: lib/mv_web/live/user_live/form.ex:75 -#: lib/mv_web/live/user_live/form.ex:83 +#: lib/mv_web/live/user_live/form.ex:107 +#: lib/mv_web/live/user_live/form.ex:115 #, elixir-autogen, elixir-format msgid "Note" msgstr "" #: lib/mv_web/live/user_live/index.html.heex:52 -#: lib/mv_web/live/user_live/show.ex:26 +#: lib/mv_web/live/user_live/show.ex:51 #, elixir-autogen, elixir-format msgid "OIDC ID" msgstr "" -#: lib/mv_web/live/user_live/show.ex:27 +#: lib/mv_web/live/user_live/show.ex:52 #, elixir-autogen, elixir-format msgid "Password Authentication" msgstr "" -#: lib/mv_web/live/property_live/form.ex:37 -#, elixir-autogen, elixir-format -msgid "Please select a property type first" -msgstr "" - #: lib/mv_web/components/layouts/navbar.ex:88 #, elixir-autogen, elixir-format msgid "Profil" msgstr "" -#: lib/mv_web/live/property_live/form.ex:207 -#, elixir-autogen, elixir-format -msgid "Property %{action} successfully" -msgstr "" - -#: lib/mv_web/live/property_live/form.ex:18 -#, elixir-autogen, elixir-format -msgid "Property type" -msgstr "" - -#: lib/mv_web/live/property_type_live/form.ex:80 -#, elixir-autogen, elixir-format -msgid "Property type %{action} successfully" -msgstr "" - -#: lib/mv_web/live/property_type_live/form.ex:27 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Required" msgstr "" -#: lib/mv_web/live/property_live/form.ex:42 -#, elixir-autogen, elixir-format -msgid "Save Property" -msgstr "" - -#: lib/mv_web/live/property_type_live/form.ex:30 -#, elixir-autogen, elixir-format -msgid "Save Property type" -msgstr "" - #: lib/mv_web/live/member_live/index.html.heex:34 #, elixir-autogen, elixir-format msgid "Select all members" @@ -417,53 +376,43 @@ msgstr "" msgid "Settings" msgstr "" -#: lib/mv_web/live/user_live/form.ex:93 +#: lib/mv_web/live/user_live/form.ex:125 #, elixir-autogen, elixir-format msgid "Save User" msgstr "" -#: lib/mv_web/live/user_live/show.ex:54 +#: lib/mv_web/live/user_live/show.ex:79 #, elixir-autogen, elixir-format msgid "Show User" msgstr "" -#: lib/mv_web/live/user_live/show.ex:10 +#: lib/mv_web/live/user_live/show.ex:35 #, elixir-autogen, elixir-format msgid "This is a user record from your database." msgstr "" -#: lib/mv_web/live/property_live/form.ex:95 +#: lib/mv_web/live/custom_field_value_live/form.ex:128 #, elixir-autogen, elixir-format msgid "Unsupported value type: %{type}" msgstr "" -#: lib/mv_web/live/property_live/form.ex:10 -#, elixir-autogen, elixir-format -msgid "Use this form to manage property records in your database." -msgstr "" - -#: lib/mv_web/live/property_type_live/form.ex:11 -#, elixir-autogen, elixir-format -msgid "Use this form to manage property_type records in your database." -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:10 +#: lib/mv_web/live/user_live/form.ex:42 #, elixir-autogen, elixir-format msgid "Use this form to manage user records in your database." msgstr "" -#: lib/mv_web/live/user_live/form.ex:110 -#: lib/mv_web/live/user_live/show.ex:9 +#: lib/mv_web/live/user_live/form.ex:142 +#: lib/mv_web/live/user_live/show.ex:34 #, elixir-autogen, elixir-format msgid "User" msgstr "" -#: lib/mv_web/live/property_live/form.ex:59 +#: lib/mv_web/live/custom_field_value_live/form.ex:92 #, elixir-autogen, elixir-format msgid "Value" msgstr "" -#: lib/mv_web/live/property_type_live/form.ex:20 +#: lib/mv_web/live/custom_field_live/form.ex:54 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" @@ -480,57 +429,57 @@ msgstr "" msgid "descending" msgstr "" -#: lib/mv_web/live/user_live/form.ex:109 +#: lib/mv_web/live/user_live/form.ex:141 #, elixir-autogen, elixir-format msgid "New" msgstr "" -#: lib/mv_web/live/user_live/form.ex:64 +#: lib/mv_web/live/user_live/form.ex:96 #, elixir-autogen, elixir-format msgid "Admin Note" msgstr "" -#: lib/mv_web/live/user_live/form.ex:64 +#: lib/mv_web/live/user_live/form.ex:96 #, 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 "" -#: lib/mv_web/live/user_live/form.ex:55 +#: lib/mv_web/live/user_live/form.ex:87 #, elixir-autogen, elixir-format msgid "At least 8 characters" msgstr "" -#: lib/mv_web/live/user_live/form.ex:27 +#: lib/mv_web/live/user_live/form.ex:59 #, elixir-autogen, elixir-format msgid "Change Password" msgstr "" -#: lib/mv_web/live/user_live/form.ex:75 +#: lib/mv_web/live/user_live/form.ex:107 #, elixir-autogen, elixir-format msgid "Check 'Change Password' above to set a new password for this user." msgstr "" -#: lib/mv_web/live/user_live/form.ex:45 +#: lib/mv_web/live/user_live/form.ex:77 #, elixir-autogen, elixir-format msgid "Confirm Password" msgstr "" -#: lib/mv_web/live/user_live/form.ex:57 +#: lib/mv_web/live/user_live/form.ex:89 #, elixir-autogen, elixir-format msgid "Consider using special characters" msgstr "" -#: lib/mv_web/live/user_live/form.ex:56 +#: lib/mv_web/live/user_live/form.ex:88 #, elixir-autogen, elixir-format msgid "Include both letters and numbers" msgstr "" -#: lib/mv_web/live/user_live/form.ex:35 +#: lib/mv_web/live/user_live/form.ex:67 #, elixir-autogen, elixir-format msgid "Password" msgstr "" -#: lib/mv_web/live/user_live/form.ex:53 +#: lib/mv_web/live/user_live/form.ex:85 #, elixir-autogen, elixir-format msgid "Password requirements" msgstr "" @@ -545,44 +494,44 @@ msgstr "" msgid "Select user" msgstr "" -#: lib/mv_web/live/user_live/form.ex:27 +#: lib/mv_web/live/user_live/form.ex:59 #, elixir-autogen, elixir-format msgid "Set Password" msgstr "" -#: lib/mv_web/live/user_live/form.ex:83 +#: lib/mv_web/live/user_live/form.ex:115 #, elixir-autogen, elixir-format msgid "User will be created without a password. Check 'Set Password' to add one." msgstr "" -#: lib/mv_web/live/user_live/show.ex:30 +#: lib/mv_web/live/user_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Linked Member" msgstr "" -#: lib/mv_web/live/member_live/show.ex:41 +#: lib/mv_web/live/member_live/show.ex:63 #, elixir-autogen, elixir-format msgid "Linked User" msgstr "" -#: lib/mv_web/live/user_live/show.ex:40 +#: lib/mv_web/live/user_live/show.ex:65 #, elixir-autogen, elixir-format msgid "No member linked" msgstr "" -#: lib/mv_web/live/member_live/show.ex:51 +#: lib/mv_web/live/member_live/show.ex:73 #, elixir-autogen, elixir-format msgid "No user linked" msgstr "" -#: lib/mv_web/live/member_live/show.ex:14 -#: lib/mv_web/live/member_live/show.ex:16 +#: lib/mv_web/live/member_live/show.ex:36 +#: lib/mv_web/live/member_live/show.ex:38 #, elixir-autogen, elixir-format msgid "Back to members list" msgstr "" -#: lib/mv_web/live/user_live/show.ex:13 -#: lib/mv_web/live/user_live/show.ex:15 +#: lib/mv_web/live/user_live/show.ex:38 +#: lib/mv_web/live/user_live/show.ex:40 #, elixir-autogen, elixir-format msgid "Back to users list" msgstr "" @@ -651,3 +600,54 @@ msgstr "" #, elixir-autogen, elixir-format msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." msgstr "" + +#: lib/mv_web/live/custom_field_value_live/form.ex:53 +#, elixir-autogen, elixir-format +msgid "Choose a custom field" +msgstr "" + +#: lib/mv_web/live/member_live/form.ex:59 +#: lib/mv_web/live/member_live/show.ex:78 +#, elixir-autogen, elixir-format +msgid "Custom Field Values" +msgstr "" + +#: lib/mv_web/live/custom_field_value_live/form.ex:51 +#, elixir-autogen, elixir-format +msgid "Custom field" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form.ex:114 +#, elixir-autogen, elixir-format +msgid "Custom field %{action} successfully" +msgstr "" + +#: lib/mv_web/live/custom_field_value_live/form.ex:242 +#, elixir-autogen, elixir-format +msgid "Custom field value %{action} successfully" +msgstr "" + +#: lib/mv_web/live/custom_field_value_live/form.ex:70 +#, elixir-autogen, elixir-format +msgid "Please select a custom field first" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form.ex:64 +#, elixir-autogen, elixir-format +msgid "Save Custom field" +msgstr "" + +#: lib/mv_web/live/custom_field_value_live/form.ex:75 +#, elixir-autogen, elixir-format +msgid "Save Custom field value" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form.ex:45 +#, elixir-autogen, elixir-format +msgid "Use this form to manage custom_field records in your database." +msgstr "" + +#: lib/mv_web/live/custom_field_value_live/form.ex:42 +#, elixir-autogen, elixir-format +msgid "Use this form to manage custom_field_value records in your database." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index bc0e16c..01b3e95 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -28,9 +28,9 @@ msgstr "" msgid "Attempting to reconnect" msgstr "" -#: lib/mv_web/live/member_live/form.ex:25 +#: lib/mv_web/live/member_live/form.ex:54 #: lib/mv_web/live/member_live/index.html.heex:145 -#: lib/mv_web/live/member_live/show.ex:37 +#: lib/mv_web/live/member_live/show.ex:59 #, elixir-autogen, elixir-format msgid "City" msgstr "" @@ -42,43 +42,43 @@ msgid "Delete" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:194 -#: lib/mv_web/live/user_live/form.ex:109 +#: lib/mv_web/live/user_live/form.ex:141 #: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format msgid "Edit" msgstr "" -#: lib/mv_web/live/member_live/show.ex:19 -#: lib/mv_web/live/member_live/show.ex:95 +#: lib/mv_web/live/member_live/show.ex:41 +#: lib/mv_web/live/member_live/show.ex:117 #, elixir-autogen, elixir-format msgid "Edit Member" msgstr "" -#: lib/mv_web/live/member_live/form.ex:18 +#: lib/mv_web/live/member_live/form.ex:47 #: lib/mv_web/live/member_live/index.html.heex:77 -#: lib/mv_web/live/member_live/show.ex:28 -#: lib/mv_web/live/user_live/form.ex:14 +#: lib/mv_web/live/member_live/show.ex:50 +#: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/index.html.heex:44 -#: lib/mv_web/live/user_live/show.ex:25 +#: lib/mv_web/live/user_live/show.ex:50 #, elixir-autogen, elixir-format msgid "Email" msgstr "" -#: lib/mv_web/live/member_live/form.ex:16 -#: lib/mv_web/live/member_live/show.ex:26 +#: lib/mv_web/live/member_live/form.ex:45 +#: lib/mv_web/live/member_live/show.ex:48 #, elixir-autogen, elixir-format msgid "First Name" msgstr "" -#: lib/mv_web/live/member_live/form.ex:22 +#: lib/mv_web/live/member_live/form.ex:51 #: lib/mv_web/live/member_live/index.html.heex:179 -#: lib/mv_web/live/member_live/show.ex:34 +#: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" msgstr "" -#: lib/mv_web/live/member_live/form.ex:17 -#: lib/mv_web/live/member_live/show.ex:27 +#: lib/mv_web/live/member_live/form.ex:46 +#: lib/mv_web/live/member_live/show.ex:49 #, elixir-autogen, elixir-format msgid "Last Name" msgstr "" @@ -109,117 +109,111 @@ msgstr "" msgid "close" msgstr "" -#: lib/mv_web/live/member_live/form.ex:19 -#: lib/mv_web/live/member_live/show.ex:29 +#: lib/mv_web/live/member_live/form.ex:48 +#: lib/mv_web/live/member_live/show.ex:51 #, elixir-autogen, elixir-format msgid "Birth Date" msgstr "" -#: lib/mv_web/live/member_live/form.ex:30 -#: lib/mv_web/live/member_live/show.ex:56 -#, elixir-autogen, elixir-format -msgid "Custom Properties" -msgstr "" - -#: lib/mv_web/live/member_live/form.ex:23 -#: lib/mv_web/live/member_live/show.ex:35 +#: lib/mv_web/live/member_live/form.ex:52 +#: lib/mv_web/live/member_live/show.ex:57 #, elixir-autogen, elixir-format msgid "Exit Date" msgstr "" -#: lib/mv_web/live/member_live/form.ex:27 +#: lib/mv_web/live/member_live/form.ex:56 #: lib/mv_web/live/member_live/index.html.heex:111 -#: lib/mv_web/live/member_live/show.ex:39 +#: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "House Number" msgstr "" -#: lib/mv_web/live/member_live/form.ex:24 -#: lib/mv_web/live/member_live/show.ex:36 +#: lib/mv_web/live/member_live/form.ex:53 +#: lib/mv_web/live/member_live/show.ex:58 #, elixir-autogen, elixir-format msgid "Notes" msgstr "" -#: lib/mv_web/live/member_live/form.ex:20 -#: lib/mv_web/live/member_live/show.ex:30 +#: lib/mv_web/live/member_live/form.ex:49 +#: lib/mv_web/live/member_live/show.ex:52 #, elixir-autogen, elixir-format msgid "Paid" msgstr "" -#: lib/mv_web/live/member_live/form.ex:21 +#: lib/mv_web/live/member_live/form.ex:50 #: lib/mv_web/live/member_live/index.html.heex:162 -#: lib/mv_web/live/member_live/show.ex:33 +#: lib/mv_web/live/member_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Phone Number" msgstr "" -#: lib/mv_web/live/member_live/form.ex:28 +#: lib/mv_web/live/member_live/form.ex:57 #: lib/mv_web/live/member_live/index.html.heex:128 -#: lib/mv_web/live/member_live/show.ex:40 +#: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" msgstr "" -#: lib/mv_web/live/member_live/form.ex:50 +#: lib/mv_web/live/member_live/form.ex:80 #, elixir-autogen, elixir-format, fuzzy msgid "Save Member" msgstr "" -#: lib/mv_web/live/member_live/form.ex:49 -#: lib/mv_web/live/property_live/form.ex:41 -#: lib/mv_web/live/property_type_live/form.ex:29 -#: lib/mv_web/live/user_live/form.ex:92 +#: lib/mv_web/live/custom_field_live/form.ex:63 +#: lib/mv_web/live/custom_field_value_live/form.ex:74 +#: lib/mv_web/live/member_live/form.ex:79 +#: lib/mv_web/live/user_live/form.ex:124 #, elixir-autogen, elixir-format msgid "Saving..." msgstr "" -#: lib/mv_web/live/member_live/form.ex:26 +#: lib/mv_web/live/member_live/form.ex:55 #: lib/mv_web/live/member_live/index.html.heex:94 -#: lib/mv_web/live/member_live/show.ex:38 +#: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" msgstr "" -#: lib/mv_web/live/member_live/form.ex:11 +#: lib/mv_web/live/member_live/form.ex:40 #, elixir-autogen, elixir-format msgid "Use this form to manage member records and their properties." msgstr "" -#: lib/mv_web/live/member_live/show.ex:25 +#: lib/mv_web/live/member_live/show.ex:47 #, elixir-autogen, elixir-format msgid "Id" msgstr "" -#: lib/mv_web/live/member_live/show.ex:31 +#: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "No" msgstr "" -#: lib/mv_web/live/member_live/show.ex:94 +#: lib/mv_web/live/member_live/show.ex:116 #, elixir-autogen, elixir-format, fuzzy msgid "Show Member" msgstr "" -#: lib/mv_web/live/member_live/show.ex:11 +#: lib/mv_web/live/member_live/show.ex:33 #, elixir-autogen, elixir-format msgid "This is a member record from your database." msgstr "" -#: lib/mv_web/live/member_live/show.ex:31 +#: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Yes" msgstr "" -#: lib/mv_web/live/member_live/form.ex:108 -#: lib/mv_web/live/property_live/form.ex:200 -#: lib/mv_web/live/property_type_live/form.ex:73 +#: lib/mv_web/live/custom_field_live/form.ex:107 +#: lib/mv_web/live/custom_field_value_live/form.ex:233 +#: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "" -#: lib/mv_web/live/member_live/form.ex:109 -#: lib/mv_web/live/property_live/form.ex:201 -#: lib/mv_web/live/property_type_live/form.ex:74 +#: lib/mv_web/live/custom_field_live/form.ex:108 +#: lib/mv_web/live/custom_field_value_live/form.ex:234 +#: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format msgid "update" msgstr "" @@ -229,7 +223,7 @@ msgstr "" msgid "Incorrect email or password" msgstr "" -#: lib/mv_web/live/member_live/form.ex:115 +#: lib/mv_web/live/member_live/form.ex:145 #, elixir-autogen, elixir-format msgid "Member %{action} successfully" msgstr "" @@ -259,45 +253,40 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/member_live/form.ex:52 -#: lib/mv_web/live/property_live/form.ex:44 -#: lib/mv_web/live/property_type_live/form.ex:32 -#: lib/mv_web/live/user_live/form.ex:95 +#: lib/mv_web/live/custom_field_live/form.ex:66 +#: lib/mv_web/live/custom_field_value_live/form.ex:77 +#: lib/mv_web/live/member_live/form.ex:82 +#: lib/mv_web/live/user_live/form.ex:127 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" -#: lib/mv_web/live/property_live/form.ex:29 +#: lib/mv_web/live/custom_field_value_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Choose a member" msgstr "" -#: lib/mv_web/live/property_live/form.ex:20 -#, elixir-autogen, elixir-format -msgid "Choose a property type" -msgstr "" - -#: lib/mv_web/live/property_type_live/form.ex:25 +#: lib/mv_web/live/custom_field_live/form.ex:59 #, elixir-autogen, elixir-format msgid "Description" msgstr "" -#: lib/mv_web/live/user_live/show.ex:18 +#: lib/mv_web/live/user_live/show.ex:43 #, elixir-autogen, elixir-format, fuzzy msgid "Edit User" msgstr "" -#: lib/mv_web/live/user_live/show.ex:28 +#: lib/mv_web/live/user_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Enabled" msgstr "" -#: lib/mv_web/live/user_live/show.ex:24 +#: lib/mv_web/live/user_live/show.ex:49 #, elixir-autogen, elixir-format msgid "ID" msgstr "" -#: lib/mv_web/live/property_type_live/form.ex:26 +#: lib/mv_web/live/custom_field_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -307,25 +296,25 @@ msgstr "" msgid "Logout" msgstr "" -#: lib/mv_web/live/user_live/index.ex:12 +#: lib/mv_web/live/user_live/index.ex:33 #: lib/mv_web/live/user_live/index.html.heex:3 #, elixir-autogen, elixir-format, fuzzy msgid "Listing Users" msgstr "" -#: lib/mv_web/live/property_live/form.ex:27 +#: lib/mv_web/live/custom_field_value_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Member" msgstr "" #: lib/mv_web/components/layouts/navbar.ex:19 -#: lib/mv_web/live/member_live/index.ex:10 +#: lib/mv_web/live/member_live/index.ex:39 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "" -#: lib/mv_web/live/property_type_live/form.ex:16 +#: lib/mv_web/live/custom_field_live/form.ex:50 #, elixir-autogen, elixir-format msgid "Name" msgstr "" @@ -335,73 +324,43 @@ msgstr "" msgid "New User" msgstr "" -#: lib/mv_web/live/user_live/show.ex:28 +#: lib/mv_web/live/user_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Not enabled" msgstr "" -#: lib/mv_web/live/user_live/show.ex:26 +#: lib/mv_web/live/user_live/show.ex:51 #, elixir-autogen, elixir-format, fuzzy msgid "Not set" msgstr "" -#: lib/mv_web/live/user_live/form.ex:75 -#: lib/mv_web/live/user_live/form.ex:83 +#: lib/mv_web/live/user_live/form.ex:107 +#: lib/mv_web/live/user_live/form.ex:115 #, elixir-autogen, elixir-format, fuzzy msgid "Note" msgstr "" #: lib/mv_web/live/user_live/index.html.heex:52 -#: lib/mv_web/live/user_live/show.ex:26 +#: lib/mv_web/live/user_live/show.ex:51 #, elixir-autogen, elixir-format msgid "OIDC ID" msgstr "" -#: lib/mv_web/live/user_live/show.ex:27 +#: lib/mv_web/live/user_live/show.ex:52 #, elixir-autogen, elixir-format msgid "Password Authentication" msgstr "" -#: lib/mv_web/live/property_live/form.ex:37 -#, elixir-autogen, elixir-format -msgid "Please select a property type first" -msgstr "" - #: lib/mv_web/components/layouts/navbar.ex:88 #, elixir-autogen, elixir-format msgid "Profil" msgstr "" -#: lib/mv_web/live/property_live/form.ex:207 -#, elixir-autogen, elixir-format, fuzzy -msgid "Property %{action} successfully" -msgstr "" - -#: lib/mv_web/live/property_live/form.ex:18 -#, elixir-autogen, elixir-format -msgid "Property type" -msgstr "" - -#: lib/mv_web/live/property_type_live/form.ex:80 -#, elixir-autogen, elixir-format -msgid "Property type %{action} successfully" -msgstr "" - -#: lib/mv_web/live/property_type_live/form.ex:27 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Required" msgstr "" -#: lib/mv_web/live/property_live/form.ex:42 -#, elixir-autogen, elixir-format -msgid "Save Property" -msgstr "" - -#: lib/mv_web/live/property_type_live/form.ex:30 -#, elixir-autogen, elixir-format -msgid "Save Property type" -msgstr "" - #: lib/mv_web/live/member_live/index.html.heex:34 #, elixir-autogen, elixir-format msgid "Select all members" @@ -417,53 +376,43 @@ msgstr "" msgid "Settings" msgstr "" -#: lib/mv_web/live/user_live/form.ex:93 +#: lib/mv_web/live/user_live/form.ex:125 #, elixir-autogen, elixir-format, fuzzy msgid "Save User" msgstr "" -#: lib/mv_web/live/user_live/show.ex:54 +#: lib/mv_web/live/user_live/show.ex:79 #, elixir-autogen, elixir-format, fuzzy msgid "Show User" msgstr "" -#: lib/mv_web/live/user_live/show.ex:10 +#: lib/mv_web/live/user_live/show.ex:35 #, elixir-autogen, elixir-format, fuzzy msgid "This is a user record from your database." msgstr "" -#: lib/mv_web/live/property_live/form.ex:95 +#: lib/mv_web/live/custom_field_value_live/form.ex:128 #, elixir-autogen, elixir-format msgid "Unsupported value type: %{type}" msgstr "" -#: lib/mv_web/live/property_live/form.ex:10 -#, elixir-autogen, elixir-format, fuzzy -msgid "Use this form to manage property records in your database." -msgstr "" - -#: lib/mv_web/live/property_type_live/form.ex:11 -#, elixir-autogen, elixir-format, fuzzy -msgid "Use this form to manage property_type records in your database." -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:10 +#: lib/mv_web/live/user_live/form.ex:42 #, elixir-autogen, elixir-format, fuzzy msgid "Use this form to manage user records in your database." msgstr "" -#: lib/mv_web/live/user_live/form.ex:110 -#: lib/mv_web/live/user_live/show.ex:9 +#: lib/mv_web/live/user_live/form.ex:142 +#: lib/mv_web/live/user_live/show.ex:34 #, elixir-autogen, elixir-format msgid "User" msgstr "" -#: lib/mv_web/live/property_live/form.ex:59 +#: lib/mv_web/live/custom_field_value_live/form.ex:92 #, elixir-autogen, elixir-format msgid "Value" msgstr "" -#: lib/mv_web/live/property_type_live/form.ex:20 +#: lib/mv_web/live/custom_field_live/form.ex:54 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" @@ -480,57 +429,57 @@ msgstr "" msgid "descending" msgstr "" -#: lib/mv_web/live/user_live/form.ex:109 +#: lib/mv_web/live/user_live/form.ex:141 #, elixir-autogen, elixir-format msgid "New" msgstr "" -#: lib/mv_web/live/user_live/form.ex:64 +#: lib/mv_web/live/user_live/form.ex:96 #, elixir-autogen, elixir-format msgid "Admin Note" msgstr "" -#: lib/mv_web/live/user_live/form.ex:64 +#: lib/mv_web/live/user_live/form.ex:96 #, 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 "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system." -#: lib/mv_web/live/user_live/form.ex:55 +#: lib/mv_web/live/user_live/form.ex:87 #, elixir-autogen, elixir-format msgid "At least 8 characters" msgstr "At least 8 characters" -#: lib/mv_web/live/user_live/form.ex:27 +#: lib/mv_web/live/user_live/form.ex:59 #, elixir-autogen, elixir-format msgid "Change Password" msgstr "" -#: lib/mv_web/live/user_live/form.ex:75 +#: lib/mv_web/live/user_live/form.ex:107 #, elixir-autogen, elixir-format msgid "Check 'Change Password' above to set a new password for this user." msgstr "Check 'Change Password' above to set a new password for this user." -#: lib/mv_web/live/user_live/form.ex:45 +#: lib/mv_web/live/user_live/form.ex:77 #, elixir-autogen, elixir-format msgid "Confirm Password" msgstr "Confirm Password" -#: lib/mv_web/live/user_live/form.ex:57 +#: lib/mv_web/live/user_live/form.ex:89 #, elixir-autogen, elixir-format msgid "Consider using special characters" msgstr "Consider using special characters" -#: lib/mv_web/live/user_live/form.ex:56 +#: lib/mv_web/live/user_live/form.ex:88 #, elixir-autogen, elixir-format msgid "Include both letters and numbers" msgstr "Include both letters and numbers" -#: lib/mv_web/live/user_live/form.ex:35 +#: lib/mv_web/live/user_live/form.ex:67 #, elixir-autogen, elixir-format msgid "Password" msgstr "Password" -#: lib/mv_web/live/user_live/form.ex:53 +#: lib/mv_web/live/user_live/form.ex:85 #, elixir-autogen, elixir-format msgid "Password requirements" msgstr "Password requirements" @@ -545,44 +494,44 @@ msgstr "" msgid "Select user" msgstr "" -#: lib/mv_web/live/user_live/form.ex:27 +#: lib/mv_web/live/user_live/form.ex:59 #, elixir-autogen, elixir-format msgid "Set Password" msgstr "Set Password" -#: lib/mv_web/live/user_live/form.ex:83 +#: lib/mv_web/live/user_live/form.ex:115 #, elixir-autogen, elixir-format 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/live/user_live/show.ex:30 +#: lib/mv_web/live/user_live/show.ex:55 #, elixir-autogen, elixir-format, fuzzy msgid "Linked Member" msgstr "" -#: lib/mv_web/live/member_live/show.ex:41 +#: lib/mv_web/live/member_live/show.ex:63 #, elixir-autogen, elixir-format msgid "Linked User" msgstr "" -#: lib/mv_web/live/user_live/show.ex:40 +#: lib/mv_web/live/user_live/show.ex:65 #, elixir-autogen, elixir-format msgid "No member linked" msgstr "" -#: lib/mv_web/live/member_live/show.ex:51 +#: lib/mv_web/live/member_live/show.ex:73 #, elixir-autogen, elixir-format msgid "No user linked" msgstr "" -#: lib/mv_web/live/member_live/show.ex:14 -#: lib/mv_web/live/member_live/show.ex:16 +#: lib/mv_web/live/member_live/show.ex:36 +#: lib/mv_web/live/member_live/show.ex:38 #, elixir-autogen, elixir-format msgid "Back to members list" msgstr "" -#: lib/mv_web/live/user_live/show.ex:13 -#: lib/mv_web/live/user_live/show.ex:15 +#: lib/mv_web/live/user_live/show.ex:38 +#: lib/mv_web/live/user_live/show.ex:40 #, elixir-autogen, elixir-format msgid "Back to users list" msgstr "" @@ -651,3 +600,54 @@ msgstr "" #, elixir-autogen, elixir-format msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." msgstr "" + +#: lib/mv_web/live/custom_field_value_live/form.ex:53 +#, elixir-autogen, elixir-format +msgid "Choose a custom field" +msgstr "" + +#: lib/mv_web/live/member_live/form.ex:59 +#: lib/mv_web/live/member_live/show.ex:78 +#, elixir-autogen, elixir-format +msgid "Custom Field Values" +msgstr "" + +#: lib/mv_web/live/custom_field_value_live/form.ex:51 +#, elixir-autogen, elixir-format +msgid "Custom field" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form.ex:114 +#, elixir-autogen, elixir-format +msgid "Custom field %{action} successfully" +msgstr "" + +#: lib/mv_web/live/custom_field_value_live/form.ex:242 +#, elixir-autogen, elixir-format +msgid "Custom field value %{action} successfully" +msgstr "" + +#: lib/mv_web/live/custom_field_value_live/form.ex:70 +#, elixir-autogen, elixir-format +msgid "Please select a custom field first" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form.ex:64 +#, elixir-autogen, elixir-format +msgid "Save Custom field" +msgstr "" + +#: lib/mv_web/live/custom_field_value_live/form.ex:75 +#, elixir-autogen, elixir-format +msgid "Save Custom field value" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form.ex:45 +#, elixir-autogen, elixir-format, fuzzy +msgid "Use this form to manage custom_field records in your database." +msgstr "" + +#: lib/mv_web/live/custom_field_value_live/form.ex:42 +#, elixir-autogen, elixir-format, fuzzy +msgid "Use this form to manage custom_field_value records in your database." +msgstr "" diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 4342c32..8d3cb6f 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -7,33 +7,91 @@ alias Mv.Membership alias Mv.Accounts for attrs <- [ + # Basic example fields (for testing) %{ name: "String Field", value_type: :string, description: "Example for a field of type string", immutable: true, - required: true + required: false }, %{ name: "Date Field", value_type: :date, description: "Example for a field of type date", immutable: true, - required: true + required: false }, %{ name: "Boolean Field", value_type: :boolean, description: "Example for a field of type boolean", immutable: true, - required: true + required: false }, %{ name: "Email Field", value_type: :email, description: "Example for a field of type email", immutable: true, - required: true + required: false + }, + # Realistic custom fields + %{ + name: "Membership Number", + value_type: :string, + description: "Unique membership identification number", + immutable: false, + required: false + }, + %{ + name: "Emergency Contact", + value_type: :string, + description: "Emergency contact person name and phone", + immutable: false, + required: false + }, + %{ + name: "T-Shirt Size", + value_type: :string, + description: "T-Shirt size for events (XS, S, M, L, XL, XXL)", + immutable: false, + required: false + }, + %{ + name: "Newsletter Subscription", + value_type: :boolean, + description: "Whether member wants to receive newsletter", + immutable: false, + required: false + }, + %{ + name: "Date of Last Medical Check", + value_type: :date, + description: "Date of last medical examination", + immutable: false, + required: false + }, + %{ + name: "Secondary Email", + value_type: :email, + description: "Alternative email address", + immutable: false, + required: false + }, + %{ + name: "Membership Type", + value_type: :string, + description: "Type of membership (e.g., Regular, Student, Senior)", + immutable: false, + required: false + }, + %{ + name: "Parking Permit", + value_type: :boolean, + description: "Whether member has parking permit", + immutable: false, + required: false } ] do Membership.create_custom_field!( @@ -180,9 +238,94 @@ Enum.each(linked_members, fn member_attrs -> end end) +# Create sample custom field values for some members +all_members = Ash.read!(Membership.Member) +all_custom_fields = Ash.read!(Membership.CustomField) + +# Helper function to find custom field by name +find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end +find_member = fn email -> Enum.find(all_members, &(&1.email == email)) end + +# Add custom field values for Hans Müller +if hans = find_member.("hans.mueller@example.de") do + [ + {find_field.("Membership Number"), + %{"_union_type" => "string", "_union_value" => "M-2023-001"}}, + {find_field.("T-Shirt Size"), %{"_union_type" => "string", "_union_value" => "L"}}, + {find_field.("Newsletter Subscription"), + %{"_union_type" => "boolean", "_union_value" => true}}, + {find_field.("Membership Type"), %{"_union_type" => "string", "_union_value" => "Regular"}}, + {find_field.("Parking Permit"), %{"_union_type" => "boolean", "_union_value" => true}}, + {find_field.("Secondary Email"), + %{"_union_type" => "email", "_union_value" => "hans.m@private.de"}} + ] + |> Enum.each(fn {field, value} -> + if field do + Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: hans.id, + custom_field_id: field.id, + value: value + }) + |> Ash.create!(upsert?: true, upsert_identity: :unique_custom_field_per_member) + end + end) +end + +# Add custom field values for Greta Schmidt +if greta = find_member.("greta.schmidt@example.de") do + [ + {find_field.("Membership Number"), + %{"_union_type" => "string", "_union_value" => "M-2023-015"}}, + {find_field.("T-Shirt Size"), %{"_union_type" => "string", "_union_value" => "M"}}, + {find_field.("Newsletter Subscription"), + %{"_union_type" => "boolean", "_union_value" => true}}, + {find_field.("Membership Type"), %{"_union_type" => "string", "_union_value" => "Student"}}, + {find_field.("Emergency Contact"), + %{"_union_type" => "string", "_union_value" => "Anna Schmidt, +49301234567"}} + ] + |> Enum.each(fn {field, value} -> + if field do + Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: greta.id, + custom_field_id: field.id, + value: value + }) + |> Ash.create!(upsert?: true, upsert_identity: :unique_custom_field_per_member) + end + end) +end + +# Add custom field values for Friedrich Wagner +if friedrich = find_member.("friedrich.wagner@example.de") do + [ + {find_field.("Membership Number"), + %{"_union_type" => "string", "_union_value" => "M-2022-042"}}, + {find_field.("T-Shirt Size"), %{"_union_type" => "string", "_union_value" => "XL"}}, + {find_field.("Newsletter Subscription"), + %{"_union_type" => "boolean", "_union_value" => false}}, + {find_field.("Membership Type"), %{"_union_type" => "string", "_union_value" => "Senior"}}, + {find_field.("Parking Permit"), %{"_union_type" => "boolean", "_union_value" => false}}, + {find_field.("Date of Last Medical Check"), + %{"_union_type" => "date", "_union_value" => ~D[2024-03-15]}} + ] + |> Enum.each(fn {field, value} -> + if field do + Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: friedrich.id, + custom_field_id: field.id, + value: value + }) + |> Ash.create!(upsert?: true, upsert_identity: :unique_custom_field_per_member) + end + end) +end + IO.puts("✅ Seeds completed successfully!") IO.puts("📝 Created sample data:") -IO.puts(" - Custom fields: String, Date, Boolean, Email") +IO.puts(" - Custom fields: 12 fields (String, Date, Boolean, Email, + 8 realistic fields)") IO.puts(" - Admin user: admin@mv.local (password: testpassword)") IO.puts(" - Sample members: Hans, Greta, Friedrich") @@ -194,4 +337,8 @@ 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!") +IO.puts( + " - Custom field values: Sample data for Hans (6 fields), Greta (5 fields), Friedrich (6 fields)" +) + +IO.puts("🔗 Visit the application to see user-member relationships and custom fields in action!") From 158ac52d97bb432df249f6d740681d652156957b Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 13 Nov 2025 18:50:24 +0100 Subject: [PATCH 15/15] feat: Add Custom Fields link to navbar --- lib/mv_web/components/layouts/navbar.ex | 1 + priv/gettext/de/LC_MESSAGES/default.po | 21 +++++++++++++-------- priv/gettext/default.pot | 21 +++++++++++++-------- priv/gettext/en/LC_MESSAGES/default.po | 21 +++++++++++++-------- 4 files changed, 40 insertions(+), 24 deletions(-) diff --git a/lib/mv_web/components/layouts/navbar.ex b/lib/mv_web/components/layouts/navbar.ex index 9fec3f4..1de4c7f 100644 --- a/lib/mv_web/components/layouts/navbar.ex +++ b/lib/mv_web/components/layouts/navbar.ex @@ -17,6 +17,7 @@ defmodule MvWeb.Layouts.Navbar do Mitgliederverwaltung diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index c7f0048..f6acdca 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -290,7 +290,7 @@ msgstr "ID" msgid "Immutable" msgstr "Unveränderlich" -#: lib/mv_web/components/layouts/navbar.ex:93 +#: lib/mv_web/components/layouts/navbar.ex:94 #, elixir-autogen, elixir-format msgid "Logout" msgstr "Abmelden" @@ -350,7 +350,7 @@ msgstr "OIDC ID" msgid "Password Authentication" msgstr "Passwort-Authentifizierung" -#: lib/mv_web/components/layouts/navbar.ex:88 +#: lib/mv_web/components/layouts/navbar.ex:89 #, elixir-autogen, elixir-format msgid "Profil" msgstr "Profil" @@ -370,7 +370,7 @@ msgstr "Alle Mitglieder auswählen" msgid "Select member" msgstr "Mitglied auswählen" -#: lib/mv_web/components/layouts/navbar.ex:91 +#: lib/mv_web/components/layouts/navbar.ex:92 #, elixir-autogen, elixir-format msgid "Settings" msgstr "Einstellungen" @@ -535,14 +535,14 @@ msgstr "Zurück zur Mitgliederliste" msgid "Back to users list" msgstr "Zurück zur Benutzer*innen-Liste" -#: lib/mv_web/components/layouts/navbar.ex:26 -#: lib/mv_web/components/layouts/navbar.ex:32 +#: 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:39 -#: lib/mv_web/components/layouts/navbar.ex:59 +#: 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" @@ -553,7 +553,7 @@ msgstr "Dunklen Modus umschalten" msgid "Search..." msgstr "Suchen..." -#: lib/mv_web/components/layouts/navbar.ex:20 +#: lib/mv_web/components/layouts/navbar.ex:21 #, elixir-autogen, elixir-format msgid "Users" msgstr "Benutzer*innen" @@ -650,3 +650,8 @@ msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenba #, elixir-autogen, elixir-format msgid "Use this form to manage custom_field_value records in your database." msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Datenbank zu verwalten." + +#: lib/mv_web/components/layouts/navbar.ex:20 +#, elixir-autogen, elixir-format +msgid "Custom Fields" +msgstr "Benutzerdefinierte Felder" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 684515b..d150a60 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -291,7 +291,7 @@ msgstr "" msgid "Immutable" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:93 +#: lib/mv_web/components/layouts/navbar.ex:94 #, elixir-autogen, elixir-format msgid "Logout" msgstr "" @@ -351,7 +351,7 @@ msgstr "" msgid "Password Authentication" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:88 +#: lib/mv_web/components/layouts/navbar.ex:89 #, elixir-autogen, elixir-format msgid "Profil" msgstr "" @@ -371,7 +371,7 @@ msgstr "" msgid "Select member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:91 +#: lib/mv_web/components/layouts/navbar.ex:92 #, elixir-autogen, elixir-format msgid "Settings" msgstr "" @@ -536,14 +536,14 @@ msgstr "" msgid "Back to users list" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:26 -#: lib/mv_web/components/layouts/navbar.ex:32 +#: 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:39 -#: lib/mv_web/components/layouts/navbar.ex:59 +#: 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 "" @@ -554,7 +554,7 @@ msgstr "" msgid "Search..." msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:20 +#: lib/mv_web/components/layouts/navbar.ex:21 #, elixir-autogen, elixir-format msgid "Users" msgstr "" @@ -651,3 +651,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Use this form to manage custom_field_value records in your database." msgstr "" + +#: lib/mv_web/components/layouts/navbar.ex:20 +#, elixir-autogen, elixir-format +msgid "Custom Fields" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 01b3e95..df56e75 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -291,7 +291,7 @@ msgstr "" msgid "Immutable" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:93 +#: lib/mv_web/components/layouts/navbar.ex:94 #, elixir-autogen, elixir-format msgid "Logout" msgstr "" @@ -351,7 +351,7 @@ msgstr "" msgid "Password Authentication" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:88 +#: lib/mv_web/components/layouts/navbar.ex:89 #, elixir-autogen, elixir-format msgid "Profil" msgstr "" @@ -371,7 +371,7 @@ msgstr "" msgid "Select member" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:91 +#: lib/mv_web/components/layouts/navbar.ex:92 #, elixir-autogen, elixir-format msgid "Settings" msgstr "" @@ -536,14 +536,14 @@ msgstr "" msgid "Back to users list" msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:26 -#: lib/mv_web/components/layouts/navbar.ex:32 +#: 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:39 -#: lib/mv_web/components/layouts/navbar.ex:59 +#: 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 "" @@ -554,7 +554,7 @@ msgstr "" msgid "Search..." msgstr "" -#: lib/mv_web/components/layouts/navbar.ex:20 +#: lib/mv_web/components/layouts/navbar.ex:21 #, elixir-autogen, elixir-format, fuzzy msgid "Users" msgstr "" @@ -651,3 +651,8 @@ msgstr "" #, elixir-autogen, elixir-format, fuzzy msgid "Use this form to manage custom_field_value records in your database." msgstr "" + +#: lib/mv_web/components/layouts/navbar.ex:20 +#, elixir-autogen, elixir-format, fuzzy +msgid "Custom Fields" +msgstr ""