diff --git a/.credo.exs b/.credo.exs
index 4eddee8..3a4f8dc 100644
--- a/.credo.exs
+++ b/.credo.exs
@@ -82,8 +82,14 @@
# You can customize the priority of any check
# Priority values are: `low, normal, high, higher`
#
+ # AliasUsage only for lib and support; test files excluded (many nested module refs by design)
{Credo.Check.Design.AliasUsage,
- [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
+ [
+ priority: :low,
+ if_nested_deeper_than: 2,
+ if_called_more_often_than: 0,
+ files: %{excluded: ["test/"]}
+ ]},
{Credo.Check.Design.TagFIXME, []},
# You can also customize the exit_status of each check.
# If you don't want TODO comments to cause `mix credo` to fail, just
diff --git a/.drone.yml b/.drone.yml
index 5eefd61..70ea161 100644
--- a/.drone.yml
+++ b/.drone.yml
@@ -52,7 +52,7 @@ steps:
# Check for dependencies that are not maintained anymore
- mix hex.audit
# Provide hints for improving code quality
- - mix credo
+ - mix credo --strict
# Check that translations are up to date
- mix gettext.extract --check-up-to-date
@@ -159,7 +159,7 @@ steps:
# Check for dependencies that are not maintained anymore
- mix hex.audit
# Provide hints for improving code quality
- - mix credo
+ - mix credo --strict
# Check that translations are up to date
- mix gettext.extract --check-up-to-date
diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md
index b3f1c3f..bb127f1 100644
--- a/CODE_GUIDELINES.md
+++ b/CODE_GUIDELINES.md
@@ -269,6 +269,16 @@ defmodule Mv.Membership.Member do
end
```
+### 1.2.1 Database Seeds
+
+Seeds are split into **bootstrap** and **dev**:
+
+- **`priv/repo/seeds.exs`** – Entrypoint. Runs `seeds_bootstrap.exs` always; runs `seeds_dev.exs` only when `Mix.env()` is `:dev` or `:test`.
+- **`priv/repo/seeds_bootstrap.exs`** – Creates only data required for system startup: membership fee types, custom fields, roles, admin user, system user, global settings (including default membership fee type). No members, no groups. Used in all environments (dev, test, prod).
+- **`priv/repo/seeds_dev.exs`** – Creates 20 sample members, groups, and optional custom field values. Run only in dev and test.
+
+In production, running `mix run priv/repo/seeds.exs` executes only the bootstrap part (no dev seeds).
+
### 1.3 Domain-Driven Design
**Use Ash Domains for Context Boundaries:**
diff --git a/Justfile b/Justfile
index bce8bf6..f3ad5a3 100644
--- a/Justfile
+++ b/Justfile
@@ -31,7 +31,7 @@ gettext:
lint:
mix format --check-formatted
mix compile --warnings-as-errors
- mix credo
+ mix credo --strict
# Check that all German translations are filled (UI must be in German)
@bash -c 'for file in priv/gettext/de/LC_MESSAGES/*.po; do awk "/^msgid \"\"$/{header=1; next} /^msgid /{header=0} /^msgstr \"\"$/ && !header{print FILENAME\":\"NR\": \" \$0; exit 1}" "$file" || exit 1; done'
mix gettext.extract --check-up-to-date
diff --git a/assets/css/app.css b/assets/css/app.css
index 6f00298..4b28fb7 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -108,8 +108,7 @@
word-spacing: inherit;
}
-/* WCAG 2 AA: success/error text on light backgrounds (e.g. base-200). Use instead of
- text-success/text-error when contrast ratio of theme colors is insufficient. */
+/* WCAG 2 AA: success/error/warning text. Light theme: dark tones on light bg; dark theme: light tones on dark bg. */
.text-success-aa {
color: oklch(0.35 0.12 165);
}
@@ -118,6 +117,22 @@
color: oklch(0.45 0.2 25);
}
+.text-warning-aa {
+ color: oklch(0.45 0.14 75);
+}
+
+[data-theme="dark"] .text-success-aa {
+ color: oklch(0.72 0.12 165);
+}
+
+[data-theme="dark"] .text-error-aa {
+ color: oklch(0.75 0.18 25);
+}
+
+[data-theme="dark"] .text-warning-aa {
+ color: oklch(0.78 0.14 75);
+}
+
/* WCAG 2.2 AA: Badge contrast. DaisyUI .badge-outline uses transparent bg; we use
Core Component <.badge style="outline"> which adds .bg-base-100. This rule ensures
outline badges always have a visible background in both themes. */
@@ -548,4 +563,96 @@
--color-secondary-content: oklch(98% 0 0);
}
+/* ============================================
+ WCAG 2.2 AA: Tab list inactive tab text contrast (4.5:1)
+ ============================================ */
+#member-tablist .tab:not(.tab-active) {
+ color: oklch(0.35 0.02 285);
+}
+[data-theme="dark"] #member-tablist .tab:not(.tab-active) {
+ color: oklch(0.72 0.02 257);
+}
+
+/* ============================================
+ WCAG 2.2 AA: Link contrast - primary and accent
+ ============================================ */
+[data-theme="light"] .link.link-primary {
+ color: oklch(0.45 0.15 35);
+}
+[data-theme="light"] .link.link-primary:hover {
+ color: oklch(0.38 0.14 35);
+}
+[data-theme="dark"] .link.link-primary {
+ color: oklch(0.82 0.14 45);
+}
+[data-theme="dark"] .link.link-primary:hover {
+ color: oklch(0.88 0.12 45);
+}
+[data-theme="dark"] .link.link-accent {
+ color: oklch(0.82 0.18 292);
+}
+[data-theme="dark"] .link.link-accent:hover {
+ color: oklch(0.88 0.16 292);
+}
+
+/* ============================================
+ WCAG 2.2 AA: Danger zone heading contrast (dark theme)
+ ============================================ */
+[data-theme="dark"] #danger-zone-heading.text-error {
+ color: oklch(0.78 0.18 25);
+}
+
+/* ============================================
+ WCAG 2.2 AA: Blue link contrast in dark theme
+ ============================================ */
+[data-theme="dark"] a.text-blue-700,
+[data-theme="dark"] a.text-blue-600,
+[data-theme="dark"] a.hover\:text-blue-800 {
+ color: oklch(0.72 0.16 255);
+}
+[data-theme="dark"] a.text-blue-700:hover,
+[data-theme="dark"] a.text-blue-600:hover {
+ color: oklch(0.82 0.14 255);
+}
+
+/* ============================================
+ WCAG 2.2 AA: Password / form label on light box in dark theme
+ ============================================ */
+[data-theme="dark"] .bg-gray-50 {
+ background-color: var(--color-base-200);
+ color: var(--color-base-content);
+}
+[data-theme="dark"] .bg-gray-50 .label,
+[data-theme="dark"] .bg-gray-50 .mb-1.label,
+[data-theme="dark"] .bg-gray-50 .text-gray-600,
+[data-theme="dark"] .bg-gray-50 .text-gray-700,
+[data-theme="dark"] .bg-gray-50 strong,
+[data-theme="dark"] .bg-gray-50 p,
+[data-theme="dark"] .bg-gray-50 li {
+ color: var(--color-base-content);
+}
+
+/* Dark mode: orange/red info boxes (admin note, OIDC warning) – dark bg, light text */
+[data-theme="dark"] .bg-orange-50 {
+ background-color: oklch(0.32 0.06 55);
+ border-color: oklch(0.42 0.08 55);
+ color: var(--color-base-content);
+}
+[data-theme="dark"] .bg-orange-50 .text-orange-800,
+[data-theme="dark"] .bg-orange-50 p,
+[data-theme="dark"] .bg-orange-50 strong {
+ color: var(--color-base-content);
+}
+[data-theme="dark"] .bg-red-50 {
+ background-color: oklch(0.32 0.08 25);
+ border-color: oklch(0.42 0.12 25);
+ color: var(--color-base-content);
+}
+[data-theme="dark"] .bg-red-50 .text-red-800,
+[data-theme="dark"] .bg-red-50 .text-red-700,
+[data-theme="dark"] .bg-red-50 p,
+[data-theme="dark"] .bg-red-50 strong {
+ color: var(--color-base-content);
+}
+
/* This file is for your main application CSS */
diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md
index 66b46eb..23f19b7 100644
--- a/docs/feature-roadmap.md
+++ b/docs/feature-roadmap.md
@@ -1,7 +1,7 @@
# Feature Roadmap & Implementation Plan
**Project:** Mila - Membership Management System
-**Last Updated:** 2026-01-27
+**Last Updated:** 2026-03-03
**Status:** Active Development
---
@@ -371,6 +371,7 @@
- ✅ Production Dockerfile
- ✅ Drone CI/CD pipeline
- ✅ Renovate for dependency updates
+- ✅ Database seeds split into bootstrap (all envs) and dev-only seeds (20 members, groups; 2026-03-03)
- ⚠️ No staging environment
**Open Issues:**
diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex
index 5e24445..6b9cd1e 100644
--- a/lib/accounts/user.ex
+++ b/lib/accounts/user.ex
@@ -11,6 +11,11 @@ defmodule Mv.Accounts.User do
require Ash.Query
import Ash.Expr
+ alias Ash.Resource.Preparation.Builtins
+ alias Mv.Authorization.Role, as: RoleResource
+ alias Mv.Helpers.SystemActor
+ alias Mv.OidcRoleSync
+
postgres do
table "users"
repo Mv.Repo
@@ -282,20 +287,20 @@ defmodule Mv.Accounts.User do
# Sync role from OIDC groups after sign-in (e.g. admin group → Admin role)
# get? true can return nil, a single %User{}, or a list; normalize to list for Enum.each
- prepare Ash.Resource.Preparation.Builtins.after_action(fn query, result, _context ->
+ prepare Builtins.after_action(fn query, result, _context ->
user_info = Ash.Query.get_argument(query, :user_info) || %{}
oauth_tokens = Ash.Query.get_argument(query, :oauth_tokens) || %{}
users =
case result do
nil -> []
- u when is_struct(u, User) -> [u]
+ u when is_struct(u, __MODULE__) -> [u]
list when is_list(list) -> list
_ -> []
end
Enum.each(users, fn user ->
- Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens)
+ OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens)
end)
{:ok, result}
@@ -483,10 +488,10 @@ defmodule Mv.Accounts.User do
|> Enum.map(& &1.id)
# Count only non-system users with admin role (system user is for internal ops)
- system_email = Mv.Helpers.SystemActor.system_user_email()
+ system_email = SystemActor.system_user_email()
count =
- Mv.Accounts.User
+ __MODULE__
|> Ash.Query.for_read(:read)
|> Ash.Query.filter(expr(role_id in ^admin_role_ids))
|> Ash.Query.filter(expr(email != ^system_email))
@@ -512,7 +517,7 @@ defmodule Mv.Accounts.User do
# Prevent modification of the system actor user (required for internal operations).
# Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests.
validate fn changeset, _context ->
- if Mv.Helpers.SystemActor.system_user?(changeset.data) do
+ if SystemActor.system_user?(changeset.data) do
{:error,
field: :email,
message:
@@ -641,8 +646,8 @@ defmodule Mv.Accounts.User do
case Process.get({__MODULE__, :default_role_id}) do
nil ->
role_id =
- case Mv.Authorization.Role.get_mitglied_role() do
- {:ok, %Mv.Authorization.Role{id: id}} -> id
+ case RoleResource.get_mitglied_role() do
+ {:ok, %RoleResource{id: id}} -> id
_ -> nil
end
diff --git a/lib/accounts/user/validations/oidc_email_collision.ex b/lib/accounts/user/validations/oidc_email_collision.ex
index 08a8911..7ae8510 100644
--- a/lib/accounts/user/validations/oidc_email_collision.ex
+++ b/lib/accounts/user/validations/oidc_email_collision.ex
@@ -26,7 +26,9 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
use Ash.Resource.Validation
require Logger
+ alias Mv.Accounts.User
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
+ alias Mv.Helpers.SystemActor
@impl true
def init(opts), do: {:ok, opts}
@@ -43,10 +45,10 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
# Check if a user with this oidc_id already exists
# If yes, this will be an upsert (email update), not a new registration
# Use SystemActor for authorization during OIDC registration (no logged-in actor)
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ system_actor = SystemActor.get_system_actor()
existing_oidc_user =
- case Mv.Accounts.User
+ case User
|> Ash.Query.filter(oidc_id == ^to_string(oidc_id))
|> Ash.read_one(actor: system_actor) do
{:ok, user} -> user
@@ -62,7 +64,7 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user, system_actor) do
# Find existing user with this email
# Use SystemActor for authorization during OIDC registration (no logged-in actor)
- case Mv.Accounts.User
+ case User
|> Ash.Query.filter(email == ^to_string(email))
|> Ash.read_one(actor: system_actor) do
{:ok, nil} ->
@@ -164,7 +166,7 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
end
@impl true
- def atomic?(), do: false
+ def atomic?, do: false
@impl true
def describe(_opts) do
diff --git a/lib/membership/member.ex b/lib/membership/member.ex
index 8f24595..be99b7f 100644
--- a/lib/membership/member.ex
+++ b/lib/membership/member.ex
@@ -37,12 +37,19 @@ defmodule Mv.Membership.Member do
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
+ import Bitwise
require Ash.Query
import Ash.Expr
+ alias Ecto.Adapters.SQL, as: EctoSQL
alias Mv.Helpers
- require Logger
-
+ alias Mv.Helpers.SystemActor
alias Mv.Membership.Helpers.VisibilityConfig
+ alias Mv.MembershipFees.CalendarCycles
+ alias Mv.MembershipFees.CycleGenerator
+ alias Mv.MembershipFees.MembershipFeeCycle
+ alias Mv.Repo
+
+ require Logger
# Module constants
@member_search_limit 10
@@ -813,7 +820,7 @@ defmodule Mv.Membership.Member do
case Map.get(cycle, :membership_fee_type) do
%{interval: interval} ->
cycle_end =
- Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
+ CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Date.compare(cycle.cycle_start, today) in [:lt, :eq] and
Date.compare(today, cycle_end) in [:lt, :eq]
@@ -847,7 +854,7 @@ defmodule Mv.Membership.Member do
case Map.get(cycle, :membership_fee_type) do
%{interval: interval} ->
cycle_end =
- Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
+ CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Date.compare(today, cycle_end) == :gt
@@ -863,7 +870,7 @@ defmodule Mv.Membership.Member do
cycles,
fn cycle ->
interval = Map.get(cycle, :membership_fee_type).interval
- Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
+ CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
end,
{:desc, Date}
)
@@ -890,7 +897,7 @@ defmodule Mv.Membership.Member do
case Map.get(cycle, :membership_fee_type) do
%{interval: interval} ->
cycle_end =
- Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
+ CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
cycle.status == :unpaid and Date.compare(today, cycle_end) == :gt
@@ -900,6 +907,25 @@ defmodule Mv.Membership.Member do
end)
end
+ # Returns a deterministic 64-bit key for pg_advisory_xact_lock from a member id (UUID string).
+ # Reduces collision risk vs phash2 when multiple members are locked.
+ @doc false
+ def advisory_lock_key_for_member_id(member_id) when is_binary(member_id) do
+ hex = String.replace(member_id, "-", "")
+
+ if String.length(hex) >= 16 do
+ first_8_hex = String.slice(hex, 0, 16)
+ bin = Base.decode16!(first_8_hex, case: :lower)
+ decoded = :binary.decode_unsigned(bin, :big)
+ # Postgres bigint is signed 64-bit; keep in non-negative range
+ rem(decoded, 1 <<< 63)
+ else
+ :erlang.phash2(member_id)
+ end
+ rescue
+ ArgumentError -> :erlang.phash2(member_id)
+ end
+
# Regenerates cycles when membership fee type changes
# Deletes future unpaid cycles and regenerates them with the new type/amount
# Uses advisory lock to prevent concurrent modifications
@@ -908,15 +934,12 @@ defmodule Mv.Membership.Member do
@doc false
# Uses system actor for cycle regeneration (mandatory side effect)
def regenerate_cycles_on_type_change(member, _opts \\ []) do
- alias Mv.Helpers
- alias Mv.Helpers.SystemActor
-
today = Date.utc_today()
- lock_key = :erlang.phash2(member.id)
+ lock_key = advisory_lock_key_for_member_id(member.id)
# Use advisory lock to prevent concurrent deletion and regeneration
# This ensures atomicity when multiple updates happen simultaneously
- if Mv.Repo.in_transaction?() do
+ if Repo.in_transaction?() do
regenerate_cycles_in_transaction(member, today, lock_key)
else
regenerate_cycles_new_transaction(member, today, lock_key)
@@ -926,15 +949,15 @@ defmodule Mv.Membership.Member do
# Already in transaction: use advisory lock directly
# Returns {:ok, notifications} - notifications should be returned to after_action hook
defp regenerate_cycles_in_transaction(member, today, lock_key) do
- Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
+ EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
end
# Not in transaction: start new transaction with advisory lock
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
defp regenerate_cycles_new_transaction(member, today, lock_key) do
- Mv.Repo.transaction(fn ->
- Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
+ Repo.transaction(fn ->
+ EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
{:ok, notifications} ->
@@ -942,7 +965,7 @@ defmodule Mv.Membership.Member do
notifications
{:error, reason} ->
- Mv.Repo.rollback(reason)
+ Repo.rollback(reason)
end
end)
|> case do
@@ -956,9 +979,6 @@ defmodule Mv.Membership.Member do
# notifications are collected to be sent after transaction commits
# Uses system actor for all operations
defp do_regenerate_cycles_on_type_change(member, today, opts) do
- alias Mv.Helpers
- alias Mv.Helpers.SystemActor
-
require Ash.Query
skip_lock? = Keyword.get(opts, :skip_lock?, false)
@@ -968,7 +988,7 @@ defmodule Mv.Membership.Member do
# Find all unpaid cycles for this member
# We need to check cycle_end for each cycle using its own interval
all_unpaid_cycles_query =
- Mv.MembershipFees.MembershipFeeCycle
+ MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.Query.filter(status == :unpaid)
|> Ash.Query.load([:membership_fee_type])
@@ -997,7 +1017,7 @@ defmodule Mv.Membership.Member do
case cycle.membership_fee_type do
%{interval: interval} ->
cycle_end =
- Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
+ CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Date.compare(today, cycle_end) in [:lt, :eq]
@@ -1025,18 +1045,17 @@ defmodule Mv.Membership.Member do
end
end
- # Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise
- # Uses system actor for authorization to ensure deletion always works
+ # Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise.
+ # Returns the first error for debugging; uses system actor for authorization.
defp delete_cycles(cycles_to_delete, actor_opts) do
delete_results =
Enum.map(cycles_to_delete, fn cycle ->
Ash.destroy(cycle, actor_opts)
end)
- if Enum.any?(delete_results, &match?({:error, _}, &1)) do
- {:error, :deletion_failed}
- else
- :ok
+ case Enum.find(delete_results, &match?({:error, _}, &1)) do
+ {:error, reason} -> {:error, reason}
+ nil -> :ok
end
end
@@ -1047,7 +1066,7 @@ defmodule Mv.Membership.Member do
defp regenerate_cycles(member_id, today, opts) do
skip_lock? = Keyword.get(opts, :skip_lock?, false)
- case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
+ case CycleGenerator.generate_cycles_for_member(
member_id,
today: today,
skip_lock?: skip_lock?
@@ -1078,7 +1097,7 @@ defmodule Mv.Membership.Member do
# Runs cycle generation synchronously (for test environment)
defp handle_cycle_generation_sync(member, initiator) do
- case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
+ case CycleGenerator.generate_cycles_for_member(
member.id,
today: Date.utc_today(),
initiator: initiator
@@ -1099,7 +1118,7 @@ defmodule Mv.Membership.Member do
# Runs cycle generation asynchronously (for production environment)
defp handle_cycle_generation_async(member, initiator) do
Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
- case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id,
+ case CycleGenerator.generate_cycles_for_member(member.id,
initiator: initiator
) do
{:ok, cycles, notifications} ->
diff --git a/lib/mv/application.ex b/lib/mv/application.ex
index 1967ddd..835652f 100644
--- a/lib/mv/application.ex
+++ b/lib/mv/application.ex
@@ -5,22 +5,28 @@ defmodule Mv.Application do
use Application
+ alias Mv.Helpers.SystemActor
+ alias Mv.Repo
+ alias Mv.Vereinfacht.SyncFlash
+ alias MvWeb.Endpoint
+ alias MvWeb.Telemetry
+
@impl true
def start(_type, _args) do
- Mv.Vereinfacht.SyncFlash.create_table!()
+ SyncFlash.create_table!()
children = [
- MvWeb.Telemetry,
- Mv.Repo,
+ Telemetry,
+ Repo,
{Task.Supervisor, name: Mv.TaskSupervisor},
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Mv.PubSub},
{AshAuthentication.Supervisor, otp_app: :my},
- Mv.Helpers.SystemActor,
+ SystemActor,
# Start a worker by calling: Mv.Worker.start_link(arg)
# {Mv.Worker, arg},
# Start to serve requests, typically the last entry
- MvWeb.Endpoint
+ Endpoint
]
# See https://hexdocs.pm/elixir/Supervisor.html
diff --git a/lib/mv/authorization/checks/actor_is_system_user.ex b/lib/mv/authorization/checks/actor_is_system_user.ex
index a614a83..804a24d 100644
--- a/lib/mv/authorization/checks/actor_is_system_user.ex
+++ b/lib/mv/authorization/checks/actor_is_system_user.ex
@@ -7,9 +7,11 @@ defmodule Mv.Authorization.Checks.ActorIsSystemUser do
"""
use Ash.Policy.SimpleCheck
+ alias Mv.Helpers.SystemActor
+
@impl true
def describe(_opts), do: "actor is the system user"
@impl true
- def match?(actor, _context, _opts), do: Mv.Helpers.SystemActor.system_user?(actor)
+ def match?(actor, _context, _opts), do: SystemActor.system_user?(actor)
end
diff --git a/lib/mv/authorization/checks/custom_field_value_create_scope.ex b/lib/mv/authorization/checks/custom_field_value_create_scope.ex
index 0b24e74..bbbdacc 100644
--- a/lib/mv/authorization/checks/custom_field_value_create_scope.ex
+++ b/lib/mv/authorization/checks/custom_field_value_create_scope.ex
@@ -22,6 +22,7 @@ defmodule Mv.Authorization.Checks.CustomFieldValueCreateScope do
end
"""
use Ash.Policy.Check
+ alias Mv.Authorization.Actor
alias Mv.Authorization.PermissionSets
@impl true
@@ -67,5 +68,5 @@ defmodule Mv.Authorization.Checks.CustomFieldValueCreateScope do
end
end
- defp ensure_role_loaded(actor), do: Mv.Authorization.Actor.ensure_loaded(actor)
+ defp ensure_role_loaded(actor), do: Actor.ensure_loaded(actor)
end
diff --git a/lib/mv/authorization/checks/has_permission.ex b/lib/mv/authorization/checks/has_permission.ex
index 721cee7..a11bf2e 100644
--- a/lib/mv/authorization/checks/has_permission.ex
+++ b/lib/mv/authorization/checks/has_permission.ex
@@ -81,6 +81,7 @@ defmodule Mv.Authorization.Checks.HasPermission do
use Ash.Policy.Check
require Ash.Query
import Ash.Expr
+ alias Mv.Authorization.Actor
alias Mv.Authorization.PermissionSets
require Logger
@@ -397,6 +398,6 @@ defmodule Mv.Authorization.Checks.HasPermission do
# Fallback: Load role if not loaded (in case on_mount didn't run)
# Delegates to centralized Actor helper
defp ensure_role_loaded(actor) do
- Mv.Authorization.Actor.ensure_loaded(actor)
+ Actor.ensure_loaded(actor)
end
end
diff --git a/lib/mv/authorization/role.ex b/lib/mv/authorization/role.ex
index 8700a33..77e0507 100644
--- a/lib/mv/authorization/role.ex
+++ b/lib/mv/authorization/role.ex
@@ -94,14 +94,16 @@ defmodule Mv.Authorization.Role do
end
end
+ alias Mv.Authorization.PermissionSets
+
validations do
validate one_of(
:permission_set_name,
- Mv.Authorization.PermissionSets.all_permission_sets()
+ PermissionSets.all_permission_sets()
|> Enum.map(&Atom.to_string/1)
),
message:
- "must be one of: #{Mv.Authorization.PermissionSets.all_permission_sets() |> Enum.map_join(", ", &Atom.to_string/1)}"
+ "must be one of: #{PermissionSets.all_permission_sets() |> Enum.map_join(", ", &Atom.to_string/1)}"
validate fn changeset, _context ->
if changeset.data.is_system_role do
diff --git a/lib/mv/membership/member_export.ex b/lib/mv/membership/member_export.ex
index bbfbb6e..635c832 100644
--- a/lib/mv/membership/member_export.ex
+++ b/lib/mv/membership/member_export.ex
@@ -13,6 +13,7 @@ defmodule Mv.Membership.MemberExport do
alias Mv.Membership.CustomField
alias Mv.Membership.Member
alias Mv.Membership.MemberExportSort
+ alias MvWeb.MemberLive.Index
alias MvWeb.MemberLive.Index.MembershipFeeStatus
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
@@ -169,7 +170,7 @@ defmodule Mv.Membership.MemberExport do
if parsed.selected_ids == [] do
members
|> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
- |> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
+ |> Index.apply_boolean_custom_field_filters(
parsed.boolean_filters || %{},
Map.values(custom_fields_by_id)
)
diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex
index 9a1c03a..7159679 100644
--- a/lib/mv/membership/member_export/build.ex
+++ b/lib/mv/membership/member_export/build.ex
@@ -21,6 +21,7 @@ defmodule Mv.Membership.MemberExport.Build do
import Ash.Expr
alias Mv.Membership.{CustomField, CustomFieldValueFormatter, Member, MemberExportSort}
+ alias MvWeb.MemberLive.Index
alias MvWeb.MemberLive.Index.MembershipFeeStatus
@custom_field_prefix Mv.Constants.custom_field_prefix()
@@ -169,7 +170,7 @@ defmodule Mv.Membership.MemberExport.Build do
if parsed.selected_ids == [] do
members
|> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
- |> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
+ |> Index.apply_boolean_custom_field_filters(
parsed.boolean_filters || %{},
Map.values(custom_fields_by_id)
)
@@ -519,11 +520,9 @@ defmodule Mv.Membership.MemberExport.Build do
defp key_to_atom(k) when is_atom(k), do: k
defp key_to_atom(k) when is_binary(k) do
- try do
- String.to_existing_atom(k)
- rescue
- ArgumentError -> k
- end
+ String.to_existing_atom(k)
+ rescue
+ ArgumentError -> k
end
defp get_cfv_by_id(member, id) do
diff --git a/lib/mv/membership/members_csv.ex b/lib/mv/membership/members_csv.ex
index 3d1fdd8..6331893 100644
--- a/lib/mv/membership/members_csv.ex
+++ b/lib/mv/membership/members_csv.ex
@@ -74,11 +74,9 @@ defmodule Mv.Membership.MembersCSV do
defp key_to_atom(k) when is_atom(k), do: k
defp key_to_atom(k) when is_binary(k) do
- try do
- String.to_existing_atom(k)
- rescue
- ArgumentError -> k
- end
+ String.to_existing_atom(k)
+ rescue
+ ArgumentError -> k
end
defp get_cfv_by_id(member, id) do
diff --git a/lib/mv/membership/members_pdf.ex b/lib/mv/membership/members_pdf.ex
index 0d6e469..b2989ca 100644
--- a/lib/mv/membership/members_pdf.ex
+++ b/lib/mv/membership/members_pdf.ex
@@ -299,11 +299,9 @@ defmodule Mv.Membership.MembersPDF do
defp date_column?(_), do: false
defp key_to_atom_safe(key) when is_binary(key) do
- try do
- String.to_existing_atom(key)
- rescue
- ArgumentError -> key
- end
+ String.to_existing_atom(key)
+ rescue
+ ArgumentError -> key
end
defp key_to_atom_safe(key), do: key
diff --git a/lib/mv/membership_fees/cycle_generator.ex b/lib/mv/membership_fees/cycle_generator.ex
index 1a33ca8..8f1bc7c 100644
--- a/lib/mv/membership_fees/cycle_generator.ex
+++ b/lib/mv/membership_fees/cycle_generator.ex
@@ -54,6 +54,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.Repo
+ alias Ecto.Adapters.SQL, as: EctoSQL
+
require Ash.Query
require Logger
@@ -110,10 +112,10 @@ defmodule Mv.MembershipFees.CycleGenerator do
end
defp do_generate_cycles_with_lock(member, today, false, opts) do
- lock_key = :erlang.phash2(member.id)
+ lock_key = Member.advisory_lock_key_for_member_id(member.id)
Repo.transaction(fn ->
- Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
+ EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_generate_cycles(member, today, opts) do
{:ok, cycles, notifications} ->
diff --git a/lib/mv/oidc_role_sync.ex b/lib/mv/oidc_role_sync.ex
index fbec9de..a13748a 100644
--- a/lib/mv/oidc_role_sync.ex
+++ b/lib/mv/oidc_role_sync.ex
@@ -82,11 +82,9 @@ defmodule Mv.OidcRoleSync do
end
defp safe_get_atom(map, key) when is_binary(key) do
- try do
- Map.get(map, String.to_existing_atom(key))
- rescue
- ArgumentError -> nil
- end
+ Map.get(map, String.to_existing_atom(key))
+ rescue
+ ArgumentError -> nil
end
defp safe_get_atom(_map, _key), do: nil
diff --git a/lib/mv/vereinfacht/changes/sync_contact.ex b/lib/mv/vereinfacht/changes/sync_contact.ex
index 99875e0..f3679d4 100644
--- a/lib/mv/vereinfacht/changes/sync_contact.ex
+++ b/lib/mv/vereinfacht/changes/sync_contact.ex
@@ -14,6 +14,9 @@ defmodule Mv.Vereinfacht.Changes.SyncContact do
"""
use Ash.Resource.Change
+ alias Mv.Vereinfacht
+ alias Mv.Vereinfacht.SyncFlash
+
require Logger
@synced_attributes [
@@ -60,13 +63,13 @@ defmodule Mv.Vereinfacht.Changes.SyncContact do
# Ash calls after_transaction with (changeset, result) only - 2 args.
defp sync_after_transaction(_changeset, {:ok, member}) do
- case Mv.Vereinfacht.sync_member(member) do
+ case Vereinfacht.sync_member(member) do
:ok ->
- Mv.Vereinfacht.SyncFlash.store(to_string(member.id), :ok, "Synced to Vereinfacht.")
+ SyncFlash.store(to_string(member.id), :ok, "Synced to Vereinfacht.")
{:ok, member}
{:ok, member_updated} ->
- Mv.Vereinfacht.SyncFlash.store(
+ SyncFlash.store(
to_string(member_updated.id),
:ok,
"Synced to Vereinfacht."
@@ -77,10 +80,10 @@ defmodule Mv.Vereinfacht.Changes.SyncContact do
{:error, reason} ->
Logger.warning("Vereinfacht sync failed for member #{member.id}: #{inspect(reason)}")
- Mv.Vereinfacht.SyncFlash.store(
+ SyncFlash.store(
to_string(member.id),
:warning,
- Mv.Vereinfacht.format_error(reason)
+ Vereinfacht.format_error(reason)
)
{:ok, member}
diff --git a/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex b/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex
index cffb079..4465690 100644
--- a/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex
+++ b/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex
@@ -10,10 +10,10 @@ defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do
use Ash.Resource.Change
require Logger
- alias Mv.Membership.Member
- alias Mv.Membership
- alias Mv.Helpers.SystemActor
alias Mv.Helpers
+ alias Mv.Helpers.SystemActor
+ alias Mv.Membership
+ alias Mv.Membership.Member
@impl true
def change(changeset, _opts, _context) do
diff --git a/lib/mv/vereinfacht/vereinfacht.ex b/lib/mv/vereinfacht/vereinfacht.ex
index 6520b64..83492b7 100644
--- a/lib/mv/vereinfacht/vereinfacht.ex
+++ b/lib/mv/vereinfacht/vereinfacht.ex
@@ -9,10 +9,10 @@ defmodule Mv.Vereinfacht do
"""
require Ash.Query
import Ash.Expr
- alias Mv.Vereinfacht.Client
- alias Mv.Membership.Member
- alias Mv.Helpers.SystemActor
alias Mv.Helpers
+ alias Mv.Helpers.SystemActor
+ alias Mv.Membership.Member
+ alias Mv.Vereinfacht.Client
@doc """
Tests the connection to the Vereinfacht API using the current configuration.
diff --git a/lib/mv_web.ex b/lib/mv_web.ex
index 2b1ade6..f827e2f 100644
--- a/lib/mv_web.ex
+++ b/lib/mv_web.ex
@@ -94,8 +94,8 @@ defmodule MvWeb do
import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2]
# Common modules used in templates
- alias Phoenix.LiveView.JS
alias MvWeb.Layouts
+ alias Phoenix.LiveView.JS
# Routes generation with the ~p sigil
unquote(verified_routes())
diff --git a/lib/mv_web/auth_overrides.ex b/lib/mv_web/auth_overrides.ex
index f28d81f..5cab4d2 100644
--- a/lib/mv_web/auth_overrides.ex
+++ b/lib/mv_web/auth_overrides.ex
@@ -32,10 +32,11 @@ defmodule MvWeb.AuthOverrides do
set :root_class, "md:min-w-md"
end
- # Replace banner logo with text
+ # Replace banner logo with text (no image in light or dark so link has discernible text)
override AshAuthentication.Phoenix.Components.Banner do
set :text, "Mitgliederverwaltung"
set :image_url, nil
+ set :dark_image_url, nil
end
# Translate the "or" in the horizontal rule (between password form and SSO).
diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex
index 78b8bfb..bb5529e 100644
--- a/lib/mv_web/components/core_components.ex
+++ b/lib/mv_web/components/core_components.ex
@@ -29,6 +29,7 @@ defmodule MvWeb.CoreComponents do
use Phoenix.Component
use Gettext, backend: MvWeb.Gettext
+ alias Phoenix.HTML.Form, as: HTMLForm
alias Phoenix.LiveView.JS
# WCAG 2.4.7 / 2.4.11: Shared focus ring for buttons and dropdown (trigger + items)
@@ -561,13 +562,17 @@ defmodule MvWeb.CoreComponents do
phx-target={@phx_target}
>
<%= if @checkboxes do %>
-
+
+ >
+ <.icon name="hero-check" class="size-4 shrink-0" />
+
<% end %>
{item.label}
@@ -669,7 +674,7 @@ defmodule MvWeb.CoreComponents do
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
- Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
+ HTMLForm.normalize_value("checkbox", assigns[:value])
end)
# For checkboxes, we don't use HTML required attribute (means "must be checked")
@@ -736,7 +741,7 @@ defmodule MvWeb.CoreComponents do
{@rest}
>
- {Phoenix.HTML.Form.options_for_select(@options, @value)}
+ {HTMLForm.options_for_select(@options, @value)}
<.error :for={msg <- @errors}>{msg}
@@ -765,7 +770,7 @@ defmodule MvWeb.CoreComponents do
@errors != [] && (@error_class || "textarea-error")
]}
{@rest}
- >{Phoenix.HTML.Form.normalize_value("textarea", @value)}
+ >{HTMLForm.normalize_value("textarea", @value)}
<.error :for={msg <- @errors}>{msg}
@@ -790,7 +795,7 @@ defmodule MvWeb.CoreComponents do
type={@type}
name={@name}
id={@id}
- value={Phoenix.HTML.Form.normalize_value(@type, @value)}
+ value={HTMLForm.normalize_value(@type, @value)}
class={[
@class || "w-full input",
@errors != [] && (@error_class || "input-error")
diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex
index 715f86a..a1730ee 100644
--- a/lib/mv_web/controllers/member_export_controller.ex
+++ b/lib/mv_web/controllers/member_export_controller.ex
@@ -14,8 +14,8 @@ defmodule MvWeb.MemberExportController do
alias Mv.Membership.CustomField
alias Mv.Membership.Member
alias Mv.Membership.MembersCSV
- alias MvWeb.Translations.MemberFields
alias MvWeb.MemberLive.Index.MembershipFeeStatus
+ alias MvWeb.Translations.MemberFields
use Gettext, backend: MvWeb.Gettext
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
@@ -105,12 +105,10 @@ defmodule MvWeb.MemberExportController do
end
defp atom_exists?(name) do
- try do
- _ = String.to_existing_atom(name)
- true
- rescue
- ArgumentError -> false
- end
+ _ = String.to_existing_atom(name)
+ true
+ rescue
+ ArgumentError -> false
end
defp extract_list(params, key) do
diff --git a/lib/mv_web/live/auth/link_oidc_account_live.ex b/lib/mv_web/live/auth/link_oidc_account_live.ex
index 01bd57b..c4b3ab0 100644
--- a/lib/mv_web/live/auth/link_oidc_account_live.ex
+++ b/lib/mv_web/live/auth/link_oidc_account_live.ex
@@ -18,15 +18,19 @@ defmodule MvWeb.LinkOidcAccountLive do
require Ash.Query
require Logger
+ alias AshAuthentication.Strategy.Password.Actions, as: PasswordActions
+ alias Mv.Accounts.User, as: UserResource
+ alias Mv.Helpers.SystemActor
+
@impl true
def mount(_params, session, socket) do
# Use SystemActor for authorization during OIDC linking (user is not yet logged in)
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ system_actor = SystemActor.get_system_actor()
with user_id when not is_nil(user_id) <- Map.get(session, "oidc_linking_user_id"),
oidc_user_info when not is_nil(oidc_user_info) <-
Map.get(session, "oidc_linking_user_info"),
- {:ok, user} <- Ash.get(Mv.Accounts.User, user_id, actor: system_actor) do
+ {:ok, user} <- Ash.get(UserResource, user_id, actor: system_actor) do
# Check if user is passwordless
if passwordless?(user) do
# Auto-link passwordless user immediately
@@ -50,9 +54,9 @@ defmodule MvWeb.LinkOidcAccountLive do
defp reload_user!(user_id) do
# Use SystemActor for authorization during OIDC linking (user is not yet logged in)
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ system_actor = SystemActor.get_system_actor()
- Mv.Accounts.User
+ UserResource
|> Ash.Query.filter(id == ^user_id)
|> Ash.read_one!(actor: system_actor)
end
@@ -65,7 +69,7 @@ defmodule MvWeb.LinkOidcAccountLive do
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
# Use SystemActor for authorization (passwordless user auto-linking)
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ system_actor = SystemActor.get_system_actor()
case user.id
|> reload_user!()
@@ -176,11 +180,11 @@ defmodule MvWeb.LinkOidcAccountLive do
defp verify_password(email, password) do
# Use AshAuthentication password strategy to verify
- strategies = AshAuthentication.Info.authentication_strategies(Mv.Accounts.User)
+ strategies = AshAuthentication.Info.authentication_strategies(UserResource)
password_strategy = Enum.find(strategies, fn s -> s.name == :password end)
if password_strategy do
- AshAuthentication.Strategy.Password.Actions.sign_in(
+ PasswordActions.sign_in(
password_strategy,
%{
"email" => email,
@@ -197,7 +201,7 @@ defmodule MvWeb.LinkOidcAccountLive do
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
# Use SystemActor for authorization (user just verified password but is not yet logged in)
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ system_actor = SystemActor.get_system_actor()
# Update the user with the OIDC ID
case user.id
diff --git a/lib/mv_web/live/auth/sign_in_live.ex b/lib/mv_web/live/auth/sign_in_live.ex
index aa0d640..7ef330b 100644
--- a/lib/mv_web/live/auth/sign_in_live.ex
+++ b/lib/mv_web/live/auth/sign_in_live.ex
@@ -54,13 +54,15 @@ defmodule MvWeb.SignInLive do
@impl true
def render(assigns) do
~H"""
-
+
{dgettext("auth", "Sign in")}
<%!-- Language selector --%>
-
+
"""
end
end
diff --git a/lib/mv_web/live/custom_field_live/form_component.ex b/lib/mv_web/live/custom_field_live/form_component.ex
index 3872e56..2e98aeb 100644
--- a/lib/mv_web/live/custom_field_live/form_component.ex
+++ b/lib/mv_web/live/custom_field_live/form_component.ex
@@ -32,9 +32,9 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
-
+
{if @custom_field, do: gettext("Edit Data Field"), else: gettext("New Data Field")}
-
+
<.form
diff --git a/lib/mv_web/live/custom_field_live/index_component.ex b/lib/mv_web/live/custom_field_live/index_component.ex
index 6d1fc2f..4d654e2 100644
--- a/lib/mv_web/live/custom_field_live/index_component.ex
+++ b/lib/mv_web/live/custom_field_live/index_component.ex
@@ -12,11 +12,13 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
"""
use MvWeb, :live_component
+ alias MvWeb.Translations.FieldTypes
+
require Logger
@impl true
def render(assigns) do
- assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1)
+ assigns = assign(assigns, :field_type_label, &FieldTypes.label/1)
~H"""
diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex
index 289d721..58eed2a 100644
--- a/lib/mv_web/live/global_settings_live.ex
+++ b/lib/mv_web/live/global_settings_live.ex
@@ -26,7 +26,11 @@ defmodule MvWeb.GlobalSettingsLive do
require Ash.Query
import Ash.Expr
+ alias Mv.Helpers
+ alias Mv.Helpers.SystemActor
alias Mv.Membership
+ alias Mv.Membership.Member, as: MemberResource
+ alias MvWeb.Helpers.MemberHelpers
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@@ -300,22 +304,20 @@ defmodule MvWeb.GlobalSettingsLive do
}
/>
<.form
diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex
index c83837f..abb29e3 100644
--- a/lib/mv_web/live/member_live/form.ex
+++ b/lib/mv_web/live/member_live/form.ex
@@ -25,8 +25,11 @@ defmodule MvWeb.MemberLive.Form do
alias Mv.Membership
alias Mv.Membership.Helpers.VisibilityConfig
+ alias Mv.Membership.Member, as: MemberResource
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
+ alias Mv.Vereinfacht.SyncFlash
+ alias MvWeb.Helpers.MemberHelpers
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true
@@ -51,15 +54,10 @@ defmodule MvWeb.MemberLive.Form do
<%= if @member do %>
- {MvWeb.Helpers.MemberHelpers.display_name(@member)}
+ {MemberHelpers.display_name(@member)}
<% else %>
{gettext("New Member")}
<% end %>
- <:actions>
- <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
- {gettext("Save")}
-
-
@@ -217,14 +215,16 @@ defmodule MvWeb.MemberLive.Form do
<.form_section title={gettext("Membership Fee")}>
-