From e920d6b39c02dba160a650655b75ce28c2bef6c6 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 30 Oct 2025 16:38:57 +0100 Subject: [PATCH 01/35] chore: added trigram migration for fuzzy search --- .../20251001141005_add_trigram_to_members.exs | 66 ++++++ .../repo/members/20251001141005.json | 199 ++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 priv/repo/migrations/20251001141005_add_trigram_to_members.exs create mode 100644 priv/resource_snapshots/repo/members/20251001141005.json diff --git a/priv/repo/migrations/20251001141005_add_trigram_to_members.exs b/priv/repo/migrations/20251001141005_add_trigram_to_members.exs new file mode 100644 index 0000000..f502003 --- /dev/null +++ b/priv/repo/migrations/20251001141005_add_trigram_to_members.exs @@ -0,0 +1,66 @@ +defmodule Mv.Repo.Migrations.AddTrigramToMembers 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 + # activate trigram-extension + execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;") + + # ------------------------------------------------- + # Trigram‑Indizes (GIN) for fields we want to search in + # ------------------------------------------------- + # + # `gin_trgm_ops` ist the operator-class-name + # + + execute(""" + CREATE INDEX members_first_name_trgm_idx + ON members + USING GIN (first_name gin_trgm_ops); + """) + + execute(""" + CREATE INDEX members_last_name_trgm_idx + ON members + USING GIN (last_name gin_trgm_ops); + """) + + execute(""" + CREATE INDEX members_email_trgm_idx + ON members + USING GIN (email gin_trgm_ops); + """) + + execute(""" + CREATE INDEX members_city_trgm_idx + ON members + USING GIN (city gin_trgm_ops); + """) + + execute(""" + CREATE INDEX members_street_trgm_idx + ON members + USING GIN (street gin_trgm_ops); + """) + + execute(""" + CREATE INDEX members_notes_trgm_idx + ON members + USING GIN (notes gin_trgm_ops); + """) + end + + def down do + execute("DROP INDEX IF EXISTS members_first_name_trgm_idx;") + execute("DROP INDEX IF EXISTS members_last_name_trgm_idx;") + execute("DROP INDEX IF EXISTS members_email_trgm_idx;") + execute("DROP INDEX IF EXISTS members_city_trgm_idx;") + execute("DROP INDEX IF EXISTS members_street_trgm_idx;") + execute("DROP INDEX IF EXISTS members_notes_trgm_idx;") + end +end diff --git a/priv/resource_snapshots/repo/members/20251001141005.json b/priv/resource_snapshots/repo/members/20251001141005.json new file mode 100644 index 0000000..a541fc0 --- /dev/null +++ b/priv/resource_snapshots/repo/members/20251001141005.json @@ -0,0 +1,199 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "first_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "birth_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "paid", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "phone_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "join_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "exit_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "notes", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "city", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "street", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "house_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "postal_code", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "search_vector", + "type": "tsvector" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "9019AD59832AB926899B6A871A368CF65F757533795E4E38D5C0EE6AE58BE070", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "members" +} \ No newline at end of file From c7c6d329fbd6d62cb3503cdb7f05763fdd8534a3 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 30 Oct 2025 16:39:21 +0100 Subject: [PATCH 02/35] chore: enable trigram extension --- lib/mv/repo.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mv/repo.ex b/lib/mv/repo.ex index a8d696a..0a4a04d 100644 --- a/lib/mv/repo.ex +++ b/lib/mv/repo.ex @@ -5,7 +5,7 @@ defmodule Mv.Repo do @impl true def installed_extensions do # Add extensions here, and the migration generator will install them. - ["ash-functions", "citext"] + ["ash-functions", "citext", "pg_trgm"] end # Don't open unnecessary transactions From f6bfeadb7bdf6ac42e81235d7c57a22e498db6ad Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 30 Oct 2025 16:39:44 +0100 Subject: [PATCH 03/35] feat(member). added search action to ressource --- lib/membership/member.ex | 63 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 56549fc..c62c47d 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -3,6 +3,11 @@ defmodule Mv.Membership.Member do domain: Mv.Membership, data_layer: AshPostgres.DataLayer + require Ash.Query + import Ash.Expr + + @default_fields [:first_name, :last_name, :email, :phone_number, :city, :street, :house_number, :postal_code] + postgres do table "members" repo Mv.Repo @@ -108,6 +113,47 @@ defmodule Mv.Membership.Member do where [changing(:user)] end end + + read :search do + argument :query, :string, allow_nil?: true + argument :fields, {:array, :atom}, allow_nil?: true + argument :similarity_threshold, :float, allow_nil?: true + + prepare fn query, _ctx -> + q = Ash.Query.get_argument(query, :query) || "" + fields = Ash.Query.get_argument(query, :fields) || @default_fields + threshold = Ash.Query.get_argument(query, :similarity_threshold) || 0.2 + + if is_binary(q) and String.trim(q) != "" do + q2 = String.trim(q) + pat = "%" <> q2 <> "%" + + # FTS as main filter and fuzzy search just fo first name, last name and strees + query + |> Ash.Query.filter( + expr( + fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^q2) or + fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q2) or + # Substring on numeric-like fields (best effort, supports middle substrings) + contains(postal_code, ^q2) or + contains(house_number, ^q2) or + contains(phone_number, ^q2) or + fragment("? % first_name", ^q2) or + fragment("? % last_name", ^q2) or + fragment("? % street", ^q2) or + fragment("word_similarity(?, first_name) > ?", ^q2, ^threshold) or + fragment("word_similarity(?, last_name) > ?", ^q2, ^threshold) or + fragment("word_similarity(?, street) > ?", ^q2, ^threshold) or + fragment("similarity(first_name, ?) > ?", ^q2, ^threshold) or + fragment("similarity(last_name, ?) > ?", ^q2, ^threshold) or + fragment("similarity(street, ?) > ?", ^q2, ^threshold) + ) + ) + else + query + end + end + end end validations do @@ -281,4 +327,21 @@ defmodule Mv.Membership.Member do identities do identity :unique_email, [:email] end + + # Fuzzy Search function that can be called by live view and calls search action + def fuzzy_search(query, opts) do + q = (opts[:query] || opts["query"] || "") |> to_string() + + if String.trim(q) == "" do + query + else + args = + case (opts[:fields] || opts["fields"]) do + nil -> %{query: q} + fields -> %{query: q, fields: fields} + end + + Ash.Query.for_read(query, :search, args) + end + end end From 5406318e8d3a0444e4f70e1826abcdd4c45b6a82 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 30 Oct 2025 16:40:33 +0100 Subject: [PATCH 04/35] feat(liveview): use fuzzy search in live view --- lib/mv_web/live/member_live/index.ex | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index e8c6d56..fccd67f 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -1,7 +1,5 @@ defmodule MvWeb.MemberLive.Index do use MvWeb, :live_view - import Ash.Expr - import Ash.Query @impl true def mount(_params, _session, socket) do From 5e51f99797aa1b27a9a8fdbef01eed65564ca252 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 30 Oct 2025 16:40:46 +0100 Subject: [PATCH 05/35] test: adds tests for search --- test/membership/fuzzy_search_test.exs | 164 ++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 test/membership/fuzzy_search_test.exs diff --git a/test/membership/fuzzy_search_test.exs b/test/membership/fuzzy_search_test.exs new file mode 100644 index 0000000..9e746d3 --- /dev/null +++ b/test/membership/fuzzy_search_test.exs @@ -0,0 +1,164 @@ +defmodule Mv.Membership.FuzzySearchTest do + use Mv.DataCase, async: false + + test "fuzzy_search/2 function exists" do + assert function_exported?(Mv.Membership.Member, :fuzzy_search, 2) + end + + test "fuzzy_search returns only John Doe by fuzzy query 'john'" do + {:ok, john} = + Mv.Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "john.doe@example.com" + }) + + {:ok, _jane} = + Mv.Membership.create_member(%{ + first_name: "Adriana", + last_name: "Smith", + email: "adriana.smith@example.com" + }) + + {:ok, alice} = + Mv.Membership.create_member(%{ + first_name: "Alice", + last_name: "Johnson", + email: "alice.johnson@example.com" + }) + + result = + Mv.Membership.Member + |> Mv.Membership.Member.fuzzy_search(%{query: "john", fields: [:first_name, :last_name, :email]}) + |> Ash.read!() + + assert Enum.map(result, & &1.id) == [john.id, alice.id] + end + + test "fuzzy_search finds 'Thomas' when searching misspelled 'tomas'" do + {:ok, thomas} = + Mv.Membership.create_member(%{ + first_name: "Thomas", + last_name: "Doe", + email: "john.doe@example.com" + }) + + {:ok, jane} = + Mv.Membership.create_member(%{ + first_name: "Jane", + last_name: "Smith", + email: "jane.smith@example.com" + }) + + {:ok, _alice} = + Mv.Membership.create_member(%{ + first_name: "Alice", + last_name: "Johnson", + email: "alice.johnson@example.com" + }) + + result = + Mv.Membership.Member + |> Mv.Membership.Member.fuzzy_search(%{query: "tomas", fields: [:first_name, :last_name, :email]}) + |> Ash.read!() + + ids = Enum.map(result, & &1.id) + assert thomas.id in ids + refute jane.id in ids + assert length(ids) >= 1 + end + + test "empty query returns all members" do + {:ok, a} = + Mv.Membership.create_member(%{first_name: "A", last_name: "One", email: "a1@example.com"}) + + {:ok, b} = + Mv.Membership.create_member(%{first_name: "B", last_name: "Two", email: "b2@example.com"}) + + result = + Mv.Membership.Member + |> Mv.Membership.Member.fuzzy_search(%{query: ""}) + |> Ash.read!() + + assert Enum.sort(Enum.map(result, & &1.id)) |> Enum.uniq() |> Enum.sort() + |> Enum.all?(fn id -> id in [a.id, b.id] end) + end + + test "substring numeric search matches postal_code mid-string" do + {:ok, m1} = + Mv.Membership.create_member(%{ + first_name: "Num", + last_name: "One", + email: "n1@example.com", + postal_code: "12345" + }) + + {:ok, _m2} = + Mv.Membership.create_member(%{ + first_name: "Num", + last_name: "Two", + email: "n2@example.com", + postal_code: "67890" + }) + + result = + Mv.Membership.Member + |> Mv.Membership.Member.fuzzy_search(%{query: "345"}) + |> Ash.read!() + + ids = Enum.map(result, & &1.id) + assert m1.id in ids + end + + test "substring numeric search matches house_number mid-string" do + {:ok, m1} = + Mv.Membership.create_member(%{ + first_name: "Home", + last_name: "One", + email: "h1@example.com", + house_number: "A345B" + }) + + {:ok, _m2} = + Mv.Membership.create_member(%{ + first_name: "Home", + last_name: "Two", + email: "h2@example.com", + house_number: "77" + }) + + result = + Mv.Membership.Member + |> Mv.Membership.Member.fuzzy_search(%{query: "345"}) + |> Ash.read!() + + ids = Enum.map(result, & &1.id) + assert m1.id in ids + end + + test "fuzzy matches street misspelling" do + {:ok, s1} = + Mv.Membership.create_member(%{ + first_name: "Road", + last_name: "Test", + email: "s1@example.com", + street: "Main Street" + }) + + {:ok, _s2} = + Mv.Membership.create_member(%{ + first_name: "Road", + last_name: "Other", + email: "s2@example.com", + street: "Second Avenue" + }) + + result = + Mv.Membership.Member + |> Mv.Membership.Member.fuzzy_search(%{query: "mainn"}) + |> Ash.read!() + + ids = Enum.map(result, & &1.id) + assert s1.id in ids + end +end From 3481b9dadf2bd8b20b7c26e2606f108f8283eb6e Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 30 Oct 2025 17:16:23 +0100 Subject: [PATCH 06/35] fix: updated fuzzy search after merge with sorting --- lib/membership/member.ex | 1 + lib/mv_web/live/member_live/index.ex | 5 ++++- test/membership/fuzzy_search_test.exs | 26 ++++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index c62c47d..9f4f2f5 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -138,6 +138,7 @@ defmodule Mv.Membership.Member do contains(postal_code, ^q2) or contains(house_number, ^q2) or contains(phone_number, ^q2) or + contains(city, ^q2) or ilike(city, ^pat) or fragment("? % first_name", ^q2) or fragment("? % last_name", ^q2) or fragment("? % street", ^q2) or diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index fccd67f..0e0c558 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -192,7 +192,10 @@ defmodule MvWeb.MemberLive.Index do defp apply_search_filter(query, search_query) do if search_query && String.trim(search_query) != "" do query - |> filter(expr(fragment("search_vector @@ plainto_tsquery('simple', ?)", ^search_query))) + |> Mv.Membership.Member.fuzzy_search(%{ + query: search_query, + fields: [:first_name, :last_name, :street] + }) else query end diff --git a/test/membership/fuzzy_search_test.exs b/test/membership/fuzzy_search_test.exs index 9e746d3..4506492 100644 --- a/test/membership/fuzzy_search_test.exs +++ b/test/membership/fuzzy_search_test.exs @@ -161,4 +161,30 @@ defmodule Mv.Membership.FuzzySearchTest do ids = Enum.map(result, & &1.id) assert s1.id in ids end + + test "substring in city matches mid-string" do + {:ok, b} = + Mv.Membership.create_member(%{ + first_name: "City", + last_name: "One", + email: "city1@example.com", + city: "Berlin" + }) + + {:ok, _m} = + Mv.Membership.create_member(%{ + first_name: "City", + last_name: "Two", + email: "city2@example.com", + city: "München" + }) + + result = + Mv.Membership.Member + |> Mv.Membership.Member.fuzzy_search(%{query: "erl"}) + |> Ash.read!() + + ids = Enum.map(result, & &1.id) + assert b.id in ids + end end From 0c75776915a65e3539b949b947eda1fc0f2d2afa Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 30 Oct 2025 17:20:07 +0100 Subject: [PATCH 07/35] formatting --- lib/membership/member.ex | 15 ++++++++++++--- test/membership/fuzzy_search_test.exs | 14 +++++++++++--- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 9f4f2f5..34f7357 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -6,7 +6,16 @@ defmodule Mv.Membership.Member do require Ash.Query import Ash.Expr - @default_fields [:first_name, :last_name, :email, :phone_number, :city, :street, :house_number, :postal_code] + @default_fields [ + :first_name, + :last_name, + :email, + :phone_number, + :city, + :street, + :house_number, + :postal_code + ] postgres do table "members" @@ -132,9 +141,9 @@ defmodule Mv.Membership.Member do query |> Ash.Query.filter( expr( + # Substring on numeric-like fields (best effort, supports middle substrings) fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^q2) or fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q2) or - # Substring on numeric-like fields (best effort, supports middle substrings) contains(postal_code, ^q2) or contains(house_number, ^q2) or contains(phone_number, ^q2) or @@ -337,7 +346,7 @@ defmodule Mv.Membership.Member do query else args = - case (opts[:fields] || opts["fields"]) do + case opts[:fields] || opts["fields"] do nil -> %{query: q} fields -> %{query: q, fields: fields} end diff --git a/test/membership/fuzzy_search_test.exs b/test/membership/fuzzy_search_test.exs index 4506492..5e47631 100644 --- a/test/membership/fuzzy_search_test.exs +++ b/test/membership/fuzzy_search_test.exs @@ -29,7 +29,10 @@ defmodule Mv.Membership.FuzzySearchTest do result = Mv.Membership.Member - |> Mv.Membership.Member.fuzzy_search(%{query: "john", fields: [:first_name, :last_name, :email]}) + |> Mv.Membership.Member.fuzzy_search(%{ + query: "john", + fields: [:first_name, :last_name, :email] + }) |> Ash.read!() assert Enum.map(result, & &1.id) == [john.id, alice.id] @@ -59,7 +62,10 @@ defmodule Mv.Membership.FuzzySearchTest do result = Mv.Membership.Member - |> Mv.Membership.Member.fuzzy_search(%{query: "tomas", fields: [:first_name, :last_name, :email]}) + |> Mv.Membership.Member.fuzzy_search(%{ + query: "tomas", + fields: [:first_name, :last_name, :email] + }) |> Ash.read!() ids = Enum.map(result, & &1.id) @@ -80,7 +86,9 @@ defmodule Mv.Membership.FuzzySearchTest do |> Mv.Membership.Member.fuzzy_search(%{query: ""}) |> Ash.read!() - assert Enum.sort(Enum.map(result, & &1.id)) |> Enum.uniq() |> Enum.sort() + assert Enum.sort(Enum.map(result, & &1.id)) + |> Enum.uniq() + |> Enum.sort() |> Enum.all?(fn id -> id in [a.id, b.id] end) end From 7f5839a120f8bbb47ed8fe10b7177e06a25c4e96 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 5 Nov 2025 18:15:06 +0100 Subject: [PATCH 08/35] 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 c028601ad8daeca6bd330fbede30ab684bdc3e71 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 5 Nov 2025 18:54:27 +0100 Subject: [PATCH 09/35] 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 fa40a421566ba72e031f72174884199032ebc247 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 6 Nov 2025 11:25:14 +0100 Subject: [PATCH 10/35] 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 f1ffe532151df955fecd2b8e9d81dcc1b64f4acf Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 5 Nov 2025 19:04:34 +0100 Subject: [PATCH 11/35] 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 8e5524de5700c172cd7cfbabea3b672931094104 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 6 Nov 2025 11:33:09 +0100 Subject: [PATCH 12/35] 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 bd79a9b9e1a410bca6f45a446d1c3b348ead6106 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 6 Nov 2025 14:02:29 +0100 Subject: [PATCH 13/35] 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 fca8148b99dd0c2218db2e81d228e94006ddc2a1 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 6 Nov 2025 18:36:42 +0100 Subject: [PATCH 14/35] 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 07cb1ab57c68359618228c6614e9770a6f1f1c32 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 6 Nov 2025 18:56:22 +0100 Subject: [PATCH 15/35] 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 a69ccf0ff909cb31e5dc2ef30734275bb812520b Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 12 Nov 2025 11:55:35 +0100 Subject: [PATCH 16/35] fix: added email serach and ommitted fields --- lib/membership/member.ex | 19 +++++-------------- lib/mv_web/live/member_live/index.ex | 3 +-- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 34f7357..d0b8124 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -6,17 +6,6 @@ defmodule Mv.Membership.Member do require Ash.Query import Ash.Expr - @default_fields [ - :first_name, - :last_name, - :email, - :phone_number, - :city, - :street, - :house_number, - :postal_code - ] - postgres do table "members" repo Mv.Repo @@ -123,21 +112,22 @@ defmodule Mv.Membership.Member do end end + # Action to handle fuzzy search on specific fields read :search do argument :query, :string, allow_nil?: true - argument :fields, {:array, :atom}, allow_nil?: true argument :similarity_threshold, :float, allow_nil?: true prepare fn query, _ctx -> q = Ash.Query.get_argument(query, :query) || "" - fields = Ash.Query.get_argument(query, :fields) || @default_fields + + # 0.2 as similarity threshold (recommended) - lower value can lead to more results but also to more unspecific results threshold = Ash.Query.get_argument(query, :similarity_threshold) || 0.2 if is_binary(q) and String.trim(q) != "" do q2 = String.trim(q) pat = "%" <> q2 <> "%" - # FTS as main filter and fuzzy search just fo first name, last name and strees + # FTS as main filter and fuzzy search just for first name, last name and strees query |> Ash.Query.filter( expr( @@ -147,6 +137,7 @@ defmodule Mv.Membership.Member do contains(postal_code, ^q2) or contains(house_number, ^q2) or contains(phone_number, ^q2) or + contains(email, ^q2) or contains(city, ^q2) or ilike(city, ^pat) or fragment("? % first_name", ^q2) or fragment("? % last_name", ^q2) or diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 0e0c558..41a7516 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -193,8 +193,7 @@ defmodule MvWeb.MemberLive.Index do if search_query && String.trim(search_query) != "" do query |> Mv.Membership.Member.fuzzy_search(%{ - query: search_query, - fields: [:first_name, :last_name, :street] + query: search_query }) else query From 44f88f1ddddee6196eb554a09ec6dc2e2c728c30 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 12 Nov 2025 11:55:48 +0100 Subject: [PATCH 17/35] test: aded more tests for fuzzy search --- test/membership/fuzzy_search_test.exs | 253 +++++++++++++++++++++++++- 1 file changed, 249 insertions(+), 4 deletions(-) diff --git a/test/membership/fuzzy_search_test.exs b/test/membership/fuzzy_search_test.exs index 5e47631..6ec582b 100644 --- a/test/membership/fuzzy_search_test.exs +++ b/test/membership/fuzzy_search_test.exs @@ -30,8 +30,7 @@ defmodule Mv.Membership.FuzzySearchTest do result = Mv.Membership.Member |> Mv.Membership.Member.fuzzy_search(%{ - query: "john", - fields: [:first_name, :last_name, :email] + query: "john" }) |> Ash.read!() @@ -63,8 +62,7 @@ defmodule Mv.Membership.FuzzySearchTest do result = Mv.Membership.Member |> Mv.Membership.Member.fuzzy_search(%{ - query: "tomas", - fields: [:first_name, :last_name, :email] + query: "tomas" }) |> Ash.read!() @@ -195,4 +193,251 @@ defmodule Mv.Membership.FuzzySearchTest do ids = Enum.map(result, & &1.id) assert b.id in ids end + + test "blank character handling: query with spaces matches full name" do + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "john.doe@example.com" + }) + + {:ok, _other} = + Mv.Membership.create_member(%{ + first_name: "Jane", + last_name: "Smith", + email: "jane.smith@example.com" + }) + + result = + Mv.Membership.Member + |> Mv.Membership.Member.fuzzy_search(%{query: "john doe"}) + |> Ash.read!() + + ids = Enum.map(result, & &1.id) + assert member.id in ids + end + + test "blank character handling: query with multiple spaces is handled" do + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "Mary", + last_name: "Jane", + email: "mary.jane@example.com" + }) + + result = + Mv.Membership.Member + |> Mv.Membership.Member.fuzzy_search(%{query: "mary jane"}) + |> Ash.read!() + + ids = Enum.map(result, & &1.id) + assert member.id in ids + end + + test "special character handling: @ symbol in query matches email" do + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "Test", + last_name: "User", + email: "test.user@example.com" + }) + + {:ok, _other} = + Mv.Membership.create_member(%{ + first_name: "Other", + last_name: "Person", + email: "other.person@different.org" + }) + + result = + Mv.Membership.Member + |> Mv.Membership.Member.fuzzy_search(%{query: "example"}) + |> Ash.read!() + + ids = Enum.map(result, & &1.id) + assert member.id in ids + end + + test "special character handling: dot in query matches email" do + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "Dot", + last_name: "Test", + email: "dot.test@example.com" + }) + + {:ok, _other} = + Mv.Membership.create_member(%{ + first_name: "No", + last_name: "Dot", + email: "nodot@example.com" + }) + + result = + Mv.Membership.Member + |> Mv.Membership.Member.fuzzy_search(%{query: "dot.test"}) + |> Ash.read!() + + ids = Enum.map(result, & &1.id) + assert member.id in ids + end + + test "special character handling: hyphen in query matches data" do + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "Mary-Jane", + last_name: "Watson", + email: "mary.jane@example.com" + }) + + {:ok, _other} = + Mv.Membership.create_member(%{ + first_name: "Mary", + last_name: "Smith", + email: "mary.smith@example.com" + }) + + result = + Mv.Membership.Member + |> Mv.Membership.Member.fuzzy_search(%{query: "mary-jane"}) + |> Ash.read!() + + ids = Enum.map(result, & &1.id) + assert member.id in ids + end + + test "unicode character handling: umlaut ö in query matches data" do + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "Jörg", + last_name: "Schmidt", + email: "joerg.schmidt@example.com" + }) + + {:ok, _other} = + Mv.Membership.create_member(%{ + first_name: "John", + last_name: "Smith", + email: "john.smith@example.com" + }) + + result = + Mv.Membership.Member + |> Mv.Membership.Member.fuzzy_search(%{query: "jörg"}) + |> Ash.read!() + + ids = Enum.map(result, & &1.id) + assert member.id in ids + end + + test "unicode character handling: umlaut ä in query matches data" do + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "Märta", + last_name: "Andersson", + email: "maerta.andersson@example.com" + }) + + {:ok, _other} = + Mv.Membership.create_member(%{ + first_name: "Marta", + last_name: "Johnson", + email: "marta.johnson@example.com" + }) + + result = + Mv.Membership.Member + |> Mv.Membership.Member.fuzzy_search(%{query: "märta"}) + |> Ash.read!() + + ids = Enum.map(result, & &1.id) + assert member.id in ids + end + + test "unicode character handling: umlaut ü in query matches data" do + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "Günther", + last_name: "Müller", + email: "guenther.mueller@example.com" + }) + + {:ok, _other} = + Mv.Membership.create_member(%{ + first_name: "Gunter", + last_name: "Miller", + email: "gunter.miller@example.com" + }) + + result = + Mv.Membership.Member + |> Mv.Membership.Member.fuzzy_search(%{query: "müller"}) + |> Ash.read!() + + ids = Enum.map(result, & &1.id) + assert member.id in ids + end + + test "unicode character handling: query without umlaut matches data with umlaut" do + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "Müller", + last_name: "Schmidt", + email: "mueller.schmidt@example.com" + }) + + {:ok, _other} = + Mv.Membership.create_member(%{ + first_name: "Miller", + last_name: "Smith", + email: "miller.smith@example.com" + }) + + result = + Mv.Membership.Member + |> Mv.Membership.Member.fuzzy_search(%{query: "muller"}) + |> Ash.read!() + + ids = Enum.map(result, & &1.id) + assert member.id in ids + end + + test "very long search strings: handles long query without error" do + {:ok, _member} = + Mv.Membership.create_member(%{ + first_name: "Test", + last_name: "User", + email: "test@example.com" + }) + + long_query = String.duplicate("a", 1000) + + result = + Mv.Membership.Member + |> Mv.Membership.Member.fuzzy_search(%{query: long_query}) + |> Ash.read!() + + # Should not crash, may return empty or some results + assert is_list(result) + end + + test "very long search strings: handles extremely long query" do + {:ok, _member} = + Mv.Membership.create_member(%{ + first_name: "Test", + last_name: "User", + email: "test@example.com" + }) + + very_long_query = String.duplicate("test query ", 1000) + + result = + Mv.Membership.Member + |> Mv.Membership.Member.fuzzy_search(%{query: very_long_query}) + |> Ash.read!() + + # Should not crash, may return empty or some results + assert is_list(result) + end end From 3852655597f84aae1682fcf8afd3836d4892d6fd Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 10 Nov 2025 14:28:30 +0100 Subject: [PATCH 18/35] docs: add comprehensive project documentation and reduce redundancy Add CODE_GUIDELINES.md, database schema docs, and development-progress-log.md. Refactor README.md to eliminate redundant information by linking to detailed docs. Establish clear documentation hierarchy for better maintainability. --- CODE_GUIDELINES.md | 2576 ++++++++++++++++++++++++++++++ README.md | 38 +- docs/database-schema-readme.md | 392 +++++ docs/database_schema.dbml | 329 ++++ docs/development-progress-log.md | 1227 ++++++++++++++ 5 files changed, 4546 insertions(+), 16 deletions(-) create mode 100644 CODE_GUIDELINES.md create mode 100644 docs/database-schema-readme.md create mode 100644 docs/database_schema.dbml create mode 100644 docs/development-progress-log.md diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md new file mode 100644 index 0000000..384b2e0 --- /dev/null +++ b/CODE_GUIDELINES.md @@ -0,0 +1,2576 @@ +# Code Guidelines and Best Practices + +## Purpose + +This document serves as a foundational reference for our development team, ensuring consistency, maintainability, and quality in our codebase. It defines standards and best practices for building the Mila membership management application. + +## Project Context + +We are building a membership management system (Mila) using the following technology stack: + +**Backend & Runtime:** +- Elixir `~> 1.15` (currently 1.18.3-otp-27) +- Erlang/OTP 27.3.4 +- Phoenix Framework `~> 1.8.0` +- Ash Framework `~> 3.0` +- AshPostgres `~> 2.0` +- Ecto `~> 3.10` +- Postgrex `>= 0.0.0` +- AshAuthentication `~> 4.9` +- AshAuthenticationPhoenix `~> 2.10` +- bcrypt_elixir `~> 3.0` + +**Frontend & UI:** +- Phoenix LiveView `~> 1.1.0` +- Phoenix HTML `~> 4.1` +- Tailwind CSS 4.0.9 +- DaisyUI (as Tailwind plugin) +- Heroicons v2.2.0 +- JavaScript (ES2022) +- esbuild `~> 0.9` + +**Database:** +- PostgreSQL 17.6 (dev), 16 (prod) + +**Testing:** +- ExUnit (built-in) +- Ecto.Adapters.SQL.Sandbox + +**Development Tools:** +- asdf 0.16.5 (version management) +- Just 1.43.0 (task runner) +- Credo `~> 1.7` (code analysis) +- Sobelow `~> 0.14` (security analysis) +- mix_audit `~> 2.1` (dependency audit) + +**Infrastructure:** +- Docker & Docker Compose +- Bandit `~> 1.5` (HTTP server) + +--- + +## Table of Contents + +1. [Setup and Architectural Conventions](#1-setup-and-architectural-conventions) +2. [Coding Standards and Style](#2-coding-standards-and-style) +3. [Tooling Guidelines](#3-tooling-guidelines) +4. [Testing Standards](#4-testing-standards) +5. [Security Guidelines](#5-security-guidelines) +6. [Performance Best Practices](#6-performance-best-practices) +7. [Documentation Standards](#7-documentation-standards) +8. [Accessibility Guidelines](#8-accessibility-guidelines) + +--- + +## 1. Setup and Architectural Conventions + +### 1.1 Project Structure + +Our project follows a domain-driven design approach using Phoenix contexts and Ash domains: + +``` +lib/ +├── accounts/ # Accounts domain (AshAuthentication) +│ ├── accounts.ex # Domain definition +│ ├── user.ex # User resource +│ ├── token.ex # Token resource +│ ├── user_identity.exs # User identity helpers +│ └── user/ # User-related modules +│ ├── changes/ # Ash changes for user +│ └── preparations/ # Ash preparations for user +├── membership/ # Membership domain +│ ├── membership.ex # Domain definition +│ ├── member.ex # Member resource +│ ├── property.ex # Custom property resource +│ ├── property_type.ex # Property type resource +│ └── email.ex # Email custom type +├── mv/ # Core application modules +│ ├── accounts/ # Domain-specific logic +│ │ └── user/ +│ │ ├── senders/ # Email senders for user actions +│ │ └── validations/ +│ ├── email_sync/ # Email synchronization logic +│ │ ├── changes/ # Sync changes +│ │ ├── helpers.ex # Sync helper functions +│ │ └── loader.ex # Data loaders +│ ├── membership/ # Domain-specific logic +│ │ └── member/ +│ │ └── validations/ +│ ├── application.ex # OTP application +│ ├── mailer.ex # Email mailer +│ ├── release.ex # Release tasks +│ ├── repo.ex # Database repository +│ └── secrets.ex # Secret management +├── mv_web/ # Web interface layer +│ ├── components/ # UI components +│ │ ├── core_components.ex +│ │ ├── table_components.ex +│ │ ├── layouts.ex +│ │ └── layouts/ # Layout templates +│ │ ├── navbar.ex +│ │ └── root.html.heex +│ ├── controllers/ # HTTP controllers +│ │ ├── auth_controller.ex +│ │ ├── page_controller.ex +│ │ ├── locale_controller.ex +│ │ ├── error_html.ex +│ │ ├── error_json.ex +│ │ └── page_html/ +│ ├── live/ # LiveView modules +│ │ ├── components/ # LiveView-specific components +│ │ │ ├── search_bar_component.ex +│ │ │ └── sort_header_component.ex +│ │ ├── member_live/ # Member CRUD LiveViews +│ │ ├── property_live/ # Property CRUD LiveViews +│ │ ├── property_type_live/ +│ │ └── user_live/ # User management LiveViews +│ ├── auth_overrides.ex # AshAuthentication overrides +│ ├── endpoint.ex # Phoenix endpoint +│ ├── gettext.ex # I18n configuration +│ ├── live_helpers.ex # LiveView helpers +│ ├── live_user_auth.ex # LiveView authentication +│ ├── router.ex # Application router +│ └── telemetry.ex # Telemetry configuration +├── mv_web.ex # Web module definition +└── mv.ex # Application module definition + +test/ +├── accounts/ # Accounts domain tests +│ ├── user_test.exs +│ ├── email_sync_edge_cases_test.exs +│ ├── email_uniqueness_test.exs +│ ├── user_email_sync_test.exs +│ ├── user_member_deletion_test.exs +│ └── user_member_relationship_test.exs +├── membership/ # Membership domain tests +│ ├── member_test.exs +│ └── member_email_sync_test.exs +├── mv_web/ # Web layer tests +│ ├── components/ # Component tests +│ │ ├── layouts/ +│ │ │ └── navbar_test.exs +│ │ ├── search_bar_component_test.exs +│ │ └── sort_header_component_test.exs +│ ├── controllers/ # Controller tests +│ │ ├── auth_controller_test.exs +│ │ ├── error_html_test.exs +│ │ ├── error_json_test.exs +│ │ ├── oidc_integration_test.exs +│ │ └── page_controller_test.exs +│ ├── live/ # LiveView tests +│ │ └── profile_navigation_test.exs +│ ├── member_live/ # Member LiveView tests +│ │ └── index_test.exs +│ ├── user_live/ # User LiveView tests +│ │ ├── form_test.exs +│ │ └── index_test.exs +│ └── locale_test.exs +├── seeds_test.exs # Database seed tests +└── support/ # Test helpers + ├── conn_case.ex # Controller test helpers + └── data_case.ex # Data layer test helpers +``` + +### 1.2 Module Organization + +**Module Naming:** + +- **Modules:** Use `PascalCase` with full namespace (e.g., `Mv.Accounts.User`) +- **Domains:** Top-level domains are `Mv.Accounts` and `Mv.Membership` +- **Resources:** Resource modules should be singular nouns (e.g., `Member`, not `Members`) +- **Context functions:** Use `snake_case` and verb-first naming (e.g., `create_user`, `list_members`) + +**Module Structure:** + +```elixir +defmodule Mv.Membership.Member do + @moduledoc """ + Represents a club member with their personal information and membership status. + """ + + use Ash.Resource, + domain: Mv.Membership, + data_layer: AshPostgres.DataLayer + + # 1. Ash DSL sections in order (see Spark formatter config) + admin do + # ... + end + + postgres do + # ... + end + + resource do + # ... + end + + code_interface do + # ... + end + + actions do + # ... + end + + policies do + # ... + end + + attributes do + # ... + end + + relationships do + # ... + end + + # 2. Public functions + + # 3. Private functions +end +``` + +### 1.3 Domain-Driven Design + +**Use Ash Domains for Context Boundaries:** + +Each domain should: +- Have a clear boundary and responsibility +- Define a public API through code interfaces +- Encapsulate business logic within resources +- Handle cross-domain communication explicitly + +Example domain definition: + +```elixir +defmodule Mv.Membership do + use Ash.Domain, + extensions: [AshAdmin.Domain, AshPhoenix] + + admin do + show? true + end + + resources do + resource Mv.Membership.Member do + define :create_member, action: :create_member + define :list_members, action: :read + define :update_member, action: :update_member + define :destroy_member, action: :destroy + end + end +end +``` + +### 1.4 Dependency Management + +- **Use `mix.exs` for all dependencies:** Define versions explicitly +- **Keep dependencies up to date:** Use Renovate for automated updates +- **Version management:** Use `asdf` with `.tool-versions` for consistent environments + +### 1.5 Scalability Considerations + +- **Database indexing:** Add indexes for frequently queried fields +- **Pagination:** Use Ash's keyset pagination for large datasets (default configured) +- **Background jobs:** Plan for Oban or similar for async processing +- **Caching:** Consider caching strategies for expensive operations +- **Process design:** Use OTP principles (GenServers, Supervisors) for stateful components + +--- + +## 2. Coding Standards and Style + +### 2.1 Code Formatting + +**Use `mix format` for all Elixir code:** + +```bash +mix format +``` + +**Key formatting rules:** +- **Indentation:** 2 spaces (no tabs) +- **Line length:** Maximum 120 characters (configured in `.credo.exs`) +- **Trailing whitespace:** Not allowed +- **File endings:** Always include trailing newline + +**Naming Conventions Summary:** + +- **Elixir:** Use `snake_case` for functions/variables, `PascalCase` for modules +- **Phoenix:** Controllers end with `Controller`, LiveViews end with `Live` +- **Ash:** Resources are singular nouns, actions are verb-first (`:create_member`) +- **Files:** Match module names in `snake_case` (`user_controller.ex` for `UserController`) + +### 2.2 Function Design + +**Verb-First Function Names:** + +```elixir +# Good +def create_user(attrs) +def list_members(query) +def send_email(recipient, content) + +# Avoid +def user_create(attrs) +def members_list(query) +def email_send(recipient, content) +``` + +**Use Pattern Matching in Function Heads:** + +```elixir +# Good - multiple clauses with pattern matching +def handle_result({:ok, user}), do: {:ok, user} +def handle_result({:error, reason}), do: log_and_return_error(reason) + +# Avoid - case/cond when pattern matching suffices +def handle_result(result) do + case result do + {:ok, user} -> {:ok, user} + {:error, reason} -> log_and_return_error(reason) + end +end +``` + +**Keep Functions Small and Focused:** + +- Aim for functions under 20 lines +- Each function should have a single responsibility +- Extract complex logic into private helper functions + +**Use Guard Clauses for Early Returns:** + +```elixir +def process_user(nil), do: {:error, :user_not_found} +def process_user(%{active: false}), do: {:error, :user_inactive} +def process_user(user), do: {:ok, perform_action(user)} +``` + +### 2.3 Error Handling + +**Use Tagged Tuples:** + +```elixir +# Standard pattern +{:ok, result} | {:error, reason} + +# Examples +def create_member(attrs) do + case Ash.create(Member, attrs) do + {:ok, member} -> {:ok, member} + {:error, error} -> {:error, error} + end +end +``` + +**Use `with` for Complex Operations:** + +```elixir +def register_user(params) do + with {:ok, validated} <- validate_params(params), + {:ok, user} <- create_user(validated), + {:ok, _email} <- send_welcome_email(user) do + {:ok, user} + else + {:error, reason} -> {:error, reason} + end +end +``` + +**Let It Crash (with Supervision):** + +Don't defensively program against every possible error. Use supervisors to handle process failures: + +```elixir +# In your application.ex +children = [ + Mv.Repo, + MvWeb.Endpoint, + {Phoenix.PubSub, name: Mv.PubSub} +] + +Supervisor.start_link(children, strategy: :one_for_one) +``` + +### 2.4 Functional Programming Principles + +**Immutability:** + +```elixir +# Good - return new data structures +def add_role(user, role) do + %{user | roles: [role | user.roles]} +end + +# Avoid - mutation (not possible in Elixir anyway) +# This is just conceptual - Elixir prevents mutation +``` + +**Pure Functions:** + +Write functions that: +- Return the same output for the same input +- Have no side effects +- Are easier to test and reason about + +```elixir +# Pure function +def calculate_total(items) do + Enum.reduce(items, 0, fn item, acc -> acc + item.price end) +end + +# Impure function (side effects) +def create_and_log_user(attrs) do + Logger.info("Creating user: #{inspect(attrs)}") # Side effect + Ash.create!(User, attrs) # Side effect +end +``` + +**Pipe Operator:** + +Use the pipe operator `|>` for transformation chains: + +```elixir +# Good +def process_members(query) do + query + |> filter_active() + |> sort_by_name() + |> limit_results(10) +end + +# Avoid +def process_members(query) do + limit_results(sort_by_name(filter_active(query)), 10) +end +``` + +### 2.5 Elixir-Specific Patterns + +**Avoid Using Else with Unless:** + +```elixir +# Good +unless user.admin? do + {:error, :unauthorized} +end + +# Avoid - confusing +unless user.admin? do + {:error, :unauthorized} +else + perform_admin_action() +end +``` + +**Use `Enum` over List Comprehensions for Clarity:** + +```elixir +# Preferred for readability +users +|> Enum.filter(&(&1.active)) +|> Enum.map(&(&1.name)) + +# List comprehension (use when more concise) +for user <- users, user.active, do: user.name +``` + +**String Concatenation:** + +```elixir +# Good - interpolation +"Hello, #{user.name}!" + +# Avoid - concatenation with <> +"Hello, " <> user.name <> "!" +``` + +--- + +## 3. Tooling Guidelines + +### 3.1 Elixir & Erlang/OTP + +**Version Management with asdf:** + +Always use the versions specified in `.tool-versions`: + +```bash +# Install correct versions +asdf install + +# Verify versions +elixir --version # Should show 1.18.3 +erl -version # Should show 27.3.4 +``` + +**OTP Application Design:** + +```elixir +defmodule Mv.Application do + use Application + + def start(_type, _args) do + children = [ + # Start the database repository + Mv.Repo, + # Start the Telemetry supervisor + MvWeb.Telemetry, + # Start the PubSub system + {Phoenix.PubSub, name: Mv.PubSub}, + # Start the Endpoint + MvWeb.Endpoint + ] + + opts = [strategy: :one_for_one, name: Mv.Supervisor] + Supervisor.start_link(children, opts) + end +end +``` + +### 3.2 Phoenix Framework + +**Context-Based Organization:** + +- Use contexts to define API boundaries +- Keep controllers thin - delegate to contexts or Ash actions +- Avoid direct Repo/Ecto calls in controllers + +```elixir +# Good - thin controller +defmodule MvWeb.MemberController do + use MvWeb, :controller + + def create(conn, %{"member" => member_params}) do + case Mv.Membership.create_member(member_params) do + {:ok, member} -> + conn + |> put_flash(:info, "Member created successfully.") + |> redirect(to: ~p"/members/#{member}") + + {:error, error} -> + conn + |> put_flash(:error, "Failed to create member.") + |> render(:new, error: error) + end + end +end +``` + +**Phoenix LiveView Best Practices:** + +```elixir +defmodule MvWeb.MemberLive.Index do + use MvWeb, :live_view + + # Use mount for initial setup + def mount(_params, _session, socket) do + {:ok, assign(socket, members: [], loading: true)} + end + + # Use handle_params for URL parameter handling + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + # Use handle_event for user interactions + def handle_event("delete", %{"id" => id}, socket) do + # Handle deletion + {:noreply, socket} + end + + # Use handle_info for asynchronous messages + def handle_info({:member_updated, member}, socket) do + {:noreply, update_member_in_list(socket, member)} + end +end +``` + +**Component Design:** + +```elixir +# Function components for stateless UI elements +def button(assigns) do + ~H""" + + """ +end + +# Use attrs and slots for documentation +attr :id, :string, required: true +attr :title, :string, default: nil +slot :inner_block, required: true + +def card(assigns) do + ~H""" +
+

<%= @title %>

+ <%= render_slot(@inner_block) %> +
+ """ +end +``` + +### 3.3 Ash Framework + +**Resource Definition Best Practices:** + +```elixir +defmodule Mv.Membership.Member do + use Ash.Resource, + domain: Mv.Membership, + data_layer: AshPostgres.DataLayer + + # Follow section order from Spark formatter config + postgres do + table "members" + repo Mv.Repo + end + + attributes do + uuid_primary_key :id + + attribute :first_name, :string do + allow_nil? false + public? true + end + + attribute :email, :string do + allow_nil? false + public? true + end + + timestamps() + end + + actions do + # Define specific actions instead of using defaults.accept_all + create :create_member do + accept [:first_name, :last_name, :email] + + change fn changeset, _context -> + # Custom validation or transformation + changeset + end + end + + read :read do + primary? true + end + + update :update_member do + accept [:first_name, :last_name, :email] + end + + destroy :destroy + end + + code_interface do + define :create_member + define :list_members, action: :read + define :update_member + define :destroy_member, action: :destroy + end + + identities do + identity :unique_email, [:email] + end +end +``` + +**Ash Policies:** + +```elixir +policies do + # Admin can do everything + policy action_type([:read, :create, :update, :destroy]) do + authorize_if actor_attribute_equals(:role, :admin) + end + + # Users can only read and update their own data + policy action_type([:read, :update]) do + authorize_if relates_to_actor_via(:user) + end +end +``` + +**Ash Validations:** + +```elixir +validations do + validate present(:email), on: [:create, :update] + validate match(:email, ~r/@/), message: "must be a valid email" + validate string_length(:first_name, min: 2, max: 100) +end +``` + +### 3.4 AshPostgres & Ecto + +**Migrations with Ash:** + +```bash +# Generate migration for all changes +mix ash.codegen --name add_members_table + +# Apply migrations +mix ash.setup +``` + +**Repository Configuration:** + +```elixir +defmodule Mv.Repo do + use AshPostgres.Repo, + otp_app: :mv + + # Install PostgreSQL extensions + def installed_extensions do + ["citext", "uuid-ossp"] + end +end +``` + +**Avoid N+1 Queries:** + +```elixir +# Good - preload relationships +members = + Member + |> Ash.Query.load(:properties) + |> 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) +end) +``` + +### 3.5 Authentication (AshAuthentication) + +**Resource with Authentication:** + +```elixir +defmodule Mv.Accounts.User do + use Ash.Resource, + domain: Mv.Accounts, + data_layer: AshPostgres.DataLayer, + extensions: [AshAuthentication] + + authentication do + strategies do + password :password do + identity_field :email + hashed_password_field :hashed_password + end + + oauth2 :rauthy do + client_id fn _, _ -> + Application.fetch_env!(:mv, :rauthy)[:client_id] + end + # ... other config + end + end + end +end +``` + +### 3.6 Frontend: Tailwind CSS & DaisyUI + +**Utility-First Approach:** + +```heex + +
+
+

Member Name

+

Email: member@example.com

+
+ +
+
+
+ + +
+

Member Name

+ +
+``` + +**Responsive Design:** + +```heex + +
+ <%= for member <- @members do %> + <.member_card member={member} /> + <% end %> +
+``` + +**DaisyUI Components:** + +```heex + + +``` + +**Custom Tailwind Configuration:** + +Update `assets/tailwind.config.js` for custom needs: + +```javascript +module.exports = { + content: [ + "../lib/mv_web.ex", + "../lib/mv_web/**/*.*ex" + ], + theme: { + extend: { + colors: { + brand: "#FD4F00", + } + }, + }, + plugins: [ + require("@tailwindcss/forms"), + // DaisyUI loaded from vendor + ] +} +``` + +### 3.7 JavaScript & esbuild + +**Minimal JavaScript Philosophy:** + +Phoenix LiveView handles most interactivity. Use JavaScript only when necessary: + +```javascript +// assets/js/app.js +import "phoenix_html" +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" +import topbar from "../vendor/topbar" + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +let liveSocket = new LiveSocket("/live", Socket, { + longPollFallbackMs: 2500, + params: {_csrf_token: csrfToken} +}) + +// Show progress bar on live navigation +topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) +window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) + +liveSocket.connect() +``` + +**Custom Hooks (when needed):** + +```javascript +let Hooks = {} + +Hooks.DatePicker = { + mounted() { + // Initialize date picker + this.el.addEventListener("change", (e) => { + this.pushEvent("date_selected", {date: e.target.value}) + }) + } +} + +let liveSocket = new LiveSocket("/live", Socket, { + params: {_csrf_token: csrfToken}, + hooks: Hooks +}) +``` + +### 3.8 Code Quality: Credo + +**Run Credo Regularly:** + +```bash +# Check code quality +mix credo + +# Strict mode for CI +mix credo --strict +``` + +**Key Credo Checks Enabled:** + +- Consistency checks (spacing, line endings, parameter patterns) +- Design checks (FIXME/TODO tags, alias usage) +- Readability checks (max line length: 120, module/function names) +- Refactoring opportunities (cyclomatic complexity, nesting) +- Warnings (unused operations, unsafe operations) + +**Disabled Checks:** + +- `Credo.Check.Readability.ModuleDoc` - Disabled by team decision + (Still encouraged to add module docs for public modules) + +**Address Credo Issues:** + +```elixir +# Before +def complex_function(user, data, opts) do + if user.admin? do + if data.valid? do + if opts[:force] do + # deeply nested logic + end + end + end +end + +# After - flatten with guard clauses +def complex_function(user, _data, _opts) when not user.admin?, + do: {:error, :unauthorized} + +def complex_function(_user, data, _opts) when not data.valid?, + do: {:error, :invalid_data} + +def complex_function(_user, data, opts) do + if opts[:force] do + process_data(data) + else + validate_and_process(data) + end +end +``` + +### 3.9 Security: Sobelow + +**Run Security Analysis:** + +```bash +# Security audit +mix sobelow --config + +# With verbose output +mix sobelow --config --verbose +``` + +**Security Best Practices:** + +- Never commit secrets to version control +- Use environment variables for sensitive configuration +- Validate and sanitize all user inputs +- Use parameterized queries (Ecto handles this) +- Keep dependencies updated + +### 3.10 Dependency Auditing & Updates + +**Regular Security Audits:** + +```bash +# Audit dependencies for security vulnerabilities +mix deps.audit + +# Audit hex packages +mix hex.audit + +# Security scan with Sobelow +mix sobelow --config +``` + +**Update Dependencies:** + +```bash +# Update all dependencies +mix deps.update --all + +# Update specific dependency +mix deps.update phoenix + +# Check for outdated packages +mix hex.outdated +``` + +### 3.11 Email: Swoosh + +**Mailer Configuration:** + +```elixir +defmodule Mv.Mailer do + use Swoosh.Mailer, otp_app: :mv +end +``` + +**Sending Emails:** + +```elixir +defmodule Mv.Accounts.WelcomeEmail do + use Phoenix.Swoosh, template_root: "lib/mv_web/templates" + import Swoosh.Email + + def send(user) do + new() + |> to({user.name, user.email}) + |> from({"Mila", "noreply@mila.example.com"}) + |> subject("Welcome to Mila!") + |> render_body("welcome.html", %{user: user}) + |> Mv.Mailer.deliver() + end +end +``` + +### 3.12 Internationalization: Gettext + +**Define Translations:** + +```elixir +# In LiveView or controller +gettext("Welcome to Mila") + +# With interpolation +gettext("Hello, %{name}!", name: user.name) + +# Domain-specific translations +dgettext("auth", "Sign in with email") +``` + +**Extract and Merge:** + +```bash +# Extract new translatable strings +mix gettext.extract + +# Merge into existing translations +mix gettext.merge priv/gettext --on-obsolete=mark_as_obsolete +``` + +### 3.13 Task Runner: Just + +**Common Commands:** + +```bash +# Start development environment +just run + +# Run tests +just test + +# Run linter +just lint + +# Run security audit +just audit + +# Reset database +just reset-database + +# Format code +just format + +# Regenerate migrations +just regen-migrations migration_name +``` + +**Define Custom Tasks:** + +Edit `Justfile` for project-specific tasks: + +```makefile +# Example custom task +setup-dev: install-dependencies start-database migrate-database + mix phx.gen.secret + @echo "Development environment ready!" +``` + +--- + +## 4. Testing Standards + +### 4.1 Test Setup and Organization + +**Test Directory Structure:** + +Mirror the `lib/` directory structure in `test/`: + +``` +test/ +├── accounts/ # Tests for Accounts domain +│ ├── user_test.exs +│ ├── email_sync_test.exs +│ └── ... +├── membership/ # Tests for Membership domain +│ ├── member_test.exs +│ └── ... +├── mv_web/ # Tests for Web layer +│ ├── controllers/ +│ ├── live/ +│ └── components/ +└── support/ # Test helpers + ├── conn_case.ex # Controller test setup + └── data_case.ex # Database test setup +``` + +**Test File Naming:** + +- Use `_test.exs` suffix for all test files +- Match the module name: `user.ex` → `user_test.exs` +- Use descriptive names for integration tests: `user_member_relationship_test.exs` + +### 4.2 ExUnit Basics + +**Test Module Structure:** + +```elixir +defmodule Mv.Membership.MemberTest do + use Mv.DataCase, async: true # async: true for parallel execution + + alias Mv.Membership.Member + + describe "create_member/1" do + test "creates a member with valid attributes" do + attrs = %{ + first_name: "John", + last_name: "Doe", + email: "john@example.com" + } + + assert {:ok, %Member{} = member} = Mv.Membership.create_member(attrs) + assert member.first_name == "John" + assert member.email == "john@example.com" + end + + test "returns error with invalid attributes" do + attrs = %{first_name: nil} + + assert {:error, _error} = Mv.Membership.create_member(attrs) + end + end + + describe "list_members/0" do + setup do + # Setup code for this describe block + {:ok, member: create_test_member()} + end + + test "returns all members", %{member: member} do + members = Mv.Membership.list_members() + assert length(members) == 1 + assert List.first(members).id == member.id + end + end +end +``` + +### 4.3 Test Types + +#### 4.3.1 Unit Tests + +Test individual functions and modules in isolation: + +```elixir +defmodule Mv.Membership.EmailTest do + use ExUnit.Case, async: true + + alias Mv.Membership.Email + + describe "valid?/1" do + test "returns true for valid email" do + assert Email.valid?("user@example.com") + end + + test "returns false for invalid email" do + refute Email.valid?("invalid-email") + refute Email.valid?("missing-at-sign.com") + end + end +end +``` + +#### 4.3.2 Integration Tests + +Test interactions between multiple modules or systems: + +```elixir +defmodule Mv.Accounts.UserMemberRelationshipTest do + use Mv.DataCase, async: true + + alias Mv.Accounts.User + alias Mv.Membership.Member + + describe "user-member relationship" do + test "creating a user automatically creates a member" do + attrs = %{ + email: "test@example.com", + password: "SecurePassword123" + } + + assert {:ok, user} = Mv.Accounts.create_user(attrs) + assert {:ok, member} = Mv.Membership.get_member_by_user_id(user.id) + assert member.email == user.email + end + + test "deleting a user cascades to member" do + {:ok, user} = create_user() + {:ok, member} = Mv.Membership.get_member_by_user_id(user.id) + + assert :ok = Mv.Accounts.destroy_user(user) + assert {:error, :not_found} = Mv.Membership.get_member(member.id) + end + end +end +``` + +#### 4.3.3 Controller Tests + +Test HTTP endpoints: + +```elixir +defmodule MvWeb.PageControllerTest do + use MvWeb.ConnCase, async: true + + test "GET /", %{conn: conn} do + conn = get(conn, ~p"/") + assert html_response(conn, 200) =~ "Welcome to Mila" + end +end +``` + +#### 4.3.4 LiveView Tests + +Test LiveView interactions: + +```elixir +defmodule MvWeb.MemberLive.IndexTest do + use MvWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + setup do + member = create_test_member() + %{member: member} + end + + test "displays list of members", %{conn: conn, member: member} do + {:ok, view, html} = live(conn, ~p"/members") + + assert html =~ "Members" + assert html =~ member.first_name + end + + test "deletes member", %{conn: conn, member: member} do + {:ok, view, _html} = live(conn, ~p"/members") + + assert view + |> element("#member-#{member.id} a", "Delete") + |> render_click() + + refute has_element?(view, "#member-#{member.id}") + end +end +``` + +#### 4.3.5 Component Tests + +Test function components: + +```elixir +defmodule MvWeb.Components.SearchBarComponentTest do + use MvWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import MvWeb.Components.SearchBarComponent + + test "renders search input" do + assigns = %{search_query: "", id: "search"} + + html = + render_component(&search_bar/1, assigns) + + assert html =~ "input" + assert html =~ ~s(type="search") + end +end +``` + +### 4.4 Test Helpers and Fixtures + +**Create Test Helpers:** + +```elixir +# test/support/fixtures.ex +defmodule Mv.Fixtures do + def member_fixture(attrs \\ %{}) do + default_attrs = %{ + first_name: "Test", + last_name: "User", + email: "test#{System.unique_integer()}@example.com" + } + + {:ok, member} = + default_attrs + |> Map.merge(attrs) + |> Mv.Membership.create_member() + + member + end +end +``` + +**Use Setup Blocks:** + +```elixir +describe "with authenticated user" do + setup %{conn: conn} do + user = create_user() + conn = log_in_user(conn, user) + %{conn: conn, user: user} + end + + test "can access protected page", %{conn: conn} do + conn = get(conn, ~p"/profile") + assert html_response(conn, 200) =~ "Profile" + end +end +``` + +### 4.5 Database Testing with Sandbox + +**Use Ecto Sandbox for Isolation:** + +```elixir +# test/test_helper.exs +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(Mv.Repo, :manual) +``` + +```elixir +# test/support/data_case.ex +defmodule Mv.DataCase do + use ExUnit.CaseTemplate + + using do + quote do + import Ecto + import Ecto.Changeset + import Ecto.Query + import Mv.DataCase + + alias Mv.Repo + end + end + + setup tags do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Mv.Repo, shared: not tags[:async]) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + :ok + end +end +``` + +### 4.6 Test Coverage + +**Run Tests with Coverage:** + +```bash +# Run tests +mix test + +# Run with coverage +mix test --cover + +# Run specific test file +mix test test/membership/member_test.exs + +# Run specific test line +mix test test/membership/member_test.exs:42 +``` + +**Coverage Goals:** + +- Aim for >80% overall coverage +- 100% coverage for critical business logic +- Focus on meaningful tests, not just coverage numbers + +### 4.7 Testing Best Practices + +**Descriptive Test Names:** + +```elixir +# Good - describes what is being tested +test "creates a member with valid email address" +test "returns error when email is already taken" +test "sends welcome email after successful registration" + +# Avoid - vague or generic +test "member creation" +test "error case" +test "test 1" +``` + +**Arrange-Act-Assert Pattern:** + +```elixir +test "updates member email" do + # Arrange - set up test data + member = member_fixture() + new_email = "new@example.com" + + # Act - perform the action + {:ok, updated_member} = Mv.Membership.update_member(member, %{email: new_email}) + + # Assert - verify results + assert updated_member.email == new_email +end +``` + +**Test One Thing Per Test:** + +```elixir +# Good - focused test +test "validates email format" do + attrs = %{email: "invalid-email"} + assert {:error, _} = Mv.Membership.create_member(attrs) +end + +test "requires email to be present" do + attrs = %{email: nil} + assert {:error, _} = Mv.Membership.create_member(attrs) +end + +# Avoid - testing multiple things +test "validates email" do + # Tests both format and presence + assert {:error, _} = Mv.Membership.create_member(%{email: nil}) + assert {:error, _} = Mv.Membership.create_member(%{email: "invalid"}) +end +``` + +**Use describe Blocks for Organization:** + +```elixir +describe "create_member/1" do + test "success case" do + # ... + end + + test "error case" do + # ... + end +end + +describe "update_member/2" do + test "success case" do + # ... + end +end +``` + +**Avoid Testing Implementation Details:** + +```elixir +# Good - test behavior +test "member can be created with valid attributes" do + attrs = valid_member_attrs() + assert {:ok, %Member{}} = Mv.Membership.create_member(attrs) +end + +# Avoid - testing internal implementation +test "create_member calls Ash.create with correct params" do + # This is too coupled to implementation +end +``` + +**Keep Tests Fast:** + +- Use `async: true` when possible +- Avoid unnecessary database interactions +- Mock external services +- Use fixtures efficiently + +--- + +## 5. Security Guidelines + +### 5.1 Authentication & Authorization + +**Use AshAuthentication:** + +```elixir +# Authentication is configured at the resource level +authentication do + strategies do + password :password do + identity_field :email + hashed_password_field :hashed_password + end + + oauth2 :rauthy do + # OIDC configuration + end + end +end +``` + +**Implement Authorization Policies:** + +```elixir +policies do + # Default deny + policy action_type(:*) do + authorize_if always() + end + + # Specific permissions + policy action_type([:read, :update]) do + authorize_if relates_to_actor_via(:user) + end + + policy action_type(:destroy) do + authorize_if actor_attribute_equals(:role, :admin) + end +end +``` + +### 5.2 Password Security + +**Use bcrypt for Password Hashing:** + +```elixir +# Configured in AshAuthentication resource +password :password do + identity_field :email + hashed_password_field :hashed_password + hash_provider AshAuthentication.BcryptProvider + + confirmation_required? true +end +``` + +**Password Requirements:** + +- Minimum 12 characters +- Mix of uppercase, lowercase, numbers (enforced by validation) +- Use `bcrypt_elixir` for hashing (never store plain text passwords) + +### 5.3 Input Validation & Sanitization + +**Validate All User Input:** + +```elixir +attributes do + attribute :email, :string do + allow_nil? false + public? true + end +end + +validations do + validate present(:email) + validate match(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/) +end +``` + +**SQL Injection Prevention:** + +Ecto and Ash handle parameterized queries automatically: + +```elixir +# Safe - parameterized query +Ash.Query.filter(Member, email == ^user_email) + +# Avoid raw SQL when possible +Ecto.Adapters.SQL.query(Mv.Repo, "SELECT * FROM members WHERE email = $1", [user_email]) +``` + +### 5.4 CSRF Protection + +**Phoenix Handles CSRF Automatically:** + +```heex + +<.form for={@form} phx-submit="save"> + + +``` + +**Configure in Endpoint:** + +```elixir +# lib/mv_web/endpoint.ex +plug Plug.Session, + store: :cookie, + key: "_mv_key", + signing_salt: "secret" + +plug :protect_from_forgery +``` + +### 5.5 Secrets Management + +**Never Commit Secrets:** + +```bash +# .gitignore should include: +.env +.env.* +!.env.example +``` + +**Use Environment Variables:** + +```elixir +# config/runtime.exs +config :mv, :rauthy, + client_id: System.get_env("OIDC_CLIENT_ID") || "mv", + client_secret: System.get_env("OIDC_CLIENT_SECRET"), + base_url: System.get_env("OIDC_BASE_URL") +``` + +**Generate Secure Secrets:** + +```bash +# Generate secret key base +mix phx.gen.secret + +# Generate token signing secret +mix phx.gen.secret +``` + +### 5.6 Security Headers + +**Configure Security Headers:** + +```elixir +# lib/mv_web/endpoint.ex +plug Plug.Static, + at: "/", + from: :mv, + gzip: false, + only: MvWeb.static_paths(), + headers: %{ + "x-content-type-options" => "nosniff", + "x-frame-options" => "SAMEORIGIN", + "x-xss-protection" => "1; mode=block" + } +``` + +### 5.7 Dependency Security + +- **Use Renovate for automated dependency updates** +- **Review changelogs before updating dependencies** +- **Test thoroughly after updates** +- **Run regular audits** (see section 3.10 for audit commands) + +### 5.8 Logging & Monitoring + +**Sanitize Logs:** + +```elixir +# Don't log sensitive information +Logger.info("User login attempt", user_id: user.id) + +# Avoid +Logger.info("User login attempt", user: inspect(user)) # May contain password +``` + +**Configure Logger:** + +```elixir +# config/config.exs +config :logger, :default_formatter, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id, :user_id] +``` + +--- + +## 6. Performance Best Practices + +### 6.1 Database Performance + +**Indexing:** + +```elixir +postgres do + table "members" + repo Mv.Repo + + # Add indexes for frequently queried fields + index [:email], unique: true + index [:last_name] + index [:created_at] +end +``` + +**Avoid N+1 Queries:** + +```elixir +# Good - preload relationships +members = + Member + |> Ash.Query.load([:properties, :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! +end) +``` + +**Pagination:** + +```elixir +# Use keyset pagination (configured as default in Ash) +Ash.Query.page(Member, offset: 0, limit: 50) +``` + +**Batch Operations:** + +```elixir +# Use bulk operations for multiple records +Ash.bulk_create([member1_attrs, member2_attrs, member3_attrs], Member, :create) +``` + +### 6.2 LiveView Performance + +**Optimize Assigns:** + +```elixir +# Good - only assign what's needed +def mount(_params, _session, socket) do + {:ok, assign(socket, members_count: get_count())} +end + +# Avoid - assigning large collections unnecessarily +def mount(_params, _session, socket) do + {:ok, assign(socket, all_members: list_all_members())} # Heavy! +end +``` + +**Use Temporary Assigns:** + +```elixir +# For data that's only needed for rendering +def handle_event("load_report", _, socket) do + report_data = generate_large_report() + {:noreply, assign(socket, report: report_data) |> assign(:report, temporary_assigns: [:report])} +end +``` + +**Stream Collections:** + +```elixir +# For large collections +def mount(_params, _session, socket) do + {:ok, stream(socket, :members, list_members())} +end + +def render(assigns) do + ~H""" +
+
+ <%= member.name %> +
+
+ """ +end +``` + +### 6.3 Caching Strategies + +**Function-Level Caching:** + +```elixir +defmodule Mv.Cache do + use GenServer + + def get_or_compute(key, compute_fn) do + case get(key) do + nil -> + value = compute_fn.() + put(key, value) + value + + value -> + value + end + end +end +``` + +**ETS for In-Memory Cache:** + +```elixir +# In application.ex +:ets.new(:mv_cache, [:named_table, :public, read_concurrency: true]) + +# Usage +:ets.insert(:mv_cache, {"key", "value"}) +:ets.lookup(:mv_cache, "key") +``` + +### 6.4 Async Processing + +**Background Jobs (Future):** + +```elixir +# When Oban is added +defmodule Mv.Workers.EmailWorker do + use Oban.Worker + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"user_id" => user_id}}) do + user = Mv.Accounts.get_user!(user_id) + Mv.Mailer.send_welcome_email(user) + :ok + end +end + +# Enqueue job +%{user_id: user.id} +|> Mv.Workers.EmailWorker.new() +|> Oban.insert() +``` + +**Task Async for Concurrent Operations:** + +```elixir +def gather_dashboard_data do + tasks = [ + Task.async(fn -> get_member_count() end), + Task.async(fn -> get_recent_registrations() end), + Task.async(fn -> get_payment_status() end) + ] + + [member_count, recent, payments] = Task.await_many(tasks) + + %{ + member_count: member_count, + recent: recent, + payments: payments + } +end +``` + +### 6.5 Profiling & Monitoring + +**Use :observer:** + +```elixir +# In iex session +:observer.start() +``` + +**Use Telemetry:** + +```elixir +# Attach telemetry handlers +:telemetry.attach( + "query-duration", + [:mv, :repo, :query], + fn event, measurements, metadata, _config -> + Logger.info("Query took #{measurements.total_time}ms") + end, + nil +) +``` + +--- + +## 7. Documentation Standards + +### 7.1 Module Documentation + +**Use @moduledoc:** + +```elixir +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. + Each member is optionally linked to a user account for self-service access. + + ## Examples + + iex> Mv.Membership.create_member(%{first_name: "John", last_name: "Doe", email: "john@example.com"}) + {:ok, %Mv.Membership.Member{}} + + """ +end +``` + +**Module Documentation Should Include:** + +- Purpose of the module +- Key responsibilities +- Usage examples +- Related modules + +### 7.2 Function Documentation + +**Use @doc:** + +```elixir +@doc """ +Creates a new member with the given attributes. + +## Parameters + + - `attrs` - A map of member attributes including: + - `:first_name` (required) - The member's first name + - `:last_name` (required) - The member's last name + - `:email` (required) - The member's email address + +## Returns + + - `{:ok, member}` - Successfully created member + - `{:error, error}` - Validation or creation error + +## Examples + + iex> Mv.Membership.create_member(%{ + ...> first_name: "Jane", + ...> last_name: "Smith", + ...> email: "jane@example.com" + ...> }) + {:ok, %Mv.Membership.Member{first_name: "Jane"}} + + iex> Mv.Membership.create_member(%{first_name: nil}) + {:error, %Ash.Error.Invalid{}} + +""" +@spec create_member(map()) :: {:ok, Member.t()} | {:error, Ash.Error.t()} +def create_member(attrs) do + # Implementation +end +``` + +### 7.3 Type Specifications + +**Use @spec for Function Signatures:** + +```elixir +@spec create_member(map()) :: {:ok, Member.t()} | {:error, Ash.Error.t()} +def create_member(attrs) + +@spec list_members(keyword()) :: [Member.t()] +def list_members(opts \\ []) + +@spec get_member!(String.t()) :: Member.t() | no_return() +def get_member!(id) +``` + +### 7.4 Code Comments + +**When to Comment:** + +```elixir +# Good - explain WHY, not WHAT +def calculate_dues(member) do + # Annual dues are prorated based on join date to be fair to mid-year joiners + months_active = calculate_months_active(member) + @annual_dues * (months_active / 12) +end + +# Avoid - stating the obvious +def calculate_dues(member) do + # Calculate the dues + months_active = calculate_months_active(member) # Get months active + @annual_dues * (months_active / 12) # Multiply annual dues by fraction +end +``` + +**Complex Logic:** + +```elixir +def sync_member_email(member, user) do + # Email synchronization priority: + # 1. User email is the source of truth (authenticated account) + # 2. Member email is updated to match user email + # 3. Preserve member email history for audit purposes + + if member.email != user.email do + # Archive old email before updating + archive_member_email(member, member.email) + update_member(member, %{email: user.email}) + end +end +``` + +### 7.5 README and Project Documentation + +**Keep README.md Updated:** + +- Installation instructions +- Development setup +- Running tests +- Deployment guide +- Contributing guidelines + +**Additional Documentation:** + +- `docs/` directory for detailed guides +- Architecture decision records (ADRs) +- API documentation (generated with ExDoc) + +**Generate Documentation:** + +```bash +# Generate HTML documentation +mix docs + +# View documentation +open doc/index.html +``` + +### 7.6 Changelog + +**Maintain CHANGELOG.md:** + +```markdown +# Changelog + +## [Unreleased] + +### Added +- Member custom properties feature +- Email synchronization between user and member + +### Changed +- Updated Phoenix to 1.8.0 + +### Fixed +- Email uniqueness validation bug + +## [0.1.0] - 2025-01-15 + +### Added +- Initial release +- Basic member management +- OIDC authentication +``` + +--- + +## 8. Git Workflow + +### 8.1 Branching Strategy + +**Main Branches:** + +- `main` - Production-ready code + +**Feature Branches:** + +```bash +# Create feature branch +git checkout -b feature/member-custom-properties + +# Work on feature +git add . +git commit -m "Add custom properties to members" + +# Push to remote +git push origin feature/member-custom-properties +``` + +### 8.2 Commit Messages + +**Format:** + +``` +: + + + +