<.header>
{gettext("Members")}
<:actions>
@@ -8,6 +8,13 @@
+ <.live_component
+ module={MvWeb.Components.SearchBarComponent}
+ id="search-bar"
+ query={@query}
+ placeholder={gettext("Search...")}
+ />
+
<.table
id="members"
rows={@members}
@@ -45,23 +52,139 @@
<:col
:let={member}
label={
- sort_button(%{
- field: :first_name,
- label: gettext("Name"),
- sort_field: @sort_field,
- sort_order: @sort_order
- })
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_first_name}
+ field={:first_name}
+ label={gettext("First name")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
}
>
{member.first_name} {member.last_name}
- <:col :let={member} label={gettext("Email")}>{member.email}
- <:col :let={member} label={gettext("Street")}>{member.street}
- <:col :let={member} label={gettext("House Number")}>{member.house_number}
- <:col :let={member} label={gettext("Postal Code")}>{member.postal_code}
- <:col :let={member} label={gettext("City")}>{member.city}
- <:col :let={member} label={gettext("Phone Number")}>{member.phone_number}
- <:col :let={member} label={gettext("Join Date")}>{member.join_date}
+ <:col
+ :let={member}
+ label={
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_email}
+ field={:email}
+ label={gettext("Email")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
+ }
+ >
+ {member.email}
+
+ <:col
+ :let={member}
+ label={
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_street}
+ field={:street}
+ label={gettext("Street")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
+ }
+ >
+ {member.street}
+
+ <:col
+ :let={member}
+ label={
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_house_number}
+ field={:house_number}
+ label={gettext("House Number")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
+ }
+ >
+ {member.house_number}
+
+ <:col
+ :let={member}
+ label={
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_postal_code}
+ field={:postal_code}
+ label={gettext("Postal Code")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
+ }
+ >
+ {member.postal_code}
+
+ <:col
+ :let={member}
+ label={
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_city}
+ field={:city}
+ label={gettext("City")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
+ }
+ >
+ {member.city}
+
+ <:col
+ :let={member}
+ label={
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_phone_number}
+ field={:phone_number}
+ label={gettext("Phone Number")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
+ }
+ >
+ {member.phone_number}
+
+ <:col
+ :let={member}
+ label={
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_join_date}
+ field={:join_date}
+ label={gettext("Join Date")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
+ }
+ >
+ {member.join_date}
+
<:action :let={member}>
diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex
index 304709c..9a0ef40 100644
--- a/lib/mv_web/live/member_live/show.ex
+++ b/lib/mv_web/live/member_live/show.ex
@@ -5,14 +5,15 @@ defmodule MvWeb.MemberLive.Show do
@impl true
def render(assigns) do
~H"""
-
+
<.header>
{@member.first_name} {@member.last_name}
<:subtitle>{gettext("This is a member record from your database.")}
<:actions>
- <.button navigate={~p"/members"}>
+ <.button navigate={~p"/members"} aria-label={gettext("Back to members list")}>
<.icon name="hero-arrow-left" />
+ {gettext("Back to members list")}
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> {gettext("Edit Member")}
@@ -37,6 +38,19 @@ defmodule MvWeb.MemberLive.Show do
<:item title={gettext("Street")}>{@member.street}
<:item title={gettext("House Number")}>{@member.house_number}
<:item title={gettext("Postal Code")}>{@member.postal_code}
+ <:item title={gettext("Linked User")}>
+ <%= if @member.user do %>
+ <.link
+ navigate={~p"/users/#{@member.user}"}
+ class="text-blue-600 hover:text-blue-800 underline"
+ >
+ <.icon name="hero-user" class="h-4 w-4 inline mr-1" />
+ {@member.user.email}
+
+ <% else %>
+ {gettext("No user linked")}
+ <% end %>
+
{gettext("Custom Properties")}
@@ -67,7 +81,7 @@ defmodule MvWeb.MemberLive.Show do
query =
Mv.Membership.Member
|> filter(id == ^id)
- |> load(properties: [:property_type])
+ |> load([:user, properties: [:property_type]])
member = Ash.read_one!(query)
diff --git a/lib/mv_web/live/property_live/form.ex b/lib/mv_web/live/property_live/form.ex
index 42814a3..a60a2e4 100644
--- a/lib/mv_web/live/property_live/form.ex
+++ b/lib/mv_web/live/property_live/form.ex
@@ -4,7 +4,7 @@ defmodule MvWeb.PropertyLive.Form do
@impl true
def render(assigns) do
~H"""
-
+
<.header>
{@page_title}
<:subtitle>{gettext("Use this form to manage property records in your database.")}
diff --git a/lib/mv_web/live/property_live/index.ex b/lib/mv_web/live/property_live/index.ex
index 7e27344..70171ef 100644
--- a/lib/mv_web/live/property_live/index.ex
+++ b/lib/mv_web/live/property_live/index.ex
@@ -4,7 +4,7 @@ defmodule MvWeb.PropertyLive.Index do
@impl true
def render(assigns) do
~H"""
-
+
<.header>
Listing Properties
<:actions>
diff --git a/lib/mv_web/live/property_live/show.ex b/lib/mv_web/live/property_live/show.ex
index 6d9bb1a..2a1e2ec 100644
--- a/lib/mv_web/live/property_live/show.ex
+++ b/lib/mv_web/live/property_live/show.ex
@@ -4,7 +4,7 @@ defmodule MvWeb.PropertyLive.Show do
@impl true
def render(assigns) do
~H"""
-
+
<.header>
Property {@property.id}
<:subtitle>This is a property record from your database.
diff --git a/lib/mv_web/live/property_type_live/form.ex b/lib/mv_web/live/property_type_live/form.ex
index 87cac94..8b8b452 100644
--- a/lib/mv_web/live/property_type_live/form.ex
+++ b/lib/mv_web/live/property_type_live/form.ex
@@ -4,7 +4,7 @@ defmodule MvWeb.PropertyTypeLive.Form do
@impl true
def render(assigns) do
~H"""
-
+
<.header>
{@page_title}
<:subtitle>
diff --git a/lib/mv_web/live/property_type_live/index.ex b/lib/mv_web/live/property_type_live/index.ex
index ed9ff7d..dae4da0 100644
--- a/lib/mv_web/live/property_type_live/index.ex
+++ b/lib/mv_web/live/property_type_live/index.ex
@@ -4,7 +4,7 @@ defmodule MvWeb.PropertyTypeLive.Index do
@impl true
def render(assigns) do
~H"""
-
+
<.header>
Listing Property types
<:actions>
diff --git a/lib/mv_web/live/property_type_live/show.ex b/lib/mv_web/live/property_type_live/show.ex
index 027baa6..ec2b0bf 100644
--- a/lib/mv_web/live/property_type_live/show.ex
+++ b/lib/mv_web/live/property_type_live/show.ex
@@ -4,7 +4,7 @@ defmodule MvWeb.PropertyTypeLive.Show do
@impl true
def render(assigns) do
~H"""
-
+
<.header>
Property type {@property_type.id}
<:subtitle>This is a property_type record from your database.
diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex
index e77283a..c7fd2d0 100644
--- a/lib/mv_web/live/user_live/form.ex
+++ b/lib/mv_web/live/user_live/form.ex
@@ -4,7 +4,7 @@ defmodule MvWeb.UserLive.Form do
@impl true
def render(assigns) do
~H"""
-
+
<.header>
{@page_title}
<:subtitle>{gettext("Use this form to manage user records in your database.")}
diff --git a/lib/mv_web/live/user_live/index.html.heex b/lib/mv_web/live/user_live/index.html.heex
index 258779e..66e3b9e 100644
--- a/lib/mv_web/live/user_live/index.html.heex
+++ b/lib/mv_web/live/user_live/index.html.heex
@@ -1,4 +1,4 @@
-
+
<.header>
{gettext("Listing Users")}
<:actions>
diff --git a/lib/mv_web/live/user_live/show.ex b/lib/mv_web/live/user_live/show.ex
index 3bf6baf..bdd241b 100644
--- a/lib/mv_web/live/user_live/show.ex
+++ b/lib/mv_web/live/user_live/show.ex
@@ -4,14 +4,15 @@ defmodule MvWeb.UserLive.Show do
@impl true
def render(assigns) do
~H"""
-
+
<.header>
{gettext("User")} {@user.email}
<:subtitle>{gettext("This is a user record from your database.")}
<:actions>
- <.button navigate={~p"/users"}>
+ <.button navigate={~p"/users"} aria-label={gettext("Back to users list")}>
<.icon name="hero-arrow-left" />
+ {gettext("Back to users list")}
<.button variant="primary" navigate={~p"/users/#{@user}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> {gettext("Edit User")}
@@ -26,6 +27,19 @@ defmodule MvWeb.UserLive.Show do
<:item title={gettext("Password Authentication")}>
{if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")}
+ <:item title={gettext("Linked Member")}>
+ <%= if @user.member do %>
+ <.link
+ navigate={~p"/members/#{@user.member}"}
+ class="text-blue-600 hover:text-blue-800 underline"
+ >
+ <.icon name="hero-users" class="h-4 w-4 inline mr-1" />
+ {@user.member.first_name} {@user.member.last_name}
+
+ <% else %>
+ {gettext("No member linked")}
+ <% end %>
+
"""
@@ -33,9 +47,11 @@ defmodule MvWeb.UserLive.Show do
@impl true
def mount(%{"id" => id}, _session, socket) do
+ user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member])
+
{:ok,
socket
|> assign(:page_title, gettext("Show User"))
- |> assign(:user, Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts))}
+ |> assign(:user, user)}
end
end
diff --git a/lib/mv_web/live_helpers.ex b/lib/mv_web/live_helpers.ex
index 03d7d45..331bb5c 100644
--- a/lib/mv_web/live_helpers.ex
+++ b/lib/mv_web/live_helpers.ex
@@ -1,6 +1,6 @@
defmodule MvWeb.LiveHelpers do
def on_mount(:default, _params, session, socket) do
- locale = session["locale"] || "en"
+ locale = session["locale"] || "de"
Gettext.put_locale(locale)
{:cont, socket}
end
diff --git a/lib/mv_web/live_user_auth.ex b/lib/mv_web/live_user_auth.ex
index 67bef70..b78ba21 100644
--- a/lib/mv_web/live_user_auth.ex
+++ b/lib/mv_web/live_user_auth.ex
@@ -28,15 +28,26 @@ defmodule MvWeb.LiveUserAuth do
end
end
- def on_mount(:live_user_required, _params, _session, socket) do
- if socket.assigns[:current_user] do
- {:cont, socket}
- else
- {:halt, Phoenix.LiveView.redirect(socket, to: ~p"/sign-in")}
+ def on_mount(:live_user_required, _params, session, socket) do
+ socket = AshAuthentication.Phoenix.LiveSession.assign_new_resources(socket, session)
+
+ case socket.assigns do
+ %{current_user: %{} = user} ->
+ {:cont, assign(socket, :current_user, user)}
+
+ _ ->
+ socket = Phoenix.LiveView.redirect(socket, to: ~p"/sign-in")
+ {:halt, socket}
end
end
- def on_mount(:live_no_user, _params, _session, socket) do
+ def on_mount(:live_no_user, _params, session, socket) do
+ # Set the locale for not logged in user to set the language in the Log-In Screen
+ # otherwise the locale is not taken for the Log-In Screen
+ locale = session["locale"] || "en"
+ Gettext.put_locale(MvWeb.Gettext, locale)
+ {:cont, assign(socket, :locale, locale)}
+
if socket.assigns[:current_user] do
{:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")}
else
diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex
index 696a491..bf2c071 100644
--- a/lib/mv_web/router.ex
+++ b/lib/mv_web/router.ex
@@ -47,7 +47,7 @@ defmodule MvWeb.Router do
"""
ash_authentication_live_session :authentication_required,
on_mount: {MvWeb.LiveUserAuth, :live_user_required} do
- get "/", PageController, :home
+ live "/", MemberLive.Index, :index
live "/members", MemberLive.Index, :index
live "/members/new", MemberLive.Form, :new
@@ -85,19 +85,19 @@ defmodule MvWeb.Router do
reset_path: "/reset",
auth_routes_prefix: "/auth",
on_mount: [{MvWeb.LiveUserAuth, :live_no_user}],
- overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default],
- gettext_backend: {MvWeb.Gettext, "default"}
+ overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.DaisyUI],
+ gettext_backend: {MvWeb.Gettext, "auth"}
# Remove this if you do not want to use the reset password feature
reset_route auth_routes_prefix: "/auth",
- overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default],
- gettext_backend: {MvWeb.Gettext, "default"}
+ overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.DaisyUI],
+ gettext_backend: {MvWeb.Gettext, "auth"}
# Remove this if you do not use the confirmation strategy
confirm_route Mv.Accounts.User, :confirm_new_user,
auth_routes_prefix: "/auth",
- overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default],
- gettext_backend: {MvWeb.Gettext, "default"}
+ overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.DaisyUI],
+ gettext_backend: {MvWeb.Gettext, "auth"}
# Remove this if you do not use the magic link strategy.
# magic_sign_in_route(Mv.Accounts.User, :magic_link,
@@ -139,8 +139,47 @@ defmodule MvWeb.Router do
end
defp set_locale(conn, _opts) do
- locale = get_session(conn, :locale) || "en"
+ locale =
+ get_session(conn, :locale) ||
+ extract_locale_from_headers(conn.req_headers)
+
Gettext.put_locale(MvWeb.Gettext, locale)
+
conn
+ |> put_session(:locale, locale)
+ |> assign(:locale, locale)
end
+
+ # Get locale from user
+ defp extract_locale_from_headers(headers) do
+ headers
+ |> Enum.find_value(fn
+ {"accept-language", value} -> value
+ _ -> nil
+ end)
+ |> parse_accept_language()
+ |> Enum.find(&supported_locale?/1)
+ |> fallback_locale()
+ end
+
+ defp parse_accept_language(nil), do: []
+
+ defp parse_accept_language(header) do
+ header
+ |> String.split(",")
+ |> Enum.map(&String.trim/1)
+ |> Enum.map(fn lang ->
+ lang
+ # we only want the first part
+ |> String.split(";")
+ |> hd()
+ |> String.split("-")
+ |> hd()
+ end)
+ end
+
+ # Our supported languages for now are german and english, english as fallback language
+ defp supported_locale?(locale), do: locale in ["en", "de"]
+ defp fallback_locale(nil), do: "en"
+ defp fallback_locale(locale), do: locale
end
diff --git a/mix.exs b/mix.exs
index 9b4fdd1..86b1010 100644
--- a/mix.exs
+++ b/mix.exs
@@ -35,9 +35,9 @@ defmodule Mv.MixProject do
# Type `mix help deps` for examples and options.
defp deps do
[
- {:tidewave, "~> 0.2", only: [:dev]},
+ {:tidewave, "~> 0.5", only: [:dev]},
{:sourceror, "~> 1.8", only: [:dev, :test]},
- {:live_debugger, "~> 0.3", only: [:dev]},
+ {:live_debugger, "~> 0.4", only: [:dev]},
{:ash_admin, "~> 0.13"},
{:ash_postgres, "~> 2.0"},
{:ash_phoenix, "~> 2.0"},
@@ -56,7 +56,7 @@ defmodule Mv.MixProject do
{:lazy_html, ">= 0.0.0", only: :test},
{:phoenix_live_dashboard, "~> 0.8.3"},
{:esbuild, "~> 0.9", runtime: Mix.env() == :dev},
- {:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
+ {:tailwind, "~> 0.4", runtime: Mix.env() == :dev},
{:heroicons,
github: "tailwindlabs/heroicons",
tag: "v2.2.0",
diff --git a/mix.lock b/mix.lock
index 6599c01..28683a3 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,24 +1,25 @@
%{
- "ash": {:hex, :ash, "3.5.33", "2d4986050ce1c86f711b53f9bb40d6b227871f0cc771dab0b8b814a75a27c5ab", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.4 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.2.65 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c7d1e043059967df749f9445bb903d62ed9c1defb5d45f6ddf32754b411ae93"},
- "ash_admin": {:hex, :ash_admin, "0.13.13", "d6f491587659c63c1e37b542bdef69c1e2dce9e13696e1fa537488983b98ac10", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "3378f54b5bfdbecc735ed848f137223692be4320975d01c23bd64e47db1f1a9a"},
- "ash_authentication": {:hex, :ash_authentication, "4.9.9", "23ec61bedc3157c258ece622c6f0f6a7645df275ff5e794d513cc6e8798471eb", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, ">= 3.4.29 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "~> 0.2.13", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "ab8bd1277ff570425346dcf22dd14a059d9bbce0c28d24964b60e51fabaddda8"},
- "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.10.5", "9f3b1bee4a57f2269efea61e5efe55472683429b8a5bf1ebdd02d9748640f106", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, ">= 4.9.1 and < 5.0.0-0", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "3f25778d126c7e759444df0855077802c93299457afdf26566f8de6320ba56da"},
- "ash_phoenix": {:hex, :ash_phoenix, "2.3.12", "34116f054ca4ef97b4badc73f028d78ee517692b713fd39f4c93f90bc2afd038", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "27394e40b44ca06977e90bd0b38bce7bf41c6dab9fe2aa0b474fdb7c0c1f911b"},
- "ash_postgres": {:hex, :ash_postgres, "2.6.14", "8085b25864c63029a546ec7191d111f348405cb9d3a90677e52d805576319b55", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.2.72 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.14 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "0230343b959fb9cd24f76d1ecdbba90045d9625f03b33170ecb7c9ef011c9ac2"},
- "ash_sql": {:hex, :ash_sql, "0.2.89", "ad4ad497263b586a7f3949ceea5d44620a36cb99a1ef0ff5f58f13a77d9b99ef", [:mix], [{:ash, ">= 3.5.25 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "bd957aee95bbdf6326fc7a9212f9a2ab87329b99ee3646c373a87bb3c9968566"},
+ "ash": {:hex, :ash, "3.7.1", "abb55dee19e0959e529e52fe0622468825ae05400f535484919713e492d9a9e7", [:mix], [{:crux, "~> 0.1.0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4474ce9befe9862d1ed73cadf8a755e836c45a14a7b3b952d02e1a12f2b2e529"},
+ "ash_admin": {:hex, :ash_admin, "0.13.19", "43227905381ea0b835039fb3f3d255a3664925619937869e605402bc2f95c5e5", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "41e6262c437164df6f052e43cc93be225a7e148b49a813fc451e70172338ee38"},
+ "ash_authentication": {:hex, :ash_authentication, "4.11.0", "4165ede37e179cb0a24b7bfc38d620fa93c05fb6272fbd353cafe27652b1e68b", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, ">= 3.4.29 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "~> 0.2.13", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "8201d0169944c1df3db9b560494e50e1c3bc99c3b1a8a2ef1e61b0f77bc820df"},
+ "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.12.0", "75d7d77e3b626f3d8ea6ee44291d885950172ab399d997b2934f93d2e0a55a61", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "a423e22b40fdf3b1a7f2178e44ca68f48fdb5ba0d87e8d42a43de1a3b63ca704"},
+ "ash_phoenix": {:hex, :ash_phoenix, "2.3.17", "a074ae6d9d7135d99c4edc91ddebe4c035ca380b044592bf9c3d58471669cf52", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "94e4a6cc6ced31cddba930c45c1c3477aa59b956e7fc3cdc63095cf0e506bdf5"},
+ "ash_postgres": {:hex, :ash_postgres, "2.6.23", "5976a7e5e204b7bc627b1d17026bec9da4d880f2e09cd94bf4e8cee41fef32ce", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.7 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "61de4aedfe30f1ae14d8185cfc37a5b1940b45b60f2dfbdf9eb056f97dca41c5"},
+ "ash_sql": {:hex, :ash_sql, "0.3.7", "80affa5446075d71deb157c67290685a84b392d723be766bfb684f58fe0143de", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "ce4d974b8e784171c5a2a62593b3672b42dfd4888fa2239f01a6b32bad769038"},
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
- "bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"},
+ "bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
- "castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"},
- "cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"},
+ "castore": {:hex, :castore, "1.0.15", "8aa930c890fe18b6fe0a0cff27b27d0d4d231867897bd23ea772dee561f032a3", [:mix], [], "hexpm", "96ce4c69d7d5d7a0761420ef743e2f4096253931a3ba69e5ff8ef1844fe446d3"},
+ "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
- "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"},
- "db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"},
+ "credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"},
+ "crux": {:hex, :crux, "0.1.1", "94f2f97d2a6079ae3c57f356412bc3b307f9579a80e43f526447b1d508dd4a72", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "e59d498f038193cbe31e448f9199f5b4c53a4c67cece9922bb839595189dd2b6"},
+ "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
- "ecto": {:hex, :ecto, "3.13.2", "7d0c0863f3fc8d71d17fc3ad3b9424beae13f02712ad84191a826c7169484f01", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "669d9291370513ff56e7b7e7081b7af3283d02e046cf3d403053c557894a0b3e"},
+ "ecto": {:hex, :ecto, "3.13.3", "6a983f0917f8bdc7a89e96f2bf013f220503a0da5d8623224ba987515b3f0d80", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1927db768f53a88843ff25b6ba7946599a8ca8a055f69ad8058a1432a399af94"},
"ecto_commons": {:hex, :ecto_commons, "0.3.6", "7b1d9e59396cf8c8cbe5a26d50d03f9b6d0fe6c640210dd503622f276f1e59bb", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.2", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "3f12981a1e398f206c5d2014e7b732b7ec91b110b9cb84875cb5b28fc75d7a0a"},
"ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
@@ -26,63 +27,65 @@
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
"ex_phone_number": {:hex, :ex_phone_number, "0.4.8", "c1c5e6f0673822a2a7b439b43af7d3eb1a5c19ae582b772b8b8d12625dd51ec1", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "43e2357c6b8cfe556bcd417f4ce9aaef267a786e31a2938902daaa0d36f69757"},
"expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"},
- "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
+ "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
- "fine": {:hex, :fine, "0.1.2", "85cf7dd190c7c6c54c2840754ae977c9acc0417316255b674fad9f2678e4ecc7", [:mix], [], "hexpm", "9113531982c2b60dbea6c7233917ddf16806947cd7104b5d03011bf436ca3072"},
+ "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
- "igniter": {:hex, :igniter, "0.6.25", "e2774a4605c2bc9fc38f689232604aea0fc925c7966ae8e928fd9ea2fa9d300c", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "b1916e1e45796d5c371c7671305e81277231617eb58b1c120915aba237fbce6a"},
+ "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
+ "igniter": {:hex, :igniter, "0.6.30", "83a466369ebb8fe009e0823c7bf04314dc545122c2d48f896172fc79df33e99d", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "76a14d5b7f850bb03b5243088c3649d54a2e52e34a2aa1104dee23cf50a8bae0"},
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
"jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"},
- "lazy_html": {:hex, :lazy_html, "0.1.3", "8b9c8c135e95f7bc483de6195c4e1c0b2c913a5e2c57353ef4e82703b7ac8bd1", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "5f96f29587dcfed8a22281e8c44c6607e958ba821d90b9dfc003d1ef610f7d07"},
+ "lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
- "live_debugger": {:hex, :live_debugger, "0.3.1", "4b4d36481c3b0a49ec082c8268d37974ece34d2091ac323ccc0c906eb0c0d032", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20.4 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "e50836495134b0dde98dc96919340749130ecb83618ea99d63a3a58ed1dcc27d"},
+ "live_debugger": {:hex, :live_debugger, "0.4.2", "775c3a570ef3c44d27d261b3c1aae23ef35cac949a57f67b3e7b1aa1fb2707bc", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "5b24e37985f0424056a322a18dab4a5fb0f4e8ee4e55975985364e0b45d683b9"},
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
- "owl": {:hex, :owl, "0.12.2", "65906b525e5c3ef51bab6cba7687152be017aebe1da077bb719a5ee9f7e60762", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "6398efa9e1fea70a04d24231e10dcd66c1ac1aa2da418d20ef5357ec61de2880"},
- "phoenix": {:hex, :phoenix, "1.8.0-rc.4", "6c18c1e07938d3d8dbb957ed0d193fa591718a2997058f6883cfa7447f07612a", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "754c8caf0d1332bc691f826d678b192b3f78cfeb01df2f623683e308b363dc41"},
+ "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
+ "phoenix": {:hex, :phoenix, "1.8.1", "865473a60a979551a4879db79fbfb4503e41cd809e77c85af79716578b6a456d", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "84d77d2b2e77c3c7e7527099bd01ef5c8560cd149c036d6b3a40745f11cd2fb2"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"},
- "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"},
+ "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
- "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.0", "2791fac0e2776b640192308cc90c0dbcf67843ad51387ed4ecae2038263d708d", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b3a1fa036d7eb2f956774eda7a7638cf5123f8f2175aca6d6420a7f95e598e1c"},
- "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.0-rc.4", "1e933da296a80c0f57689b25db8711fc47feb452ac5de4b4824e8e64bccae9f9", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4747a143c9b494b19f6ac58b919be46ff773066efe4882ee37ba0fd272f673c2"},
+ "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"},
+ "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.14", "cae84abc4cd00dde4bb200b8516db556704c585c267aff9cd4955ff83cceb86c", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b827980e2bc00fddd8674e3b567519a4e855b5de04bf8607140414f1101e2627"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
"plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
- "reactor": {:hex, :reactor, "0.15.6", "d717f9add549b25a089a94c90197718d2d838e35d81dd776b1d81587d4cf2aaa", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "74db98165e3644d86e0f723672d91ceca4339eaa935bcad7e78bf146a46d77b9"},
+ "reactor": {:hex, :reactor, "0.17.0", "eb8bdb530dbae824e2d36a8538f8ec4f3aa7c2d1b61b04959fa787c634f88b49", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3c3bf71693adbad9117b11ec83cfed7d5851b916ade508ed9718de7ae165bf25"},
"req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"},
- "rewrite": {:hex, :rewrite, "1.1.2", "f5a5d10f5fed1491a6ff48e078d4585882695962ccc9e6c779bae025d1f92eda", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "7f8b94b1e3528d0a47b3e8b7bfeca559d2948a65fa7418a9ad7d7712703d39d4"},
+ "rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
- "sobelow": {:hex, :sobelow, "0.14.0", "dd82aae8f72503f924fe9dd97ffe4ca694d2f17ec463dcfd365987c9752af6ee", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "7ecf91e298acfd9b24f5d761f19e8f6e6ac585b9387fb6301023f1f2cd5eed5f"},
+ "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
"sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"},
- "spark": {:hex, :spark, "2.2.67", "67626cb9f59ea4b1c5aa85d4afdd025e0740cbd49ed82665d0a40ff007d7fd4b", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "c8575402e3afc66871362e821bece890536d16319cdb758c5fb2d1250182e46f"},
+ "spark": {:hex, :spark, "2.3.5", "f30d30ecc3b4ab9b932d9aada66af7677fc1f297a2c349b0bcec3eafb9f996e8", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "0e9d339704d5d148f77f2b2fef3bcfc873a9e9bb4224fcf289c545d65827202f"},
"spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"},
"splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"},
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
- "swoosh": {:hex, :swoosh, "1.19.5", "5abd71be78302ba21be56a2b68d05c9946ff1f1bd254f949efef09d253b771ac", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c953f51ee0a8b237e0f4307c9cefd3eb1eb751c35fcdda2a8bccb991766473be"},
- "tailwind": {:hex, :tailwind, "0.3.1", "a89d2835c580748c7a975ad7dd3f2ea5e63216dc16d44f9df492fbd12c094bed", [:mix], [], "hexpm", "98a45febdf4a87bc26682e1171acdedd6317d0919953c353fcd1b4f9f4b676a2"},
+ "swoosh": {:hex, :swoosh, "1.19.8", "0576f2ea96d1bb3a6e02cc9f79cbd7d497babc49a353eef8dce1a1f9f82d7915", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d7503c2daf0f9899afd8eba9923eeddef4b62e70816e1d3b6766e4d6c60e94ad"},
+ "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
- "thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"},
- "tidewave": {:hex, :tidewave, "0.2.0", "e98378803e535d3035138e4b354dcfca26b7f862fd44cffef5aa697b814c0b0b", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.47 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "6ad11829f4600cd69955ffc66935e6456b775fea095172147244ba6f65986735"},
+ "thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
+ "tidewave": {:hex, :tidewave, "0.5.0", "8f278d7eb2d0af36ae6d4f73a5872bd066815bd57b57401125187ba901f095a4", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "9a1eb5d2f12ff4912328dfbfe652c27fded462c6ed6fd11814ee28d3e9d016b4"},
+ "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
- "yaml_elixir": {:hex, :yaml_elixir, "2.11.0", "9e9ccd134e861c66b84825a3542a1c22ba33f338d82c07282f4f1f52d847bd50", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "53cc28357ee7eb952344995787f4bb8cc3cecbf189652236e9b163e8ce1bc242"},
+ "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"},
"ymlr": {:hex, :ymlr, "5.1.4", "b924d61e1fc1ec371cde6ab3ccd9311110b1e052fc5c2460fb322e8380e7712a", [:mix], [], "hexpm", "75f16cf0709fbd911b30311a0359a7aa4b5476346c01882addefd5f2b1cfaa51"},
}
diff --git a/priv/gettext/auth.pot b/priv/gettext/auth.pot
new file mode 100644
index 0000000..29ee991
--- /dev/null
+++ b/priv/gettext/auth.pot
@@ -0,0 +1,64 @@
+## This file is a PO Template file.
+##
+## "msgid"s here are often extracted from source code.
+## Add new messages manually only if they're dynamic
+## messages that can't be statically extracted.
+##
+## Leave "msgstr"s empty as changing them here has no
+## effect: edit them in PO (.po) files instead.
+#
+msgid ""
+msgstr ""
+"Language: en\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+msgid "Already have an account?"
+msgstr ""
+
+msgid "Email or password was incorrect"
+msgstr ""
+
+msgid "Email"
+msgstr ""
+
+msgid "Forgot your password?"
+msgstr ""
+
+msgid "If this user exists in our database you will contacted with a sign-in link shortly."
+msgstr ""
+
+msgid "If this user exists in our system, you will be contacted with reset instructions shortly."
+msgstr ""
+
+msgid "Need an account?"
+msgstr ""
+
+msgid "Password"
+msgstr ""
+
+msgid "Password Confirmation"
+msgstr ""
+
+msgid "Request magic link"
+msgstr ""
+
+msgid "Request password reset token"
+msgstr ""
+
+msgid "Requesting ..."
+msgstr ""
+
+msgid "Reset password with token"
+msgstr ""
+
+msgid "Sign in"
+msgstr ""
+
+msgid "Signing in ..."
+msgstr ""
+
+msgid "Your password has successfully been reset"
+msgstr ""
diff --git a/priv/gettext/de/LC_MESSAGES/auth.po b/priv/gettext/de/LC_MESSAGES/auth.po
new file mode 100644
index 0000000..967755e
--- /dev/null
+++ b/priv/gettext/de/LC_MESSAGES/auth.po
@@ -0,0 +1,66 @@
+## "msgid"s in this file come from POT (.pot) files.
+###
+### Do not add, change, or remove "msgid"s manually here as
+### they're tied to the ones in the corresponding POT file
+### (with the same domain).
+###
+### Use "mix gettext.extract --merge" or "mix gettext.merge"
+### to merge POT files into PO files.
+msgid ""
+msgstr ""
+"Language: de\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+msgid "Already have an account?"
+msgstr "Bereit zum Anmelden?"
+
+msgid "Email or password was incorrect"
+msgstr "Email oder Passwort nicht korrekt"
+
+msgid "Email"
+msgstr "Email"
+
+msgid "Forgot your password?"
+msgstr "Passwort vergessen?"
+
+msgid "If this user exists in our database you will contacted with a sign-in link shortly."
+msgstr "Falls diese*r Benutzer*in bekannt ist, wird jetzt eine Email mit Anmelde-Link versendet."
+
+msgid "If this user exists in our system, you will be contacted with reset instructions shortly."
+msgstr "Falls diese*r Benutzer*in bekannt ist, wird jetzt eine Email mit einer Anleitung zum ZurΓΌcksetzen versendet."
+
+msgid "Need an account?"
+msgstr "Konto anlegen?"
+
+msgid "Password"
+msgstr "Passwort"
+
+msgid "Password Confirmation"
+msgstr "Passwort Wiederholung"
+
+msgid "Request magic link"
+msgstr "Magischen Link anfordern"
+
+msgid "Request password reset token"
+msgstr "Passwort zurΓΌcksetzen"
+
+msgid "Requesting ..."
+msgstr "Anfrage lΓ₯uft..."
+
+msgid "Reset password with token"
+msgstr "Neues Passwort setzen"
+
+msgid "Sign in"
+msgstr "Anmelden"
+
+msgid "Signing in ..."
+msgstr "Anmelden..."
+
+msgid "Your password has successfully been reset"
+msgstr "Das Passwort wurde erfolgreich zurΓΌckgesetzt"
+
+#~ msgid "Sign in with Rauthy"
+#~ msgstr "Anmelden mit der Vereinscloud"
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index 45723e7..c8c219a 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -15,69 +15,69 @@ msgstr ""
msgid "Actions"
msgstr "Aktionen"
-#: lib/mv_web/live/member_live/index.html.heex:77
-#: lib/mv_web/live/user_live/index.html.heex:69
+#: lib/mv_web/live/member_live/index.html.heex:193
+#: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format
msgid "Are you sure?"
msgstr "Bist du sicher?"
-#: lib/mv_web/components/layouts.ex:71
-#: lib/mv_web/components/layouts.ex:83
+#: lib/mv_web/components/layouts.ex:80
+#: lib/mv_web/components/layouts.ex:92
#, elixir-autogen, elixir-format
msgid "Attempting to reconnect"
msgstr "Verbindung wird wiederhergestellt"
#: lib/mv_web/live/member_live/form.ex:25
-#: lib/mv_web/live/member_live/index.html.heex:62
+#: lib/mv_web/live/member_live/index.html.heex:138
#: lib/mv_web/live/member_live/show.ex:36
#, elixir-autogen, elixir-format
msgid "City"
msgstr "Stadt"
-#: lib/mv_web/live/member_live/index.html.heex:79
-#: lib/mv_web/live/user_live/index.html.heex:71
+#: lib/mv_web/live/member_live/index.html.heex:195
+#: lib/mv_web/live/user_live/index.html.heex:67
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr "LΓΆschen"
-#: lib/mv_web/live/member_live/index.html.heex:71
+#: lib/mv_web/live/member_live/index.html.heex:187
#: lib/mv_web/live/user_live/form.ex:109
-#: lib/mv_web/live/user_live/index.html.heex:63
+#: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format
msgid "Edit"
msgstr "Bearbeite"
-#: lib/mv_web/live/member_live/show.ex:18
-#: lib/mv_web/live/member_live/show.ex:81
+#: lib/mv_web/live/member_live/show.ex:19
+#: lib/mv_web/live/member_live/show.ex:95
#, elixir-autogen, elixir-format
msgid "Edit Member"
msgstr "Mitglied bearbeiten"
#: lib/mv_web/live/member_live/form.ex:18
-#: lib/mv_web/live/member_live/index.html.heex:58
+#: lib/mv_web/live/member_live/index.html.heex:70
#: lib/mv_web/live/member_live/show.ex:27
#: lib/mv_web/live/user_live/form.ex:14
-#: lib/mv_web/live/user_live/index.html.heex:48
-#: lib/mv_web/live/user_live/show.ex:24
+#: lib/mv_web/live/user_live/index.html.heex:44
+#: lib/mv_web/live/user_live/show.ex:25
#, elixir-autogen, elixir-format
msgid "Email"
msgstr "E-Mail"
#: lib/mv_web/live/member_live/form.ex:16
-#: lib/mv_web/live/member_live/show.ex:25
+#: lib/mv_web/live/member_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "First Name"
msgstr "Vorname"
#: lib/mv_web/live/member_live/form.ex:22
-#: lib/mv_web/live/member_live/index.html.heex:64
+#: lib/mv_web/live/member_live/index.html.heex:172
#: lib/mv_web/live/member_live/show.ex:33
#, elixir-autogen, elixir-format
msgid "Join Date"
msgstr "Beitrittsdatum"
#: lib/mv_web/live/member_live/form.ex:17
-#: lib/mv_web/live/member_live/show.ex:26
+#: lib/mv_web/live/member_live/show.ex:27
#, elixir-autogen, elixir-format
msgid "Last Name"
msgstr "Nachname"
@@ -87,18 +87,18 @@ msgstr "Nachname"
msgid "New Member"
msgstr "Neues Mitglied"
-#: lib/mv_web/live/member_live/index.html.heex:68
-#: lib/mv_web/live/user_live/index.html.heex:60
+#: lib/mv_web/live/member_live/index.html.heex:184
+#: lib/mv_web/live/user_live/index.html.heex:56
#, elixir-autogen, elixir-format
msgid "Show"
msgstr "Anzeigen"
-#: lib/mv_web/components/layouts.ex:78
+#: lib/mv_web/components/layouts.ex:87
#, elixir-autogen, elixir-format
msgid "Something went wrong!"
msgstr "Etwas ist schiefgelaufen!"
-#: lib/mv_web/components/layouts.ex:66
+#: lib/mv_web/components/layouts.ex:75
#, elixir-autogen, elixir-format
msgid "We can't find the internet"
msgstr "Keine Internetverbindung gefunden"
@@ -109,51 +109,51 @@ msgid "close"
msgstr "schlieΓen"
#: lib/mv_web/live/member_live/form.ex:19
-#: lib/mv_web/live/member_live/show.ex:28
+#: lib/mv_web/live/member_live/show.ex:29
#, elixir-autogen, elixir-format
msgid "Birth Date"
msgstr "Geburtsdatum"
#: lib/mv_web/live/member_live/form.ex:30
-#: lib/mv_web/live/member_live/show.ex:42
+#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Custom Properties"
msgstr "Eigene Eigenschaften"
#: lib/mv_web/live/member_live/form.ex:23
-#: lib/mv_web/live/member_live/show.ex:34
+#: lib/mv_web/live/member_live/show.ex:35
#, elixir-autogen, elixir-format
msgid "Exit Date"
msgstr "Austrittsdatum"
#: lib/mv_web/live/member_live/form.ex:27
-#: lib/mv_web/live/member_live/index.html.heex:60
+#: lib/mv_web/live/member_live/index.html.heex:104
#: lib/mv_web/live/member_live/show.ex:38
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr "Hausnummer"
#: lib/mv_web/live/member_live/form.ex:24
-#: lib/mv_web/live/member_live/show.ex:35
+#: lib/mv_web/live/member_live/show.ex:36
#, elixir-autogen, elixir-format
msgid "Notes"
msgstr "Notizen"
#: lib/mv_web/live/member_live/form.ex:20
-#: lib/mv_web/live/member_live/show.ex:29
+#: lib/mv_web/live/member_live/show.ex:30
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr "Bezahlt"
#: lib/mv_web/live/member_live/form.ex:21
-#: lib/mv_web/live/member_live/index.html.heex:63
+#: lib/mv_web/live/member_live/index.html.heex:155
#: lib/mv_web/live/member_live/show.ex:32
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr "Telefonnummer"
#: lib/mv_web/live/member_live/form.ex:28
-#: lib/mv_web/live/member_live/index.html.heex:61
+#: lib/mv_web/live/member_live/index.html.heex:121
#: lib/mv_web/live/member_live/show.ex:39
#, elixir-autogen, elixir-format
msgid "Postal Code"
@@ -173,7 +173,7 @@ msgid "Saving..."
msgstr "Speichern..."
#: lib/mv_web/live/member_live/form.ex:26
-#: lib/mv_web/live/member_live/index.html.heex:59
+#: lib/mv_web/live/member_live/index.html.heex:87
#: lib/mv_web/live/member_live/show.ex:37
#, elixir-autogen, elixir-format
msgid "Street"
@@ -184,17 +184,17 @@ msgstr "StraΓe"
msgid "Use this form to manage member records and their properties."
msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenschaften."
-#: lib/mv_web/live/member_live/show.ex:24
+#: lib/mv_web/live/member_live/show.ex:25
#, elixir-autogen, elixir-format
msgid "Id"
msgstr "ID"
-#: lib/mv_web/live/member_live/show.ex:30
+#: lib/mv_web/live/member_live/show.ex:31
#, elixir-autogen, elixir-format
msgid "No"
msgstr "Nein"
-#: lib/mv_web/live/member_live/show.ex:80
+#: lib/mv_web/live/member_live/show.ex:94
#, elixir-autogen, elixir-format, fuzzy
msgid "Show Member"
msgstr "Mitglied anzeigen"
@@ -204,7 +204,7 @@ msgstr "Mitglied anzeigen"
msgid "This is a member record from your database."
msgstr "Dies ist ein Mitglied aus deiner Datenbank."
-#: lib/mv_web/live/member_live/show.ex:30
+#: lib/mv_web/live/member_live/show.ex:31
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr "Ja"
@@ -281,17 +281,17 @@ msgstr "Eigenschaftstyp auswΓ€hlen"
msgid "Description"
msgstr "Beschreibung"
-#: lib/mv_web/live/user_live/show.ex:17
+#: lib/mv_web/live/user_live/show.ex:18
#, elixir-autogen, elixir-format
msgid "Edit User"
-msgstr "Benutzer bearbeiten"
+msgstr "Benutzer*in bearbeiten"
-#: lib/mv_web/live/user_live/show.ex:27
+#: lib/mv_web/live/user_live/show.ex:28
#, elixir-autogen, elixir-format
msgid "Enabled"
msgstr "Aktiviert"
-#: lib/mv_web/live/user_live/show.ex:23
+#: lib/mv_web/live/user_live/show.ex:24
#, elixir-autogen, elixir-format
msgid "ID"
msgstr "ID"
@@ -301,7 +301,7 @@ msgstr "ID"
msgid "Immutable"
msgstr "UnverΓ€nderlich"
-#: lib/mv_web/components/layouts/navbar.ex:73
+#: lib/mv_web/components/layouts/navbar.ex:94
#, elixir-autogen, elixir-format
msgid "Logout"
msgstr "Abmelden"
@@ -310,7 +310,7 @@ msgstr "Abmelden"
#: lib/mv_web/live/user_live/index.html.heex:3
#, elixir-autogen, elixir-format
msgid "Listing Users"
-msgstr "Benutzer auflisten"
+msgstr "Benutzer*innen auflisten"
#: lib/mv_web/live/property_live/form.ex:27
#, elixir-autogen, elixir-format
@@ -318,13 +318,12 @@ msgid "Member"
msgstr "Mitglied"
#: lib/mv_web/components/layouts/navbar.ex:14
-#: lib/mv_web/live/member_live/index.ex:12
+#: lib/mv_web/live/member_live/index.ex:8
#: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format
msgid "Members"
msgstr "Mitglieder"
-#: lib/mv_web/live/member_live/index.html.heex:50
#: lib/mv_web/live/property_type_live/form.ex:16
#, elixir-autogen, elixir-format
msgid "Name"
@@ -333,14 +332,14 @@ msgstr "Name"
#: lib/mv_web/live/user_live/index.html.heex:6
#, elixir-autogen, elixir-format
msgid "New User"
-msgstr "Neuer Benutzer"
+msgstr "Neue*r Benutzer*in"
-#: lib/mv_web/live/user_live/show.ex:27
+#: lib/mv_web/live/user_live/show.ex:28
#, elixir-autogen, elixir-format
msgid "Not enabled"
msgstr "Nicht aktiviert"
-#: lib/mv_web/live/user_live/show.ex:25
+#: lib/mv_web/live/user_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "Not set"
msgstr "Nicht gesetzt"
@@ -351,13 +350,13 @@ msgstr "Nicht gesetzt"
msgid "Note"
msgstr "Hinweis"
-#: lib/mv_web/live/user_live/index.html.heex:56
-#: lib/mv_web/live/user_live/show.ex:25
+#: lib/mv_web/live/user_live/index.html.heex:52
+#: lib/mv_web/live/user_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "OIDC ID"
msgstr "OIDC ID"
-#: lib/mv_web/live/user_live/show.ex:26
+#: lib/mv_web/live/user_live/show.ex:27
#, elixir-autogen, elixir-format
msgid "Password Authentication"
msgstr "Passwort-Authentifizierung"
@@ -367,15 +366,15 @@ 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:69
+#: lib/mv_web/components/layouts/navbar.ex:89
#, elixir-autogen, elixir-format
msgid "Profil"
msgstr "Profil"
#: lib/mv_web/live/property_live/form.ex:207
-#, elixir-autogen, elixir-format, fuzzy
+#, elixir-autogen, elixir-format
msgid "Property %{action} successfully"
-msgstr "Mitglied %{action} erfolgreich"
+msgstr "Eigenschaft %{action} erfolgreich"
#: lib/mv_web/live/property_live/form.ex:18
#, elixir-autogen, elixir-format
@@ -402,17 +401,17 @@ msgstr "Eigenschaft speichern"
msgid "Save Property type"
msgstr "Eigenschaftstyp speichern"
-#: lib/mv_web/live/member_live/index.html.heex:27
+#: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format
msgid "Select all members"
msgstr "Alle Mitglieder auswΓ€hlen"
-#: lib/mv_web/live/member_live/index.html.heex:41
+#: lib/mv_web/live/member_live/index.html.heex:48
#, elixir-autogen, elixir-format
msgid "Select member"
msgstr "Mitglied auswΓ€hlen"
-#: lib/mv_web/components/layouts/navbar.ex:72
+#: lib/mv_web/components/layouts/navbar.ex:92
#, elixir-autogen, elixir-format
msgid "Settings"
msgstr "Einstellungen"
@@ -420,17 +419,17 @@ msgstr "Einstellungen"
#: lib/mv_web/live/user_live/form.ex:93
#, elixir-autogen, elixir-format
msgid "Save User"
-msgstr "Benutzer speichern"
+msgstr "Benutzer*in speichern"
-#: lib/mv_web/live/user_live/show.ex:38
+#: lib/mv_web/live/user_live/show.ex:54
#, elixir-autogen, elixir-format
msgid "Show User"
-msgstr "Benutzer anzeigen"
+msgstr "Benutzer*in anzeigen"
#: lib/mv_web/live/user_live/show.ex:10
#, elixir-autogen, elixir-format
msgid "This is a user record from your database."
-msgstr "Dies ist ein Benutzer-Datensatz aus Ihrer Datenbank."
+msgstr "Dies ist ein Benutzer*innen-Datensatz aus Ihrer Datenbank."
#: lib/mv_web/live/property_live/form.ex:95
#, elixir-autogen, elixir-format
@@ -438,25 +437,25 @@ msgid "Unsupported value type: %{type}"
msgstr "Nicht unterstΓΌtzter Wertetyp: %{type}"
#: lib/mv_web/live/property_live/form.ex:10
-#, elixir-autogen, elixir-format, fuzzy
+#, elixir-autogen, elixir-format
msgid "Use this form to manage property records in your database."
-msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenschaften."
+msgstr "Dieses Formular dient zur Verwaltung von Eigenschaften in der Datenbank."
#: lib/mv_web/live/property_type_live/form.ex:11
-#, elixir-autogen, elixir-format, fuzzy
+#, elixir-autogen, elixir-format
msgid "Use this form to manage property_type records in your database."
-msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenschaften."
+msgstr "Dieses Formular dient zur Verwaltung von Eigenschaftstypen in der Datenbank."
#: lib/mv_web/live/user_live/form.ex:10
#, elixir-autogen, elixir-format
msgid "Use this form to manage user records in your database."
-msgstr "Verwenden Sie dieses Formular, um Benutzer-DatensΓ€tze zu verwalten."
+msgstr "Verwenden Sie dieses Formular, um Benutzer*innen-DatensΓ€tze zu verwalten."
#: lib/mv_web/live/user_live/form.ex:110
#: lib/mv_web/live/user_live/show.ex:9
#, elixir-autogen, elixir-format
msgid "User"
-msgstr "Benutzer"
+msgstr "Benutzer*in"
#: lib/mv_web/live/property_live/form.ex:59
#, elixir-autogen, elixir-format
@@ -469,11 +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
#, 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
#, elixir-autogen, elixir-format
msgid "descending"
msgstr "absteigend"
@@ -481,17 +482,17 @@ msgstr "absteigend"
#: lib/mv_web/live/user_live/form.ex:109
#, elixir-autogen, elixir-format
msgid "New"
-msgstr "Neuer"
+msgstr "Neue*r"
#: lib/mv_web/live/user_live/form.ex:64
#, elixir-autogen, elixir-format
msgid "Admin Note"
-msgstr "Administrator-Hinweis"
+msgstr "Administrator*innen-Hinweis"
#: lib/mv_web/live/user_live/form.ex:64
#, elixir-autogen, elixir-format
msgid "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
-msgstr "Als Administrator kΓΆnnen Sie direkt ein neues Passwort fΓΌr diesen Benutzer setzen, wobei das gleiche sichere Ash Authentication System verwendet wird."
+msgstr "Als Administrator*in kΓΆnnen Sie direkt ein neues Passwort fΓΌr diese*n Benutzer*in setzen, wobei das gleiche sichere Ash Authentication System verwendet wird."
#: lib/mv_web/live/user_live/form.ex:55
#, elixir-autogen, elixir-format
@@ -506,7 +507,7 @@ msgstr "Passwort Γ€ndern"
#: lib/mv_web/live/user_live/form.ex:75
#, elixir-autogen, elixir-format
msgid "Check 'Change Password' above to set a new password for this user."
-msgstr "Aktivieren Sie 'Passwort Γ€ndern' oben, um ein neues Passwort fΓΌr diesen Benutzer zu setzen."
+msgstr "Aktivieren Sie 'Passwort Γ€ndern' oben, um ein neues Passwort fΓΌr diese*n Benutzer*in zu setzen."
#: lib/mv_web/live/user_live/form.ex:45
#, elixir-autogen, elixir-format
@@ -533,15 +534,15 @@ msgstr "Passwort"
msgid "Password requirements"
msgstr "Passwort-Anforderungen"
-#: lib/mv_web/live/user_live/index.html.heex:25
+#: lib/mv_web/live/user_live/index.html.heex:21
#, elixir-autogen, elixir-format
msgid "Select all users"
-msgstr "Alle Benutzer auswΓ€hlen"
+msgstr "Alle Benutzer*innen auswΓ€hlen"
-#: lib/mv_web/live/user_live/index.html.heex:39
+#: lib/mv_web/live/user_live/index.html.heex:35
#, elixir-autogen, elixir-format
msgid "Select user"
-msgstr "Benutzer auswΓ€hlen"
+msgstr "Benutzer*in auswΓ€hlen"
#: lib/mv_web/live/user_live/form.ex:27
#, elixir-autogen, elixir-format
@@ -551,4 +552,64 @@ msgstr "Passwort setzen"
#: lib/mv_web/live/user_live/form.ex:83
#, elixir-autogen, elixir-format
msgid "User will be created without a password. Check 'Set Password' to add one."
-msgstr "Benutzer wird ohne Passwort erstellt. Aktivieren Sie 'Passwort setzen', um eines hinzuzufΓΌgen."
+msgstr "Benutzer*in wird ohne Passwort erstellt. Aktivieren Sie 'Passwort setzen', um eines hinzuzufΓΌgen."
+
+#: lib/mv_web/live/user_live/show.ex:30
+#, elixir-autogen, elixir-format
+msgid "Linked Member"
+msgstr "VerknΓΌpftes Mitglied"
+
+#: lib/mv_web/live/member_live/show.ex:41
+#, elixir-autogen, elixir-format
+msgid "Linked User"
+msgstr "VerknΓΌpfte*r Benutzer*in"
+
+#: lib/mv_web/live/user_live/show.ex:40
+#, elixir-autogen, elixir-format
+msgid "No member linked"
+msgstr "Kein Mitglied verknΓΌpft"
+
+#: lib/mv_web/live/member_live/show.ex:51
+#, elixir-autogen, elixir-format
+msgid "No user linked"
+msgstr "Keine*r Benutzer*in verknΓΌpft"
+
+#: lib/mv_web/live/member_live/show.ex:14
+#: lib/mv_web/live/member_live/show.ex:16
+#, elixir-autogen, elixir-format
+msgid "Back to members list"
+msgstr "ZurΓΌck zur Mitgliederliste"
+
+#: lib/mv_web/live/user_live/show.ex:13
+#: lib/mv_web/live/user_live/show.ex:15
+#, elixir-autogen, elixir-format
+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
+#, 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
+#, elixir-autogen, elixir-format
+msgid "Toggle dark mode"
+msgstr "Dunklen Modus umschalten"
+
+#: lib/mv_web/live/components/search_bar_component.ex:15
+#: lib/mv_web/live/member_live/index.html.heex:15
+#, elixir-autogen, elixir-format
+msgid "Click to sort"
+msgstr "Klicke um zu sortieren"
+
+#: lib/mv_web/live/member_live/index.html.heex:53
+#, elixir-autogen, elixir-format, fuzzy
+msgid "First name"
+msgstr "Vorname"
+
+#~ #: lib/mv_web/auth_overrides.ex:30
+#~ #, elixir-autogen, elixir-format
+#~ msgid "or"
+#~ msgstr "oder"
diff --git a/priv/gettext/de/LC_MESSAGES/errors.po b/priv/gettext/de/LC_MESSAGES/errors.po
index 844c4f5..e0db8dd 100644
--- a/priv/gettext/de/LC_MESSAGES/errors.po
+++ b/priv/gettext/de/LC_MESSAGES/errors.po
@@ -12,101 +12,146 @@ msgstr ""
## From Ecto.Changeset.cast/4
msgid "can't be blank"
-msgstr ""
+msgstr "darf nicht leer sein"
## From Ecto.Changeset.unique_constraint/3
msgid "has already been taken"
-msgstr ""
+msgstr "ist bereits vergeben"
## From Ecto.Changeset.put_change/3
msgid "is invalid"
-msgstr ""
+msgstr "ist ungΓΌltig"
## From Ecto.Changeset.validate_acceptance/3
msgid "must be accepted"
-msgstr ""
+msgstr "muss akzeptiert werden"
## From Ecto.Changeset.validate_format/3
msgid "has invalid format"
-msgstr ""
+msgstr "hat ein ungΓΌltiges Format"
## From Ecto.Changeset.validate_subset/3
msgid "has an invalid entry"
-msgstr ""
+msgstr "hat einen ungΓΌltigen Eintrag"
## From Ecto.Changeset.validate_exclusion/3
msgid "is reserved"
-msgstr ""
+msgstr "ist reserviert"
## From Ecto.Changeset.validate_confirmation/3
msgid "does not match confirmation"
-msgstr ""
+msgstr "stimmt nicht mit der BestΓ€tigung ΓΌberein"
## From Ecto.Changeset.no_assoc_constraint/3
msgid "is still associated with this entry"
-msgstr ""
+msgstr "ist noch mit diesem Eintrag verknΓΌpft"
msgid "are still associated with this entry"
-msgstr ""
+msgstr "sind noch mit diesem Eintrag verknΓΌpft"
## From Ecto.Changeset.validate_length/3
msgid "should have %{count} item(s)"
msgid_plural "should have %{count} item(s)"
-msgstr[0] ""
-msgstr[1] ""
+msgstr[0] "sollte %{count} Element haben"
+msgstr[1] "sollte %{count} Elemente haben"
msgid "should be %{count} character(s)"
msgid_plural "should be %{count} character(s)"
-msgstr[0] ""
-msgstr[1] ""
+msgstr[0] "sollte %{count} Zeichen haben"
+msgstr[1] "sollte %{count} Zeichen haben"
msgid "should be %{count} byte(s)"
msgid_plural "should be %{count} byte(s)"
-msgstr[0] ""
-msgstr[1] ""
+msgstr[0] "sollte %{count} Byte haben"
+msgstr[1] "sollte %{count} Bytes haben"
msgid "should have at least %{count} item(s)"
msgid_plural "should have at least %{count} item(s)"
-msgstr[0] ""
-msgstr[1] ""
+msgstr[0] "sollte mindestens %{count} Element haben"
+msgstr[1] "sollte mindestens %{count} Elemente haben"
msgid "should be at least %{count} character(s)"
msgid_plural "should be at least %{count} character(s)"
-msgstr[0] ""
-msgstr[1] ""
+msgstr[0] "sollte mindestens %{count} Zeichen haben"
+msgstr[1] "sollte mindestens %{count} Zeichen haben"
msgid "should be at least %{count} byte(s)"
msgid_plural "should be at least %{count} byte(s)"
-msgstr[0] ""
-msgstr[1] ""
+msgstr[0] "sollte mindestens %{count} Byte haben"
+msgstr[1] "sollte mindestens %{count} Bytes haben"
msgid "should have at most %{count} item(s)"
msgid_plural "should have at most %{count} item(s)"
-msgstr[0] ""
-msgstr[1] ""
+msgstr[0] "sollte hΓΆchstens %{count} Element haben"
+msgstr[1] "sollte hΓΆchstens %{count} Elemente haben"
msgid "should be at most %{count} character(s)"
msgid_plural "should be at most %{count} character(s)"
-msgstr[0] ""
-msgstr[1] ""
+msgstr[0] "sollte hΓΆchstens %{count} Zeichen haben"
+msgstr[1] "sollte hΓΆchstens %{count} Zeichen haben"
msgid "should be at most %{count} byte(s)"
msgid_plural "should be at most %{count} byte(s)"
-msgstr[0] ""
-msgstr[1] ""
+msgstr[0] "sollte hΓΆchstens %{count} Byte haben"
+msgstr[1] "sollte hΓΆchstens %{count} Bytes haben"
## From Ecto.Changeset.validate_number/3
msgid "must be less than %{number}"
-msgstr ""
+msgstr "muss kleiner als %{number} sein"
msgid "must be greater than %{number}"
-msgstr ""
+msgstr "muss grΓΆΓer als %{number} sein"
msgid "must be less than or equal to %{number}"
-msgstr ""
+msgstr "muss kleiner oder gleich %{number} sein"
msgid "must be greater than or equal to %{number}"
-msgstr ""
+msgstr "muss grΓΆΓer oder gleich %{number} sein"
msgid "must be equal to %{number}"
-msgstr ""
+msgstr "muss gleich %{number} sein"
+
+## Ash Framework - Standard constraint messages
+msgid "length must be greater than or equal to %{min}"
+msgstr "muss mindestens %{min} Zeichen lang sein"
+
+msgid "length must be less than or equal to %{max}"
+msgstr "darf hΓΆchstens %{max} Zeichen lang sein"
+
+msgid "must be present"
+msgstr "muss vorhanden sein"
+
+## Custom validation messages from Mv.Accounts.User
+msgid "User already has a member. Remove existing member first."
+msgstr "Benutzer*in hat bereits ein Mitglied. Entfernen Sie zuerst das vorhandene Mitglied."
+
+msgid "OIDC user_info must contain a non-empty 'sub' or 'id' field"
+msgstr "OIDC user_info darf kein leeres 'sub' oder 'id' Feld enthalten"
+
+## Custom validation messages from Mv.Membership.Member
+msgid "User is already linked to another member"
+msgstr "Benutzer*in ist bereits mit einem anderen Mitglied verknΓΌpft"
+
+msgid "User not found"
+msgstr "Benutzer*in nicht gefunden"
+
+msgid "cannot be in the future"
+msgstr "darf nicht in der Zukunft liegen"
+
+msgid "cannot be before join date"
+msgstr "darf nicht vor dem Beitrittsdatum liegen"
+
+msgid "is not a valid phone number"
+msgstr "ist keine gΓΌltige Telefonnummer"
+
+msgid "must consist of 5 digits"
+msgstr "muss aus 5 Ziffern bestehen"
+
+msgid "is not a valid email"
+msgstr "ist keine gΓΌltige E-Mail-Adresse"
+
+msgid "must have length of at least 8"
+msgstr "muss mindestens 8 Zeichen lang sein"
+
+msgid "is required"
+msgstr "ist erforderlich"
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index fd064d2..4c5438a 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -16,69 +16,69 @@ msgstr ""
msgid "Actions"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:77
-#: lib/mv_web/live/user_live/index.html.heex:69
+#: lib/mv_web/live/member_live/index.html.heex:193
+#: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format
msgid "Are you sure?"
msgstr ""
-#: lib/mv_web/components/layouts.ex:71
-#: lib/mv_web/components/layouts.ex:83
+#: lib/mv_web/components/layouts.ex:80
+#: lib/mv_web/components/layouts.ex:92
#, elixir-autogen, elixir-format
msgid "Attempting to reconnect"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:25
-#: lib/mv_web/live/member_live/index.html.heex:62
+#: lib/mv_web/live/member_live/index.html.heex:138
#: lib/mv_web/live/member_live/show.ex:36
#, elixir-autogen, elixir-format
msgid "City"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:79
-#: lib/mv_web/live/user_live/index.html.heex:71
+#: lib/mv_web/live/member_live/index.html.heex:195
+#: lib/mv_web/live/user_live/index.html.heex:67
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:71
+#: lib/mv_web/live/member_live/index.html.heex:187
#: lib/mv_web/live/user_live/form.ex:109
-#: lib/mv_web/live/user_live/index.html.heex:63
+#: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format
msgid "Edit"
msgstr ""
-#: lib/mv_web/live/member_live/show.ex:18
-#: lib/mv_web/live/member_live/show.ex:81
+#: lib/mv_web/live/member_live/show.ex:19
+#: lib/mv_web/live/member_live/show.ex:95
#, elixir-autogen, elixir-format
msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:18
-#: lib/mv_web/live/member_live/index.html.heex:58
+#: lib/mv_web/live/member_live/index.html.heex:70
#: lib/mv_web/live/member_live/show.ex:27
#: lib/mv_web/live/user_live/form.ex:14
-#: lib/mv_web/live/user_live/index.html.heex:48
-#: lib/mv_web/live/user_live/show.ex:24
+#: lib/mv_web/live/user_live/index.html.heex:44
+#: lib/mv_web/live/user_live/show.ex:25
#, elixir-autogen, elixir-format
msgid "Email"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:16
-#: lib/mv_web/live/member_live/show.ex:25
+#: lib/mv_web/live/member_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "First Name"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:22
-#: lib/mv_web/live/member_live/index.html.heex:64
+#: lib/mv_web/live/member_live/index.html.heex:172
#: lib/mv_web/live/member_live/show.ex:33
#, elixir-autogen, elixir-format
msgid "Join Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:17
-#: lib/mv_web/live/member_live/show.ex:26
+#: lib/mv_web/live/member_live/show.ex:27
#, elixir-autogen, elixir-format
msgid "Last Name"
msgstr ""
@@ -88,18 +88,18 @@ msgstr ""
msgid "New Member"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:68
-#: lib/mv_web/live/user_live/index.html.heex:60
+#: lib/mv_web/live/member_live/index.html.heex:184
+#: lib/mv_web/live/user_live/index.html.heex:56
#, elixir-autogen, elixir-format
msgid "Show"
msgstr ""
-#: lib/mv_web/components/layouts.ex:78
+#: lib/mv_web/components/layouts.ex:87
#, elixir-autogen, elixir-format
msgid "Something went wrong!"
msgstr ""
-#: lib/mv_web/components/layouts.ex:66
+#: lib/mv_web/components/layouts.ex:75
#, elixir-autogen, elixir-format
msgid "We can't find the internet"
msgstr ""
@@ -110,51 +110,51 @@ msgid "close"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:19
-#: lib/mv_web/live/member_live/show.ex:28
+#: lib/mv_web/live/member_live/show.ex:29
#, elixir-autogen, elixir-format
msgid "Birth Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:30
-#: lib/mv_web/live/member_live/show.ex:42
+#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Custom Properties"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:23
-#: lib/mv_web/live/member_live/show.ex:34
+#: lib/mv_web/live/member_live/show.ex:35
#, elixir-autogen, elixir-format
msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:27
-#: lib/mv_web/live/member_live/index.html.heex:60
+#: lib/mv_web/live/member_live/index.html.heex:104
#: lib/mv_web/live/member_live/show.ex:38
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:24
-#: lib/mv_web/live/member_live/show.ex:35
+#: lib/mv_web/live/member_live/show.ex:36
#, elixir-autogen, elixir-format
msgid "Notes"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:20
-#: lib/mv_web/live/member_live/show.ex:29
+#: lib/mv_web/live/member_live/show.ex:30
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:21
-#: lib/mv_web/live/member_live/index.html.heex:63
+#: lib/mv_web/live/member_live/index.html.heex:155
#: lib/mv_web/live/member_live/show.ex:32
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:28
-#: lib/mv_web/live/member_live/index.html.heex:61
+#: lib/mv_web/live/member_live/index.html.heex:121
#: lib/mv_web/live/member_live/show.ex:39
#, elixir-autogen, elixir-format
msgid "Postal Code"
@@ -174,7 +174,7 @@ msgid "Saving..."
msgstr ""
#: lib/mv_web/live/member_live/form.ex:26
-#: lib/mv_web/live/member_live/index.html.heex:59
+#: lib/mv_web/live/member_live/index.html.heex:87
#: lib/mv_web/live/member_live/show.ex:37
#, elixir-autogen, elixir-format
msgid "Street"
@@ -185,17 +185,17 @@ msgstr ""
msgid "Use this form to manage member records and their properties."
msgstr ""
-#: lib/mv_web/live/member_live/show.ex:24
+#: lib/mv_web/live/member_live/show.ex:25
#, elixir-autogen, elixir-format
msgid "Id"
msgstr ""
-#: lib/mv_web/live/member_live/show.ex:30
+#: lib/mv_web/live/member_live/show.ex:31
#, elixir-autogen, elixir-format
msgid "No"
msgstr ""
-#: lib/mv_web/live/member_live/show.ex:80
+#: lib/mv_web/live/member_live/show.ex:94
#, elixir-autogen, elixir-format
msgid "Show Member"
msgstr ""
@@ -205,7 +205,7 @@ msgstr ""
msgid "This is a member record from your database."
msgstr ""
-#: lib/mv_web/live/member_live/show.ex:30
+#: lib/mv_web/live/member_live/show.ex:31
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr ""
@@ -282,17 +282,17 @@ msgstr ""
msgid "Description"
msgstr ""
-#: lib/mv_web/live/user_live/show.ex:17
+#: lib/mv_web/live/user_live/show.ex:18
#, elixir-autogen, elixir-format
msgid "Edit User"
msgstr ""
-#: lib/mv_web/live/user_live/show.ex:27
+#: lib/mv_web/live/user_live/show.ex:28
#, elixir-autogen, elixir-format
msgid "Enabled"
msgstr ""
-#: lib/mv_web/live/user_live/show.ex:23
+#: lib/mv_web/live/user_live/show.ex:24
#, elixir-autogen, elixir-format
msgid "ID"
msgstr ""
@@ -302,7 +302,7 @@ msgstr ""
msgid "Immutable"
msgstr ""
-#: lib/mv_web/components/layouts/navbar.ex:73
+#: lib/mv_web/components/layouts/navbar.ex:94
#, elixir-autogen, elixir-format
msgid "Logout"
msgstr ""
@@ -319,13 +319,12 @@ msgid "Member"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:14
-#: lib/mv_web/live/member_live/index.ex:12
+#: lib/mv_web/live/member_live/index.ex:8
#: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format
msgid "Members"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:50
#: lib/mv_web/live/property_type_live/form.ex:16
#, elixir-autogen, elixir-format
msgid "Name"
@@ -336,12 +335,12 @@ msgstr ""
msgid "New User"
msgstr ""
-#: lib/mv_web/live/user_live/show.ex:27
+#: lib/mv_web/live/user_live/show.ex:28
#, elixir-autogen, elixir-format
msgid "Not enabled"
msgstr ""
-#: lib/mv_web/live/user_live/show.ex:25
+#: lib/mv_web/live/user_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "Not set"
msgstr ""
@@ -352,13 +351,13 @@ msgstr ""
msgid "Note"
msgstr ""
-#: lib/mv_web/live/user_live/index.html.heex:56
-#: lib/mv_web/live/user_live/show.ex:25
+#: lib/mv_web/live/user_live/index.html.heex:52
+#: lib/mv_web/live/user_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "OIDC ID"
msgstr ""
-#: lib/mv_web/live/user_live/show.ex:26
+#: lib/mv_web/live/user_live/show.ex:27
#, elixir-autogen, elixir-format
msgid "Password Authentication"
msgstr ""
@@ -368,7 +367,7 @@ msgstr ""
msgid "Please select a property type first"
msgstr ""
-#: lib/mv_web/components/layouts/navbar.ex:69
+#: lib/mv_web/components/layouts/navbar.ex:89
#, elixir-autogen, elixir-format
msgid "Profil"
msgstr ""
@@ -403,17 +402,17 @@ msgstr ""
msgid "Save Property type"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:27
+#: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format
msgid "Select all members"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:41
+#: lib/mv_web/live/member_live/index.html.heex:48
#, elixir-autogen, elixir-format
msgid "Select member"
msgstr ""
-#: lib/mv_web/components/layouts/navbar.ex:72
+#: lib/mv_web/components/layouts/navbar.ex:92
#, elixir-autogen, elixir-format
msgid "Settings"
msgstr ""
@@ -423,7 +422,7 @@ msgstr ""
msgid "Save User"
msgstr ""
-#: lib/mv_web/live/user_live/show.ex:38
+#: lib/mv_web/live/user_live/show.ex:54
#, elixir-autogen, elixir-format
msgid "Show User"
msgstr ""
@@ -470,11 +469,13 @@ msgid "Value type"
msgstr ""
#: lib/mv_web/components/table_components.ex:30
+#: lib/mv_web/live/components/sort_header_component.ex:55
#, 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
#, elixir-autogen, elixir-format
msgid "descending"
msgstr ""
@@ -534,12 +535,12 @@ msgstr ""
msgid "Password requirements"
msgstr ""
-#: lib/mv_web/live/user_live/index.html.heex:25
+#: lib/mv_web/live/user_live/index.html.heex:21
#, elixir-autogen, elixir-format
msgid "Select all users"
msgstr ""
-#: lib/mv_web/live/user_live/index.html.heex:39
+#: lib/mv_web/live/user_live/index.html.heex:35
#, elixir-autogen, elixir-format
msgid "Select user"
msgstr ""
@@ -553,3 +554,66 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "User will be created without a password. Check 'Set Password' to add one."
msgstr ""
+
+#: lib/mv_web/live/user_live/show.ex:30
+#, elixir-autogen, elixir-format
+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:27
+#: lib/mv_web/components/layouts/navbar.ex:33
+#, elixir-autogen, elixir-format
+msgid "Select language"
+msgstr ""
+
+#: lib/mv_web/components/layouts/navbar.ex:40
+#: lib/mv_web/components/layouts/navbar.ex:60
+#, elixir-autogen, elixir-format
+msgid "Toggle dark mode"
+msgstr ""
+
+#: 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
+msgid "Users"
+#: lib/mv_web/live/components/sort_header_component.ex:60
+#, elixir-autogen, elixir-format
+msgid "Click to sort"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index.html.heex:53
+#, elixir-autogen, elixir-format
+msgid "First name"
+msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/auth.po b/priv/gettext/en/LC_MESSAGES/auth.po
new file mode 100644
index 0000000..59ce742
--- /dev/null
+++ b/priv/gettext/en/LC_MESSAGES/auth.po
@@ -0,0 +1,63 @@
+## "msgid"s in this file come from POT (.pot) files.
+###
+### Do not add, change, or remove "msgid"s manually here as
+### they're tied to the ones in the corresponding POT file
+### (with the same domain).
+###
+### Use "mix gettext.extract --merge" or "mix gettext.merge"
+### to merge POT files into PO files.
+msgid ""
+msgstr ""
+"Language: en\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+msgid "Already have an account?"
+msgstr ""
+
+msgid "Email or password was incorrect"
+msgstr ""
+
+msgid "Email"
+msgstr ""
+
+msgid "Forgot your password?"
+msgstr ""
+
+msgid "If this user exists in our database you will contacted with a sign-in link shortly."
+msgstr ""
+
+msgid "If this user exists in our system, you will be contacted with reset instructions shortly."
+msgstr ""
+
+msgid "Need an account?"
+msgstr ""
+
+msgid "Password"
+msgstr ""
+
+msgid "Password Confirmation"
+msgstr ""
+
+msgid "Request magic link"
+msgstr ""
+
+msgid "Request password reset token"
+msgstr ""
+
+msgid "Requesting ..."
+msgstr ""
+
+msgid "Reset password with token"
+msgstr ""
+
+msgid "Sign in"
+msgstr ""
+
+msgid "Signing in ..."
+msgstr ""
+
+msgid "Your password has successfully been reset"
+msgstr ""
+
+#~ msgid "Sign in with Rauthy"
+#~ msgstr "Sign in with Vereinscloud"
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 41a35b4..451ba84 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -16,69 +16,69 @@ msgstr ""
msgid "Actions"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:77
-#: lib/mv_web/live/user_live/index.html.heex:69
+#: lib/mv_web/live/member_live/index.html.heex:193
+#: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format
msgid "Are you sure?"
msgstr ""
-#: lib/mv_web/components/layouts.ex:71
-#: lib/mv_web/components/layouts.ex:83
+#: lib/mv_web/components/layouts.ex:80
+#: lib/mv_web/components/layouts.ex:92
#, elixir-autogen, elixir-format
msgid "Attempting to reconnect"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:25
-#: lib/mv_web/live/member_live/index.html.heex:62
+#: lib/mv_web/live/member_live/index.html.heex:138
#: lib/mv_web/live/member_live/show.ex:36
#, elixir-autogen, elixir-format
msgid "City"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:79
-#: lib/mv_web/live/user_live/index.html.heex:71
+#: lib/mv_web/live/member_live/index.html.heex:195
+#: lib/mv_web/live/user_live/index.html.heex:67
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:71
+#: lib/mv_web/live/member_live/index.html.heex:187
#: lib/mv_web/live/user_live/form.ex:109
-#: lib/mv_web/live/user_live/index.html.heex:63
+#: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format
msgid "Edit"
msgstr ""
-#: lib/mv_web/live/member_live/show.ex:18
-#: lib/mv_web/live/member_live/show.ex:81
+#: lib/mv_web/live/member_live/show.ex:19
+#: lib/mv_web/live/member_live/show.ex:95
#, elixir-autogen, elixir-format
msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:18
-#: lib/mv_web/live/member_live/index.html.heex:58
+#: lib/mv_web/live/member_live/index.html.heex:70
#: lib/mv_web/live/member_live/show.ex:27
#: lib/mv_web/live/user_live/form.ex:14
-#: lib/mv_web/live/user_live/index.html.heex:48
-#: lib/mv_web/live/user_live/show.ex:24
+#: lib/mv_web/live/user_live/index.html.heex:44
+#: lib/mv_web/live/user_live/show.ex:25
#, elixir-autogen, elixir-format
msgid "Email"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:16
-#: lib/mv_web/live/member_live/show.ex:25
+#: lib/mv_web/live/member_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "First Name"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:22
-#: lib/mv_web/live/member_live/index.html.heex:64
+#: lib/mv_web/live/member_live/index.html.heex:172
#: lib/mv_web/live/member_live/show.ex:33
#, elixir-autogen, elixir-format
msgid "Join Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:17
-#: lib/mv_web/live/member_live/show.ex:26
+#: lib/mv_web/live/member_live/show.ex:27
#, elixir-autogen, elixir-format
msgid "Last Name"
msgstr ""
@@ -88,18 +88,18 @@ msgstr ""
msgid "New Member"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:68
-#: lib/mv_web/live/user_live/index.html.heex:60
+#: lib/mv_web/live/member_live/index.html.heex:184
+#: lib/mv_web/live/user_live/index.html.heex:56
#, elixir-autogen, elixir-format
msgid "Show"
msgstr ""
-#: lib/mv_web/components/layouts.ex:78
+#: lib/mv_web/components/layouts.ex:87
#, elixir-autogen, elixir-format
msgid "Something went wrong!"
msgstr ""
-#: lib/mv_web/components/layouts.ex:66
+#: lib/mv_web/components/layouts.ex:75
#, elixir-autogen, elixir-format
msgid "We can't find the internet"
msgstr ""
@@ -110,51 +110,51 @@ msgid "close"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:19
-#: lib/mv_web/live/member_live/show.ex:28
+#: lib/mv_web/live/member_live/show.ex:29
#, elixir-autogen, elixir-format
msgid "Birth Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:30
-#: lib/mv_web/live/member_live/show.ex:42
+#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Custom Properties"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:23
-#: lib/mv_web/live/member_live/show.ex:34
+#: lib/mv_web/live/member_live/show.ex:35
#, elixir-autogen, elixir-format
msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:27
-#: lib/mv_web/live/member_live/index.html.heex:60
+#: lib/mv_web/live/member_live/index.html.heex:104
#: lib/mv_web/live/member_live/show.ex:38
#, elixir-autogen, elixir-format
msgid "House Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:24
-#: lib/mv_web/live/member_live/show.ex:35
+#: lib/mv_web/live/member_live/show.ex:36
#, elixir-autogen, elixir-format
msgid "Notes"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:20
-#: lib/mv_web/live/member_live/show.ex:29
+#: lib/mv_web/live/member_live/show.ex:30
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:21
-#: lib/mv_web/live/member_live/index.html.heex:63
+#: lib/mv_web/live/member_live/index.html.heex:155
#: lib/mv_web/live/member_live/show.ex:32
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:28
-#: lib/mv_web/live/member_live/index.html.heex:61
+#: lib/mv_web/live/member_live/index.html.heex:121
#: lib/mv_web/live/member_live/show.ex:39
#, elixir-autogen, elixir-format
msgid "Postal Code"
@@ -174,7 +174,7 @@ msgid "Saving..."
msgstr ""
#: lib/mv_web/live/member_live/form.ex:26
-#: lib/mv_web/live/member_live/index.html.heex:59
+#: lib/mv_web/live/member_live/index.html.heex:87
#: lib/mv_web/live/member_live/show.ex:37
#, elixir-autogen, elixir-format
msgid "Street"
@@ -185,17 +185,17 @@ msgstr ""
msgid "Use this form to manage member records and their properties."
msgstr ""
-#: lib/mv_web/live/member_live/show.ex:24
+#: lib/mv_web/live/member_live/show.ex:25
#, elixir-autogen, elixir-format
msgid "Id"
msgstr ""
-#: lib/mv_web/live/member_live/show.ex:30
+#: lib/mv_web/live/member_live/show.ex:31
#, elixir-autogen, elixir-format
msgid "No"
msgstr ""
-#: lib/mv_web/live/member_live/show.ex:80
+#: lib/mv_web/live/member_live/show.ex:94
#, elixir-autogen, elixir-format, fuzzy
msgid "Show Member"
msgstr ""
@@ -205,7 +205,7 @@ msgstr ""
msgid "This is a member record from your database."
msgstr ""
-#: lib/mv_web/live/member_live/show.ex:30
+#: lib/mv_web/live/member_live/show.ex:31
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr ""
@@ -282,17 +282,17 @@ msgstr ""
msgid "Description"
msgstr ""
-#: lib/mv_web/live/user_live/show.ex:17
+#: lib/mv_web/live/user_live/show.ex:18
#, elixir-autogen, elixir-format, fuzzy
msgid "Edit User"
msgstr ""
-#: lib/mv_web/live/user_live/show.ex:27
+#: lib/mv_web/live/user_live/show.ex:28
#, elixir-autogen, elixir-format
msgid "Enabled"
msgstr ""
-#: lib/mv_web/live/user_live/show.ex:23
+#: lib/mv_web/live/user_live/show.ex:24
#, elixir-autogen, elixir-format
msgid "ID"
msgstr ""
@@ -302,7 +302,7 @@ msgstr ""
msgid "Immutable"
msgstr ""
-#: lib/mv_web/components/layouts/navbar.ex:73
+#: lib/mv_web/components/layouts/navbar.ex:94
#, elixir-autogen, elixir-format
msgid "Logout"
msgstr ""
@@ -319,13 +319,12 @@ msgid "Member"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:14
-#: lib/mv_web/live/member_live/index.ex:12
+#: lib/mv_web/live/member_live/index.ex:8
#: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format
msgid "Members"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:50
#: lib/mv_web/live/property_type_live/form.ex:16
#, elixir-autogen, elixir-format
msgid "Name"
@@ -336,12 +335,12 @@ msgstr ""
msgid "New User"
msgstr ""
-#: lib/mv_web/live/user_live/show.ex:27
+#: lib/mv_web/live/user_live/show.ex:28
#, elixir-autogen, elixir-format
msgid "Not enabled"
msgstr ""
-#: lib/mv_web/live/user_live/show.ex:25
+#: lib/mv_web/live/user_live/show.ex:26
#, elixir-autogen, elixir-format, fuzzy
msgid "Not set"
msgstr ""
@@ -352,13 +351,13 @@ msgstr ""
msgid "Note"
msgstr ""
-#: lib/mv_web/live/user_live/index.html.heex:56
-#: lib/mv_web/live/user_live/show.ex:25
+#: lib/mv_web/live/user_live/index.html.heex:52
+#: lib/mv_web/live/user_live/show.ex:26
#, elixir-autogen, elixir-format
msgid "OIDC ID"
msgstr ""
-#: lib/mv_web/live/user_live/show.ex:26
+#: lib/mv_web/live/user_live/show.ex:27
#, elixir-autogen, elixir-format
msgid "Password Authentication"
msgstr ""
@@ -368,7 +367,7 @@ msgstr ""
msgid "Please select a property type first"
msgstr ""
-#: lib/mv_web/components/layouts/navbar.ex:69
+#: lib/mv_web/components/layouts/navbar.ex:89
#, elixir-autogen, elixir-format
msgid "Profil"
msgstr ""
@@ -403,17 +402,17 @@ msgstr ""
msgid "Save Property type"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:27
+#: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format
msgid "Select all members"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:41
+#: lib/mv_web/live/member_live/index.html.heex:48
#, elixir-autogen, elixir-format
msgid "Select member"
msgstr ""
-#: lib/mv_web/components/layouts/navbar.ex:72
+#: lib/mv_web/components/layouts/navbar.ex:92
#, elixir-autogen, elixir-format
msgid "Settings"
msgstr ""
@@ -423,7 +422,7 @@ msgstr ""
msgid "Save User"
msgstr ""
-#: lib/mv_web/live/user_live/show.ex:38
+#: lib/mv_web/live/user_live/show.ex:54
#, elixir-autogen, elixir-format, fuzzy
msgid "Show User"
msgstr ""
@@ -470,11 +469,13 @@ msgid "Value type"
msgstr ""
#: lib/mv_web/components/table_components.ex:30
+#: lib/mv_web/live/components/sort_header_component.ex:55
#, 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
#, elixir-autogen, elixir-format
msgid "descending"
msgstr ""
@@ -534,12 +535,12 @@ msgstr "Password"
msgid "Password requirements"
msgstr "Password requirements"
-#: lib/mv_web/live/user_live/index.html.heex:25
+#: lib/mv_web/live/user_live/index.html.heex:21
#, elixir-autogen, elixir-format, fuzzy
msgid "Select all users"
msgstr ""
-#: lib/mv_web/live/user_live/index.html.heex:39
+#: lib/mv_web/live/user_live/index.html.heex:35
#, elixir-autogen, elixir-format, fuzzy
msgid "Select user"
msgstr ""
@@ -553,3 +554,18 @@ msgstr "Set Password"
#, elixir-autogen, elixir-format
msgid "User will be created without a password. Check 'Set Password' to add one."
msgstr "User will be created without a password. Check 'Set Password' to add one."
+
+#: lib/mv_web/live/components/sort_header_component.ex:60
+#, elixir-autogen, elixir-format
+msgid "Click to sort"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index.html.heex:53
+#, elixir-autogen, elixir-format, fuzzy
+msgid "First name"
+msgstr ""
+
+#~ #: lib/mv_web/auth_overrides.ex:30
+#~ #, elixir-autogen, elixir-format
+#~ msgid "or"
+#~ msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po
index 844c4f5..62df4a7 100644
--- a/priv/gettext/en/LC_MESSAGES/errors.po
+++ b/priv/gettext/en/LC_MESSAGES/errors.po
@@ -110,3 +110,48 @@ msgstr ""
msgid "must be equal to %{number}"
msgstr ""
+
+## Ash Framework - Standard constraint messages
+msgid "length must be greater than or equal to %{min}"
+msgstr ""
+
+msgid "length must be less than or equal to %{max}"
+msgstr ""
+
+msgid "must be present"
+msgstr ""
+
+## Custom validation messages from Mv.Accounts.User
+msgid "User already has a member. Remove existing member first."
+msgstr ""
+
+msgid "OIDC user_info must contain a non-empty 'sub' or 'id' field"
+msgstr ""
+
+## Custom validation messages from Mv.Membership.Member
+msgid "User is already linked to another member"
+msgstr ""
+
+msgid "User not found"
+msgstr ""
+
+msgid "cannot be in the future"
+msgstr ""
+
+msgid "cannot be before join date"
+msgstr ""
+
+msgid "is not a valid phone number"
+msgstr ""
+
+msgid "must consist of 5 digits"
+msgstr ""
+
+msgid "is not a valid email"
+msgstr ""
+
+msgid "must have length of at least 8"
+msgstr ""
+
+msgid "is required"
+msgstr ""
diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot
index eef2de2..8f522c0 100644
--- a/priv/gettext/errors.pot
+++ b/priv/gettext/errors.pot
@@ -107,3 +107,48 @@ msgstr ""
msgid "must be equal to %{number}"
msgstr ""
+
+## Ash Framework - Standard constraint messages
+msgid "length must be greater than or equal to %{min}"
+msgstr ""
+
+msgid "length must be less than or equal to %{max}"
+msgstr ""
+
+msgid "must be present"
+msgstr ""
+
+## Custom validation messages from Mv.Accounts.User
+msgid "User already has a member. Remove existing member first."
+msgstr ""
+
+msgid "OIDC user_info must contain a non-empty 'sub' or 'id' field"
+msgstr ""
+
+## Custom validation messages from Mv.Membership.Member
+msgid "User is already linked to another member"
+msgstr ""
+
+msgid "User not found"
+msgstr ""
+
+msgid "cannot be in the future"
+msgstr ""
+
+msgid "cannot be before join date"
+msgstr ""
+
+msgid "is not a valid phone number"
+msgstr ""
+
+msgid "must consist of 5 digits"
+msgstr ""
+
+msgid "is not a valid email"
+msgstr ""
+
+msgid "must have length of at least 8"
+msgstr ""
+
+msgid "is required"
+msgstr ""
diff --git a/priv/repo/migrations/20250912085235_AddSearchVectorToMembers.exs b/priv/repo/migrations/20250912085235_AddSearchVectorToMembers.exs
new file mode 100644
index 0000000..126f369
--- /dev/null
+++ b/priv/repo/migrations/20250912085235_AddSearchVectorToMembers.exs
@@ -0,0 +1,60 @@
+defmodule Mv.Repo.Migrations.AddSearchVectorToMembers 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
+ alter table(:members) do
+ add :search_vector, :tsvector
+ end
+
+ execute("""
+ CREATE INDEX members_search_vector_idx
+ ON members
+ USING GIN (search_vector)
+ """)
+
+ # Eigene Trigger-Funktion mit Gewichtung
+ execute("""
+ CREATE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
+ BEGIN
+ NEW.search_vector :=
+ setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
+ setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
+ setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
+ setweight(to_tsvector('simple', coalesce(NEW.birth_date::text, '')), 'C') ||
+ setweight(to_tsvector('simple', coalesce(NEW.phone_number, '')), 'C') ||
+ setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
+ setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
+ setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
+ setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
+ setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
+ setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
+ setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C');
+ RETURN NEW;
+ END
+ $$ LANGUAGE plpgsql;
+ """)
+
+ execute("""
+ CREATE TRIGGER update_search_vector
+ BEFORE INSERT OR UPDATE ON members
+ FOR EACH ROW
+ EXECUTE FUNCTION members_search_vector_trigger()
+ """)
+ end
+
+ def down do
+ execute("DROP TRIGGER IF EXISTS update_search_vector ON members")
+ execute("DROP FUNCTION IF EXISTS members_search_vector_trigger()")
+ execute("DROP INDEX IF EXISTS members_search_vector_idx")
+
+ alter table(:members) do
+ remove :search_vector
+ end
+ end
+end
diff --git a/priv/repo/migrations/20250926164519_member_relation.exs b/priv/repo/migrations/20250926164519_member_relation.exs
new file mode 100644
index 0000000..1f63f73
--- /dev/null
+++ b/priv/repo/migrations/20250926164519_member_relation.exs
@@ -0,0 +1,19 @@
+defmodule Mv.Repo.Migrations.MemberRelation do
+ @moduledoc """
+ Updates resources based on their most recent snapshots.
+
+ This file was autogenerated with `mix ash_postgres.generate_migrations`
+ """
+
+ use Ecto.Migration
+
+ def up do
+ # Ensure 1:1 relationship - one user can only be linked to one member
+ # This prevents multiple users from sharing the same member account
+ create unique_index(:users, [:member_id], name: "users_unique_member_index")
+ end
+
+ def down do
+ drop_if_exists unique_index(:users, [:member_id], name: "users_unique_member_index")
+ end
+end
diff --git a/priv/repo/migrations/20250926180341_add_unique_email_to_members.exs b/priv/repo/migrations/20250926180341_add_unique_email_to_members.exs
new file mode 100644
index 0000000..a33ce2f
--- /dev/null
+++ b/priv/repo/migrations/20250926180341_add_unique_email_to_members.exs
@@ -0,0 +1,19 @@
+defmodule Mv.Repo.Migrations.AddUniqueEmailToMembers do
+ @moduledoc """
+ Updates resources based on their most recent snapshots.
+
+ This file was autogenerated with `mix ash_postgres.generate_migrations`
+ """
+
+ use Ecto.Migration
+
+ def up do
+ # Ensure email uniqueness across all members
+ # This supports upsert operations and prevents duplicate member accounts
+ create unique_index(:members, [:email], name: "members_unique_email_index")
+ end
+
+ def down do
+ drop_if_exists unique_index(:members, [:email], name: "members_unique_email_index")
+ end
+end
diff --git a/priv/repo/migrations/20251016130855_add_constraints_for_user_member_and_property.exs b/priv/repo/migrations/20251016130855_add_constraints_for_user_member_and_property.exs
new file mode 100644
index 0000000..ee13a71
--- /dev/null
+++ b/priv/repo/migrations/20251016130855_add_constraints_for_user_member_and_property.exs
@@ -0,0 +1,46 @@
+defmodule Mv.Repo.Migrations.AddConstraintsForUserMemberAndProperty do
+ @moduledoc """
+ Updates resources based on their most recent snapshots.
+
+ This file was autogenerated with `mix ash_postgres.generate_migrations`
+ """
+
+ use Ecto.Migration
+
+ def up do
+ drop constraint(:users, "users_member_id_fkey")
+
+ alter table(:users) do
+ modify :member_id,
+ references(:members,
+ column: :id,
+ name: "users_member_id_fkey",
+ type: :uuid,
+ prefix: "public",
+ on_delete: :nilify_all
+ )
+ end
+
+ create unique_index(:properties, [:member_id, :property_type_id],
+ name: "properties_unique_property_per_member_index"
+ )
+ end
+
+ def down do
+ drop_if_exists unique_index(:properties, [:member_id, :property_type_id],
+ name: "properties_unique_property_per_member_index"
+ )
+
+ drop constraint(:users, "users_member_id_fkey")
+
+ alter table(:users) do
+ modify :member_id,
+ references(:members,
+ column: :id,
+ name: "users_member_id_fkey",
+ type: :uuid,
+ prefix: "public"
+ )
+ end
+ end
+end
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs
index 306b627..a0299fd 100644
--- a/priv/repo/seeds.exs
+++ b/priv/repo/seeds.exs
@@ -47,3 +47,151 @@ end
Accounts.create_user!(%{email: "admin@mv.local"}, upsert?: true, upsert_identity: :unique_email)
|> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"})
|> Ash.update!()
+
+# Create sample members for testing - use upsert to prevent duplicates
+for member_attrs <- [
+ %{
+ first_name: "Hans",
+ last_name: "MΓΌller",
+ email: "hans.mueller@example.de",
+ birth_date: ~D[1985-06-15],
+ join_date: ~D[2023-01-15],
+ paid: true,
+ phone_number: "+49301234567",
+ city: "MΓΌnchen",
+ street: "HauptstraΓe",
+ house_number: "42",
+ postal_code: "80331"
+ },
+ %{
+ first_name: "Greta",
+ last_name: "Schmidt",
+ email: "greta.schmidt@example.de",
+ birth_date: ~D[1990-03-22],
+ join_date: ~D[2023-02-01],
+ paid: false,
+ phone_number: "+49309876543",
+ city: "Hamburg",
+ street: "LindenstraΓe",
+ house_number: "17",
+ postal_code: "20095",
+ notes: "Interessiert an Fortgeschrittenen-Kursen"
+ },
+ %{
+ first_name: "Friedrich",
+ last_name: "Wagner",
+ email: "friedrich.wagner@example.de",
+ birth_date: ~D[1978-11-08],
+ join_date: ~D[2022-11-10],
+ paid: true,
+ phone_number: "+49301122334",
+ city: "Berlin",
+ street: "Kastanienallee",
+ house_number: "8"
+ },
+ %{
+ first_name: "Marianne",
+ last_name: "Wagner",
+ email: "marianne.wagner@example.de",
+ birth_date: ~D[1978-11-08],
+ join_date: ~D[2022-11-10],
+ paid: true,
+ phone_number: "+49301122334",
+ city: "Berlin",
+ street: "Kastanienallee",
+ house_number: "8"
+ }
+ ] do
+ # Use upsert to prevent duplicates based on email
+ Membership.create_member!(member_attrs, upsert?: true, upsert_identity: :unique_email)
+end
+
+# Create additional users for user-member linking examples
+additional_users = [
+ %{email: "hans.mueller@example.de"},
+ %{email: "greta.schmidt@example.de"},
+ %{email: "maria.weber@example.de"},
+ %{email: "thomas.klein@example.de"}
+]
+
+created_users =
+ Enum.map(additional_users, fn user_attrs ->
+ Accounts.create_user!(user_attrs, upsert?: true, upsert_identity: :unique_email)
+ |> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"})
+ |> Ash.update!()
+ end)
+
+# Create members with linked users to demonstrate the 1:1 relationship
+# Only create if users don't already have members
+linked_members = [
+ %{
+ first_name: "Maria",
+ last_name: "Weber",
+ email: "maria.weber@example.de",
+ birth_date: ~D[1992-07-14],
+ join_date: ~D[2023-03-15],
+ paid: true,
+ phone_number: "+49301357924",
+ city: "Frankfurt",
+ street: "Goetheplatz",
+ house_number: "5",
+ postal_code: "60313",
+ notes: "Linked to user account",
+ # Link to the third user (maria.weber@example.de)
+ user: Enum.at(created_users, 2)
+ },
+ %{
+ first_name: "Thomas",
+ last_name: "Klein",
+ email: "thomas.klein@example.de",
+ birth_date: ~D[1988-12-03],
+ join_date: ~D[2023-04-01],
+ paid: false,
+ phone_number: "+49302468135",
+ city: "KΓΆln",
+ street: "RheinstraΓe",
+ house_number: "23",
+ postal_code: "50667",
+ notes: "Linked to user account - needs payment follow-up",
+ # Link to the fourth user (thomas.klein@example.de)
+ user: Enum.at(created_users, 3)
+ }
+]
+
+# Create the linked members - use upsert to prevent duplicates
+Enum.each(linked_members, fn member_attrs ->
+ user = member_attrs.user
+ member_attrs_without_user = Map.delete(member_attrs, :user)
+
+ # Check if user already has a member
+ if user.member_id == nil do
+ # User is free, create member and link - use upsert to prevent duplicates
+ Membership.create_member!(
+ Map.put(member_attrs_without_user, :user, %{id: user.id}),
+ upsert?: true,
+ upsert_identity: :unique_email
+ )
+ else
+ # User already has a member, just create the member without linking - use upsert to prevent duplicates
+ Membership.create_member!(member_attrs_without_user,
+ upsert?: true,
+ upsert_identity: :unique_email
+ )
+ end
+end)
+
+IO.puts("β
Seeds completed successfully!")
+IO.puts("π Created sample data:")
+IO.puts(" - Property types: String, Date, Boolean, Email")
+IO.puts(" - Admin user: admin@mv.local (password: testpassword)")
+IO.puts(" - Sample members: Hans, Greta, Friedrich")
+
+IO.puts(
+ " - Additional users: hans.mueller@example.de, greta.schmidt@example.de, maria.weber@example.de, thomas.klein@example.de"
+)
+
+IO.puts(
+ " - Linked members: Maria Weber β maria.weber@example.de, Thomas Klein β thomas.klein@example.de"
+)
+
+IO.puts("π Visit the application to see user-member relationships in action!")
diff --git a/priv/resource_snapshots/repo/members/20250912085235.json b/priv/resource_snapshots/repo/members/20250912085235.json
new file mode 100644
index 0000000..a8b86da
--- /dev/null
+++ b/priv/resource_snapshots/repo/members/20250912085235.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_vectors",
+ "type": "tsvector"
+ }
+ ],
+ "base_filter": null,
+ "check_constraints": [],
+ "custom_indexes": [],
+ "custom_statements": [],
+ "has_create_action": true,
+ "hash": "3B162FD69B92BF8258DB56BA0CBB6108FBE996B1F7231C5F2D9EC53D956EFC75",
+ "identities": [],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.Mv.Repo",
+ "schema": null,
+ "table": "members"
+}
\ No newline at end of file
diff --git a/priv/resource_snapshots/repo/members/20250926180341.json b/priv/resource_snapshots/repo/members/20250926180341.json
new file mode 100644
index 0000000..3582051
--- /dev/null
+++ b/priv/resource_snapshots/repo/members/20250926180341.json
@@ -0,0 +1,202 @@
+{
+ "attributes": [
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"uuid_generate_v7()\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": true,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "first_name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "last_name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "email",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "birth_date",
+ "type": "date"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "paid",
+ "type": "boolean"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "phone_number",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "join_date",
+ "type": "date"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "exit_date",
+ "type": "date"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "notes",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "city",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "street",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "house_number",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "postal_code",
+ "type": "text"
+ }
+ ],
+ "base_filter": null,
+ "check_constraints": [],
+ "custom_indexes": [],
+ "custom_statements": [],
+ "has_create_action": true,
+ "hash": "5F070A1E5BEE9883AE864FB5A4A5E81F487A1C57D41576C23BAC8D933005D565",
+ "identities": [
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "members_unique_email_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "email"
+ }
+ ],
+ "name": "unique_email",
+ "nils_distinct?": true,
+ "where": null
+ }
+ ],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.Mv.Repo",
+ "schema": null,
+ "table": "members"
+}
\ No newline at end of file
diff --git a/priv/resource_snapshots/repo/members/20251016130855.json b/priv/resource_snapshots/repo/members/20251016130855.json
new file mode 100644
index 0000000..5188002
--- /dev/null
+++ b/priv/resource_snapshots/repo/members/20251016130855.json
@@ -0,0 +1,214 @@
+{
+ "attributes": [
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"uuid_generate_v7()\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": true,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "first_name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "last_name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "email",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "birth_date",
+ "type": "date"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "paid",
+ "type": "boolean"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "phone_number",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "join_date",
+ "type": "date"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "exit_date",
+ "type": "date"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "notes",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "city",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "street",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "house_number",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "postal_code",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "search_vector",
+ "type": "tsvector"
+ }
+ ],
+ "base_filter": null,
+ "check_constraints": [],
+ "custom_indexes": [],
+ "custom_statements": [],
+ "has_create_action": true,
+ "hash": "6AAC71BCCA5F112087CEF6877A5BBF74EF8965D5DA4812C44CD6E672F882CC3F",
+ "identities": [
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "members_unique_email_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "email"
+ }
+ ],
+ "name": "unique_email",
+ "nils_distinct?": true,
+ "where": null
+ }
+ ],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.Mv.Repo",
+ "schema": null,
+ "table": "members"
+}
\ No newline at end of file
diff --git a/priv/resource_snapshots/repo/properties/20251016130855.json b/priv/resource_snapshots/repo/properties/20251016130855.json
new file mode 100644
index 0000000..13958c0
--- /dev/null
+++ b/priv/resource_snapshots/repo/properties/20251016130855.json
@@ -0,0 +1,124 @@
+{
+ "attributes": [
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"gen_random_uuid()\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": true,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "value",
+ "type": "map"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": {
+ "deferrable": false,
+ "destination_attribute": "id",
+ "destination_attribute_default": null,
+ "destination_attribute_generated": null,
+ "index?": false,
+ "match_type": null,
+ "match_with": null,
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "name": "properties_member_id_fkey",
+ "on_delete": "delete",
+ "on_update": null,
+ "primary_key?": true,
+ "schema": "public",
+ "table": "members"
+ },
+ "scale": null,
+ "size": null,
+ "source": "member_id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": {
+ "deferrable": false,
+ "destination_attribute": "id",
+ "destination_attribute_default": null,
+ "destination_attribute_generated": null,
+ "index?": false,
+ "match_type": null,
+ "match_with": null,
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "name": "properties_property_type_id_fkey",
+ "on_delete": null,
+ "on_update": null,
+ "primary_key?": true,
+ "schema": "public",
+ "table": "property_types"
+ },
+ "scale": null,
+ "size": null,
+ "source": "property_type_id",
+ "type": "uuid"
+ }
+ ],
+ "base_filter": null,
+ "check_constraints": [],
+ "custom_indexes": [],
+ "custom_statements": [],
+ "has_create_action": true,
+ "hash": "8F90B1AAD1063CF2BB0BDEBBDFBA86AF0B24D854689FB834BC20DFAB2143A451",
+ "identities": [
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "properties_unique_property_per_member_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "member_id"
+ },
+ {
+ "type": "atom",
+ "value": "property_type_id"
+ }
+ ],
+ "name": "unique_property_per_member",
+ "nils_distinct?": true,
+ "where": null
+ }
+ ],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.Mv.Repo",
+ "schema": null,
+ "table": "properties"
+}
\ No newline at end of file
diff --git a/priv/resource_snapshots/repo/users/20250926164519.json b/priv/resource_snapshots/repo/users/20250926164519.json
new file mode 100644
index 0000000..7eb68f2
--- /dev/null
+++ b/priv/resource_snapshots/repo/users/20250926164519.json
@@ -0,0 +1,141 @@
+{
+ "attributes": [
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"gen_random_uuid()\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": true,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "email",
+ "type": "citext"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "hashed_password",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "oidc_id",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": {
+ "deferrable": false,
+ "destination_attribute": "id",
+ "destination_attribute_default": null,
+ "destination_attribute_generated": null,
+ "index?": false,
+ "match_type": null,
+ "match_with": null,
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "name": "users_member_id_fkey",
+ "on_delete": null,
+ "on_update": null,
+ "primary_key?": true,
+ "schema": "public",
+ "table": "members"
+ },
+ "scale": null,
+ "size": null,
+ "source": "member_id",
+ "type": "uuid"
+ }
+ ],
+ "base_filter": null,
+ "check_constraints": [],
+ "custom_indexes": [],
+ "custom_statements": [],
+ "has_create_action": true,
+ "hash": "FDEBD4840449609DDA8B50D6741C2EEDE9D81DFBC1E26D4BC77DBD9B5A8EA4DC",
+ "identities": [
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "users_unique_email_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "email"
+ }
+ ],
+ "name": "unique_email",
+ "nils_distinct?": true,
+ "where": null
+ },
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "users_unique_oidc_id_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "oidc_id"
+ }
+ ],
+ "name": "unique_oidc_id",
+ "nils_distinct?": true,
+ "where": null
+ },
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "users_unique_member_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "member_id"
+ }
+ ],
+ "name": "unique_member",
+ "nils_distinct?": true,
+ "where": null
+ }
+ ],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.Mv.Repo",
+ "schema": null,
+ "table": "users"
+}
\ No newline at end of file
diff --git a/priv/resource_snapshots/repo/users/20251016130855.json b/priv/resource_snapshots/repo/users/20251016130855.json
new file mode 100644
index 0000000..2698fd5
--- /dev/null
+++ b/priv/resource_snapshots/repo/users/20251016130855.json
@@ -0,0 +1,141 @@
+{
+ "attributes": [
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"gen_random_uuid()\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": true,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "email",
+ "type": "citext"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "hashed_password",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "oidc_id",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": {
+ "deferrable": false,
+ "destination_attribute": "id",
+ "destination_attribute_default": null,
+ "destination_attribute_generated": null,
+ "index?": false,
+ "match_type": null,
+ "match_with": null,
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "name": "users_member_id_fkey",
+ "on_delete": "nilify",
+ "on_update": null,
+ "primary_key?": true,
+ "schema": "public",
+ "table": "members"
+ },
+ "scale": null,
+ "size": null,
+ "source": "member_id",
+ "type": "uuid"
+ }
+ ],
+ "base_filter": null,
+ "check_constraints": [],
+ "custom_indexes": [],
+ "custom_statements": [],
+ "has_create_action": true,
+ "hash": "1D22936DF847949B543000F3E2E4BDA7D78682AAE6EE0CB9CBD55A4F8F4A7228",
+ "identities": [
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "users_unique_email_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "email"
+ }
+ ],
+ "name": "unique_email",
+ "nils_distinct?": true,
+ "where": null
+ },
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "users_unique_member_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "member_id"
+ }
+ ],
+ "name": "unique_member",
+ "nils_distinct?": true,
+ "where": null
+ },
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "users_unique_oidc_id_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "oidc_id"
+ }
+ ],
+ "name": "unique_oidc_id",
+ "nils_distinct?": true,
+ "where": null
+ }
+ ],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.Mv.Repo",
+ "schema": null,
+ "table": "users"
+}
\ No newline at end of file
diff --git a/renovate.json b/renovate.json
index 78fbbad..f134b7b 100644
--- a/renovate.json
+++ b/renovate.json
@@ -1,7 +1,7 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
- "schedule": ["* * 3 * *"],
+ "schedule": ["* * 1-7 * *"],
"packageRules": [
{
"groupName": "Mix dependencies",
diff --git a/test/accounts/email_sync_edge_cases_test.exs b/test/accounts/email_sync_edge_cases_test.exs
new file mode 100644
index 0000000..b872235
--- /dev/null
+++ b/test/accounts/email_sync_edge_cases_test.exs
@@ -0,0 +1,93 @@
+defmodule Mv.Accounts.EmailSyncEdgeCasesTest do
+ @moduledoc """
+ Edge case tests for email synchronization between User and Member.
+ Tests various boundary conditions and validation scenarios.
+ """
+ use Mv.DataCase, async: false
+ alias Mv.Accounts
+ alias Mv.Membership
+
+ describe "Email sync edge cases" do
+ @valid_user_attrs %{
+ email: "user@example.com"
+ }
+
+ @valid_member_attrs %{
+ first_name: "John",
+ last_name: "Doe",
+ email: "member@example.com"
+ }
+
+ test "simultaneous email updates use user email as source of truth" do
+ # Create linked user and member
+ {:ok, member} = Membership.create_member(@valid_member_attrs)
+
+ {:ok, user} =
+ Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
+
+ # Verify link and initial sync
+ {:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
+ assert synced_member.email == "user@example.com"
+
+ # Scenario: Both emails are updated "simultaneously"
+ # In practice, this tests that when a member email is updated,
+ # it syncs to user, and user remains the source of truth
+
+ # Update member email first
+ {:ok, _updated_member} =
+ Membership.update_member(member, %{email: "member-new@example.com"})
+
+ # Verify it synced to user
+ {:ok, user_after_member_update} = Ash.get(Mv.Accounts.User, user.id)
+ assert to_string(user_after_member_update.email) == "member-new@example.com"
+
+ # Now update user email - this should override
+ {:ok, _updated_user} =
+ Accounts.update_user(user_after_member_update, %{email: "user-final@example.com"})
+
+ # Reload both
+ {:ok, final_user} = Ash.get(Mv.Accounts.User, user.id)
+ {:ok, final_member} = Ash.get(Mv.Membership.Member, member.id)
+
+ # User email should be the final truth
+ assert to_string(final_user.email) == "user-final@example.com"
+ assert final_member.email == "user-final@example.com"
+ end
+
+ test "email validation works for both user and member" do
+ # Test that invalid emails are rejected for both resources
+
+ # Invalid email for user
+ invalid_user_result = Accounts.create_user(%{email: "not-an-email"})
+ assert {:error, %Ash.Error.Invalid{}} = invalid_user_result
+
+ # Invalid email for member
+ invalid_member_attrs = Map.put(@valid_member_attrs, :email, "also-not-an-email")
+ invalid_member_result = Membership.create_member(invalid_member_attrs)
+ assert {:error, %Ash.Error.Invalid{}} = invalid_member_result
+
+ # Valid emails should work
+ {:ok, _user} = Accounts.create_user(@valid_user_attrs)
+ {:ok, _member} = Membership.create_member(@valid_member_attrs)
+ end
+
+ test "identity constraints prevent duplicate emails" do
+ # Create first user with an email
+ {:ok, user1} = Accounts.create_user(%{email: "duplicate@example.com"})
+ assert to_string(user1.email) == "duplicate@example.com"
+
+ # Try to create second user with same email - should fail due to unique constraint
+ result = Accounts.create_user(%{email: "duplicate@example.com"})
+ assert {:error, %Ash.Error.Invalid{}} = result
+
+ # Same for members
+ member_attrs = Map.put(@valid_member_attrs, :email, "member-dup@example.com")
+ {:ok, member1} = Membership.create_member(member_attrs)
+ assert member1.email == "member-dup@example.com"
+
+ # Try to create second member with same email - should fail
+ result2 = Membership.create_member(member_attrs)
+ assert {:error, %Ash.Error.Invalid{}} = result2
+ end
+ end
+end
diff --git a/test/accounts/email_uniqueness_test.exs b/test/accounts/email_uniqueness_test.exs
new file mode 100644
index 0000000..a16ebdd
--- /dev/null
+++ b/test/accounts/email_uniqueness_test.exs
@@ -0,0 +1,480 @@
+defmodule Mv.Accounts.EmailUniquenessTest do
+ use Mv.DataCase, async: false
+
+ alias Mv.Accounts
+ alias Mv.Membership
+
+ describe "Email uniqueness validation - Creation" do
+ test "CAN create member with existing unlinked user email" do
+ # Create a user with email
+ {:ok, _user} =
+ Accounts.create_user(%{
+ email: "existing@example.com"
+ })
+
+ # Create member with same email - should succeed
+ {:ok, member} =
+ Membership.create_member(%{
+ first_name: "John",
+ last_name: "Doe",
+ email: "existing@example.com"
+ })
+
+ assert to_string(member.email) == "existing@example.com"
+ end
+
+ test "CAN create user with existing unlinked member email" do
+ # Create a member with email
+ {:ok, _member} =
+ Membership.create_member(%{
+ first_name: "John",
+ last_name: "Doe",
+ email: "existing@example.com"
+ })
+
+ # Create user with same email - should succeed
+ {:ok, user} =
+ Accounts.create_user(%{
+ email: "existing@example.com"
+ })
+
+ assert to_string(user.email) == "existing@example.com"
+ end
+ end
+
+ describe "Email uniqueness validation - Updating unlinked entities" do
+ test "unlinked member email CAN be changed to an existing unlinked user email" do
+ # Create a user with email
+ {:ok, _user} =
+ Accounts.create_user(%{
+ email: "existing_user@example.com"
+ })
+
+ # Create an unlinked member with different email
+ {:ok, member} =
+ Membership.create_member(%{
+ first_name: "John",
+ last_name: "Doe",
+ email: "member@example.com"
+ })
+
+ # Change member email to existing user email - should succeed (member is unlinked)
+ {:ok, updated_member} =
+ Membership.update_member(member, %{
+ email: "existing_user@example.com"
+ })
+
+ assert to_string(updated_member.email) == "existing_user@example.com"
+ end
+
+ test "unlinked user email CAN be changed to an existing unlinked member email" do
+ # Create a member with email
+ {:ok, _member} =
+ Membership.create_member(%{
+ first_name: "John",
+ last_name: "Doe",
+ email: "existing_member@example.com"
+ })
+
+ # Create an unlinked user with different email
+ {:ok, user} =
+ Accounts.create_user(%{
+ email: "user@example.com"
+ })
+
+ # Change user email to existing member email - should succeed (user is unlinked)
+ {:ok, updated_user} =
+ Accounts.update_user(user, %{
+ email: "existing_member@example.com"
+ })
+
+ assert to_string(updated_user.email) == "existing_member@example.com"
+ end
+
+ test "unlinked member email CANNOT be changed to an existing linked user email" do
+ # Create a user and link it to a member - this makes the user "linked"
+ {:ok, user} =
+ Accounts.create_user(%{
+ email: "linked_user@example.com"
+ })
+
+ {:ok, _member_a} =
+ Membership.create_member(%{
+ first_name: "Member",
+ last_name: "A",
+ email: "temp@example.com",
+ user: %{id: user.id}
+ })
+
+ # Create an unlinked member with different email
+ {:ok, member_b} =
+ Membership.create_member(%{
+ first_name: "Member",
+ last_name: "B",
+ email: "member_b@example.com"
+ })
+
+ # Try to change unlinked member's email to linked user's email - should fail
+ result =
+ Membership.update_member(member_b, %{
+ email: "linked_user@example.com"
+ })
+
+ assert {:error, %Ash.Error.Invalid{} = error} = result
+
+ assert error.errors
+ |> Enum.any?(fn e ->
+ e.field == :email and
+ (String.contains?(e.message, "already") or String.contains?(e.message, "used"))
+ end)
+ end
+
+ test "unlinked user email CANNOT be changed to an existing linked member email" do
+ # Create a user and link it to a member - this makes the member "linked"
+ {:ok, user_a} =
+ Accounts.create_user(%{
+ email: "user_a@example.com"
+ })
+
+ {:ok, _member_a} =
+ Membership.create_member(%{
+ first_name: "Member",
+ last_name: "A",
+ email: "temp@example.com",
+ user: %{id: user_a.id}
+ })
+
+ # Reload user to get updated member_id and linked member email
+ {:ok, user_a_reloaded} = Ash.get(Mv.Accounts.User, user_a.id)
+ {:ok, user_a_with_member} = Ash.load(user_a_reloaded, :member)
+ linked_member_email = to_string(user_a_with_member.member.email)
+
+ # Create an unlinked user with different email
+ {:ok, user_b} =
+ Accounts.create_user(%{
+ email: "user_b@example.com"
+ })
+
+ # Try to change unlinked user's email to linked member's email - should fail
+ result =
+ Accounts.update_user(user_b, %{
+ email: linked_member_email
+ })
+
+ assert {:error, %Ash.Error.Invalid{} = error} = result
+
+ assert error.errors
+ |> Enum.any?(fn e ->
+ e.field == :email and
+ (String.contains?(e.message, "already") or String.contains?(e.message, "used"))
+ end)
+ end
+ end
+
+ describe "Email uniqueness validation - Creating with linked emails" do
+ test "CANNOT create member with existing linked user email" do
+ # Create a user and link it to a member
+ {:ok, user} =
+ Accounts.create_user(%{
+ email: "linked@example.com"
+ })
+
+ {:ok, _member} =
+ Membership.create_member(%{
+ first_name: "First",
+ last_name: "Member",
+ email: "temp@example.com",
+ user: %{id: user.id}
+ })
+
+ # Try to create a new member with the linked user's email - should fail
+ result =
+ Membership.create_member(%{
+ first_name: "Second",
+ last_name: "Member",
+ email: "linked@example.com"
+ })
+
+ assert {:error, %Ash.Error.Invalid{} = error} = result
+
+ assert error.errors
+ |> Enum.any?(fn e ->
+ e.field == :email and
+ (String.contains?(e.message, "already") or String.contains?(e.message, "used"))
+ end)
+ end
+
+ test "CANNOT create user with existing linked member email" do
+ # Create a user and link it to a member
+ {:ok, user} =
+ Accounts.create_user(%{
+ email: "user@example.com"
+ })
+
+ {:ok, _member} =
+ Membership.create_member(%{
+ first_name: "Member",
+ last_name: "One",
+ email: "temp@example.com",
+ user: %{id: user.id}
+ })
+
+ # Reload user to get the linked member's email
+ {:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id)
+ {:ok, user_with_member} = Ash.load(user_reloaded, :member)
+ linked_member_email = to_string(user_with_member.member.email)
+
+ # Try to create a new user with the linked member's email - should fail
+ result =
+ Accounts.create_user(%{
+ email: linked_member_email
+ })
+
+ assert {:error, %Ash.Error.Invalid{} = error} = result
+
+ assert error.errors
+ |> Enum.any?(fn e ->
+ e.field == :email and
+ (String.contains?(e.message, "already") or String.contains?(e.message, "used"))
+ end)
+ end
+ end
+
+ describe "Email uniqueness validation - Updating linked entities" do
+ test "linked member email CANNOT be changed to an existing user email" do
+ # Create a user with email
+ {:ok, _other_user} =
+ Accounts.create_user(%{
+ email: "other_user@example.com"
+ })
+
+ # Create a user and link it to a member
+ {:ok, user} =
+ Accounts.create_user(%{
+ email: "user@example.com"
+ })
+
+ {:ok, member} =
+ Membership.create_member(%{
+ first_name: "John",
+ last_name: "Doe",
+ email: "temp@example.com",
+ user: %{id: user.id}
+ })
+
+ # Try to change linked member's email to other user's email - should fail
+ result =
+ Membership.update_member(member, %{
+ email: "other_user@example.com"
+ })
+
+ assert {:error, %Ash.Error.Invalid{} = error} = result
+
+ assert error.errors
+ |> Enum.any?(fn e ->
+ e.field == :email and
+ (String.contains?(e.message, "already") or String.contains?(e.message, "used"))
+ end)
+ end
+
+ test "linked user email CANNOT be changed to an existing member email" do
+ # Create a member with email
+ {:ok, _other_member} =
+ Membership.create_member(%{
+ first_name: "Jane",
+ last_name: "Doe",
+ email: "other_member@example.com"
+ })
+
+ # Create a user and link it to a member
+ {:ok, user} =
+ Accounts.create_user(%{
+ email: "user@example.com"
+ })
+
+ {:ok, _member} =
+ Membership.create_member(%{
+ first_name: "John",
+ last_name: "Doe",
+ email: "temp@example.com",
+ user: %{id: user.id}
+ })
+
+ # Reload user to get updated member_id
+ {:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id)
+
+ # Try to change linked user's email to other member's email - should fail
+ result =
+ Accounts.update_user(user_reloaded, %{
+ email: "other_member@example.com"
+ })
+
+ assert {:error, %Ash.Error.Invalid{} = error} = result
+
+ assert error.errors
+ |> Enum.any?(fn e ->
+ e.field == :email and
+ (String.contains?(e.message, "already") or String.contains?(e.message, "used"))
+ end)
+ end
+ end
+
+ describe "Email uniqueness validation - Linking" do
+ test "CANNOT link user to member if user email is already used by another unlinked member" do
+ # Create a member with email
+ {:ok, _other_member} =
+ Membership.create_member(%{
+ first_name: "Jane",
+ last_name: "Doe",
+ email: "duplicate@example.com"
+ })
+
+ # Create a user with same email
+ {:ok, user} =
+ Accounts.create_user(%{
+ email: "duplicate@example.com"
+ })
+
+ # Create a member to link with the user
+ {:ok, member} =
+ Membership.create_member(%{
+ first_name: "John",
+ last_name: "Smith",
+ email: "john@example.com"
+ })
+
+ # Try to link user to member - should fail because user.email is already used by other_member
+ result =
+ Accounts.update_user(user, %{
+ member: %{id: member.id}
+ })
+
+ assert {:error, %Ash.Error.Invalid{} = error} = result
+
+ assert error.errors
+ |> Enum.any?(fn e ->
+ e.field == :email and
+ (String.contains?(e.message, "already") or String.contains?(e.message, "used"))
+ end)
+ end
+
+ test "CAN link member to user even if member email is used by another user (member email gets overridden)" do
+ # Create a user with email
+ {:ok, _other_user} =
+ Accounts.create_user(%{
+ email: "duplicate@example.com"
+ })
+
+ # Create a member with same email
+ {:ok, member} =
+ Membership.create_member(%{
+ first_name: "John",
+ last_name: "Doe",
+ email: "duplicate@example.com"
+ })
+
+ # Create a user to link with the member
+ {:ok, user} =
+ Accounts.create_user(%{
+ email: "user@example.com"
+ })
+
+ # Link member to user - should succeed because member.email will be overridden
+ {:ok, updated_member} =
+ Membership.update_member(member, %{
+ user: %{id: user.id}
+ })
+
+ # Member email should now be the same as user email
+ {:ok, member_reloaded} = Ash.get(Mv.Membership.Member, updated_member.id)
+ assert to_string(member_reloaded.email) == "user@example.com"
+ end
+ end
+
+ describe "Email syncing" do
+ test "member email syncs to linked user email without validation error" do
+ # Create a user
+ {:ok, user} =
+ Accounts.create_user(%{
+ email: "user@example.com"
+ })
+
+ # Create a member linked to this user
+ # The override change will set member.email = user.email automatically
+ {:ok, member} =
+ Membership.create_member(%{
+ first_name: "John",
+ last_name: "Doe",
+ email: "member@example.com",
+ user: %{id: user.id}
+ })
+
+ # Member email should have been overridden to user email
+ # This happens through our sync mechanism, which should NOT trigger
+ # the "email already used" validation because it's the same user
+ {:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id)
+ assert member_after_link.email == "user@example.com"
+ end
+
+ test "user email syncs to linked member without validation error" do
+ # Create a member
+ {:ok, member} =
+ Membership.create_member(%{
+ first_name: "John",
+ last_name: "Doe",
+ email: "member@example.com"
+ })
+
+ # Create a user linked to this member
+ # The override change will set member.email = user.email automatically
+ {:ok, _user} =
+ Accounts.create_user(%{
+ email: "user@example.com",
+ member: %{id: member.id}
+ })
+
+ # Member email should have been overridden to user email
+ # This happens through our sync mechanism, which should NOT trigger
+ # the "email already used" validation because it's the same member
+ {:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id)
+ assert member_after_link.email == "user@example.com"
+ end
+
+ test "two unlinked users cannot have the same email" do
+ # Create first user
+ {:ok, _user1} =
+ Accounts.create_user(%{
+ email: "duplicate@example.com"
+ })
+
+ # Try to create second user with same email
+ result =
+ Accounts.create_user(%{
+ email: "duplicate@example.com"
+ })
+
+ assert {:error, %Ash.Error.Invalid{}} = result
+ end
+
+ test "two unlinked members cannot have the same email (members have unique constraint)" do
+ # Create first member
+ {:ok, _member1} =
+ Membership.create_member(%{
+ first_name: "John",
+ last_name: "Doe",
+ email: "duplicate@example.com"
+ })
+
+ # Try to create second member with same email - should fail
+ result =
+ Membership.create_member(%{
+ first_name: "Jane",
+ last_name: "Smith",
+ email: "duplicate@example.com"
+ })
+
+ assert {:error, %Ash.Error.Invalid{}} = result
+ # Members DO have a unique email constraint at database level
+ end
+ end
+end
diff --git a/test/accounts/user_email_sync_test.exs b/test/accounts/user_email_sync_test.exs
new file mode 100644
index 0000000..6d08d61
--- /dev/null
+++ b/test/accounts/user_email_sync_test.exs
@@ -0,0 +1,169 @@
+defmodule Mv.Accounts.UserEmailSyncTest do
+ @moduledoc """
+ Tests for email synchronization from User to Member.
+ When a user and member are linked, email changes should sync bidirectionally.
+ User.email is the source of truth when linking occurs.
+ """
+ use Mv.DataCase, async: false
+ alias Mv.Accounts
+ alias Mv.Membership
+
+ describe "User email synchronization to linked Member" do
+ @valid_user_attrs %{
+ email: "user@example.com"
+ }
+
+ @valid_member_attrs %{
+ first_name: "John",
+ last_name: "Doe",
+ email: "member@example.com"
+ }
+
+ test "updating user email syncs to linked member" do
+ # Create a member
+ {:ok, member} = Membership.create_member(@valid_member_attrs)
+ assert member.email == "member@example.com"
+
+ # Create a user linked to the member
+ {:ok, user} =
+ Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
+
+ # Verify initial state - member email should be overridden by user email
+ {:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id)
+ assert member_after_link.email == "user@example.com"
+
+ # Update user email
+ {:ok, updated_user} = Accounts.update_user(user, %{email: "newemail@example.com"})
+ assert to_string(updated_user.email) == "newemail@example.com"
+
+ # Verify member email was also updated
+ {:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
+ assert synced_member.email == "newemail@example.com"
+ end
+
+ test "creating user linked to member overrides member email" do
+ # Create a member with their own email
+ {:ok, member} = Membership.create_member(@valid_member_attrs)
+ assert member.email == "member@example.com"
+
+ # Create a user linked to this member
+ {:ok, user} =
+ Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
+
+ assert to_string(user.email) == "user@example.com"
+ assert user.member_id == member.id
+
+ # Verify member email was overridden with user email
+ {:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id)
+ assert updated_member.email == "user@example.com"
+ end
+
+ test "linking user to existing member syncs user email to member" do
+ # Create a standalone member
+ {:ok, member} = Membership.create_member(@valid_member_attrs)
+ assert member.email == "member@example.com"
+
+ # Create a standalone user
+ {:ok, user} = Accounts.create_user(@valid_user_attrs)
+ assert to_string(user.email) == "user@example.com"
+ assert user.member_id == nil
+
+ # Link the user to the member
+ {:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member.id}})
+ assert linked_user.member_id == member.id
+
+ # Verify member email was overridden with user email
+ {:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
+ assert synced_member.email == "user@example.com"
+ end
+
+ test "updating user email when no member linked does not error" do
+ # Create a standalone user without member link
+ {:ok, user} = Accounts.create_user(@valid_user_attrs)
+ assert to_string(user.email) == "user@example.com"
+ assert user.member_id == nil
+
+ # Update user email - should work fine without error
+ {:ok, updated_user} = Accounts.update_user(user, %{email: "newemail@example.com"})
+ assert to_string(updated_user.email) == "newemail@example.com"
+ assert updated_user.member_id == nil
+ end
+
+ test "unlinking user from member does not sync email" do
+ # Create member
+ {:ok, member} = Membership.create_member(@valid_member_attrs)
+
+ # Create user linked to member
+ {:ok, user} =
+ Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
+
+ assert user.member_id == member.id
+
+ # Verify member email was synced
+ {:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
+ assert synced_member.email == "user@example.com"
+
+ # Unlink user from member
+ {:ok, unlinked_user} = Accounts.update_user(user, %{member: nil})
+ assert unlinked_user.member_id == nil
+
+ # Member email should remain unchanged after unlinking
+ {:ok, member_after_unlink} = Ash.get(Mv.Membership.Member, member.id)
+ assert member_after_unlink.email == "user@example.com"
+ end
+ end
+
+ describe "AshAuthentication compatibility" do
+ test "AshAuthentication password strategy still works with email" do
+ # This test ensures that the email field remains accessible for password auth
+ email = "test@example.com"
+ password = "securepassword123"
+
+ # Create user with password strategy (simulating registration)
+ {:ok, user} =
+ Mv.Accounts.User
+ |> Ash.Changeset.for_create(:register_with_password, %{
+ email: email,
+ password: password
+ })
+ |> Ash.create()
+
+ assert to_string(user.email) == email
+ assert user.hashed_password != nil
+
+ # Verify we can sign in with email
+ {:ok, signed_in_user} =
+ Mv.Accounts.User
+ |> Ash.Query.for_read(:sign_in_with_password, %{
+ email: email,
+ password: password
+ })
+ |> Ash.read_one()
+
+ assert signed_in_user.id == user.id
+ assert to_string(signed_in_user.email) == email
+ end
+
+ test "AshAuthentication OIDC strategy still works with email" do
+ # This test ensures the OIDC flow can still set email
+ user_info = %{
+ "preferred_username" => "oidc@example.com",
+ "sub" => "oidc-user-123"
+ }
+
+ oauth_tokens = %{"access_token" => "mock_token"}
+
+ # Simulate OIDC registration
+ {:ok, user} =
+ Mv.Accounts.User
+ |> Ash.Changeset.for_create(:register_with_rauthy, %{
+ user_info: user_info,
+ oauth_tokens: oauth_tokens
+ })
+ |> Ash.create()
+
+ assert to_string(user.email) == "oidc@example.com"
+ assert user.oidc_id == "oidc-user-123"
+ end
+ end
+end
diff --git a/test/accounts/user_member_deletion_test.exs b/test/accounts/user_member_deletion_test.exs
new file mode 100644
index 0000000..52a3865
--- /dev/null
+++ b/test/accounts/user_member_deletion_test.exs
@@ -0,0 +1,88 @@
+defmodule Mv.Accounts.UserMemberDeletionTest do
+ @moduledoc """
+ Tests for ON DELETE SET NULL constraint on users.member_id.
+ When a member is deleted, the linked user should remain but with member_id set to NULL.
+ """
+ use Mv.DataCase, async: true
+ alias Mv.Accounts
+ alias Mv.Membership
+
+ describe "User remains when linked Member is deleted (ON DELETE SET NULL)" do
+ @valid_user_attrs %{
+ email: "test@example.com"
+ }
+
+ @valid_member_attrs %{
+ first_name: "John",
+ last_name: "Doe",
+ email: "john@example.com"
+ }
+
+ test "deleting a member sets the user's member_id to NULL" do
+ # Create a member
+ {:ok, member} = Membership.create_member(@valid_member_attrs)
+
+ # Create a user linked to the member
+ {:ok, user} =
+ Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
+
+ # Verify the relationship is established
+ {:ok, user_before_delete} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
+ assert user_before_delete.member_id == member.id
+ assert user_before_delete.member.id == member.id
+
+ # Delete the member
+ :ok = Membership.destroy_member(member)
+
+ # Verify the user still exists but member_id is NULL
+ {:ok, user_after_delete} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
+ assert user_after_delete.id == user.id
+ assert user_after_delete.member_id == nil
+ assert user_after_delete.member == nil
+ end
+
+ test "user can be linked to a new member after old member is deleted" do
+ # Create first member
+ {:ok, member1} = Membership.create_member(@valid_member_attrs)
+
+ # Create user linked to first member
+ {:ok, user} =
+ Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member1.id}))
+
+ assert user.member_id == member1.id
+
+ # Delete first member
+ :ok = Membership.destroy_member(member1)
+
+ # Reload user from database to get updated member_id (should be NULL)
+ {:ok, user_after_delete} = Ash.get(Mv.Accounts.User, user.id)
+ assert user_after_delete.member_id == nil
+
+ # Create second member
+ {:ok, member2} =
+ Membership.create_member(%{
+ first_name: "Jane",
+ last_name: "Smith",
+ email: "jane@example.com"
+ })
+
+ # Link user to second member (use reloaded user)
+ {:ok, updated_user} = Accounts.update_user(user_after_delete, %{member: %{id: member2.id}})
+
+ # Verify new relationship
+ {:ok, final_user} = Ash.get(Mv.Accounts.User, updated_user.id, load: [:member])
+ assert final_user.member_id == member2.id
+ assert final_user.member.id == member2.id
+ end
+
+ test "member without linked user can be deleted normally" do
+ {:ok, member} = Membership.create_member(@valid_member_attrs)
+
+ # Delete member (no users linked)
+ assert :ok = Membership.destroy_member(member)
+
+ # Verify member is deleted
+ assert {:error, _} = Ash.get(Mv.Membership.Member, member.id)
+ end
+ end
+end
diff --git a/test/accounts/user_member_relationship_test.exs b/test/accounts/user_member_relationship_test.exs
new file mode 100644
index 0000000..b64f5ec
--- /dev/null
+++ b/test/accounts/user_member_relationship_test.exs
@@ -0,0 +1,197 @@
+defmodule Mv.Accounts.UserMemberRelationshipTest do
+ # Using async: true for faster test execution
+ # This is safe because all database operations are sandboxed per test
+ use Mv.DataCase, async: true
+ alias Mv.Accounts
+ alias Mv.Membership
+
+ describe "User-Member Relationship - Basic Tests" do
+ @valid_user_attrs %{
+ email: "test@example.com"
+ }
+
+ @valid_member_attrs %{
+ first_name: "John",
+ last_name: "Doe",
+ email: "john@example.com"
+ }
+
+ test "user can exist without member" do
+ {:ok, user} = Accounts.create_user(@valid_user_attrs)
+ assert user.member_id == nil
+
+ # Load the relationship to test it
+ {:ok, user_with_member} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
+ assert user_with_member.member == nil
+ end
+
+ test "member can exist without user" do
+ {:ok, member} = Membership.create_member(@valid_member_attrs)
+ assert member.id != nil
+ assert member.first_name == "John"
+
+ # Load the relationship to test it
+ {:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
+ assert member_with_user.user == nil
+ end
+ end
+
+ describe "User-Member Relationship - Linking Tests" do
+ @valid_user_attrs %{
+ email: "test1@example.com"
+ }
+
+ @valid_member_attrs %{
+ first_name: "Alice",
+ last_name: "Johnson",
+ email: "alice@example.com"
+ }
+
+ test "user can be linked to member during user creation" do
+ {:ok, member} = Membership.create_member(@valid_member_attrs)
+
+ user_attrs = Map.put(@valid_user_attrs, :member, %{id: member.id})
+ {:ok, user} = Accounts.create_user(user_attrs)
+
+ # Load the relationship to test it
+ {:ok, user_with_member} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
+ assert user_with_member.member.id == member.id
+ end
+
+ test "member can be linked to user during member creation using manage_relationship" do
+ {:ok, user} = Accounts.create_user(@valid_user_attrs)
+
+ member_attrs = Map.put(@valid_member_attrs, :user, %{id: user.id})
+ {:ok, member} = Membership.create_member(member_attrs)
+
+ # Load the relationship to test it
+ {:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
+ assert member_with_user.user.id == user.id
+ end
+
+ test "user can be linked to member during update" do
+ {:ok, user} = Accounts.create_user(@valid_user_attrs)
+ {:ok, member} = Membership.create_member(@valid_member_attrs)
+
+ {:ok, updated_user} = Accounts.update_user(user, %{member: %{id: member.id}})
+
+ # Load the relationship to test it
+ {:ok, user_with_member} = Ash.get(Mv.Accounts.User, updated_user.id, load: [:member])
+ assert user_with_member.member.id == member.id
+ end
+
+ test "member can be linked to user during update using manage_relationship" do
+ {:ok, user} = Accounts.create_user(@valid_user_attrs)
+ {:ok, member} = Membership.create_member(@valid_member_attrs)
+
+ {:ok, _updated_member} = Membership.update_member(member, %{user: %{id: user.id}})
+
+ # Load the relationship to test it
+ {:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
+ assert member_with_user.user.id == user.id
+ end
+ end
+
+ describe "User-Member Relationship - Inverse Relationship Tests" do
+ @valid_user_attrs %{
+ email: "test2@example.com"
+ }
+
+ @valid_member_attrs %{
+ first_name: "Bob",
+ last_name: "Smith",
+ email: "bob@example.com"
+ }
+
+ test "ash resolves inverse relationship automatically" do
+ {:ok, member} = Membership.create_member(@valid_member_attrs)
+
+ user_attrs = Map.put(@valid_user_attrs, :member, %{id: member.id})
+ {:ok, user} = Accounts.create_user(user_attrs)
+
+ # Load relationships
+ {:ok, user_with_member} = Ash.get(Mv.Accounts.User, user.id, load: [:member])
+ {:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
+
+ assert user_with_member.member.id == member.id
+ assert member_with_user.user.id == user.id
+ end
+
+ test "member can find associated user" do
+ {:ok, member} = Membership.create_member(@valid_member_attrs)
+
+ {:ok, user} = Accounts.create_user(%{email: "test3@example.com", member: %{id: member.id}})
+ {:ok, member_with_user} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
+ assert member_with_user.user.id == user.id
+ end
+ end
+
+ describe "User-Member Relationship - Preventing Duplicates" do
+ @valid_user_attrs %{
+ email: "test4@example.com"
+ }
+
+ @valid_member_attrs %{
+ first_name: "Charlie",
+ last_name: "Brown",
+ email: "charlie@example.com"
+ }
+
+ test "prevents overwriting a member of already linked user on update" do
+ {:ok, existing_member} = Membership.create_member(@valid_member_attrs)
+
+ user_attrs = Map.put(@valid_user_attrs, :member, %{id: existing_member.id})
+ {:ok, user} = Accounts.create_user(user_attrs)
+
+ {:ok, member2} =
+ Membership.create_member(%{
+ first_name: "Dave",
+ last_name: "Wilson",
+ email: "dave@example.com"
+ })
+
+ assert {:error, %Ash.Error.Invalid{}} =
+ Accounts.update_user(user, %{member: %{id: member2.id}})
+ end
+
+ test "prevents linking user to already linked member on update" do
+ {:ok, existing_user} = Accounts.create_user(@valid_user_attrs)
+ {:ok, member} = Membership.create_member(@valid_member_attrs)
+
+ {:ok, _updated_user} = Accounts.update_user(existing_user, %{member: %{id: member.id}})
+
+ {:ok, user2} = Accounts.create_user(%{email: "test5@example.com"})
+
+ assert {:error, %Ash.Error.Invalid{}} =
+ Accounts.update_user(user2, %{member: %{id: member.id}})
+ end
+
+ test "prevents linking member to already linked user on creation" do
+ {:ok, existing_member} = Membership.create_member(@valid_member_attrs)
+
+ user_attrs = Map.put(@valid_user_attrs, :member, %{id: existing_member.id})
+ {:ok, user} = Accounts.create_user(user_attrs)
+
+ assert {:error, %Ash.Error.Invalid{}} =
+ Membership.create_member(%{
+ first_name: "Dave",
+ last_name: "Wilson",
+ email: "dave@example.com",
+ user: %{id: user.id}
+ })
+ end
+
+ test "prevents linking user to already linked member on creation" do
+ {:ok, existing_user} = Accounts.create_user(@valid_user_attrs)
+ {:ok, member} = Membership.create_member(@valid_member_attrs)
+
+ {:ok, _updated_user} = Accounts.update_user(existing_user, %{member: %{id: member.id}})
+
+ assert {:error, %Ash.Error.Invalid{}} =
+ Accounts.create_user(%{
+ email: "test5@example.com",
+ member: %{id: member.id}
+ })
+ end
+ end
+end
diff --git a/test/membership/member_email_sync_test.exs b/test/membership/member_email_sync_test.exs
new file mode 100644
index 0000000..eeef210
--- /dev/null
+++ b/test/membership/member_email_sync_test.exs
@@ -0,0 +1,127 @@
+defmodule Mv.Membership.MemberEmailSyncTest do
+ @moduledoc """
+ Tests for email synchronization from Member to User.
+ When a member and user are linked, email changes should sync bidirectionally.
+ User.email is the source of truth when linking occurs.
+ """
+ use Mv.DataCase, async: false
+ alias Mv.Accounts
+ alias Mv.Membership
+
+ describe "Member email synchronization to linked User" do
+ @valid_user_attrs %{
+ email: "user@example.com"
+ }
+
+ @valid_member_attrs %{
+ first_name: "John",
+ last_name: "Doe",
+ email: "member@example.com"
+ }
+
+ test "updating member email syncs to linked user" do
+ # Create a user
+ {:ok, user} = Accounts.create_user(@valid_user_attrs)
+ assert to_string(user.email) == "user@example.com"
+
+ # Create a member linked to the user
+ {:ok, member} =
+ Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id}))
+
+ # Verify initial state - member email should be overridden by user email
+ {:ok, member_after_create} = Ash.get(Mv.Membership.Member, member.id)
+ assert member_after_create.email == "user@example.com"
+
+ # Update member email
+ {:ok, updated_member} =
+ Membership.update_member(member, %{email: "newmember@example.com"})
+
+ assert updated_member.email == "newmember@example.com"
+
+ # Verify user email was also updated
+ {:ok, synced_user} = Ash.get(Mv.Accounts.User, user.id)
+ assert to_string(synced_user.email) == "newmember@example.com"
+ end
+
+ test "creating member linked to user syncs user email to member" do
+ # Create a user with their own email
+ {:ok, user} = Accounts.create_user(@valid_user_attrs)
+ assert to_string(user.email) == "user@example.com"
+
+ # Create a member linked to this user
+ {:ok, member} =
+ Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id}))
+
+ # Member should have been created with user's email (user is source of truth)
+ assert member.email == "user@example.com"
+
+ # Verify the link
+ {:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
+ assert loaded_member.user.id == user.id
+ end
+
+ test "linking member to existing user syncs user email to member" do
+ # Create a standalone user
+ {:ok, user} = Accounts.create_user(@valid_user_attrs)
+ assert to_string(user.email) == "user@example.com"
+
+ # Create a standalone member
+ {:ok, member} = Membership.create_member(@valid_member_attrs)
+ assert member.email == "member@example.com"
+
+ # Link the member to the user
+ {:ok, linked_member} = Membership.update_member(member, %{user: %{id: user.id}})
+
+ # Verify the link
+ {:ok, loaded_member} = Ash.get(Mv.Membership.Member, linked_member.id, load: [:user])
+ assert loaded_member.user.id == user.id
+
+ # Verify member email was overridden with user email
+ assert loaded_member.email == "user@example.com"
+ end
+
+ test "updating member email when no user linked does not error" do
+ # Create a standalone member without user link
+ {:ok, member} = Membership.create_member(@valid_member_attrs)
+ assert member.email == "member@example.com"
+
+ # Load to verify no user link
+ {:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
+ assert loaded_member.user == nil
+
+ # Update member email - should work fine without error
+ {:ok, updated_member} =
+ Membership.update_member(member, %{email: "newemail@example.com"})
+
+ assert updated_member.email == "newemail@example.com"
+ end
+
+ test "unlinking member from user does not sync email" do
+ # Create user
+ {:ok, user} = Accounts.create_user(@valid_user_attrs)
+
+ # Create member linked to user
+ {:ok, member} =
+ Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id}))
+
+ # Verify member email was synced to user email
+ {:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
+ assert synced_member.email == "user@example.com"
+
+ # Verify link exists
+ {:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
+ assert loaded_member.user != nil
+
+ # Unlink member from user
+ {:ok, unlinked_member} = Membership.update_member(member, %{user: nil})
+
+ # Verify unlink
+ {:ok, loaded_unlinked} = Ash.get(Mv.Membership.Member, unlinked_member.id, load: [:user])
+ assert loaded_unlinked.user == nil
+
+ # User email should remain unchanged after unlinking
+ {:ok, user_after_unlink} = Ash.get(Mv.Accounts.User, user.id)
+ assert to_string(user_after_unlink.email) == "user@example.com"
+ end
+ end
+end
diff --git a/test/mv_web/components/layouts/navbar_test.exs b/test/mv_web/components/layouts/navbar_test.exs
new file mode 100644
index 0000000..b6fa556
--- /dev/null
+++ b/test/mv_web/components/layouts/navbar_test.exs
@@ -0,0 +1,88 @@
+defmodule MvWeb.Layouts.NavbarTest do
+ use MvWeb.ConnCase, async: true
+ import Phoenix.LiveViewTest
+
+ describe "navbar profile section" do
+ test "renders profile button with correct attributes", %{conn: _conn} do
+ # Setup: Create a user
+ user = create_test_user(%{email: "test@example.com"})
+
+ html =
+ render_component(&MvWeb.Layouts.Navbar.navbar/1, %{
+ current_user: user
+ })
+
+ # Test dropdown structure
+ assert html =~ "dropdown-content"
+ assert html =~ "dropdown-end"
+ assert html =~ ~s(role="button")
+
+ # Test profile link
+ assert html =~ ~s(href="/users/#{user.id}")
+ assert html =~ "Profil"
+ end
+
+ @tag :skip
+ # TODO: Implement user initials in navbar avatar - see issue #170
+ test "shows user initials in avatar", %{conn: _conn} do
+ # Setup: Create a user with specific email for testing initials
+ user = create_test_user(%{email: "test.user@example.com"})
+
+ html =
+ render_component(&MvWeb.Layouts.Navbar.navbar/1, %{
+ current_user: user
+ })
+
+ # Initials from test.user@example.com
+ assert html =~ "TU"
+ end
+
+ @tag :skip
+ # TODO: Implement user initials in navbar avatar - see issue #170
+ test "shows different initials for OIDC user", %{conn: _conn} do
+ # Setup: Create OIDC user
+ user_info = %{
+ "sub" => "oidc_123",
+ "preferred_username" => "oidc.user@example.com"
+ }
+
+ oauth_tokens = %{
+ "access_token" => "test_token",
+ "id_token" => "test_id_token"
+ }
+
+ user =
+ Mv.Accounts.User
+ |> Ash.Changeset.for_create(:register_with_rauthy, %{
+ user_info: user_info,
+ oauth_tokens: oauth_tokens
+ })
+ |> Ash.create!(domain: Mv.Accounts)
+
+ html =
+ render_component(&MvWeb.Layouts.Navbar.navbar/1, %{
+ current_user: user
+ })
+
+ # Initials from oidc.user@example.com
+ assert html =~ "OU"
+ end
+
+ test "includes all required navigation items", %{conn: _conn} do
+ user = create_test_user(%{email: "test@example.com"})
+
+ html =
+ render_component(&MvWeb.Layouts.Navbar.navbar/1, %{
+ current_user: user
+ })
+
+ # Check for all required menu items
+ assert html =~ "Profil"
+ assert html =~ "Settings"
+ assert html =~ "Logout"
+
+ # Check for correct logout path
+ assert html =~ ~s(href="/sign-out")
+ end
+ end
+end
diff --git a/test/mv_web/components/search_bar_component_test.exs b/test/mv_web/components/search_bar_component_test.exs
new file mode 100644
index 0000000..bc8bc46
--- /dev/null
+++ b/test/mv_web/components/search_bar_component_test.exs
@@ -0,0 +1,33 @@
+defmodule MvWeb.Components.SearchBarComponentTest do
+ use MvWeb.ConnCase, async: true
+ import Phoenix.LiveViewTest
+
+ describe "SearchBarComponent" do
+ test "renders with placeholder", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ assert has_element?(view, "input[placeholder='Search...']")
+ end
+
+ test "updates query when user types", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ # simulate search input and check that other members are not listed
+ html =
+ view
+ |> element("form[role=search]")
+ |> render_submit(%{"query" => "Friedrich"})
+
+ refute html =~ "Greta"
+
+ html =
+ view
+ |> element("form[role=search]")
+ |> render_submit(%{"query" => "Greta"})
+
+ refute html =~ "Friedrich"
+ end
+ end
+end
diff --git a/test/mv_web/components/sort_header_component_test.exs b/test/mv_web/components/sort_header_component_test.exs
new file mode 100644
index 0000000..2e6d4fe
--- /dev/null
+++ b/test/mv_web/components/sort_header_component_test.exs
@@ -0,0 +1,319 @@
+defmodule MvWeb.Components.SortHeaderComponentTest do
+ use MvWeb.ConnCase, async: true
+ import Phoenix.LiveViewTest
+
+ describe "rendering" do
+ test "renders with correct attributes", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ # Test that the component renders with correct attributes
+ assert has_element?(view, "[data-testid='first_name']")
+ assert has_element?(view, "button[phx-value-field='city']")
+ assert has_element?(view, "button[phx-value-field='first_name']", "First name")
+ end
+
+ test "renders all sortable headers", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ sortable_fields = [
+ :first_name,
+ :email,
+ :street,
+ :house_number,
+ :postal_code,
+ :city,
+ :phone_number,
+ :join_date
+ ]
+
+ for field <- sortable_fields do
+ assert has_element?(view, "button[phx-value-field='#{field}']")
+ end
+ end
+
+ test "renders correct labels", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ # Test specific labels
+ assert has_element?(view, "button[phx-value-field='first_name']", "First name")
+ assert has_element?(view, "button[phx-value-field='email']", "Email")
+ assert has_element?(view, "button[phx-value-field='city']", "City")
+ end
+ end
+
+ describe "sort icons" do
+ test "shows neutral icon for specific field when not sorted", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ # The neutral icon has the opcity class we can test for
+ # Test that EMAIL field specifically shows neutral icon
+ assert has_element?(view, "[data-testid='email'] .opacity-40")
+
+ # Test that CITY field specifically shows neutral icon
+ assert has_element?(view, "[data-testid='city'] .opacity-40")
+ end
+
+ test "shows ascending icon for specific field when sorted ascending", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, html} = live(conn, "/members?query=&sort_field=city&sort_order=asc")
+
+ # Test that FIRST_NAME field specifically shows ascending icon
+ # Test CSS classes - no opacity for active state
+ refute has_element?(view, "[data-testid='city'] .opacity-40")
+
+ # Test that OTHER fields still show neutral icons
+ assert has_element?(view, "[data-testid='first_name'] .opacity-40")
+
+ # Test HTML content - should contain chevronup AND chevron up down
+ assert html =~ "hero-chevron-up"
+ assert html =~ "hero-chevron-up-down"
+
+ # Count occurrences to ensure only one ascending icon
+ up_count = html |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
+ # Should be exactly one chevronup icon
+ assert up_count == 1
+ end
+
+ test "shows descending icon for specific field when sorted descending", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, _view, html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
+
+ # Count occurrences to ensure only one descending icon
+ down_count = html |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
+ # Should be exactly one chevrondown icon
+ assert down_count == 1
+ end
+
+ test "multiple fields can have different icon states", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members?query=&sort_field=city&sort_order=asc")
+
+ # CITY field should be active (ascending)
+ refute has_element?(view, "[data-testid='city'] .opacity-40")
+
+ # All other fields should be neutral
+ assert has_element?(view, "[data-testid='first_name'] .opacity-40")
+ assert has_element?(view, "[data-testid='email'] .opacity-40")
+ assert has_element?(view, "[data-testid='street'] .opacity-40")
+ assert has_element?(view, "[data-testid='house_number'] .opacity-40")
+ assert has_element?(view, "[data-testid='postal_code'] .opacity-40")
+ assert has_element?(view, "[data-testid='phone_number'] .opacity-40")
+ assert has_element?(view, "[data-testid='join_date'] .opacity-40")
+ end
+
+ test "icon state changes correctly when clicking different fields", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ # Start: all fields neutral except first name as default
+ assert has_element?(view, "[data-testid='city'] .opacity-40")
+ refute has_element?(view, "[data-testid='first_name'] .opacity-40")
+ assert has_element?(view, "[data-testid='email'] .opacity-40")
+
+ # Click city - should become active
+ view
+ |> element("button[phx-value-field='city']")
+ |> render_click()
+
+ # city should be active, email should still be neutral
+ refute has_element?(view, "[data-testid='city'] .opacity-40")
+ assert has_element?(view, "[data-testid='email'] .opacity-40")
+
+ # Click email - should switch active field
+ view
+ |> element("button[phx-value-field='email']")
+ |> render_click()
+
+ # email should be active, city should be neutral again
+ refute has_element?(view, "[data-testid='email'] .opacity-40")
+ assert has_element?(view, "[data-testid='city'] .opacity-40")
+ end
+
+ test "specific field shows correct icon for each sort state", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+
+ # Test EMAIL field specifically
+ {:ok, view, html_asc} = live(conn, "/members?sort_field=email&sort_order=asc")
+ assert html_asc =~ "hero-chevron-up"
+ refute has_element?(view, "[data-testid='email'] .opacity-40")
+
+ {:ok, view, html_desc} = live(conn, "/members?sort_field=email&sort_order=desc")
+ assert html_desc =~ "hero-chevron-down"
+ refute has_element?(view, "[data-testid='email'] .opacity-40")
+
+ {:ok, view, html_neutral} = live(conn, "/members")
+ assert html_neutral =~ "hero-chevron-up-down"
+ assert has_element?(view, "[data-testid='email'] .opacity-40")
+ end
+
+ test "icon distribution is correct for all fields", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+
+ # Test neutral state - all fields except first name (default) should show neutral icons
+ {:ok, _view, html_neutral} = live(conn, "/members")
+
+ # Count neutral icons (should be 7 - one for each field)
+ neutral_count =
+ html_neutral |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1)
+
+ assert neutral_count == 7
+
+ # Count active icons (should be 1)
+ up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
+ down_count = html_neutral |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
+ assert up_count == 1
+ assert down_count == 0
+
+ # Test ascending state - one field active, others neutral
+ {:ok, _view, html_asc} = live(conn, "/members?sort_field=first_name&sort_order=asc")
+
+ # Should have exactly 1 ascending icon and 7 neutral icons
+ up_count = html_asc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
+ neutral_count = html_asc |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1)
+ down_count = html_asc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
+
+ assert up_count == 1
+ assert neutral_count == 7
+ assert down_count == 0
+ end
+ end
+
+ describe "accessibility" do
+ test "sets aria-label correctly for unsorted state", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ # Check aria-label for unsorted state
+ assert has_element?(view, "button[phx-value-field='city'][aria-label='Click to sort']")
+ end
+
+ test "sets aria-label correctly for ascending sort", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc")
+
+ # Check aria-label for ascending sort
+ assert has_element?(view, "button[phx-value-field='first_name'][aria-label='ascending']")
+ end
+
+ test "sets aria-label correctly for descending sort", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=desc")
+
+ # Check aria-label for descending sort
+ assert has_element?(view, "button[phx-value-field='first_name'][aria-label='descending']")
+ end
+
+ test "includes tooltip with correct aria-label", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc")
+
+ # Check that tooltip div exists with correct data-tip
+ assert has_element?(view, "[data-testid='first_name']")
+ assert has_element?(view, "button[phx-value-field='first_name'][aria-label='ascending']")
+ end
+
+ test "aria-labels work for all sortable fields", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members?sort_field=email&sort_order=desc")
+
+ # Test aria-labels for different fields
+ assert has_element?(view, "button[phx-value-field='email'][aria-label='descending']")
+
+ assert has_element?(
+ view,
+ "button[phx-value-field='first_name'][aria-label='Click to sort']"
+ )
+
+ assert has_element?(view, "button[phx-value-field='city'][aria-label='Click to sort']")
+ end
+ end
+
+ describe "component behavior" do
+ test "clicking sends sort message to parent", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ # Click on the first name sort header
+ view
+ |> element("button[phx-value-field='first_name']")
+ |> render_click()
+
+ # The component should send a message to the parent LiveView
+ # This is tested indirectly through the URL change in integration tests
+ end
+
+ test "component handles different field types correctly", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ # Test that different field types render correctly
+ assert has_element?(view, "button[phx-value-field='first_name']")
+ assert has_element?(view, "button[phx-value-field='email']")
+ assert has_element?(view, "button[phx-value-field='join_date']")
+ end
+ end
+
+ describe "edge cases" do
+ test "handles invalid sort field gracefully", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, html} = live(conn, "/members?sort_field=invalid_field&sort_order=asc")
+
+ # Should not crash and should default sorting for first name
+ assert html =~ "hero-chevron-up-down"
+ refute has_element?(view, "[data-testid='first_name'] .opacity-40")
+ end
+
+ test "handles invalid sort order gracefully", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, html} = live(conn, "/members?sort_field=first_name&sort_order=invalid")
+
+ # Should default to ascending
+ assert html =~ "hero-chevron-up"
+ refute has_element?(view, "[data-testid='first_name'] [aria-label='ascending']")
+ end
+
+ test "handles empty sort parameters", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, html} = live(conn, "/members?sort_field=&sort_order=")
+
+ # Should show neutral icons
+ assert html =~ "hero-chevron-up-down"
+ assert has_element?(view, "[data-testid='city'] .opacity-40")
+ end
+ end
+
+ describe "icon state transitions" do
+ test "icon changes when sorting state changes", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ # Start with neutral state
+ assert has_element?(view, "[data-testid='city'] .opacity-40")
+
+ # Click to sort ascending
+ view
+ |> element("button[phx-value-field='city']")
+ |> render_click()
+
+ # Should now be ascending (no opacity class)
+ refute has_element?(view, "[data-testid='city'] .opacity-40")
+ end
+
+ test "multiple fields can be tested for icon states", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, html} = live(conn, "/members?sort_field=email&sort_order=desc")
+
+ # Email should be active (descending)
+ assert html =~ "hero-chevron-down"
+ refute has_element?(view, "[data-testid='email'] .opacity-40")
+
+ # Other fields should be neutral
+ assert has_element?(view, "[data-testid='first_name'] .opacity-40")
+ assert has_element?(view, "[data-testid='city'] .opacity-40")
+ end
+ end
+end
diff --git a/test/mv_web/live/profile_navigation_test.exs b/test/mv_web/live/profile_navigation_test.exs
new file mode 100644
index 0000000..8a59656
--- /dev/null
+++ b/test/mv_web/live/profile_navigation_test.exs
@@ -0,0 +1,182 @@
+defmodule MvWeb.ProfileNavigationTest do
+ use MvWeb.ConnCase, async: true
+ import Phoenix.LiveViewTest
+
+ describe "profile navigation" do
+ test "clicking profile button redirects to current user profile", %{conn: conn} do
+ # Setup: Create and login a user
+ user = create_test_user(%{email: "test@example.com"})
+ conn = conn_with_password_user(conn, user)
+ {:ok, view, _html} = live(conn, "/")
+
+ # Click the profile button
+ view |> element("a", "Profil") |> render_click()
+
+ # Verify we're on the profile page
+ assert_redirected(view, "/users/#{user.id}")
+ end
+
+ test "profile navigation shows correct user data", %{conn: conn} do
+ # Setup: Create and login a user
+ user = create_test_user(%{email: "test@example.com"})
+ conn = conn_with_password_user(conn, user)
+
+ # Navigate to profile
+ {:ok, view, _html} = live(conn, "/")
+ view |> element("a", "Profil") |> render_click()
+
+ # Verify profile data
+ {:ok, _profile_view, html} = live(conn, "/users/#{user.id}")
+ assert html =~ to_string(user.email)
+ assert html =~ "Password Authentication"
+ assert html =~ "Enabled"
+ end
+ end
+
+ describe "navbar" do
+ test "renders profile button with correct attributes", %{conn: conn} do
+ # Setup: Create and login a user
+ user = create_test_user(%{email: "test@example.com"})
+ conn = conn_with_password_user(conn, user)
+ {:ok, _view, html} = live(conn, "/")
+
+ assert html =~ ~s(role="button")
+ assert html =~ "dropdown-content"
+ assert html =~ "avatar"
+ assert html =~ "Profil"
+ end
+
+ @tag :skip
+ # TODO: Implement user initials in navbar avatar - see issue #170
+ test "shows user initials in avatar", %{conn: conn} do
+ # Setup: Create and login a user
+ user = create_test_user(%{email: "test.user@example.com"})
+ conn = conn_with_password_user(conn, user)
+ {:ok, _view, html} = live(conn, "/")
+
+ # Initials from test.user@example.com
+ assert html =~ "TU"
+ end
+ end
+
+ describe "profile navigation with OIDC user" do
+ test "shows correct profile data for OIDC user", %{conn: conn} do
+ # Setup: Create OIDC user with sub claim
+ user_info = %{
+ "sub" => "oidc_123",
+ "preferred_username" => "oidc.user@example.com"
+ }
+
+ oauth_tokens = %{
+ "access_token" => "test_token",
+ "id_token" => "test_id_token"
+ }
+
+ user =
+ Mv.Accounts.User
+ |> Ash.Changeset.for_create(:register_with_rauthy, %{
+ user_info: user_info,
+ oauth_tokens: oauth_tokens
+ })
+ |> Ash.create!(domain: Mv.Accounts)
+
+ # Login user via OIDC
+ conn = sign_in_user_via_oidc(conn, user)
+
+ # Navigate to home and click profile
+ {:ok, view, _html} = live(conn, "/")
+ view |> element("a", "Profil") |> render_click()
+
+ # Verify we're on the correct profile page with OIDC specific information
+ {:ok, _profile_view, html} = live(conn, "/users/#{user.id}")
+ assert html =~ to_string(user.email)
+ # OIDC ID should be visible
+ assert html =~ "oidc_123"
+ # Password auth should be disabled for OIDC users
+ assert html =~ "Not enabled"
+ end
+
+ test "profile navigation works across different authentication methods", %{conn: conn} do
+ # Create password user
+ password_user =
+ create_test_user(%{
+ email: "password2@example.com",
+ password: "test_password123"
+ })
+
+ # Create OIDC user
+ user_info = %{
+ "sub" => "oidc_789",
+ "preferred_username" => "oidc@example.com"
+ }
+
+ oauth_tokens = %{
+ "access_token" => "test_token",
+ "id_token" => "test_id_token"
+ }
+
+ oidc_user =
+ Mv.Accounts.User
+ |> Ash.Changeset.for_create(:register_with_rauthy, %{
+ user_info: user_info,
+ oauth_tokens: oauth_tokens
+ })
+ |> Ash.create!(domain: Mv.Accounts)
+
+ # Test with password user
+ conn_password = conn_with_password_user(conn, password_user)
+ {:ok, view_password, _html} = live(conn_password, "/")
+ view_password |> element("a", "Profil") |> render_click()
+ assert_redirected(view_password, "/users/#{password_user.id}")
+
+ # Test with OIDC user
+ conn_oidc = sign_in_user_via_oidc(conn, oidc_user)
+ {:ok, view_oidc, _html} = live(conn_oidc, "/")
+ view_oidc |> element("a", "Profil") |> render_click()
+ assert_redirected(view_oidc, "/users/#{oidc_user.id}")
+ end
+ end
+
+ describe "authenticated views" do
+ setup %{conn: conn} do
+ user = create_test_user(%{email: "test@example.com"})
+ conn = conn_with_password_user(conn, user)
+ {:ok, conn: conn, user: user}
+ end
+
+ @authenticated_paths [
+ "/",
+ "/members",
+ "/members/new",
+ "/properties",
+ "/properties/new",
+ "/property_types",
+ "/property_types/new",
+ "/users",
+ "/users/new"
+ ]
+
+ for path <- @authenticated_paths do
+ @path path
+ test "layout shows user data on #{path}", %{conn: conn, user: user} do
+ {:ok, _view, html} = live(conn, @path)
+ # The navbar (which requires current_user) should be visible
+ assert html =~ "navbar"
+ # Profile button should be visible
+ assert html =~ "Profil"
+ # User ID should be in profile link
+ assert html =~ ~p"/users/#{user.id}"
+ end
+ end
+
+ test "layout shows user data on user profile page", %{conn: conn, user: user} do
+ {:ok, _view, html} = live(conn, ~p"/users/#{user.id}")
+ # The navbar (which requires current_user) should be visible
+ assert html =~ "navbar"
+ # Profile button should be visible
+ assert html =~ "Profil"
+ # User ID should be in profile link
+ assert html =~ ~p"/users/#{user.id}"
+ end
+ end
+end
diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs
index e3e77dc..0668202 100644
--- a/test/mv_web/member_live/index_test.exs
+++ b/test/mv_web/member_live/index_test.exs
@@ -1,6 +1,7 @@
defmodule MvWeb.MemberLive.IndexTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
+ require Ash.Query
test "shows translated title in German", %{conn: conn} do
conn = conn_with_oidc_user(conn)
@@ -55,7 +56,6 @@ defmodule MvWeb.MemberLive.IndexTest do
test "shows translated flash message after creating a member in English", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- conn = Plug.Test.init_test_session(conn, locale: "en")
{:ok, form_view, _html} = live(conn, "/members/new")
form_data = %{
@@ -73,4 +73,180 @@ defmodule MvWeb.MemberLive.IndexTest do
assert has_element?(index_view, "#flash-group", "Member create successfully")
end
+
+ describe "sorting integration" do
+ test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ # The component data test ids are built with the name of the field
+ # First click β should sort ASC
+ view
+ |> element("[data-testid='email']")
+ |> render_click()
+
+ # The LiveView pushes a patch with the new query params
+ assert_patch(view, "/members?query=&sort_field=email&sort_order=asc")
+
+ # Second click β toggles to DESC
+ view
+ |> element("[data-testid='email']")
+ |> render_click()
+
+ assert_patch(view, "/members?query=&sort_field=email&sort_order=desc")
+ end
+
+ test "clicking different column header resets order to ascending", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members?sort_field=email&sort_order=desc")
+
+ # Click on a different column
+ view
+ |> element("[data-testid='first_name']")
+ |> render_click()
+
+ assert_patch(view, "/members?query=&sort_field=first_name&sort_order=asc")
+ end
+
+ test "all sortable columns work correctly", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ # default ascending sorting with first name
+ assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
+
+ sortable_fields = [
+ :email,
+ :street,
+ :house_number,
+ :postal_code,
+ :city,
+ :phone_number,
+ :join_date
+ ]
+
+ for field <- sortable_fields do
+ view
+ |> element("[data-testid='#{field}']")
+ |> render_click()
+
+ assert_patch(view, "/members?query=&sort_field=#{field}&sort_order=asc")
+ end
+ end
+
+ test "sorting works with search query", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members?query=test")
+
+ view
+ |> element("[data-testid='email']")
+ |> render_click()
+
+ assert_patch(view, "/members?query=test&sort_field=email&sort_order=asc")
+ end
+
+ test "sorting maintains search query when toggling order", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc")
+
+ view
+ |> element("[data-testid='email']")
+ |> render_click()
+
+ assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc")
+ end
+ end
+
+ describe "URL param handling" do
+ test "handle_params reads sort query and applies it", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
+
+ # Check that the sort state is correctly applied
+ assert has_element?(view, "[data-testid='email'][aria-label='descending']")
+ end
+
+ test "handle_params handles invalid sort field gracefully", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members?query=&sort_field=invalid_field&sort_order=asc")
+
+ # Should not crash and should show default first name order
+ assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
+ end
+
+ test "handle_params preserves search query with sort params", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=desc")
+
+ # Both search and sort should be preserved
+ assert has_element?(view, "[data-testid='email'][aria-label='descending']")
+ end
+ end
+
+ describe "search and sort integration" do
+ test "search maintains sort state", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
+
+ # Perform search
+ view
+ |> element("[data-testid='search-input']")
+ |> render_change(%{value: "test"})
+
+ # Sort state should be maintained
+ assert has_element?(view, "[data-testid='email'][aria-label='descending']")
+ end
+
+ test "sort maintains search state", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc")
+
+ # Perform sort
+ view
+ |> element("[data-testid='email']")
+ |> render_click()
+
+ # Search state should be maintained
+ assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc")
+ end
+ end
+
+ test "handle_info(:search_changed) updates assigns with search results", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ send(view.pid, {:search_changed, "Friedrich"})
+
+ state = :sys.get_state(view.pid)
+
+ assert state.socket.assigns.query == "Friedrich"
+ assert is_list(state.socket.assigns.members)
+ end
+
+ test "can delete a member without error", %{conn: conn} do
+ # Create a test member first
+ {:ok, member} =
+ Mv.Membership.create_member(%{
+ first_name: "Test",
+ last_name: "User",
+ email: "test@example.com"
+ })
+
+ conn = conn_with_oidc_user(conn)
+ {:ok, index_view, _html} = live(conn, "/members")
+
+ # Verify the member is displayed
+ assert has_element?(index_view, "#members", "Test User")
+
+ # Click the delete link for this member
+ index_view
+ |> element("a", "Delete")
+ |> render_click()
+
+ # Verify the member is no longer displayed
+ refute has_element?(index_view, "#members", "Test User")
+
+ # Verify the member was actually deleted from the database
+ assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?())
+ end
end
diff --git a/test/mv_web/user_live/form_test.exs b/test/mv_web/user_live/form_test.exs
index decc789..111ff42 100644
--- a/test/mv_web/user_live/form_test.exs
+++ b/test/mv_web/user_live/form_test.exs
@@ -255,7 +255,7 @@ defmodule MvWeb.UserLive.FormTest do
conn = Plug.Test.init_test_session(conn, locale: "de")
{:ok, _view, html} = live(conn, "/users/new")
- assert html =~ "Neuer Benutzer"
+ assert html =~ "Neue*r Benutzer*in"
assert html =~ "E-Mail"
assert html =~ "Passwort setzen"
end
diff --git a/test/mv_web/user_live/index_test.exs b/test/mv_web/user_live/index_test.exs
index 40756eb..6393e3b 100644
--- a/test/mv_web/user_live/index_test.exs
+++ b/test/mv_web/user_live/index_test.exs
@@ -7,7 +7,7 @@ defmodule MvWeb.UserLive.IndexTest do
conn = conn_with_oidc_user(conn)
conn = Plug.Test.init_test_session(conn, locale: "de")
{:ok, _view, html} = live(conn, "/users")
- assert html =~ "Benutzer auflisten"
+ assert html =~ "Benutzer*innen auflisten"
end
test "shows translated title in English", %{conn: conn} do
@@ -288,7 +288,7 @@ defmodule MvWeb.UserLive.IndexTest do
|> element("input[type='checkbox'][name='select_all'][checked]")
|> has_element?()
- # Select second user
+ # Select second user
html = view |> element("input[type='checkbox'][name='#{user2.id}']") |> render_click()
# Now select all should be automatically checked (all individual users are selected)
@@ -362,8 +362,8 @@ defmodule MvWeb.UserLive.IndexTest do
conn = Plug.Test.init_test_session(conn, locale: "de")
{:ok, _view, html} = live(conn, "/users")
- assert html =~ "Alle Benutzer auswΓ€hlen"
- assert html =~ "Benutzer auswΓ€hlen"
+ assert html =~ "Alle Benutzer*innen auswΓ€hlen"
+ assert html =~ "Benutzer*in auswΓ€hlen"
end
test "shows English translations for selection", %{conn: conn} do
@@ -388,7 +388,8 @@ defmodule MvWeb.UserLive.IndexTest do
assert html =~ "Email"
assert html =~ "OIDC ID"
# Should show the authenticated user at minimum
- assert html =~ "user@example.com"
+ # Matches the generated email pattern oidc.user{unique_id}@example.com
+ assert html =~ "oidc.user"
end
test "handles users with missing OIDC ID", %{conn: conn} do
diff --git a/test/seeds_test.exs b/test/seeds_test.exs
new file mode 100644
index 0000000..5c589ae
--- /dev/null
+++ b/test/seeds_test.exs
@@ -0,0 +1,46 @@
+defmodule Mv.SeedsTest do
+ use Mv.DataCase, async: false
+
+ describe "Seeds script" do
+ test "runs successfully without errors" do
+ # Run the seeds script - should not raise any errors
+ assert Code.eval_file("priv/repo/seeds.exs")
+
+ # Basic smoke test: ensure some data was created
+ {:ok, users} = Ash.read(Mv.Accounts.User)
+ {:ok, members} = Ash.read(Mv.Membership.Member)
+ {:ok, property_types} = Ash.read(Mv.Membership.PropertyType)
+
+ assert length(users) > 0, "Seeds should create at least one user"
+ assert length(members) > 0, "Seeds should create at least one member"
+ assert length(property_types) > 0, "Seeds should create at least one property type"
+ end
+
+ test "can be run multiple times (idempotent)" do
+ # Run seeds first time
+ assert Code.eval_file("priv/repo/seeds.exs")
+
+ # Count records
+ {:ok, users_count_1} = Ash.read(Mv.Accounts.User)
+ {:ok, members_count_1} = Ash.read(Mv.Membership.Member)
+ {:ok, property_types_count_1} = Ash.read(Mv.Membership.PropertyType)
+
+ # Run seeds second time - should not raise errors
+ assert Code.eval_file("priv/repo/seeds.exs")
+
+ # Count records again - should be the same (upsert, not duplicate)
+ {:ok, users_count_2} = Ash.read(Mv.Accounts.User)
+ {:ok, members_count_2} = Ash.read(Mv.Membership.Member)
+ {:ok, property_types_count_2} = Ash.read(Mv.Membership.PropertyType)
+
+ assert length(users_count_1) == length(users_count_2),
+ "Users count should remain same after re-running seeds"
+
+ assert length(members_count_1) == length(members_count_2),
+ "Members count should remain same after re-running seeds"
+
+ assert length(property_types_count_1) == length(property_types_count_2),
+ "PropertyTypes count should remain same after re-running seeds"
+ end
+ end
+end
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex
index c51fb61..0ee2364 100644
--- a/test/support/conn_case.ex
+++ b/test/support/conn_case.ex
@@ -100,11 +100,28 @@ defmodule MvWeb.ConnCase do
Signs in a user via OIDC and returns a connection with the user authenticated.
By default creates a user with "user@example.com" for consistency.
"""
- def conn_with_oidc_user(conn, user_attrs \\ %{email: "user@example.com"}) do
- user = create_test_user(user_attrs)
+ def conn_with_oidc_user(conn, user_attrs \\ %{}) do
+ # Ensure unique email for OIDC users
+ unique_id = System.unique_integer([:positive])
+
+ default_attrs = %{
+ email: "oidc.user#{unique_id}@example.com",
+ oidc_id: "oidc_#{unique_id}"
+ }
+
+ user = create_test_user(Map.merge(default_attrs, user_attrs))
sign_in_user_via_oidc(conn, user)
end
+ @doc """
+ Signs in a user via password authentication and returns a connection with the user authenticated.
+ """
+ def conn_with_password_user(conn, user) do
+ conn
+ |> Phoenix.ConnTest.init_test_session(%{})
+ |> AshAuthentication.Plug.Helpers.store_in_session(user)
+ end
+
setup tags do
Mv.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}