fix existing flakiness + cut runtime closes #533 #544
118 changed files with 767 additions and 1148 deletions
30
.credo.exs
30
.credo.exs
|
|
@ -114,6 +114,7 @@
|
|||
{Credo.Check.Readability.RedundantBlankLines, []},
|
||||
{Credo.Check.Readability.Semicolons, []},
|
||||
{Credo.Check.Readability.SpaceAfterCommas, []},
|
||||
{Credo.Check.Readability.StrictModuleLayout, []},
|
||||
{Credo.Check.Readability.StringSigils, []},
|
||||
{Credo.Check.Readability.TrailingBlankLine, []},
|
||||
{Credo.Check.Readability.TrailingWhiteSpace, []},
|
||||
|
|
@ -166,13 +167,19 @@
|
|||
{Credo.Check.Warning.UnusedTupleOperation, []},
|
||||
{Credo.Check.Warning.WrongTestFileExtension, []},
|
||||
# Module documentation check (enabled after adding @moduledoc to all modules)
|
||||
{Credo.Check.Readability.ModuleDoc, []}
|
||||
{Credo.Check.Readability.ModuleDoc, []},
|
||||
|
||||
# Promoted in the cleanup ratchet (each currently at zero violations):
|
||||
{Credo.Check.Refactor.DoubleBooleanNegation, []},
|
||||
{Credo.Check.Refactor.FilterReject, []},
|
||||
{Credo.Check.Refactor.RejectFilter, []},
|
||||
{Credo.Check.Refactor.UtcNowTruncate, []},
|
||||
{Credo.Check.Warning.LazyLogging, []},
|
||||
{Credo.Check.Warning.LeakyEnvironment, []},
|
||||
{Credo.Check.Warning.MapGetUnsafePass, []},
|
||||
{Credo.Check.Warning.MixEnv, []}
|
||||
],
|
||||
disabled: [
|
||||
#
|
||||
# Checks scheduled for next check update (opt-in for now)
|
||||
{Credo.Check.Refactor.UtcNowTruncate, []},
|
||||
|
||||
#
|
||||
# Controversial and experimental checks (opt-in, just move the check to `:enabled`
|
||||
# and be sure to use `mix credo --strict` to see low priority checks)
|
||||
|
|
@ -183,6 +190,7 @@
|
|||
{Credo.Check.Design.SkipTestWithoutComment, []},
|
||||
{Credo.Check.Readability.AliasAs, []},
|
||||
{Credo.Check.Readability.BlockPipe, []},
|
||||
# ImplTrue: ~269 violations; deferred to a follow-up.
|
||||
{Credo.Check.Readability.ImplTrue, []},
|
||||
{Credo.Check.Readability.MultiAlias, []},
|
||||
{Credo.Check.Readability.NestedFunctionCalls, []},
|
||||
|
|
@ -192,24 +200,20 @@
|
|||
{Credo.Check.Readability.SingleFunctionToBlockPipe, []},
|
||||
{Credo.Check.Readability.SinglePipe, []},
|
||||
{Credo.Check.Readability.Specs, []},
|
||||
{Credo.Check.Readability.StrictModuleLayout, []},
|
||||
{Credo.Check.Readability.WithCustomTaggedTuple, []},
|
||||
{Credo.Check.Refactor.ABCSize, []},
|
||||
# AppendSingleItem: ~10 violations (mostly tests); deferred to a follow-up.
|
||||
{Credo.Check.Refactor.AppendSingleItem, []},
|
||||
{Credo.Check.Refactor.DoubleBooleanNegation, []},
|
||||
{Credo.Check.Refactor.FilterReject, []},
|
||||
# IoPuts: 3 violations in Mv.Release seed output; deferred to a follow-up.
|
||||
{Credo.Check.Refactor.IoPuts, []},
|
||||
# MapMap: ~8 violations; deferred to a follow-up.
|
||||
{Credo.Check.Refactor.MapMap, []},
|
||||
{Credo.Check.Refactor.ModuleDependencies, []},
|
||||
# NegatedIsNil: ~63 violations; deferred to a follow-up.
|
||||
{Credo.Check.Refactor.NegatedIsNil, []},
|
||||
{Credo.Check.Refactor.PassAsyncInTestCases, []},
|
||||
{Credo.Check.Refactor.PipeChainStart, []},
|
||||
{Credo.Check.Refactor.RejectFilter, []},
|
||||
{Credo.Check.Refactor.VariableRebinding, []},
|
||||
{Credo.Check.Warning.LazyLogging, []},
|
||||
{Credo.Check.Warning.LeakyEnvironment, []},
|
||||
{Credo.Check.Warning.MapGetUnsafePass, []},
|
||||
{Credo.Check.Warning.MixEnv, []},
|
||||
{Credo.Check.Warning.UnsafeToAtom, []}
|
||||
|
||||
# {Credo.Check.Refactor.MapInto, []},
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
## [1.3.0] - 2026-06-16
|
||||
|
||||
### Added
|
||||
- **GDPR/DSGVO join-form description** – Custom fields can carry a "join form description" that is shown as the field's label on the public join form, with clickable external links (whole URLs and Markdown `[text](url)`). Useful for presenting a GDPR confirmation with a link to an externally hosted privacy declaration before sign-up.
|
||||
|
|
@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- **Default GDPR custom field** – The seeded GDPR field was shortened from "Datenschutzerklärung akzeptiert" to "DSGVO" and now ships with a default join-form description (with a placeholder link to replace).
|
||||
|
||||
### Fixed
|
||||
- **Authentication background workers start correctly** – The token-cleanup (Expunger) and audit-log batching workers now boot under the application's real configuration instead of an unused OTP app, so they run as intended in production rather than silently doing nothing.
|
||||
- **CSV date round-trip** – Date custom-field values are now exported as ISO-8601 (`YYYY-MM-DD`), so an exported CSV can be re-imported without date-parsing errors.
|
||||
- **CSV import – fee-status columns ignored** – Columns such as `Bezahlstatus` / `Membership Fee Status` are always ignored on import and never stored as a custom-field value, even when a custom field of the same name exists.
|
||||
- **Column-header tooltips clipped** – Tooltips on the members-overview column headers are no longer clipped by the sticky table header.
|
||||
|
|
|
|||
4
Justfile
4
Justfile
|
|
@ -12,6 +12,10 @@ MIX_QUIET := "1"
|
|||
run: install-dependencies start-database migrate-database seed-database
|
||||
mix phx.server
|
||||
|
||||
# Dev web server + its database only — no mailcrab/rauthy.
|
||||
server: install-dependencies start-test-db migrate-database seed-database
|
||||
mix phx.server
|
||||
|
||||
install-dependencies:
|
||||
mix deps.get
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
|
||||
- `OIDC_ADMIN_GROUP_NAME` – OIDC group name that maps to the Admin role. If unset, no role sync.
|
||||
- `OIDC_GROUPS_CLAIM` – JWT claim name for group list (default "groups").
|
||||
- Module: Mv.OidcRoleSyncConfig (oidc_admin_group_name/0, oidc_groups_claim/0).
|
||||
- Module: Mv.Config (oidc_admin_group_name/0, oidc_groups_claim/0).
|
||||
|
||||
### Sign-in page (OIDC-only mode)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ defmodule Mv.Accounts.User do
|
|||
extensions: [AshAuthentication],
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
alias Ash.Resource.Preparation.Builtins
|
||||
|
|
@ -16,6 +15,8 @@ defmodule Mv.Accounts.User do
|
|||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.OidcRoleSync
|
||||
|
||||
require Ash.Query
|
||||
|
||||
postgres do
|
||||
table "users"
|
||||
repo Mv.Repo
|
||||
|
|
|
|||
|
|
@ -24,12 +24,13 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
|
|||
- Allow (new user will be created)
|
||||
"""
|
||||
use Ash.Resource.Validation
|
||||
require Logger
|
||||
|
||||
alias Mv.Accounts.User
|
||||
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||
alias Mv.Helpers.SystemActor
|
||||
|
||||
require Logger
|
||||
|
||||
@impl true
|
||||
def init(opts), do: {:ok, opts}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
defmodule Mv.Accounts.UserIdentity do
|
||||
@moduledoc """
|
||||
AshAuthentication specific ressource
|
||||
"""
|
||||
use Ash.Resource,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshAuthentication.UserIdentity],
|
||||
domain: Mv.Accounts
|
||||
|
||||
postgres do
|
||||
table "user_identities"
|
||||
repo Mv.Repo
|
||||
end
|
||||
|
||||
user_identity do
|
||||
user_resource Mv.Accounts.User
|
||||
end
|
||||
end
|
||||
|
|
@ -38,6 +38,10 @@ defmodule Mv.Membership.Email do
|
|||
@min_length 5
|
||||
@max_length 254
|
||||
|
||||
# These compile-time constants are referenced by the `use` options below, so
|
||||
# they must be declared first; StrictModuleLayout cannot be satisfied by
|
||||
# reordering here without breaking the macro expansion.
|
||||
# credo:disable-for-next-line Credo.Check.Readability.StrictModuleLayout
|
||||
use Ash.Type.NewType,
|
||||
subtype_of: :string,
|
||||
constraints: [
|
||||
|
|
|
|||
|
|
@ -39,9 +39,10 @@ defmodule Mv.Membership.Group do
|
|||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
require Ash.Query
|
||||
alias Mv.Helpers
|
||||
alias Mv.Helpers.SystemActor
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
postgres do
|
||||
|
|
|
|||
|
|
@ -37,9 +37,9 @@ defmodule Mv.Membership.Member do
|
|||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
import Bitwise
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
import Bitwise
|
||||
|
||||
alias Ecto.Adapters.SQL, as: EctoSQL
|
||||
alias Mv.Helpers
|
||||
alias Mv.Helpers.SystemActor
|
||||
|
|
@ -49,6 +49,7 @@ defmodule Mv.Membership.Member do
|
|||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.Repo
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
@typedoc "An `Mv.Membership.Member` resource record."
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ defmodule Mv.Membership.MemberGroup do
|
|||
policies do
|
||||
bypass action_type(:read) do
|
||||
description "own_data: read only member_groups where member_id == actor.member_id"
|
||||
authorize_if Mv.Authorization.Checks.MemberGroupReadLinkedForOwnData
|
||||
authorize_if {Mv.Authorization.Checks.ReadLinkedForOwnData, member_id_field: :member_id}
|
||||
end
|
||||
|
||||
policy action_type(:read) do
|
||||
|
|
|
|||
|
|
@ -26,13 +26,15 @@ defmodule Mv.Membership do
|
|||
use Ash.Domain,
|
||||
extensions: [AshAdmin.Domain, AshPhoenix]
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
alias Ash.Error.Query.NotFound, as: NotFoundError
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Membership.JoinRequest
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.Membership.SettingsCache
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
admin do
|
||||
|
|
|
|||
|
|
@ -65,12 +65,12 @@ defmodule Mv.Membership.Setting do
|
|||
data_layer: AshPostgres.DataLayer,
|
||||
primary_read_warning?: false
|
||||
|
||||
alias Ash.Resource.Info, as: ResourceInfo
|
||||
|
||||
# Used in join_form_field_ids validation (compile-time to avoid recompiling regex and list on every validation)
|
||||
@uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
@valid_join_form_member_fields Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
alias Ash.Resource.Info, as: ResourceInfo
|
||||
|
||||
postgres do
|
||||
table "settings"
|
||||
repo Mv.Repo
|
||||
|
|
|
|||
98
lib/membership/setting/changes/jsonb_result.ex
Normal file
98
lib/membership/setting/changes/jsonb_result.ex
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
defmodule Mv.Membership.Setting.Changes.JsonbResult do
|
||||
@moduledoc """
|
||||
Shared normalization for the JSONB column values returned by the atomic
|
||||
single-member-field settings updates.
|
||||
|
||||
PostgreSQL may return a JSONB column as an already-decoded map (atom or string
|
||||
keys) or as a raw JSON string depending on the driver path. This helper
|
||||
normalizes either form to a string-keyed map, returning `%{}` for unexpected
|
||||
or undecodable input.
|
||||
"""
|
||||
|
||||
alias Ash.Error.Invalid
|
||||
alias Ecto.Adapters.SQL
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Runs an atomic single-statement JSONB settings UPDATE inside an after_action
|
||||
and maps the RETURNING row back onto the settings struct.
|
||||
|
||||
Shared by the single-member-field change modules, which differ only in the
|
||||
SQL statement, its parameters, the row-to-settings mapping, and the error
|
||||
labels. The three-branch result handling (row found / not found / SQL error)
|
||||
is identical and lives here.
|
||||
|
||||
Options:
|
||||
- `:sql` - The UPDATE statement with a RETURNING clause (required).
|
||||
- `:params` - The full parameter list for the statement (required).
|
||||
- `:on_row` - 1-arity function mapping the RETURNING row (a list of column
|
||||
values) to the updated settings struct (required).
|
||||
- `:error_field` - Ash error field for the not-found / failure errors.
|
||||
- `:not_found_message` - Error message when no row matched.
|
||||
- `:error_message` - Error message when the SQL statement failed.
|
||||
- `:log_message` - Log prefix written on SQL failure.
|
||||
"""
|
||||
@spec run_update(keyword()) ::
|
||||
{:ok, map()} | {:error, Exception.t()}
|
||||
def run_update(opts) do
|
||||
sql = Keyword.fetch!(opts, :sql)
|
||||
params = Keyword.fetch!(opts, :params)
|
||||
on_row = Keyword.fetch!(opts, :on_row)
|
||||
error_field = Keyword.fetch!(opts, :error_field)
|
||||
not_found_message = Keyword.fetch!(opts, :not_found_message)
|
||||
error_message = Keyword.fetch!(opts, :error_message)
|
||||
log_message = Keyword.fetch!(opts, :log_message)
|
||||
|
||||
case SQL.query(Mv.Repo, sql, params) do
|
||||
{:ok, %{rows: [row | _]}} ->
|
||||
{:ok, on_row.(row)}
|
||||
|
||||
{:ok, %{rows: []}} ->
|
||||
{:error,
|
||||
Invalid.exception(
|
||||
field: error_field,
|
||||
message: not_found_message
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
Logger.error("#{log_message}: #{inspect(error)}")
|
||||
|
||||
{:error,
|
||||
Invalid.exception(
|
||||
field: error_field,
|
||||
message: error_message
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Normalizes a JSONB column value to a string-keyed map.
|
||||
"""
|
||||
@spec normalize(map() | binary() | any()) :: map()
|
||||
def normalize(updated_jsonb) do
|
||||
case updated_jsonb do
|
||||
map when is_map(map) ->
|
||||
Enum.reduce(map, %{}, fn
|
||||
{k, v}, acc when is_atom(k) -> Map.put(acc, Atom.to_string(k), v)
|
||||
{k, v}, acc -> Map.put(acc, k, v)
|
||||
end)
|
||||
|
||||
binary when is_binary(binary) ->
|
||||
case Jason.decode(binary) do
|
||||
{:ok, decoded} when is_map(decoded) ->
|
||||
decoded
|
||||
|
||||
{:ok, _} ->
|
||||
%{}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to decode JSONB: #{inspect(reason)}")
|
||||
%{}
|
||||
end
|
||||
|
||||
_ ->
|
||||
Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}")
|
||||
%{}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -19,9 +19,7 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do
|
|||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias Ash.Error.Invalid
|
||||
alias Ecto.Adapters.SQL
|
||||
require Logger
|
||||
alias Mv.Membership.Setting.Changes.JsonbResult
|
||||
|
||||
def change(changeset, _opts, _context) do
|
||||
with {:ok, field} <- get_and_validate_field(changeset),
|
||||
|
|
@ -118,62 +116,21 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do
|
|||
|
||||
uuid_binary = Ecto.UUID.dump!(settings.id)
|
||||
|
||||
case SQL.query(Mv.Repo, sql, [field, show_in_overview, required, uuid_binary]) do
|
||||
{:ok, %{rows: [[updated_visibility, updated_required] | _]}} ->
|
||||
vis = normalize_jsonb_result(updated_visibility)
|
||||
req = normalize_jsonb_result(updated_required)
|
||||
|
||||
updated_settings = %{
|
||||
JsonbResult.run_update(
|
||||
sql: sql,
|
||||
params: [field, show_in_overview, required, uuid_binary],
|
||||
on_row: fn [updated_visibility, updated_required | _] ->
|
||||
%{
|
||||
settings
|
||||
| member_field_visibility: vis,
|
||||
member_field_required: req
|
||||
| member_field_visibility: JsonbResult.normalize(updated_visibility),
|
||||
member_field_required: JsonbResult.normalize(updated_required)
|
||||
}
|
||||
|
||||
{:ok, updated_settings}
|
||||
|
||||
{:ok, %{rows: []}} ->
|
||||
{:error,
|
||||
Invalid.exception(
|
||||
field: :member_field_required,
|
||||
message: "Settings not found"
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
Logger.error("Failed to atomically update member field settings: #{inspect(error)}")
|
||||
|
||||
{:error,
|
||||
Invalid.exception(
|
||||
field: :member_field_required,
|
||||
message: "Failed to update member field settings"
|
||||
)}
|
||||
end
|
||||
end,
|
||||
error_field: :member_field_required,
|
||||
not_found_message: "Settings not found",
|
||||
error_message: "Failed to update member field settings",
|
||||
log_message: "Failed to atomically update member field settings"
|
||||
)
|
||||
end)
|
||||
end
|
||||
|
||||
defp normalize_jsonb_result(updated_jsonb) do
|
||||
case updated_jsonb do
|
||||
map when is_map(map) ->
|
||||
Enum.reduce(map, %{}, fn
|
||||
{k, v}, acc when is_atom(k) -> Map.put(acc, Atom.to_string(k), v)
|
||||
{k, v}, acc -> Map.put(acc, k, v)
|
||||
end)
|
||||
|
||||
binary when is_binary(binary) ->
|
||||
case Jason.decode(binary) do
|
||||
{:ok, decoded} when is_map(decoded) ->
|
||||
decoded
|
||||
|
||||
{:ok, _} ->
|
||||
%{}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to decode JSONB: #{inspect(reason)}")
|
||||
%{}
|
||||
end
|
||||
|
||||
_ ->
|
||||
Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}")
|
||||
%{}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -19,9 +19,7 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility do
|
|||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias Ash.Error.Invalid
|
||||
alias Ecto.Adapters.SQL
|
||||
require Logger
|
||||
alias Mv.Membership.Setting.Changes.JsonbResult
|
||||
|
||||
def change(changeset, _opts, _context) do
|
||||
with {:ok, field} <- get_and_validate_field(changeset),
|
||||
|
|
@ -106,59 +104,17 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility do
|
|||
# Convert UUID string to binary for PostgreSQL
|
||||
uuid_binary = Ecto.UUID.dump!(settings.id)
|
||||
|
||||
case SQL.query(Mv.Repo, sql, [field, show_in_overview, uuid_binary]) do
|
||||
{:ok, %{rows: [[updated_jsonb] | _]}} ->
|
||||
updated_visibility = normalize_jsonb_result(updated_jsonb)
|
||||
|
||||
# Update the settings struct with the new visibility
|
||||
updated_settings = %{settings | member_field_visibility: updated_visibility}
|
||||
{:ok, updated_settings}
|
||||
|
||||
{:ok, %{rows: []}} ->
|
||||
{:error,
|
||||
Invalid.exception(
|
||||
field: :member_field_visibility,
|
||||
message: "Settings not found"
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
Logger.error("Failed to atomically update member_field_visibility: #{inspect(error)}")
|
||||
|
||||
{:error,
|
||||
Invalid.exception(
|
||||
field: :member_field_visibility,
|
||||
message: "Failed to update visibility"
|
||||
)}
|
||||
end
|
||||
JsonbResult.run_update(
|
||||
sql: sql,
|
||||
params: [field, show_in_overview, uuid_binary],
|
||||
on_row: fn [updated_jsonb | _] ->
|
||||
%{settings | member_field_visibility: JsonbResult.normalize(updated_jsonb)}
|
||||
end,
|
||||
error_field: :member_field_visibility,
|
||||
not_found_message: "Settings not found",
|
||||
error_message: "Failed to update visibility",
|
||||
log_message: "Failed to atomically update member_field_visibility"
|
||||
)
|
||||
end)
|
||||
end
|
||||
|
||||
defp normalize_jsonb_result(updated_jsonb) do
|
||||
case updated_jsonb do
|
||||
map when is_map(map) ->
|
||||
# Convert atom keys to strings if needed
|
||||
Enum.reduce(map, %{}, fn
|
||||
{k, v}, acc when is_atom(k) -> Map.put(acc, Atom.to_string(k), v)
|
||||
{k, v}, acc -> Map.put(acc, k, v)
|
||||
end)
|
||||
|
||||
binary when is_binary(binary) ->
|
||||
case Jason.decode(binary) do
|
||||
{:ok, decoded} when is_map(decoded) ->
|
||||
decoded
|
||||
|
||||
# Not a map after decode
|
||||
{:ok, _} ->
|
||||
%{}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to decode JSONB: #{inspect(reason)}")
|
||||
%{}
|
||||
end
|
||||
|
||||
_ ->
|
||||
Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}")
|
||||
%{}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
|
|||
policies do
|
||||
bypass action_type(:read) do
|
||||
description "own_data: read only cycles where member_id == actor.member_id"
|
||||
authorize_if Mv.Authorization.Checks.MembershipFeeCycleReadLinkedForOwnData
|
||||
authorize_if {Mv.Authorization.Checks.ReadLinkedForOwnData, member_id_field: :member_id}
|
||||
end
|
||||
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
defmodule Mix.Tasks.JoinRequests.CleanupExpired do
|
||||
@shortdoc "Deletes join requests in pending_confirmation with expired confirmation token"
|
||||
@moduledoc """
|
||||
Hard-deletes JoinRequests in status `pending_confirmation` whose confirmation link has expired.
|
||||
|
||||
|
|
@ -16,12 +17,10 @@ defmodule Mix.Tasks.JoinRequests.CleanupExpired do
|
|||
"""
|
||||
use Mix.Task
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
alias Mv.Membership.JoinRequest
|
||||
|
||||
@shortdoc "Deletes join requests in pending_confirmation with expired confirmation token"
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
@impl Mix.Task
|
||||
def run(_args) do
|
||||
|
|
|
|||
|
|
@ -13,13 +13,14 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
|||
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
||||
|
||||
use MvWeb, :verified_routes
|
||||
import Swoosh.Email
|
||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||
|
||||
require Logger
|
||||
import Swoosh.Email
|
||||
|
||||
alias Mv.Mailer
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Sends a confirmation email to a new user.
|
||||
|
||||
|
|
|
|||
|
|
@ -13,13 +13,14 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
|
|||
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
||||
|
||||
use MvWeb, :verified_routes
|
||||
import Swoosh.Email
|
||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||
|
||||
require Logger
|
||||
import Swoosh.Email
|
||||
|
||||
alias Mv.Mailer
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Sends a password reset email to a user.
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ defmodule Mv.Application do
|
|||
{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},
|
||||
{AshAuthentication.Supervisor, otp_app: :mv},
|
||||
SystemActor,
|
||||
# Start a worker by calling: Mv.Worker.start_link(arg)
|
||||
# {Mv.Worker, arg},
|
||||
|
|
|
|||
|
|
@ -49,10 +49,10 @@ defmodule Mv.Authorization.Actor do
|
|||
adds complexity and potential for inconsistency.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Mv.Helpers.SystemActor
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Ensures the actor (User) has their `:role` relationship loaded.
|
||||
|
||||
|
|
|
|||
|
|
@ -79,10 +79,13 @@ 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 Ash.Query
|
||||
require Logger
|
||||
|
||||
@impl true
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
defmodule Mv.Authorization.Checks.MemberGroupReadLinkedForOwnData do
|
||||
@moduledoc """
|
||||
Policy check for MemberGroup read: true only when actor has permission set "own_data"
|
||||
AND record.member_id == actor.member_id.
|
||||
|
||||
Used in a bypass so that own_data gets the linked filter (via auto_filter for list queries),
|
||||
while admin with member_id does not match and gets :all from HasPermission.
|
||||
|
||||
- With a record (e.g. get by id): returns true only when own_data and member_id match.
|
||||
- Without a record (list query): strict_check returns false; auto_filter adds filter when own_data.
|
||||
"""
|
||||
use Ash.Policy.Check
|
||||
|
||||
alias Mv.Authorization.Checks.ActorPermissionSetIs
|
||||
|
||||
@impl true
|
||||
def type, do: :filter
|
||||
|
||||
@impl true
|
||||
def describe(_opts),
|
||||
do: "own_data can read only member_groups where member_id == actor.member_id"
|
||||
|
||||
@impl true
|
||||
def strict_check(actor, authorizer, _opts) do
|
||||
record = get_record_from_authorizer(authorizer)
|
||||
is_own_data = ActorPermissionSetIs.match?(actor, authorizer, permission_set_name: "own_data")
|
||||
|
||||
cond do
|
||||
# List query + own_data: return :unknown so authorizer applies auto_filter (keyword list)
|
||||
is_nil(record) and is_own_data ->
|
||||
{:ok, :unknown}
|
||||
|
||||
is_nil(record) ->
|
||||
{:ok, false}
|
||||
|
||||
not is_own_data ->
|
||||
{:ok, false}
|
||||
|
||||
record.member_id == actor.member_id ->
|
||||
{:ok, true}
|
||||
|
||||
true ->
|
||||
{:ok, false}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def auto_filter(actor, _authorizer, _opts) do
|
||||
if ActorPermissionSetIs.match?(actor, nil, permission_set_name: "own_data") &&
|
||||
Map.get(actor, :member_id) do
|
||||
[member_id: actor.member_id]
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
defp get_record_from_authorizer(authorizer) do
|
||||
case authorizer.subject do
|
||||
%{data: data} when not is_nil(data) -> data
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
defmodule Mv.Authorization.Checks.MembershipFeeCycleReadLinkedForOwnData do
|
||||
@moduledoc """
|
||||
Policy check for MembershipFeeCycle read: true only when actor has permission set "own_data"
|
||||
AND record.member_id == actor.member_id.
|
||||
|
||||
Used in a bypass so that own_data gets the linked filter (via auto_filter for list queries),
|
||||
while admin with member_id does not match and gets :all from HasPermission.
|
||||
|
||||
- With a record (e.g. get by id): returns true only when own_data and member_id match.
|
||||
- Without a record (list query): return :unknown so authorizer applies auto_filter.
|
||||
"""
|
||||
use Ash.Policy.Check
|
||||
|
||||
alias Mv.Authorization.Checks.ActorPermissionSetIs
|
||||
|
||||
@impl true
|
||||
def type, do: :filter
|
||||
|
||||
@impl true
|
||||
def describe(_opts),
|
||||
do: "own_data can read only membership_fee_cycles where member_id == actor.member_id"
|
||||
|
||||
@impl true
|
||||
def strict_check(actor, authorizer, _opts) do
|
||||
record = get_record_from_authorizer(authorizer)
|
||||
is_own_data = ActorPermissionSetIs.match?(actor, authorizer, permission_set_name: "own_data")
|
||||
|
||||
cond do
|
||||
is_nil(record) and is_own_data ->
|
||||
{:ok, :unknown}
|
||||
|
||||
is_nil(record) ->
|
||||
{:ok, false}
|
||||
|
||||
not is_own_data ->
|
||||
{:ok, false}
|
||||
|
||||
record.member_id == actor.member_id ->
|
||||
{:ok, true}
|
||||
|
||||
true ->
|
||||
{:ok, false}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def auto_filter(actor, _authorizer, _opts) do
|
||||
if ActorPermissionSetIs.match?(actor, nil, permission_set_name: "own_data") &&
|
||||
Map.get(actor, :member_id) do
|
||||
[member_id: actor.member_id]
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
defp get_record_from_authorizer(authorizer) do
|
||||
case authorizer.subject do
|
||||
%{data: data} when not is_nil(data) -> data
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
74
lib/mv/authorization/checks/read_linked_for_own_data.ex
Normal file
74
lib/mv/authorization/checks/read_linked_for_own_data.ex
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
defmodule Mv.Authorization.Checks.ReadLinkedForOwnData do
|
||||
@moduledoc """
|
||||
Generic policy check for resources that link to a member via a member-id
|
||||
attribute: read is allowed only when the actor has the "own_data" permission
|
||||
set AND `record.<member_id_field> == actor.member_id`.
|
||||
|
||||
Used in a read bypass so that own_data gets the linked filter (via auto_filter
|
||||
for list queries), while admin with a member_id does not match and falls
|
||||
through to `HasPermission` for `:all`.
|
||||
|
||||
- With a record (e.g. get by id): returns true only when own_data and the
|
||||
member ids match.
|
||||
- Without a record (list query) + own_data: returns `:unknown` so the
|
||||
authorizer applies `auto_filter`.
|
||||
|
||||
## Options
|
||||
- `:member_id_field` - the attribute on the resource holding the member id.
|
||||
Defaults to `:member_id`.
|
||||
"""
|
||||
use Ash.Policy.Check
|
||||
|
||||
alias Mv.Authorization.Checks.ActorPermissionSetIs
|
||||
|
||||
@impl true
|
||||
def type, do: :filter
|
||||
|
||||
@impl true
|
||||
def describe(opts) do
|
||||
"own_data can read only records where #{member_id_field(opts)} == actor.member_id"
|
||||
end
|
||||
|
||||
@impl true
|
||||
def strict_check(actor, authorizer, opts) do
|
||||
field = member_id_field(opts)
|
||||
record = get_record_from_authorizer(authorizer)
|
||||
is_own_data = ActorPermissionSetIs.match?(actor, authorizer, permission_set_name: "own_data")
|
||||
|
||||
cond do
|
||||
is_nil(record) and is_own_data ->
|
||||
{:ok, :unknown}
|
||||
|
||||
is_nil(record) ->
|
||||
{:ok, false}
|
||||
|
||||
not is_own_data ->
|
||||
{:ok, false}
|
||||
|
||||
Map.get(record, field) == actor.member_id ->
|
||||
{:ok, true}
|
||||
|
||||
true ->
|
||||
{:ok, false}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def auto_filter(actor, _authorizer, opts) do
|
||||
if ActorPermissionSetIs.match?(actor, nil, permission_set_name: "own_data") &&
|
||||
Map.get(actor, :member_id) do
|
||||
[{member_id_field(opts), actor.member_id}]
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
defp member_id_field(opts), do: Keyword.get(opts, :member_id_field, :member_id)
|
||||
|
||||
defp get_record_from_authorizer(authorizer) do
|
||||
case authorizer.subject do
|
||||
%{data: data} when not is_nil(data) -> data
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -6,9 +6,10 @@ defmodule Mv.EmailSync.Helpers do
|
|||
provides clean abstractions for email updates within transactions.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
import Ecto.Changeset
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Extracts the record from an Ash action result.
|
||||
|
||||
|
|
|
|||
|
|
@ -48,10 +48,10 @@ defmodule Mv.Helpers.SystemActor do
|
|||
|
||||
use Agent
|
||||
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Config
|
||||
|
||||
require Ash.Query
|
||||
|
||||
@doc """
|
||||
Starts the SystemActor Agent.
|
||||
|
||||
|
|
|
|||
|
|
@ -27,11 +27,12 @@ defmodule Mv.Mailer do
|
|||
ENV takes priority over Settings (same pattern as OIDC and Vereinfacht).
|
||||
"""
|
||||
use Swoosh.Mailer, otp_app: :mv
|
||||
|
||||
import Swoosh.Email
|
||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||
|
||||
import Swoosh.Email
|
||||
|
||||
alias Mv.Smtp.ConfigBuilder
|
||||
|
||||
require Logger
|
||||
|
||||
# Simple format check for test-email recipient only (e.g. allows a@b.c). Not for strict RFC validation.
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ defmodule Mv.Membership.Import.ColumnResolver do
|
|||
This module has no Phoenix or web dependencies.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Mv.Membership.Import.HeaderMapper
|
||||
|
||||
require Logger
|
||||
|
||||
@preview_row_limit 3
|
||||
|
||||
@type numbered_row :: {pos_integer(), [String.t()]}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,14 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
MemberCSV.process_chunk(chunk, import_state.column_map, import_state.custom_field_map, [])
|
||||
"""
|
||||
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Membership.Import.ColumnResolver
|
||||
alias Mv.Membership.Import.CsvParser
|
||||
alias Mv.Membership.Import.HeaderMapper
|
||||
alias MvWeb.Translations.FieldTypes
|
||||
|
||||
defmodule Error do
|
||||
@moduledoc """
|
||||
Error struct for CSV import errors.
|
||||
|
|
@ -101,17 +109,6 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
groups_found: list(Mv.Membership.Group.t() | %{id: String.t(), name: String.t()})
|
||||
}
|
||||
|
||||
alias Mv.Membership.Import.ColumnResolver
|
||||
alias Mv.Membership.Import.CsvParser
|
||||
alias Mv.Membership.Import.HeaderMapper
|
||||
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Helpers.SystemActor
|
||||
|
||||
# Import FieldTypes for human-readable type labels
|
||||
alias MvWeb.Translations.FieldTypes
|
||||
|
||||
# Configuration constants
|
||||
@default_max_errors 50
|
||||
@default_chunk_size 200
|
||||
|
|
|
|||
|
|
@ -7,12 +7,6 @@ defmodule Mv.Membership.MemberExport do
|
|||
and sends the download.
|
||||
"""
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
alias Mv.Membership.CustomField
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.Membership.MemberExportSort
|
||||
alias MvWeb.MemberLive.Index
|
||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||
|
||||
|
|
@ -35,261 +29,8 @@ defmodule Mv.Membership.MemberExport do
|
|||
["membership_fee_type", "membership_fee_status", "groups"]
|
||||
@computed_export_fields ["membership_fee_status"]
|
||||
@computed_insert_after "membership_fee_start_date"
|
||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||
@domain_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
@doc """
|
||||
Fetches members and column specs for export.
|
||||
|
||||
- `actor` - Ash actor (e.g. current user)
|
||||
- `parsed` - Map from controller's parse_and_validate (selected_ids, member_fields, etc.)
|
||||
|
||||
Returns `{:ok, members, column_specs}` or `{:error, :forbidden}`.
|
||||
Column specs have `:kind`, `:key`, and for custom fields `:custom_field`;
|
||||
the controller adds `:header` and optional computed columns to members before CSV export.
|
||||
"""
|
||||
@spec fetch(struct(), map()) ::
|
||||
{:ok, [struct()], [map()]} | {:error, :forbidden}
|
||||
def fetch(actor, parsed) do
|
||||
custom_field_ids_union =
|
||||
(parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})) |> Enum.uniq()
|
||||
|
||||
with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(custom_field_ids_union, actor),
|
||||
{:ok, members} <- load_members(actor, parsed, custom_fields_by_id) do
|
||||
column_specs = build_column_specs(parsed, custom_fields_by_id)
|
||||
{:ok, members, column_specs}
|
||||
end
|
||||
end
|
||||
|
||||
defp load_custom_fields_by_id([], _actor), do: {:ok, %{}}
|
||||
|
||||
defp load_custom_fields_by_id(custom_field_ids, actor) do
|
||||
query =
|
||||
CustomField
|
||||
|> Ash.Query.filter(expr(id in ^custom_field_ids))
|
||||
|> Ash.Query.select([:id, :name, :value_type])
|
||||
|
||||
case Ash.read(query, actor: actor) do
|
||||
{:ok, custom_fields} ->
|
||||
by_id = build_custom_fields_by_id(custom_field_ids, custom_fields)
|
||||
{:ok, by_id}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:error, :forbidden}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_custom_fields_by_id(custom_field_ids, custom_fields) do
|
||||
Enum.reduce(custom_field_ids, %{}, fn id, acc ->
|
||||
find_and_add_custom_field(acc, id, custom_fields)
|
||||
end)
|
||||
end
|
||||
|
||||
defp find_and_add_custom_field(acc, id, custom_fields) do
|
||||
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
|
||||
nil -> acc
|
||||
cf -> Map.put(acc, id, cf)
|
||||
end
|
||||
end
|
||||
|
||||
defp build_column_specs(parsed, custom_fields_by_id) do
|
||||
member_specs = build_member_column_specs(parsed)
|
||||
custom_specs = build_custom_column_specs(parsed, custom_fields_by_id)
|
||||
|
||||
member_specs ++ custom_specs
|
||||
end
|
||||
|
||||
defp build_member_column_specs(parsed) do
|
||||
Enum.map(parsed.member_fields, fn f ->
|
||||
build_single_member_spec(f, parsed.selectable_member_fields)
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
end
|
||||
|
||||
defp build_single_member_spec(field, selectable_member_fields) do
|
||||
if field in selectable_member_fields do
|
||||
%{kind: :member_field, key: field}
|
||||
else
|
||||
build_computed_spec(field)
|
||||
end
|
||||
end
|
||||
|
||||
defp build_computed_spec(field) do
|
||||
# only allow known computed export fields to avoid crashing on unknown atoms
|
||||
if field in @computed_export_fields do
|
||||
%{kind: :computed, key: String.to_existing_atom(field)}
|
||||
else
|
||||
# ignore unknown non-selectable fields defensively
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp build_custom_column_specs(parsed, custom_fields_by_id) do
|
||||
parsed.custom_field_ids
|
||||
|> Enum.map(fn id -> Map.get(custom_fields_by_id, id) end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.map(fn cf -> %{kind: :custom_field, key: cf.id, custom_field: cf} end)
|
||||
end
|
||||
|
||||
defp load_members(actor, parsed, custom_fields_by_id) do
|
||||
query = build_members_query(parsed, custom_fields_by_id)
|
||||
|
||||
case Ash.read(query, actor: actor) do
|
||||
{:ok, members} ->
|
||||
processed_members = process_loaded_members(members, parsed, custom_fields_by_id)
|
||||
{:ok, processed_members}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:error, :forbidden}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_members_query(parsed, _custom_fields_by_id) do
|
||||
select_fields =
|
||||
[:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)
|
||||
|
||||
custom_field_ids_union = parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})
|
||||
|
||||
need_cycles =
|
||||
parsed.show_current_cycle or parsed.cycle_status_filter != nil or
|
||||
parsed.computed_fields != [] or
|
||||
"membership_fee_status" in parsed.member_fields
|
||||
|
||||
query =
|
||||
Member
|
||||
|> Ash.Query.new()
|
||||
|> Ash.Query.select(select_fields)
|
||||
|> load_custom_field_values_query(custom_field_ids_union)
|
||||
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
||||
|
||||
if parsed.selected_ids != [] do
|
||||
Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
|
||||
else
|
||||
query
|
||||
|> apply_search(parsed.query)
|
||||
|> then(fn q ->
|
||||
{q, _sort_after_load} = maybe_sort(q, parsed.sort_field, parsed.sort_order)
|
||||
q
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
defp process_loaded_members(members, parsed, custom_fields_by_id) do
|
||||
members
|
||||
|> apply_post_load_filters(parsed, custom_fields_by_id)
|
||||
|> apply_post_load_sorting(parsed, custom_fields_by_id)
|
||||
|> add_computed_fields(parsed.computed_fields, parsed.show_current_cycle)
|
||||
end
|
||||
|
||||
defp apply_post_load_filters(members, parsed, custom_fields_by_id) do
|
||||
if parsed.selected_ids == [] do
|
||||
members
|
||||
|> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
|
||||
|> Index.apply_boolean_custom_field_filters(
|
||||
parsed.boolean_filters || %{},
|
||||
Map.values(custom_fields_by_id)
|
||||
)
|
||||
else
|
||||
members
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_post_load_sorting(members, parsed, custom_fields_by_id) do
|
||||
if parsed.selected_ids == [] and sort_after_load?(parsed.sort_field) do
|
||||
sort_members_by_custom_field(
|
||||
members,
|
||||
parsed.sort_field,
|
||||
parsed.sort_order,
|
||||
Map.values(custom_fields_by_id)
|
||||
)
|
||||
else
|
||||
members
|
||||
end
|
||||
end
|
||||
|
||||
defp load_custom_field_values_query(query, []), do: query
|
||||
|
||||
defp load_custom_field_values_query(query, custom_field_ids) do
|
||||
cfv_query =
|
||||
Mv.Membership.CustomFieldValue
|
||||
|> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
|
||||
|> Ash.Query.load(custom_field: [:id, :name, :value_type])
|
||||
|
||||
Ash.Query.load(query, custom_field_values: cfv_query)
|
||||
end
|
||||
|
||||
defp apply_search(query, nil), do: query
|
||||
defp apply_search(query, ""), do: query
|
||||
|
||||
defp apply_search(query, q) when is_binary(q) do
|
||||
if String.trim(q) != "" do
|
||||
Member.fuzzy_search(query, %{query: q})
|
||||
else
|
||||
query
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_sort(query, nil, _order), do: {query, false}
|
||||
defp maybe_sort(query, _field, nil), do: {query, false}
|
||||
|
||||
defp maybe_sort(query, field, order) when is_binary(field) do
|
||||
if custom_field_sort?(field) do
|
||||
{query, true}
|
||||
else
|
||||
field_atom = String.to_existing_atom(field)
|
||||
|
||||
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
|
||||
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
|
||||
else
|
||||
{query, false}
|
||||
end
|
||||
end
|
||||
rescue
|
||||
ArgumentError -> {query, false}
|
||||
end
|
||||
|
||||
defp sort_after_load?(field) when is_binary(field),
|
||||
do: String.starts_with?(field, @custom_field_prefix)
|
||||
|
||||
defp sort_after_load?(_), do: false
|
||||
|
||||
defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [],
|
||||
do: []
|
||||
|
||||
defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do
|
||||
id_str = String.trim_leading(field, @custom_field_prefix)
|
||||
custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
|
||||
if is_nil(custom_field), do: members
|
||||
|
||||
key_fn = fn member ->
|
||||
cfv = find_cfv(member, custom_field)
|
||||
raw = if cfv, do: cfv.value, else: nil
|
||||
MemberExportSort.custom_field_sort_key(custom_field.value_type, raw)
|
||||
end
|
||||
|
||||
members
|
||||
|> Enum.map(fn m -> {m, key_fn.(m)} end)
|
||||
|> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end)
|
||||
|> Enum.map(fn {m, _} -> m end)
|
||||
end
|
||||
|
||||
defp find_cfv(member, custom_field) do
|
||||
(member.custom_field_values || [])
|
||||
|> Enum.find(fn cfv ->
|
||||
to_string(cfv.custom_field_id) == to_string(custom_field.id) or
|
||||
(Map.get(cfv, :custom_field) &&
|
||||
to_string(cfv.custom_field.id) == to_string(custom_field.id))
|
||||
end)
|
||||
end
|
||||
|
||||
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
|
||||
|
||||
defp maybe_load_cycles(query, false, _show_current), do: query
|
||||
|
||||
defp maybe_load_cycles(query, true, show_current) do
|
||||
MembershipFeeStatus.load_cycles_for_members(query, show_current)
|
||||
end
|
||||
|
||||
defp apply_cycle_status_filter(members, nil, _show_current), do: members
|
||||
|
||||
defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do
|
||||
|
|
@ -298,20 +39,6 @@ defmodule Mv.Membership.MemberExport do
|
|||
|
||||
defp apply_cycle_status_filter(members, _status, _show_current), do: members
|
||||
|
||||
defp add_computed_fields(members, computed_fields, show_current_cycle) do
|
||||
computed_fields = computed_fields || []
|
||||
|
||||
if "membership_fee_status" in computed_fields do
|
||||
Enum.map(members, fn member ->
|
||||
status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle)
|
||||
# <= Atom rein
|
||||
Map.put(member, :membership_fee_status, status)
|
||||
end)
|
||||
else
|
||||
members
|
||||
end
|
||||
end
|
||||
|
||||
# Called by controller to build parsed map from raw params (kept here so controller stays thin)
|
||||
@doc """
|
||||
Parses and validates export params (from JSON payload).
|
||||
|
|
|
|||
|
|
@ -17,13 +17,14 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
No translations/Gettext in this module - labels come from the web layer via a function.
|
||||
"""
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
alias Mv.Membership.{CustomField, CustomFieldValueFormatter, Member, MemberExportSort}
|
||||
alias MvWeb.MemberLive.Index
|
||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||
|
||||
require Ash.Query
|
||||
|
||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -12,12 +12,12 @@ defmodule Mv.Membership.MembersPDF do
|
|||
to avoid symlink issues and ensure isolation.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Config
|
||||
|
||||
require Logger
|
||||
|
||||
@template_filename "members_export.typ"
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -59,27 +59,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
|
|||
|
||||
"""
|
||||
@spec run() :: {:ok, CycleGenerator.results_summary()} | {:error, Ash.Error.t()}
|
||||
def run do
|
||||
Logger.info("Starting membership fee cycle generation job")
|
||||
start_time = System.monotonic_time(:millisecond)
|
||||
|
||||
result = CycleGenerator.generate_cycles_for_all_members()
|
||||
|
||||
elapsed = System.monotonic_time(:millisecond) - start_time
|
||||
|
||||
case result do
|
||||
{:ok, stats} ->
|
||||
Logger.info(
|
||||
"Cycle generation completed in #{elapsed}ms: #{stats.success} success, #{stats.failed} failed, #{stats.total} total"
|
||||
)
|
||||
|
||||
result
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Cycle generation failed: #{inspect(reason)}")
|
||||
result
|
||||
end
|
||||
end
|
||||
def run, do: run([])
|
||||
|
||||
@doc """
|
||||
Runs cycle generation with custom options.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,4 @@
|
|||
defmodule Mv.MembershipFees.CycleGenerator do
|
||||
@typedoc "Aggregate counts returned by a batch cycle-generation run."
|
||||
@type results_summary :: %{
|
||||
success: non_neg_integer(),
|
||||
failed: non_neg_integer(),
|
||||
total: non_neg_integer()
|
||||
}
|
||||
|
||||
@moduledoc """
|
||||
Module for generating membership fee cycles for members.
|
||||
|
||||
|
|
@ -66,6 +59,13 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
|||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
@typedoc "Aggregate counts returned by a batch cycle-generation run."
|
||||
@type results_summary :: %{
|
||||
success: non_neg_integer(),
|
||||
failed: non_neg_integer(),
|
||||
total: non_neg_integer()
|
||||
}
|
||||
|
||||
@type generate_result ::
|
||||
{:ok, [MembershipFeeCycle.t()], [Ash.Notifier.Notification.t()]} | {:error, term()}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ defmodule Mv.OidcRoleSync do
|
|||
|
||||
Used after OIDC registration (register_with_oidc) and on sign-in so that
|
||||
users in the configured admin group get the Admin role; others get Mitglied.
|
||||
Configure via OIDC_ADMIN_GROUP_NAME and OIDC_GROUPS_CLAIM (see OidcRoleSyncConfig).
|
||||
Configure via OIDC_ADMIN_GROUP_NAME and OIDC_GROUPS_CLAIM (see Mv.Config).
|
||||
|
||||
Groups are read from user_info (ID token claims) first; if missing or empty,
|
||||
the access_token from oauth_tokens is decoded as JWT and the groups claim is
|
||||
|
|
@ -23,7 +23,7 @@ defmodule Mv.OidcRoleSync do
|
|||
"""
|
||||
alias Mv.Accounts.User
|
||||
alias Mv.Authorization.Role
|
||||
alias Mv.OidcRoleSyncConfig
|
||||
alias Mv.Config
|
||||
|
||||
@doc """
|
||||
Applies Admin or Mitglied role to the user based on OIDC groups claim.
|
||||
|
|
@ -38,12 +38,12 @@ defmodule Mv.OidcRoleSync do
|
|||
@spec apply_admin_role_from_user_info(User.t(), map(), map() | nil) :: :ok
|
||||
def apply_admin_role_from_user_info(user, user_info, oauth_tokens \\ nil)
|
||||
when is_map(user_info) do
|
||||
admin_group = OidcRoleSyncConfig.oidc_admin_group_name()
|
||||
admin_group = Config.oidc_admin_group_name()
|
||||
|
||||
if is_nil(admin_group) or admin_group == "" do
|
||||
:ok
|
||||
else
|
||||
claim = OidcRoleSyncConfig.oidc_groups_claim()
|
||||
claim = Config.oidc_groups_claim()
|
||||
groups = groups_from_user_info(user_info, claim)
|
||||
|
||||
groups =
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
defmodule Mv.OidcRoleSyncConfig do
|
||||
@moduledoc """
|
||||
Runtime configuration for OIDC group → role sync (e.g. admin group → Admin role).
|
||||
|
||||
Reads from Mv.Config (ENV first, then Settings):
|
||||
- `oidc_admin_group_name/0` – OIDC group name that maps to Admin role (optional; when nil, no sync).
|
||||
- `oidc_groups_claim/0` – JWT/user_info claim name for groups (default: `"groups"`).
|
||||
|
||||
Set via ENV: OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM; or via Settings (Basic settings → OIDC).
|
||||
"""
|
||||
@doc "Returns the OIDC group name that maps to Admin role, or nil if not configured."
|
||||
def oidc_admin_group_name do
|
||||
Mv.Config.oidc_admin_group_name()
|
||||
end
|
||||
|
||||
@doc "Returns the JWT/user_info claim name for groups; defaults to \"groups\"."
|
||||
def oidc_groups_claim do
|
||||
Mv.Config.oidc_groups_claim()
|
||||
end
|
||||
end
|
||||
|
|
@ -12,8 +12,6 @@ defmodule Mv.Release do
|
|||
or ADMIN_PASSWORD_FILE). Idempotent; can be run on every deployment or via shell
|
||||
to update the admin password without redeploying.
|
||||
"""
|
||||
@app :mv
|
||||
|
||||
alias Mv.Accounts
|
||||
alias Mv.Accounts.User
|
||||
alias Mv.Authorization.Role
|
||||
|
|
@ -21,6 +19,8 @@ defmodule Mv.Release do
|
|||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
@app :mv
|
||||
|
||||
def migrate do
|
||||
_ = load_app()
|
||||
|
||||
|
|
|
|||
|
|
@ -7,15 +7,15 @@ defmodule Mv.Statistics do
|
|||
to Ash reads so that policies are enforced.
|
||||
"""
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
require Logger
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Returns the earliest year in which any member has a join_date.
|
||||
|
||||
|
|
|
|||
|
|
@ -9,11 +9,9 @@ defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do
|
|||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias Mv.EmailSync.Loader
|
||||
|
||||
require Logger
|
||||
alias Mv.Helpers
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.Member
|
||||
|
||||
@impl true
|
||||
def change(changeset, _opts, _context) do
|
||||
|
|
@ -32,7 +30,7 @@ defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do
|
|||
end
|
||||
|
||||
defp sync_linked_member_after_transaction(_changeset, {:ok, user}) do
|
||||
case load_linked_member(user) do
|
||||
case Loader.get_linked_member(user) do
|
||||
nil ->
|
||||
{:ok, user}
|
||||
|
||||
|
|
@ -55,17 +53,4 @@ defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do
|
|||
end
|
||||
|
||||
defp sync_linked_member_after_transaction(_changeset, result), do: result
|
||||
|
||||
defp load_linked_member(%{member_id: nil}), do: nil
|
||||
defp load_linked_member(%{member_id: ""}), do: nil
|
||||
|
||||
defp load_linked_member(user) do
|
||||
actor = SystemActor.get_system_actor()
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
case Ash.get(Member, user.member_id, [domain: Membership] ++ opts) do
|
||||
{:ok, %Member{} = member} -> member
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -7,13 +7,15 @@ defmodule Mv.Vereinfacht do
|
|||
the linked member's email via Ecto (e.g. user email change).
|
||||
- `sync_members_without_contact/0` – Bulk sync of members without a contact ID.
|
||||
"""
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
alias Mv.Helpers
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.Vereinfacht.Client
|
||||
|
||||
require Ash.Query
|
||||
|
||||
@doc """
|
||||
Tests the connection to the Vereinfacht API using the current configuration.
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ defmodule MvWeb.TableComponents do
|
|||
TableComponents that can be used in tables as components (like a button for sorting, a filter...)
|
||||
"""
|
||||
use Phoenix.Component
|
||||
import MvWeb.CoreComponents
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
import MvWeb.CoreComponents
|
||||
|
||||
attr :field, :atom, required: true
|
||||
attr :label, :string, required: true
|
||||
attr :sort_field, :atom, default: nil
|
||||
|
|
|
|||
22
lib/mv_web/controllers/controller_helpers.ex
Normal file
22
lib/mv_web/controllers/controller_helpers.ex
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
defmodule MvWeb.ControllerHelpers do
|
||||
@moduledoc """
|
||||
Shared helpers for plug-based controllers.
|
||||
|
||||
The LiveView equivalent lives in `MvWeb.LiveHelpers`; this module is the
|
||||
controller-side counterpart that works on a `Plug.Conn`.
|
||||
"""
|
||||
|
||||
alias Mv.Authorization.Actor
|
||||
|
||||
@doc """
|
||||
Returns the request actor for a controller, loaded for authorization.
|
||||
|
||||
Reads `:current_user` from the connection assigns and ensures it is loaded via
|
||||
`Mv.Authorization.Actor.ensure_loaded/1`.
|
||||
"""
|
||||
@spec current_actor(Plug.Conn.t()) :: Mv.Accounts.User.t() | nil
|
||||
def current_actor(conn) do
|
||||
conn.assigns[:current_user]
|
||||
|> Actor.ensure_loaded()
|
||||
end
|
||||
end
|
||||
|
|
@ -13,7 +13,8 @@ defmodule MvWeb.ImportTemplateController do
|
|||
"""
|
||||
use MvWeb, :controller
|
||||
|
||||
alias Mv.Authorization.Actor
|
||||
import MvWeb.ControllerHelpers, only: [current_actor: 1]
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.Membership.MembersCSV
|
||||
alias MvWeb.Authorization
|
||||
|
|
@ -105,11 +106,6 @@ defmodule MvWeb.ImportTemplateController do
|
|||
end
|
||||
end
|
||||
|
||||
defp current_actor(conn) do
|
||||
conn.assigns[:current_user]
|
||||
|> Actor.ensure_loaded()
|
||||
end
|
||||
|
||||
defp return_forbidden(conn) do
|
||||
conn
|
||||
|> put_status(403)
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ defmodule MvWeb.MemberExportController do
|
|||
Same permission and actor context as the member overview; 403 if unauthorized.
|
||||
"""
|
||||
use MvWeb, :controller
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
import MvWeb.ControllerHelpers, only: [current_actor: 1]
|
||||
|
||||
alias Mv.Authorization.Actor
|
||||
alias Mv.Membership.CustomField
|
||||
alias Mv.Membership.CustomFieldSort
|
||||
alias Mv.Membership.Member
|
||||
|
|
@ -18,7 +18,8 @@ defmodule MvWeb.MemberExportController do
|
|||
alias Mv.Membership.MembersCSV
|
||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||
alias MvWeb.Translations.MemberFields
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
require Ash.Query
|
||||
|
||||
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
||||
["membership_fee_type", "groups"]
|
||||
|
|
@ -53,11 +54,6 @@ defmodule MvWeb.MemberExportController do
|
|||
|> json(%{error: message})
|
||||
end
|
||||
|
||||
defp current_actor(conn) do
|
||||
conn.assigns[:current_user]
|
||||
|> Actor.ensure_loaded()
|
||||
end
|
||||
|
||||
defp return_forbidden(conn) do
|
||||
conn
|
||||
|> put_status(403)
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@ defmodule MvWeb.MemberPdfExportController do
|
|||
"""
|
||||
|
||||
use MvWeb, :controller
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
require Logger
|
||||
import MvWeb.ControllerHelpers, only: [current_actor: 1]
|
||||
|
||||
alias Mv.Authorization.Actor
|
||||
alias Mv.Membership.{MemberExport, MemberExport.Build, MembersPDF}
|
||||
alias MvWeb.Translations.MemberFields
|
||||
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
require Logger
|
||||
|
||||
@payload_required_message "payload required"
|
||||
@invalid_json_message "invalid JSON"
|
||||
|
|
@ -79,13 +79,6 @@ defmodule MvWeb.MemberPdfExportController do
|
|||
bad_request(conn, @payload_required_message)
|
||||
end
|
||||
|
||||
# --- Actor / auth ---
|
||||
|
||||
defp current_actor(conn) do
|
||||
conn.assigns[:current_user]
|
||||
|> Actor.ensure_loaded()
|
||||
end
|
||||
|
||||
defp forbidden(conn) do
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|
|
|
|||
|
|
@ -5,15 +5,9 @@ defmodule MvWeb.Emails.JoinAlreadyMemberEmail do
|
|||
Used for anti-enumeration: the UI shows the same success message; only the email
|
||||
informs the recipient. Uses the unified email layout.
|
||||
"""
|
||||
use Phoenix.Swoosh,
|
||||
view: MvWeb.EmailsView,
|
||||
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
||||
|
||||
use MvWeb, :verified_routes
|
||||
import Swoosh.Email
|
||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||
|
||||
alias Mv.Mailer
|
||||
alias MvWeb.Emails.JoinEmail
|
||||
|
||||
@doc """
|
||||
Sends the "already a member" notice to the given address.
|
||||
|
|
@ -23,20 +17,6 @@ defmodule MvWeb.Emails.JoinAlreadyMemberEmail do
|
|||
def send(email_address) when is_binary(email_address) do
|
||||
subject = gettext("Membership application – already a member")
|
||||
|
||||
assigns = %{
|
||||
subject: subject,
|
||||
app_name: Mailer.mail_from() |> elem(0),
|
||||
locale: Gettext.get_locale(MvWeb.Gettext)
|
||||
}
|
||||
|
||||
email =
|
||||
new()
|
||||
|> from(Mailer.mail_from())
|
||||
|> to(email_address)
|
||||
|> subject(subject)
|
||||
|> put_view(MvWeb.EmailsView)
|
||||
|> render_body("join_already_member.html", assigns)
|
||||
|
||||
Mailer.deliver(email, Mailer.smtp_config())
|
||||
JoinEmail.deliver(email_address, "join_already_member.html", subject)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,15 +6,9 @@ defmodule MvWeb.Emails.JoinAlreadyPendingEmail do
|
|||
Used for anti-enumeration: the UI shows the same success message; only the email
|
||||
informs the recipient. Uses the unified email layout.
|
||||
"""
|
||||
use Phoenix.Swoosh,
|
||||
view: MvWeb.EmailsView,
|
||||
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
||||
|
||||
use MvWeb, :verified_routes
|
||||
import Swoosh.Email
|
||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||
|
||||
alias Mv.Mailer
|
||||
alias MvWeb.Emails.JoinEmail
|
||||
|
||||
@doc """
|
||||
Sends the "application already under review" notice to the given address.
|
||||
|
|
@ -24,20 +18,6 @@ defmodule MvWeb.Emails.JoinAlreadyPendingEmail do
|
|||
def send(email_address) when is_binary(email_address) do
|
||||
subject = gettext("Membership application – already under review")
|
||||
|
||||
assigns = %{
|
||||
subject: subject,
|
||||
app_name: Mailer.mail_from() |> elem(0),
|
||||
locale: Gettext.get_locale(MvWeb.Gettext)
|
||||
}
|
||||
|
||||
email =
|
||||
new()
|
||||
|> from(Mailer.mail_from())
|
||||
|> to(email_address)
|
||||
|> subject(subject)
|
||||
|> put_view(MvWeb.EmailsView)
|
||||
|> render_body("join_already_pending.html", assigns)
|
||||
|
||||
Mailer.deliver(email, Mailer.smtp_config())
|
||||
JoinEmail.deliver(email_address, "join_already_pending.html", subject)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,15 +2,10 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do
|
|||
@moduledoc """
|
||||
Sends the join request confirmation email (double opt-in) using the unified email layout.
|
||||
"""
|
||||
use Phoenix.Swoosh,
|
||||
view: MvWeb.EmailsView,
|
||||
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
||||
|
||||
use MvWeb, :verified_routes
|
||||
import Swoosh.Email
|
||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||
|
||||
alias Mv.Mailer
|
||||
alias MvWeb.Emails.JoinEmail
|
||||
|
||||
@doc """
|
||||
Sends the join confirmation email to the given address with the confirmation link.
|
||||
|
|
@ -31,22 +26,9 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do
|
|||
confirm_url = url(~p"/confirm_join/#{token}")
|
||||
subject = gettext("Confirm your membership request")
|
||||
|
||||
assigns = %{
|
||||
JoinEmail.deliver(email_address, "join_confirmation.html", subject, %{
|
||||
confirm_url: confirm_url,
|
||||
subject: subject,
|
||||
app_name: Mailer.mail_from() |> elem(0),
|
||||
locale: Gettext.get_locale(MvWeb.Gettext),
|
||||
resend: Keyword.get(opts, :resend, false)
|
||||
}
|
||||
|
||||
email =
|
||||
new()
|
||||
|> from(Mailer.mail_from())
|
||||
|> to(email_address)
|
||||
|> subject(subject)
|
||||
|> put_view(MvWeb.EmailsView)
|
||||
|> render_body("join_confirmation.html", assigns)
|
||||
|
||||
Mailer.deliver(email, Mailer.smtp_config())
|
||||
})
|
||||
end
|
||||
end
|
||||
|
|
|
|||
54
lib/mv_web/emails/join_email.ex
Normal file
54
lib/mv_web/emails/join_email.ex
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
defmodule MvWeb.Emails.JoinEmail do
|
||||
@moduledoc """
|
||||
Shared build/deliver skeleton for the join-flow emails (confirmation,
|
||||
already-a-member, already-pending).
|
||||
|
||||
Each concrete join email supplies only its subject, template, and any
|
||||
email-specific assigns; this module builds the Swoosh email with the unified
|
||||
layout and delivers it via `Mailer.deliver/2` with `Mailer.smtp_config/0`,
|
||||
preserving the `{:ok, email}` / `{:error, reason}` contract.
|
||||
"""
|
||||
use Phoenix.Swoosh,
|
||||
view: MvWeb.EmailsView,
|
||||
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
||||
|
||||
import Swoosh.Email
|
||||
|
||||
alias Mv.Mailer
|
||||
|
||||
@doc """
|
||||
Builds and delivers a join-flow email.
|
||||
|
||||
- `email_address` - recipient address
|
||||
- `template` - the EmailsView template to render (e.g. `"join_confirmation.html"`)
|
||||
- `subject` - already-translated subject line
|
||||
- `extra_assigns` - email-specific assigns merged on top of the common ones
|
||||
(`subject`, `app_name`, `locale`)
|
||||
|
||||
Returns `{:ok, email}` on success, `{:error, reason}` on delivery failure.
|
||||
"""
|
||||
@spec deliver(String.t(), String.t(), String.t(), map()) ::
|
||||
{:ok, Swoosh.Email.t()} | {:error, term()}
|
||||
def deliver(email_address, template, subject, extra_assigns \\ %{})
|
||||
when is_binary(email_address) and is_binary(template) and is_binary(subject) do
|
||||
assigns =
|
||||
Map.merge(
|
||||
%{
|
||||
subject: subject,
|
||||
app_name: Mailer.mail_from() |> elem(0),
|
||||
locale: Gettext.get_locale(MvWeb.Gettext)
|
||||
},
|
||||
extra_assigns
|
||||
)
|
||||
|
||||
email =
|
||||
new()
|
||||
|> from(Mailer.mail_from())
|
||||
|> to(email_address)
|
||||
|> subject(subject)
|
||||
|> put_view(MvWeb.EmailsView)
|
||||
|> render_body(template, assigns)
|
||||
|
||||
Mailer.deliver(email, Mailer.smtp_config())
|
||||
end
|
||||
end
|
||||
|
|
@ -9,8 +9,11 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
|
|||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias MvWeb.Helpers.DateFormatter
|
||||
|
||||
@doc """
|
||||
Formats a decimal amount as currency string.
|
||||
|
|
@ -98,8 +101,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
|
|||
@spec format_cycle_range(Date.t(), :monthly | :quarterly | :half_yearly | :yearly) :: String.t()
|
||||
def format_cycle_range(cycle_start, interval) do
|
||||
cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
|
||||
start_str = format_date(cycle_start)
|
||||
end_str = format_date(cycle_end)
|
||||
start_str = DateFormatter.format_date(cycle_start)
|
||||
end_str = DateFormatter.format_date(cycle_end)
|
||||
"#{start_str} - #{end_str}"
|
||||
end
|
||||
|
||||
|
|
@ -249,8 +252,68 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
|
|||
def status_icon(:unpaid), do: "hero-x-circle"
|
||||
def status_icon(:suspended), do: "hero-pause-circle"
|
||||
|
||||
# Private helper function for date formatting
|
||||
defp format_date(%Date{} = date) do
|
||||
Calendar.strftime(date, "%d.%m.%Y")
|
||||
@doc """
|
||||
Handles a membership-fee-type "delete" event for the fee-type list and the
|
||||
fee-settings LiveViews.
|
||||
|
||||
Loads the fee type, attempts to destroy it, and returns the updated socket
|
||||
with the matching flash. On success the deleted type and its member count are
|
||||
dropped from the `:membership_fee_types` and `:member_counts` assigns. The
|
||||
NotFound, Forbidden (delete and access), and generic error branches preserve
|
||||
the exact messages both views used before they shared this block.
|
||||
"""
|
||||
@spec delete_fee_type(Phoenix.LiveView.Socket.t(), String.t(), term()) ::
|
||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||
def delete_fee_type(socket, id, actor) do
|
||||
case Ash.get(MembershipFeeType, id, domain: MembershipFees, actor: actor) do
|
||||
{:ok, fee_type} ->
|
||||
destroy_fee_type(socket, fee_type, id, actor)
|
||||
|
||||
{:error, %Ash.Error.Query.NotFound{}} ->
|
||||
{:noreply,
|
||||
Phoenix.LiveView.put_flash(socket, :error, gettext("Membership fee type not found"))}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
Phoenix.LiveView.put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("You do not have permission to access this membership fee type")
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, Phoenix.LiveView.put_flash(socket, :error, fee_error_message(error))}
|
||||
end
|
||||
end
|
||||
|
||||
defp destroy_fee_type(socket, fee_type, id, actor) do
|
||||
case Ash.destroy(fee_type, domain: MembershipFees, actor: actor) do
|
||||
:ok ->
|
||||
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
|
||||
updated_counts = Map.delete(socket.assigns.member_counts, id)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> Phoenix.Component.assign(:membership_fee_types, updated_types)
|
||||
|> Phoenix.Component.assign(:member_counts, updated_counts)
|
||||
|> Phoenix.LiveView.put_flash(:success, gettext("Membership fee type deleted"))}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
Phoenix.LiveView.put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("You do not have permission to delete this membership fee type")
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, Phoenix.LiveView.put_flash(socket, :error, fee_error_message(error))}
|
||||
end
|
||||
end
|
||||
|
||||
defp fee_error_message(%Ash.Error.Invalid{} = error) do
|
||||
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
||||
end
|
||||
|
||||
defp fee_error_message(_error), do: gettext("An error occurred")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -15,13 +15,14 @@ defmodule MvWeb.LinkOidcAccountLive do
|
|||
6. User is redirected to complete OIDC login
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
alias AshAuthentication.Strategy.Password.Actions, as: PasswordActions
|
||||
alias Mv.Accounts.User, as: UserResource
|
||||
alias Mv.Helpers.SystemActor
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
# Use SystemActor for authorization during OIDC linking (user is not yet logged in)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
alias Mv.Helpers
|
||||
|
|
@ -44,6 +43,8 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
alias MvWeb.Helpers.MemberHelpers
|
||||
alias MvWeb.Translations.MemberFields
|
||||
|
||||
require Ash.Query
|
||||
|
||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
|
||||
@impl true
|
||||
|
|
|
|||
|
|
@ -15,14 +15,15 @@ defmodule MvWeb.GroupLive.Show do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
require Logger
|
||||
|
||||
import Ash.Expr
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
import MvWeb.Authorization
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
|
||||
alias Mv.Membership
|
||||
alias MvWeb.Helpers.MemberHelpers, as: MemberHelpers
|
||||
alias MvWeb.Live.MemberDropdownNav
|
||||
|
||||
require Logger
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
|
|
@ -566,56 +567,8 @@ defmodule MvWeb.GroupLive.Show do
|
|||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
max_index = length(socket.assigns.available_members) - 1
|
||||
current = socket.assigns.focused_member_index
|
||||
|
||||
new_index =
|
||||
case current do
|
||||
nil -> 0
|
||||
index when index < max_index -> index + 1
|
||||
_ -> current
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, focused_member_index: new_index)}
|
||||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
current = socket.assigns.focused_member_index
|
||||
|
||||
new_index =
|
||||
case current do
|
||||
nil -> 0
|
||||
0 -> 0
|
||||
index -> index - 1
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, focused_member_index: new_index)}
|
||||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
select_focused_member(socket)
|
||||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
|
||||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("member_dropdown_keydown", _params, socket) do
|
||||
# Ignore other keys
|
||||
{:noreply, socket}
|
||||
def handle_event("member_dropdown_keydown", params, socket) do
|
||||
MemberDropdownNav.handle_keydown(params, socket, fn -> select_focused_member(socket) end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -705,14 +658,6 @@ defmodule MvWeb.GroupLive.Show do
|
|||
end
|
||||
|
||||
# Helper functions
|
||||
defp return_if_dropdown_closed(socket, fun) do
|
||||
if socket.assigns.show_member_dropdown do
|
||||
fun.()
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp select_focused_member(socket) do
|
||||
case socket.assigns.focused_member_index do
|
||||
nil ->
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ defmodule MvWeb.ImportLive.Components do
|
|||
use Phoenix.Component
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
import MvWeb.CoreComponents
|
||||
|
||||
use Phoenix.VerifiedRoutes,
|
||||
endpoint: MvWeb.Endpoint,
|
||||
router: MvWeb.Router,
|
||||
statics: MvWeb.static_paths()
|
||||
|
||||
import MvWeb.CoreComponents
|
||||
|
||||
@doc """
|
||||
Renders the info box explaining that data fields must exist before import
|
||||
and linking to Manage Member Data (custom fields).
|
||||
|
|
|
|||
|
|
@ -13,15 +13,15 @@ defmodule MvWeb.JoinRequestLive.Index do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
require Logger
|
||||
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
import MvWeb.Authorization
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
|
||||
alias Mv.Membership
|
||||
alias MvWeb.Helpers.DateFormatter
|
||||
alias MvWeb.JoinRequestLive.Helpers, as: JoinRequestHelpers
|
||||
|
||||
require Logger
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
actor = current_actor(socket)
|
||||
|
|
|
|||
|
|
@ -14,10 +14,8 @@ defmodule MvWeb.JoinRequestLive.Show do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
require Logger
|
||||
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
import MvWeb.Authorization
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
|
||||
alias Mv.Constants
|
||||
alias Mv.Membership
|
||||
|
|
@ -26,6 +24,8 @@ defmodule MvWeb.JoinRequestLive.Show do
|
|||
alias MvWeb.JoinRequestLive.Helpers, as: JoinRequestHelpers
|
||||
alias MvWeb.Translations.MemberFields, as: MemberFieldsTranslations
|
||||
|
||||
require Logger
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if Membership.join_form_enabled?() do
|
||||
|
|
|
|||
83
lib/mv_web/live/member_dropdown_nav.ex
Normal file
83
lib/mv_web/live/member_dropdown_nav.ex
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
defmodule MvWeb.Live.MemberDropdownNav do
|
||||
@moduledoc """
|
||||
Shared keyboard-navigation logic for the member-search dropdown used by the
|
||||
user form and the group show LiveViews.
|
||||
|
||||
Both views keep their own `member_dropdown_keydown` event entry point and their
|
||||
own `select_focused_member/1` (the selection effect differs per view), but the
|
||||
arrow/enter/escape navigation over `:focused_member_index` and the
|
||||
dropdown-open guard are identical and live here.
|
||||
|
||||
The caller passes a zero-arity `select_focused` callback that performs the
|
||||
view-specific selection of the currently focused entry.
|
||||
"""
|
||||
|
||||
import Phoenix.Component, only: [assign: 2]
|
||||
|
||||
@type socket :: Phoenix.LiveView.Socket.t()
|
||||
@type result :: {:noreply, socket}
|
||||
|
||||
@doc """
|
||||
Handles a `member_dropdown_keydown` event for the shared dropdown.
|
||||
|
||||
Navigation keys move `:focused_member_index` within
|
||||
`[0, length(available_members) - 1]`; Enter invokes the view-specific
|
||||
`select_focused` callback; Escape closes the dropdown; any other key is a
|
||||
no-op. All key handling is guarded so that keystrokes while the dropdown is
|
||||
closed are ignored.
|
||||
"""
|
||||
@spec handle_keydown(map(), socket, (-> result)) :: result
|
||||
def handle_keydown(%{"key" => "ArrowDown"}, socket, _select_focused) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
max_index = length(socket.assigns.available_members) - 1
|
||||
current = socket.assigns.focused_member_index
|
||||
|
||||
new_index =
|
||||
case current do
|
||||
nil -> 0
|
||||
index when index < max_index -> index + 1
|
||||
_ -> current
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, focused_member_index: new_index)}
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_keydown(%{"key" => "ArrowUp"}, socket, _select_focused) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
current = socket.assigns.focused_member_index
|
||||
|
||||
new_index =
|
||||
case current do
|
||||
nil -> 0
|
||||
0 -> 0
|
||||
index -> index - 1
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, focused_member_index: new_index)}
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_keydown(%{"key" => "Enter"}, socket, select_focused) do
|
||||
return_if_dropdown_closed(socket, select_focused)
|
||||
end
|
||||
|
||||
def handle_keydown(%{"key" => "Escape"}, socket, _select_focused) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_keydown(_params, socket, _select_focused) do
|
||||
# Ignore other keys
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp return_if_dropdown_closed(socket, func) do
|
||||
if socket.assigns.show_member_dropdown do
|
||||
func.()
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -20,7 +20,6 @@ defmodule MvWeb.MemberLive.Form do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
require Logger
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||
|
||||
alias Mv.Membership
|
||||
|
|
@ -32,6 +31,8 @@ defmodule MvWeb.MemberLive.Form do
|
|||
alias MvWeb.Helpers.MemberHelpers
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
require Logger
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
# Sort custom fields by name for display only
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
import Ash.Expr
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
|
||||
|
|
@ -45,6 +43,9 @@ defmodule MvWeb.MemberLive.Index do
|
|||
alias MvWeb.MemberLive.Index.Formatter
|
||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||
@boolean_filter_prefix Mv.Constants.boolean_filter_prefix()
|
||||
@group_filter_prefix Mv.Constants.group_filter_prefix()
|
||||
|
|
@ -1026,8 +1027,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
# Errors in handle_params are handled by Phoenix LiveView
|
||||
actor = current_actor(socket)
|
||||
{time_microseconds, members} = :timer.tc(fn -> Ash.read!(query, actor: actor) end)
|
||||
Logger.info("Ash.read! in load_members/1 took #{time_microseconds / 1000} ms")
|
||||
members = Ash.read!(query, actor: actor)
|
||||
|
||||
# Custom field values are already filtered at the database level in load_custom_field_values/2
|
||||
# No need for in-memory filtering anymore
|
||||
|
|
|
|||
|
|
@ -28,12 +28,13 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
|
|||
`exit_date IS NULL OR exit_date > today` — a member who left today is hidden.
|
||||
"""
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
|
||||
alias MvWeb.MemberLive.Index.FilterParams
|
||||
|
||||
require Ash.Query
|
||||
|
||||
@join_date_from_param Mv.Constants.join_date_from_param()
|
||||
@join_date_to_param Mv.Constants.join_date_to_param()
|
||||
@exit_date_mode_param Mv.Constants.exit_date_mode_param()
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ defmodule MvWeb.MemberLive.Show do
|
|||
alias Mv.Membership.CustomFieldValue
|
||||
alias Mv.Membership.Member, as: MemberResource
|
||||
alias Mv.Vereinfacht.Client, as: VereinfachtClient
|
||||
alias MvWeb.Helpers.DateFormatter
|
||||
alias MvWeb.Helpers.MemberHelpers
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
alias Phoenix.HTML.Engine, as: HTMLEngine
|
||||
|
|
@ -159,12 +160,12 @@ defmodule MvWeb.MemberLive.Show do
|
|||
<div class="flex gap-6">
|
||||
<.data_field
|
||||
label={gettext("Join Date")}
|
||||
value={format_date(@member.join_date)}
|
||||
value={DateFormatter.format_date(@member.join_date)}
|
||||
class="w-28"
|
||||
/>
|
||||
<.data_field
|
||||
label={gettext("Exit Date")}
|
||||
value={format_date(@member.exit_date)}
|
||||
value={DateFormatter.format_date(@member.exit_date)}
|
||||
class="w-28"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -719,14 +720,6 @@ defmodule MvWeb.MemberLive.Show do
|
|||
end
|
||||
end
|
||||
|
||||
defp format_date(nil), do: nil
|
||||
|
||||
defp format_date(%Date{} = date) do
|
||||
Calendar.strftime(date, "%d.%m.%Y")
|
||||
end
|
||||
|
||||
defp format_date(date), do: to_string(date)
|
||||
|
||||
# Finds custom field value for a given custom field id
|
||||
# Returns the value (not the CustomFieldValue struct) or nil
|
||||
defp find_custom_field_value(nil, _custom_field_id), do: nil
|
||||
|
|
@ -760,7 +753,7 @@ defmodule MvWeb.MemberLive.Show do
|
|||
end
|
||||
|
||||
defp format_custom_field_value(%Date{} = date, :date) do
|
||||
Calendar.strftime(date, "%d.%m.%Y")
|
||||
DateFormatter.format_date(date)
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, :email) when is_binary(value) do
|
||||
|
|
|
|||
|
|
@ -12,10 +12,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
"""
|
||||
use MvWeb, :live_component
|
||||
|
||||
require Ash.Query
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
import MvWeb.Authorization, only: [can?: 3]
|
||||
import MvWeb.Helpers.AshErrorHelpers, only: [format_error: 1]
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.MembershipFees
|
||||
|
|
@ -25,6 +24,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
require Ash.Query
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
|
|||
|
|
@ -9,16 +9,15 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
require Ash.Query
|
||||
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
require Ash.Query
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
actor = current_actor(socket)
|
||||
|
|
@ -92,47 +91,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
actor = current_actor(socket)
|
||||
|
||||
case Ash.get(MembershipFeeType, id, domain: MembershipFees, actor: actor) do
|
||||
{:ok, fee_type} ->
|
||||
case Ash.destroy(fee_type, domain: MembershipFees, actor: actor) do
|
||||
:ok ->
|
||||
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
|
||||
updated_counts = Map.delete(socket.assigns.member_counts, id)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:membership_fee_types, updated_types)
|
||||
|> assign(:member_counts, updated_counts)
|
||||
|> put_flash(:success, gettext("Membership fee type deleted"))}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("You do not have permission to delete this membership fee type")
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
|
||||
{:error, %Ash.Error.Query.NotFound{}} ->
|
||||
{:noreply, put_flash(socket, :error, gettext("Membership fee type not found"))}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("You do not have permission to access this membership fee type")
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
MembershipFeeHelpers.delete_fee_type(socket, id, current_actor(socket))
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -465,12 +424,6 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
Map.get(member_counts, fee_type.id, 0)
|
||||
end
|
||||
|
||||
defp format_error(%Ash.Error.Invalid{} = error) do
|
||||
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
||||
end
|
||||
|
||||
defp format_error(_error), do: gettext("An error occurred")
|
||||
|
||||
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||
form =
|
||||
AshPhoenix.Form.for_update(
|
||||
|
|
|
|||
|
|
@ -15,13 +15,13 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
|||
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
require Ash.Query
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
|
|||
|
|
@ -16,14 +16,14 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
|||
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
require Ash.Query
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
actor = current_actor(socket)
|
||||
|
|
@ -141,47 +141,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
|||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
actor = current_actor(socket)
|
||||
|
||||
case Ash.get(MembershipFeeType, id, domain: MembershipFees, actor: actor) do
|
||||
{:ok, fee_type} ->
|
||||
case Ash.destroy(fee_type, domain: MembershipFees, actor: actor) do
|
||||
:ok ->
|
||||
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
|
||||
updated_counts = Map.delete(socket.assigns.member_counts, id)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:membership_fee_types, updated_types)
|
||||
|> assign(:member_counts, updated_counts)
|
||||
|> put_flash(:success, gettext("Membership fee type deleted"))}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("You do not have permission to delete this membership fee type")
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
|
||||
{:error, %Ash.Error.Query.NotFound{}} ->
|
||||
{:noreply, put_flash(socket, :error, gettext("Membership fee type not found"))}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{} = _error} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("You do not have permission to access this membership fee type")
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
MembershipFeeHelpers.delete_fee_type(socket, id, current_actor(socket))
|
||||
end
|
||||
|
||||
# Helper functions
|
||||
|
|
@ -215,12 +175,6 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
|||
Map.get(member_counts, fee_type.id, 0)
|
||||
end
|
||||
|
||||
defp format_error(%Ash.Error.Invalid{} = error) do
|
||||
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
||||
end
|
||||
|
||||
defp format_error(_error), do: gettext("An error occurred")
|
||||
|
||||
# Info card explaining the membership fee type concept
|
||||
defp info_card(assigns) do
|
||||
~H"""
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ defmodule MvWeb.RoleLive.Form do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
alias Mv.Authorization.PermissionSets
|
||||
|
||||
import MvWeb.RoleLive.Helpers, only: [format_error: 1]
|
||||
|
||||
alias Mv.Authorization.PermissionSets
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
|
|||
|
|
@ -13,13 +13,13 @@ defmodule MvWeb.RoleLive.Index do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
import MvWeb.RoleLive.Helpers, only: [permission_set_badge_variant: 1]
|
||||
|
||||
alias Mv.Accounts
|
||||
alias Mv.Authorization
|
||||
|
||||
require Ash.Query
|
||||
|
||||
import MvWeb.RoleLive.Helpers, only: [permission_set_badge_variant: 1]
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
actor = socket.assigns[:current_user]
|
||||
|
|
|
|||
|
|
@ -12,13 +12,13 @@ defmodule MvWeb.RoleLive.Show do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
import MvWeb.RoleLive.Helpers,
|
||||
only: [format_error: 1, permission_set_badge_variant: 1, opts_with_actor: 3]
|
||||
|
||||
alias Mv.Accounts
|
||||
|
||||
require Ash.Query
|
||||
|
||||
import MvWeb.RoleLive.Helpers,
|
||||
only: [format_error: 1, permission_set_badge_variant: 1, opts_with_actor: 3]
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
case Ash.get(
|
||||
|
|
|
|||
|
|
@ -6,13 +6,14 @@ defmodule MvWeb.StatisticsLive do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
require Logger
|
||||
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.Statistics
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
require Logger
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
# Only static assigns and fee types here; load_statistics runs once in handle_params
|
||||
|
|
|
|||
|
|
@ -33,7 +33,9 @@ defmodule MvWeb.UserLive.Form do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
require Jason
|
||||
import MvWeb.Authorization, only: [can?: 3]
|
||||
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||
|
||||
alias Mv.Accounts
|
||||
alias Mv.Accounts.User, as: UserResource
|
||||
|
|
@ -43,10 +45,9 @@ defmodule MvWeb.UserLive.Form do
|
|||
alias Mv.Membership
|
||||
alias Mv.Membership.Member, as: MemberResource
|
||||
alias MvWeb.Helpers.MemberHelpers
|
||||
alias MvWeb.Live.MemberDropdownNav
|
||||
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||
import MvWeb.Authorization, only: [can?: 3]
|
||||
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
|
||||
require Jason
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
|
|
@ -571,56 +572,8 @@ defmodule MvWeb.UserLive.Form do
|
|||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
max_index = length(socket.assigns.available_members) - 1
|
||||
current = socket.assigns.focused_member_index
|
||||
|
||||
new_index =
|
||||
case current do
|
||||
nil -> 0
|
||||
index when index < max_index -> index + 1
|
||||
_ -> current
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, focused_member_index: new_index)}
|
||||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
current = socket.assigns.focused_member_index
|
||||
|
||||
new_index =
|
||||
case current do
|
||||
nil -> 0
|
||||
0 -> 0
|
||||
index -> index - 1
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, focused_member_index: new_index)}
|
||||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
select_focused_member(socket)
|
||||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
|
||||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("member_dropdown_keydown", _params, socket) do
|
||||
# Ignore other keys
|
||||
{:noreply, socket}
|
||||
def handle_event("member_dropdown_keydown", params, socket) do
|
||||
MemberDropdownNav.handle_keydown(params, socket, fn -> select_focused_member(socket) end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -778,17 +731,6 @@ defmodule MvWeb.UserLive.Form do
|
|||
@spec notify_parent(any()) :: {module(), any()}
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
|
||||
# Helper to ignore keyboard events when dropdown is closed
|
||||
@spec return_if_dropdown_closed(Phoenix.LiveView.Socket.t(), function()) ::
|
||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||
defp return_if_dropdown_closed(socket, func) do
|
||||
if socket.assigns.show_member_dropdown do
|
||||
func.()
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
# Select the currently focused member from the dropdown
|
||||
@spec select_focused_member(Phoenix.LiveView.Socket.t()) ::
|
||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ defmodule MvWeb.LiveUserAuth do
|
|||
Helpers for authenticating users in LiveViews.
|
||||
"""
|
||||
|
||||
import Phoenix.Component
|
||||
use MvWeb, :verified_routes
|
||||
|
||||
import Phoenix.Component
|
||||
|
||||
alias AshAuthentication.Phoenix.LiveSession
|
||||
alias Phoenix.LiveView
|
||||
|
||||
|
|
|
|||
|
|
@ -193,8 +193,7 @@ msgid "An account with this email already exists. Please verify your password to
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/helpers/ash_error_helpers.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||
#: lib/mv_web/live/role_live/helpers.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "An error occurred"
|
||||
|
|
@ -2289,14 +2288,12 @@ msgstr ""
|
|||
msgid "Membership fee start"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee type deleted"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee type not found"
|
||||
msgstr ""
|
||||
|
|
@ -3956,8 +3953,7 @@ msgstr ""
|
|||
msgid "You do not have permission to %{action} members."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to access this membership fee type"
|
||||
msgstr ""
|
||||
|
|
@ -3973,8 +3969,7 @@ msgstr ""
|
|||
msgid "You do not have permission to delete this member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to delete this membership fee type"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
publiccodeYmlVersion: "0.2"
|
||||
name: Mila
|
||||
url: "https://git.local-it.org/local-it/mitgliederverwaltung"
|
||||
softwareVersion: "1.2.0"
|
||||
softwareVersion: "1.3.0"
|
||||
releaseDate: "2026-05-08"
|
||||
developmentStatus: beta
|
||||
logo: logo.png
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ defmodule Mv.Membership.GroupDatabaseConstraintsTest do
|
|||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
import Ash.Expr
|
||||
|
||||
alias Mv.Membership
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ defmodule Mv.Membership.GroupTest do
|
|||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
import Ash.Expr
|
||||
|
||||
alias Mv.Membership
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
|
|
|||
|
|
@ -8,13 +8,14 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do
|
|||
use Mv.DataCase, async: true
|
||||
|
||||
import Ash.Expr
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Fixtures
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.Member
|
||||
|
||||
require Ash.Query
|
||||
|
||||
defp member_count do
|
||||
actor = SystemActor.get_system_actor()
|
||||
{:ok, members} = Membership.list_members(actor: actor)
|
||||
|
|
|
|||
|
|
@ -12,13 +12,14 @@ defmodule Mv.Membership.JoinRequestTest do
|
|||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
alias Mv.Fixtures
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.JoinRequest
|
||||
|
||||
require Ash.Query
|
||||
|
||||
# Valid minimal attributes for submit (email required; confirmation_token optional for tests)
|
||||
@valid_submit_attrs %{
|
||||
email: "join#{System.unique_integer([:positive])}@example.com"
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ defmodule Mv.Membership.MemberGroupTest do
|
|||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
import Ash.Expr
|
||||
|
||||
alias Mv.Membership
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ defmodule Mv.Membership.MemberGroupsRelationshipTest do
|
|||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
import Ash.Expr
|
||||
|
||||
alias Mv.Membership
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
|
|
|||
58
test/mv/application_test.exs
Normal file
58
test/mv/application_test.exs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
defmodule Mv.ApplicationTest do
|
||||
@moduledoc """
|
||||
Guards the AshAuthentication supervisor wiring in `Mv.Application`.
|
||||
|
||||
The auth children (token Expunger, audit-log batcher/expunger) resolve their
|
||||
configuration via `Spark.sparks(otp_app, Ash.Resource)`. With the wrong
|
||||
`otp_app` they silently start against an empty resource set: the token
|
||||
Expunger then runs against no token resources and becomes a no-op. This test
|
||||
pins the corrected `:mv` wiring against the *running* application tree: the
|
||||
supervisor and all three children are present, and the token Expunger booted
|
||||
live and resolved the real `:mv` token resource.
|
||||
|
||||
The two audit-log children legitimately report an `:undefined` pid because no
|
||||
`AuditLogResource` resources exist under `:mv` (their init returns `:ignore`);
|
||||
that is a successful start, not a crash, so they are only asserted present.
|
||||
"""
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias AshAuthentication.TokenResource.Expunger
|
||||
|
||||
test "AshAuthentication children boot under the :mv otp_app and resolve real config" do
|
||||
# Locate the running AshAuthentication.Supervisor inside the live app tree.
|
||||
auth_sup =
|
||||
Mv.Supervisor
|
||||
|> Supervisor.which_children()
|
||||
|> Enum.find_value(fn
|
||||
{AshAuthentication.Supervisor, pid, _type, _mods} when is_pid(pid) -> pid
|
||||
_ -> nil
|
||||
end)
|
||||
|
||||
assert is_pid(auth_sup), "AshAuthentication.Supervisor is not running in Mv.Supervisor"
|
||||
|
||||
children = Supervisor.which_children(auth_sup)
|
||||
child_ids = children |> Enum.map(&elem(&1, 0)) |> Enum.sort()
|
||||
|
||||
# All three auth children are present in the supervision tree.
|
||||
assert child_ids ==
|
||||
Enum.sort([
|
||||
AshAuthentication.TokenResource.Expunger,
|
||||
AshAuthentication.AuditLogResource.Batcher,
|
||||
AshAuthentication.AuditLogResource.Expunger
|
||||
])
|
||||
|
||||
# The token Expunger booted as a live process and resolved the real :mv
|
||||
# token resource — proving the children run against non-empty :mv config
|
||||
# rather than the empty set the wrong otp_app produced.
|
||||
expunger_pid =
|
||||
Enum.find_value(children, fn
|
||||
{Expunger, pid, _, _} when is_pid(pid) -> pid
|
||||
_ -> nil
|
||||
end)
|
||||
|
||||
assert is_pid(expunger_pid), "token Expunger did not boot as a live process under :mv"
|
||||
assert Process.alive?(expunger_pid)
|
||||
assert %{otp_app: :mv, resources: resources} = :sys.get_state(expunger_pid)
|
||||
assert Map.has_key?(resources, Mv.Accounts.Token)
|
||||
end
|
||||
end
|
||||
|
|
@ -12,10 +12,10 @@ defmodule Mv.Authorization.Checks.HasPermissionFailClosedTest do
|
|||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
alias Mv.Authorization.Checks.HasPermission
|
||||
|
||||
import Mv.Fixtures
|
||||
|
||||
alias Mv.Authorization.Checks.HasPermission
|
||||
|
||||
test "auto_filter deny-filter matches no records (regression for NOT IN [] allow-all bug)" do
|
||||
# Arrange: create some members in DB
|
||||
_m1 = member_fixture()
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
defmodule Mv.OidcRoleSyncConfigTest do
|
||||
@moduledoc """
|
||||
Tests for OIDC role sync configuration (OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM).
|
||||
Reads via Mv.Config (ENV first, then Settings).
|
||||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.OidcRoleSyncConfig
|
||||
|
||||
describe "oidc_admin_group_name/0" do
|
||||
test "returns nil when OIDC_ADMIN_GROUP_NAME is not configured" do
|
||||
restore = clear_env("OIDC_ADMIN_GROUP_NAME")
|
||||
on_exit(restore)
|
||||
|
||||
assert OidcRoleSyncConfig.oidc_admin_group_name() == nil
|
||||
end
|
||||
|
||||
test "returns configured admin group name when set via ENV" do
|
||||
restore = set_env("OIDC_ADMIN_GROUP_NAME", "mila-admin")
|
||||
on_exit(restore)
|
||||
|
||||
assert OidcRoleSyncConfig.oidc_admin_group_name() == "mila-admin"
|
||||
end
|
||||
end
|
||||
|
||||
describe "oidc_groups_claim/0" do
|
||||
test "returns default \"groups\" when OIDC_GROUPS_CLAIM is not configured" do
|
||||
restore = clear_env("OIDC_GROUPS_CLAIM")
|
||||
on_exit(restore)
|
||||
|
||||
assert OidcRoleSyncConfig.oidc_groups_claim() == "groups"
|
||||
end
|
||||
|
||||
test "returns configured claim name when OIDC_GROUPS_CLAIM is set via ENV" do
|
||||
restore = set_env("OIDC_GROUPS_CLAIM", "ak_groups")
|
||||
on_exit(restore)
|
||||
|
||||
assert OidcRoleSyncConfig.oidc_groups_claim() == "ak_groups"
|
||||
end
|
||||
end
|
||||
|
||||
defp set_env(key, value) do
|
||||
previous = System.get_env(key)
|
||||
System.put_env(key, value)
|
||||
|
||||
fn ->
|
||||
if previous, do: System.put_env(key, previous), else: System.delete_env(key)
|
||||
end
|
||||
end
|
||||
|
||||
defp clear_env(key) do
|
||||
previous = System.get_env(key)
|
||||
System.delete_env(key)
|
||||
|
||||
fn ->
|
||||
if previous, do: System.put_env(key, previous)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -4,7 +4,6 @@ defmodule Mv.StatisticsTest do
|
|||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
import Mv.Fixtures, only: [create_fee_type: 2]
|
||||
|
||||
|
|
@ -13,6 +12,8 @@ defmodule Mv.StatisticsTest do
|
|||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.Statistics
|
||||
|
||||
require Ash.Query
|
||||
|
||||
setup do
|
||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
%{actor: actor}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,10 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
|
|||
# is not trivially async-safe; resolving that is a separate follow-up.
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Membership.CustomField
|
||||
|
||||
# Helper to create a boolean custom field (uses system_actor - only admin can create)
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
|||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
require Ash.Query
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
%{actor: system_actor}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,11 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
use MvWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||
|
||||
require Ash.Query
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
admin_role = Mv.Fixtures.role_fixture("admin")
|
||||
|
|
|
|||
|
|
@ -8,10 +8,11 @@ defmodule MvWeb.CustomFieldLive.FormTest do
|
|||
use MvWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.CustomField
|
||||
|
||||
require Ash.Query
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
admin_role = Mv.Fixtures.role_fixture("admin")
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@ defmodule MvWeb.GroupLive.FormTest do
|
|||
- Security
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Fixtures
|
||||
|
||||
describe "create form" do
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@ defmodule MvWeb.GroupLive.IndexTest do
|
|||
- Edge cases
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Fixtures
|
||||
alias Mv.Membership
|
||||
|
||||
|
|
|
|||
|
|
@ -9,14 +9,16 @@ defmodule MvWeb.GroupLive.IntegrationTest do
|
|||
- URL persistence
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
import Ash.Expr
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Fixtures
|
||||
alias Mv.Membership
|
||||
|
||||
require Ash.Query
|
||||
|
||||
describe "complete workflow" do
|
||||
test "create → view via slug → edit → view via slug (slug unchanged)", %{
|
||||
conn: conn,
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|
|||
"""
|
||||
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Fixtures
|
||||
alias Mv.Membership
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
|||
"""
|
||||
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
import MvWeb.GroupLiveHelpers
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
import MvWeb.GroupLiveHelpers
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Fixtures
|
||||
alias Mv.Membership
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
|
|||
"""
|
||||
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Fixtures
|
||||
alias Mv.Membership
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
|
|||
"""
|
||||
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Fixtures
|
||||
alias Mv.Membership
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
|
|||
"""
|
||||
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Fixtures
|
||||
alias Mv.Membership
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
|
|||
"""
|
||||
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Fixtures
|
||||
alias Mv.Membership
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
|
|||
"""
|
||||
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Fixtures
|
||||
alias Mv.Membership
|
||||
|
||||
|
|
|
|||
|
|
@ -11,13 +11,15 @@ defmodule MvWeb.GroupLive.ShowTest do
|
|||
- Delete functionality
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Fixtures
|
||||
alias Mv.Membership
|
||||
|
||||
require Ash.Query
|
||||
|
||||
describe "mount and display" do
|
||||
test "page renders successfully", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue